diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index caaf1680..27e57606 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -120,8 +120,10 @@ nothing else of the home. A project is one entry of the projects directory and a The agent kept reaching for `ssh journalctl` while the service manager's `journal` verb existed. So: - **The "instead of" table.** Every seat verb and module tool may say which shell commands it replaces - (`replaces`, in the seat's definition or the module's manifest). The module asks the controller — `tools`, - `modules`, `nodes`, `node` — at start, every ten minutes and on `claude_code_render`, keeps the answer in its + (`replaces`, in the seat's definition or the module's manifest). The module reads the bus — the seats' verbs from the + seats' contract, the placed modules' manifests from `mesh-delivery-indexer.modules` and `.manifests`, the + machines and where each module runs from `mesh-delivery-assigner.placement` (issue 1126: the controller that + answered this is not placed) — at start, every ten minutes and on `claude_code_render`, keeps it in its state (`mesh-tools.json`), and renders it into `CLAUDE.md` as one row per seat or module: `/.` and each verb with the commands it replaces. Generated, never written by hand: a verb that gains `replaces` is in the next render. Ordered by what the guard here refused most, then the machines' seats, the modules, the diff --git a/modules/claude-code/cmd/claude-code/guard_test.go b/modules/claude-code/cmd/claude-code/guard_test.go index 9b719107..5ea512ea 100644 --- a/modules/claude-code/cmd/claude-code/guard_test.go +++ b/modules/claude-code/cmd/claude-code/guard_test.go @@ -8,22 +8,26 @@ import ( "strconv" "strings" "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" ) // meshForTheGuard is a mesh of two machines and the verbs that replace what an agent runs over ssh, as the -// controller's records say them. +// seats' contract and the modules' manifests say them. func meshForTheGuard() GuardData { - tools := json.RawMessage(`{"seats":[ - {"seat":"mesh-controller","scope":"mesh","tools":[{"name":"node","description":"What one machine reported it can do.","replaces":["hostnamectl","uptime"]}]}, - {"seat":"node-service-manager","scope":"node","tools":[ - {"name":"status","description":"One unit as the service manager sees it now: its states.","replaces":["systemctl status","systemctl is-active"]}, - {"name":"restart","description":"Restart one unit.","replaces":["systemctl restart"]}, - {"name":"journal","description":"The last lines of one unit's journal.","replaces":["journalctl"]}]}, - {"seat":"node-hostname","scope":"node","tools":[ - {"name":"add","description":"Add one address and its names to the operator's lines of /etc/hosts.","replaces":["edit /etc/hosts","HOSTALIASES"]}]}]}`) - modules := json.RawMessage(`[{"module":"docker","on":["anchor"],"replaces":{"docker_logs":["docker logs"],"docker_list":["docker ps"]}}, - {"module":"systemd","on":["anchor","laptop"]}]`) - return GuardData{Node: "laptop", Replacements: ReplacementsOf(tools, modules), Machines: []Machine{ + seats := []catalogue.Seat{ + {Name: "mesh-placement", Scope: catalogue.ScopeMesh, Serves: []catalogue.Verb{ + {Name: "placement", Description: "Read placement: one node's key.", Replaces: []string{"hostnamectl", "uptime"}}}}, + {Name: "node-service-manager", Scope: catalogue.ScopeNode, Serves: []catalogue.Verb{ + {Name: "status", Description: "One unit as the service manager sees it now: its states.", Replaces: []string{"systemctl status", "systemctl is-active"}}, + {Name: "restart", Description: "Restart one unit.", Replaces: []string{"systemctl restart"}}, + {Name: "journal", Description: "The last lines of one unit's journal.", Replaces: []string{"journalctl"}}}}, + {Name: "node-hostname", Scope: catalogue.ScopeNode, Serves: []catalogue.Verb{ + {Name: "add", Description: "Add one address and its names to the operator's lines of /etc/hosts.", Replaces: []string{"edit /etc/hosts", "HOSTALIASES"}}}}, + } + modules := []ModuleTools{{Module: "docker", On: []string{"anchor"}, Replaces: map[string][]string{"docker_logs": {"docker logs"}, "docker_list": {"docker ps"}}}, + {Module: "systemd", On: []string{"anchor", "laptop"}}} + return GuardData{Node: "laptop", Replacements: ReplacementsOf(seats, modules), Machines: []Machine{ {Name: "anchor", Domains: []string{"anchor" + InternalSuffix, "anchor.example"}, Addresses: []string{"192.0.2.10", "10.10.0.1"}}, {Name: "laptop", Domains: []string{"laptop" + InternalSuffix}, Addresses: []string{"10.10.0.4"}}, }} @@ -69,21 +73,21 @@ func bash(command string) HookInput { func TestSSHToAMeshMachineIsRefusedNamingTheTool(t *testing.T) { g := guard() for command, want := range map[string]string{ - "ssh anchor journalctl -u mesh-controller -n 50": "anchor/node-service-manager.journal", + "ssh anchor journalctl -u mesh-config -n 50": "anchor/node-service-manager.journal", "ssh anchor 'sudo journalctl -fu sshd'": "anchor/node-service-manager.journal", "ssh -p 22 root@anchor.example systemctl status nats": "anchor/node-service-manager.status", "ssh anchor.internal systemctl restart nats": "anchor/node-service-manager.restart", "ssh -o StrictHostKeyChecking=no 10.10.0.1 docker ps -a": "anchor/docker.docker_list", "ssh anchor docker logs --tail 100 nats": "anchor/docker.docker_logs", "ssh laptop docker logs x": "anchor/docker.docker_logs", - "ssh a uptime": "mesh-controller.node", + "ssh a uptime": "mesh-placement.placement", "cd /tmp && ssh anchor journalctl": "anchor/node-service-manager.journal", "sudo -u root ssh anchor journalctl": "anchor/node-service-manager.journal", "timeout 10 ssh anchor journalctl": "anchor/node-service-manager.journal", "env LANG=C ssh anchor journalctl": "anchor/node-service-manager.journal", "bash -c 'ssh anchor journalctl -u x'": "anchor/node-service-manager.journal", `echo "$(ssh anchor journalctl -n 5)" | tail`: "anchor/node-service-manager.journal", - "for n in anchor laptop; do ssh $n uptime; done; ssh anchor uptime": "mesh-controller.node", + "for n in anchor laptop; do ssh $n uptime; done; ssh anchor uptime": "mesh-placement.placement", "ssh -J anchor 203.0.113.9 journalctl": "anchor/node-service-manager.journal", "ssh via-anchor journalctl": "anchor/node-service-manager.journal", "ssh nas.lan journalctl": "anchor/node-service-manager.journal", diff --git a/modules/claude-code/cmd/claude-code/insteadof_test.go b/modules/claude-code/cmd/claude-code/insteadof_test.go index 88bd5fe5..0be167b2 100644 --- a/modules/claude-code/cmd/claude-code/insteadof_test.go +++ b/modules/claude-code/cmd/claude-code/insteadof_test.go @@ -1,43 +1,82 @@ package main import ( + "crypto/sha256" "encoding/json" "errors" + "fmt" "os" "strings" "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" ) -// askingAController answers the controller's verbs as the seat does: `tools` in-process, the rest as the -// command's output and its JSON. -func askingAController(t *testing.T) Ask { +// aBus is the states claude-code reads, as a test holds them: by read, key and value. +type aBus map[string]map[string][]byte + +func (b aBus) Keys(read string) ([]string, error) { + if b[read] == nil { + return nil, errors.New("no such state") + } + var keys []string + for k := range b[read] { + keys = append(keys, k) + } + return keys, nil +} + +func (b aBus) Get(read, key string) ([]byte, bool, error) { + v, ok := b[read][key] + return v, ok, nil +} + +// someSeats are two seats as the seats' contract says them: one held once for the mesh, one on every machine. +func someSeats() []catalogue.Seat { + return []catalogue.Seat{ + {Name: "mesh-placement", Scope: catalogue.ScopeMesh, Serves: []catalogue.Verb{ + {Name: "placement", Description: "Read placement: one node's key.", Replaces: []string{"hostnamectl", "uptime"}}, + {Name: "propose", Description: "Propose a change."}}}, + {Name: "node-service-manager", Scope: catalogue.ScopeNode, Serves: []catalogue.Verb{ + {Name: "status", Description: "One unit as the service manager sees it now: its states, whether it starts at boot.", Replaces: []string{"systemctl status", "systemctl is-active"}}, + {Name: "journal", Description: "The last lines of one unit's journal.", Replaces: []string{"journalctl"}}}}, + } +} + +// aMesh is two machines, anchor (with a public domain) and laptop, docker and zsh placed on anchor, docker's manifest +// saying what its tool replaces, as the seat mesh-placement and the indexer keep them on the bus. +func aMesh(t *testing.T) aBus { t.Helper() - wrap := func(output string, answer any) json.RawMessage { - raw, _ := json.Marshal(map[string]any{"ok": true, "output": output, "answer": answer}) - return raw - } - return func(address string, args any) (json.RawMessage, error) { - switch address { - case "seat:mesh-controller.tools": - return json.RawMessage(`{"seats":[ - {"seat":"mesh-controller","scope":"mesh","tools":[{"name":"node","description":"What one machine reported it can do, what it is assigned, and why.","replaces":["hostnamectl","uptime"]},{"name":"nodes","description":"Every machine."}]}, - {"seat":"node-service-manager","scope":"node","tools":[ - {"name":"status","description":"One unit as the service manager sees it now: its states, whether it starts at boot.","replaces":["systemctl status","systemctl is-active"]}, - {"name":"journal","description":"The last lines of one unit's journal.","replaces":["journalctl"]}]}]}`), nil - case "seat:mesh-controller.modules": - return wrap("[...]", []any{map[string]any{"module": "docker", "on": []string{"anchor"}, - "replaces": map[string][]string{"docker_logs": {"docker logs"}}}, map[string]any{"module": "zsh", "on": []string{"anchor"}}}), nil - case "seat:mesh-controller.nodes": - return wrap("", []any{map[string]any{"name": "anchor"}, map[string]any{"name": "laptop"}}), nil - case "seat:mesh-controller.node": - node, _ := args.(map[string]any)["node"].(string) - if node == "anchor" { - return wrap("anchor\n mode converged\n public domain Anchor.Example\n\n what it runs\n", nil), nil - } - return wrap("laptop\n mode converged\n", nil), nil + b := aBus{PlacementRead: {}, ModulesRead: {}, ManifestsRead: {}} + for _, p := range []delivery.Placement{ + {V: 1, Node: "anchor", Decided: delivery.NodeDecided{PublicDomain: "Anchor.Example"}, + Modules: []delivery.PlacedModule{{Module: "docker"}, {Module: "zsh"}}}, + {V: 1, Node: "laptop"}, + } { + values, err := delivery.PlacementValues(p) + if err != nil { + t.Fatal(err) + } + for k, v := range values { + b[PlacementRead][k] = v } - return nil, errors.New("no such verb") } + for module, manifest := range map[string]string{ + "docker": `{"module":"docker","replaces":{"docker_logs":["docker logs"]}}`, + "zsh": `{"module":"zsh"}`, + } { + digest := fmt.Sprintf("%x", sha256.Sum256([]byte(manifest))) + mkey, _ := delivery.ManifestsKey(digest) + raw, err := json.Marshal(delivery.Manifest{V: 1, Module: module, JSON: manifest}) + if err != nil { + t.Fatal(err) + } + b[ManifestsRead][mkey] = raw + key, _ := delivery.ModulesKey(module) + b[ModulesRead][key], _ = json.Marshal(delivery.Module{V: 1, None: "nothing is built of it", Manifest: digest, Repository: "novox/mesh-catalog", Commit: "c"}) + } + return b } func resolving(name string) []string { @@ -45,10 +84,11 @@ func resolving(name string) []string { "laptop": {"127.0.0.1", "10.10.0.4"}}[name] } -// What the controller says of its tools and machines is read whole: the seats' verbs, the modules' own tools, -// every machine with its domains and addresses — a loopback address never one of them. -func TestTheMeshsToolsAreAskedOfTheController(t *testing.T) { - m, err := AskMeshTools(askingAController(t), resolving) +// Issue 1126 B13: what the mesh says of its tools and machines is read from the bus whole, never asked of the +// controller: the seats' verbs, the placed modules' own tools, every machine with its domains and addresses — a +// loopback address never one of them. +func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) { + m, err := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving) if err != nil { t.Fatal(err) } @@ -56,7 +96,7 @@ func TestTheMeshsToolsAreAskedOfTheController(t *testing.T) { for _, r := range m.Replacements { addresses = append(addresses, r.Address) } - if got := strings.Join(addresses, ","); got != "mesh-controller.node,node-service-manager.status,node-service-manager.journal,docker.docker_logs" { + if got := strings.Join(addresses, ","); got != "mesh-placement.placement,node-service-manager.status,node-service-manager.journal,docker.docker_logs" { t.Errorf("replacements: %s", got) } if len(m.Machines) != 2 || strings.Join(m.Machines[0].Domains, ",") != "anchor.internal,anchor.example" || @@ -71,12 +111,12 @@ func TestTheMeshsToolsAreAskedOfTheController(t *testing.T) { // **The table is generated, not written** (novox/hq ADR 0245): what the records say a verb replaces is a row, // the machines' seats first, and nothing is rendered where the records say nothing. func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) { - m, _ := AskMeshTools(askingAController(t), resolving) + m, _ := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving) table := InsteadOf(&m) for _, want := range []string{ "| `/node-service-manager.` | `status` — `systemctl status`, `systemctl is-active` · `journal` — `journalctl` |", "| `/docker.` | `docker_logs` — `docker logs` |", - "| `mesh-controller.` | `node` — `hostnamectl`, `uptime` |", + "| `mesh-placement.` | `placement` — `hostnamectl`, `uptime` |", "Never `ssh` to a mesh machine", } { if !strings.Contains(table, want) { @@ -84,14 +124,14 @@ func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) { } } if strings.Index(table, "node-service-manager.") > strings.Index(table, "docker.") || - strings.Index(table, "docker.") > strings.Index(table, "`mesh-controller.`") { + strings.Index(table, "docker.") > strings.Index(table, "`mesh-placement.`") { t.Errorf("not the machines' seats, then modules, then the mesh's:\n%s", table) } // What the guard here named most comes first: its row, and in its row its verb. - m.Refused = map[string]int{"node-service-manager.journal": 3, "mesh-controller.node": 1} + m.Refused = map[string]int{"node-service-manager.journal": 3, "mesh-placement.placement": 1} ranked := InsteadOf(&m) if !strings.Contains(ranked, "| `/node-service-manager.` | `journal` — `journalctl` · `status`") || - strings.Index(ranked, "`mesh-controller.`") > strings.Index(ranked, "`/docker.`") { + strings.Index(ranked, "`mesh-placement.`") > strings.Index(ranked, "`/docker.`") { t.Errorf("not ranked by the guard's record:\n%s", ranked) } m.Refused = nil @@ -121,7 +161,7 @@ func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) { // Asked again with the same answer, nothing is written and nothing renders. func TestTheMeshsToolsAreKeptOnlyWhenTheyChange(t *testing.T) { p := Paths{State: t.TempDir()} - m, _ := AskMeshTools(askingAController(t), resolving) + m, _ := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving) if !KeepMeshTools(p, m) { t.Fatal("the first answer was not kept") } @@ -138,18 +178,32 @@ func TestTheMeshsToolsAreKeptOnlyWhenTheyChange(t *testing.T) { } } -// Where claude-code runs is read from the controller's `modules` answer as it comes — JSON, from `module list -// --json` — and from the printed list too. -func TestTheMachinesRunningTheModuleAreReadFromTheControllersAnswer(t *testing.T) { - asJSON := json.RawMessage(`{"ok":true,"output":"[...]","answer":[{"module":"zsh","on":["a"]},{"module":"claude-code","on":["a","b"]}]}`) - if got := strings.Join(NodesRunning(asJSON, "claude-code"), ","); got != "a,b" { - t.Errorf("from JSON: %q", got) +// Where a module runs is read from the seat mesh-placement's placement (issue 1126 B13), never the controller's answer. +func TestTheMachinesRunningAModuleAreReadFromPlacement(t *testing.T) { + b := aMesh(t) + if got, err := NodesRunning(b, "zsh"); err != nil || strings.Join(got, ",") != "anchor" { + t.Errorf("zsh runs on %v (%v)", got, err) } - printed := json.RawMessage(`{"ok":true,"output":"zsh 1 built x on a\nclaude-code 1 built y on a, b\n"}`) - if got := strings.Join(NodesRunning(printed, "claude-code"), ","); got != "a,b" { - t.Errorf("from the printed list: %q", got) + if got, err := NodesRunning(b, "claude-code"); err != nil || got != nil { + t.Errorf("a module that runs nowhere: %v (%v)", got, err) } - if got := NodesRunning(json.RawMessage(`{"ok":true,"answer":[{"module":"zsh","on":["a"]}]}`), "claude-code"); got != nil { - t.Errorf("a module that runs nowhere: %v", got) + if _, err := NodesRunning(aBus{}, "zsh"); err == nil { + t.Error("an unreadable placement was read as no machine") + } +} + +// Issue 1126 B13 AC1 (ADR 0590): over the seats the mesh defines, the rendered instructions, their table among them, +// name no address of the controller, which is not placed: each job is named by the seat that owns it now. +func TestTheRenderedInstructionsNameNoControllerAddress(t *testing.T) { + m, err := ReadMeshToolsFrom(aMesh(t), catalogue.Seats(), resolving) + if err != nil { + t.Fatal(err) + } + md := RenderWithMesh(Facts{Node: "w", Console: "x"}, Settings{}, nil, "/h", nil, Config{}, &m)["CLAUDE.md"] + if !strings.Contains(md, "## Instead of a shell command") { + t.Fatalf("no table rendered:\n%s", md) + } + if strings.Contains(md, "mesh-controller") { + t.Errorf("the instructions name the controller:\n%s", md) } } diff --git a/modules/claude-code/cmd/claude-code/instructions.go b/modules/claude-code/cmd/claude-code/instructions.go index 117ab529..0fc2eb1a 100644 --- a/modules/claude-code/cmd/claude-code/instructions.go +++ b/modules/claude-code/cmd/claude-code/instructions.go @@ -2,11 +2,12 @@ package main // instructionsText is the managed instruction file, generated from the TypeScript renderer it replaced so // the file under the agent's managed directory did not change by a byte when the module moved to Go; -// a test holds it to that renderer's own output (testdata/rendered-by-typescript.json). +// a test holds it to that renderer's output (testdata/rendered-by-typescript.json), changed since only +// where the controller's verbs were named (issue 1126 B13: the controller is not placed, ADR 0590). func instructionsText(node, role string) string { return "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `" + node + "`\n- **Role:** " + role + - "\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `.` (the mesh's own verbs\n are `mesh-controller.`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `/.`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n" + "\n- The other nodes, their roles and what runs where: ask the placement seat (`mesh-placement.placement`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `.` (the mesh's own seats\n include `mesh-placement`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `/.`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n" } diff --git a/modules/claude-code/cmd/claude-code/main.go b/modules/claude-code/cmd/claude-code/main.go index 9e0d6c43..3c53201b 100644 --- a/modules/claude-code/cmd/claude-code/main.go +++ b/modules/claude-code/cmd/claude-code/main.go @@ -23,6 +23,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/catalogue" ) func say(format string, args ...any) { @@ -204,54 +205,21 @@ func (s stateOf) Get(key string) (json.RawMessage, bool, error) { return e.Value, true, nil } -// nodesRunningMe is the nodes claude-code runs on, from the controller's list of modules — for the register +// nodesRunningMe is the nodes claude-code runs on, from the seat mesh-placement's placement — for the register // tool's question. -func nodesRunningMe() ([]string, error) { - raw, err := ask("seat:mesh-controller.modules", map[string]any{}) - if err != nil { - return nil, err - } - return NodesRunning(raw, "claude-code"), nil -} +func nodesRunningMe() ([]string, error) { return NodesRunning(busStates{}, "claude-code") } -// NodesRunning reads where a module runs from the controller's `modules` answer. The verb runs `module list -// --json`, so the answer is a JSON list of {module, on}: the lines of the printed list it was once read as -// never came, and every "also on" hint and every `nodes: "all"` named no machine. The printed form, ` -// … on a, b`, is still read when that is what came. -func NodesRunning(raw json.RawMessage, module string) []string { - var listed []struct { - Module string `json:"module"` - On []string `json:"on"` +// busStates reads the states the manifest declares in its reads through the runtime. +type busStates struct{} + +func (busStates) Keys(read string) ([]string, error) { return stdio.State(read).Keys() } + +func (busStates) Get(read, key string) ([]byte, bool, error) { + e, err := stdio.State(read).Get(key) + if err != nil || e == nil { + return nil, false, err } - if json.Unmarshal(verbAnswer(raw), &listed) == nil { - for _, m := range listed { - if m.Module == module { - return m.On - } - } - return nil - } - text := outputOf(raw) - if text == "" { - text = string(raw) - } - for _, line := range strings.Split(text, "\n") { - if !strings.HasPrefix(line, module+" ") { - continue - } - _, on, ok := strings.Cut(line, " on ") - if !ok || strings.TrimSpace(on) == "nothing" { - return nil - } - var out []string - for _, n := range strings.Split(on, ",") { - if n = strings.TrimSpace(n); n != "" { - out = append(out, n) - } - } - return out - } - return nil + return e.Value, true, nil } func fingerprintOfFile(path string) any { @@ -320,11 +288,11 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c Description: "Claude Code on this machine as the mesh configured it: the licence it holds and when its token expires, what it reports holding, the managed files, the MCP servers registered here. Fingerprints only, never a token.", Run: func(map[string]any) (any, error) { return status(p), nil }}, {Name: "claude_code_render", - Description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here — after asking the controller again what each tool replaces and which machines the mesh has (the instructions' table and the shell's guard).", + Description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here — after reading the bus again for what each tool replaces and which machines the mesh has (the instructions' table and the shell's guard).", Run: func(map[string]any) (any, error) { answer := map[string]any{} - if m, err := AskMeshTools(ask, lookup); err != nil { - answer["asked"] = "the controller did not answer in full (" + err.Error() + "); what was kept is rendered" + if m, err := ReadMeshToolsFrom(busStates{}, catalogue.Seats(), lookup); err != nil { + answer["asked"] = "the bus could not be read in full (" + err.Error() + "); what was kept is rendered" } else { KeepMeshTools(p, m) } diff --git a/modules/claude-code/cmd/claude-code/mesh.go b/modules/claude-code/cmd/claude-code/mesh.go index 91229d23..d27b234b 100644 --- a/modules/claude-code/cmd/claude-code/mesh.go +++ b/modules/claude-code/cmd/claude-code/mesh.go @@ -4,21 +4,27 @@ package main // shell need it (novox/hq ADR 0245): every seat verb and module tool that says what shell command it // replaces, and the mesh's machines by every name and address a session could reach one by. // -// Asked of the controller — `tools` for the seats' verbs, `modules` for the modules' own tools, `nodes` -// and `node` for the machines — at start and every few minutes, kept in the module's state so a render -// never waits for the bus, and rendered whenever it changes. Nothing here is written by hand: a verb that -// gains a `replaces` in the records is in the next render's table and the guard's next refusal. +// Read from the bus at start and every few minutes (issue 1126 B13; the controller that answered this before is +// not placed, novox/hq ADR 0590): the seats' verbs from the seats' contract (mesh-sdk catalogue), the modules' +// own tools from the manifests the indexer keeps (`mesh-delivery-indexer.modules` and `.manifests`), and the +// machines and where each module runs from the seat mesh-placement's `placement`. Kept in the module's state so +// a render never waits for the bus, and rendered whenever it changes. Nothing here is written by hand: a verb +// that gains a `replaces` is in the next render's table and the guard's next refusal. import ( "context" "encoding/json" + "errors" + "fmt" "net" "os" "path/filepath" - "regexp" "sort" "strings" "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" ) // Replacement is one verb or tool and the shell commands it is the mesh's way to do. @@ -41,11 +47,11 @@ type Machine struct { Addresses []string `json:"addresses,omitempty"` } -// MeshTools is what the controller said, as kept in the module's state. +// MeshTools is what the bus said, as kept in the module's state. type MeshTools struct { Replacements []Replacement `json:"replacements"` Machines []Machine `json:"machines"` - // Asked is when the controller last answered. + // Asked is when the bus was last read whole. Asked string `json:"asked,omitempty"` // Refused is how often the guard here named each tool instead of a work-around, from its record: the // table's order. Never kept — read from the record at each render. @@ -78,42 +84,120 @@ func ReadMeshTools(p Paths) *MeshTools { return &m } -// verbAnswer is a controller verb's answer: the command's printed output, and its JSON when it printed one. -func verbAnswer(raw json.RawMessage) json.RawMessage { - var r struct { - Output string `json:"output"` - Answer json.RawMessage `json:"answer"` +// The states read, each declared in the manifest's reads. +const ( + PlacementRead = delivery.PlacementRead + ModulesRead = delivery.IndexerModule + ".modules" + ManifestsRead = delivery.IndexerModule + ".manifests" +) + +// States reads the module states the manifest declares in its reads: a state's keys, and one key's value. +type States interface { + Keys(read string) ([]string, error) + Get(read, key string) ([]byte, bool, error) +} + +// ModuleTools is one module's own tools that replace a command, as its manifest says (`replaces`), and where it runs. +type ModuleTools struct { + Module string + On []string + Replaces map[string][]string +} + +// placements is every node's placement, from the seat mesh-placement's state. +func placements(st States) (map[string]delivery.Placement, error) { + keys, err := st.Keys(PlacementRead) + if err != nil { + return nil, fmt.Errorf("%s: %w", PlacementRead, err) } - if json.Unmarshal(raw, &r) != nil { - return raw - } - if len(r.Answer) > 0 && string(r.Answer) != "null" { - return r.Answer - } - if r.Output != "" { - t := strings.TrimSpace(r.Output) - for _, open := range []string{"[", "{"} { - if strings.HasPrefix(t, open) { - return json.RawMessage(t) - } - if i := strings.Index(t, "\n"+open); i >= 0 { - return json.RawMessage(strings.TrimSpace(t[i+1:])) - } + read := delivery.NewPlacementKeys() + for _, k := range keys { + v, found, err := st.Get(PlacementRead, k) + if err != nil { + return nil, fmt.Errorf("%s %s: %w", PlacementRead, k, err) + } + if found { + _, _ = read.Put(k, v, 0, false) // a key that does not read places nothing } - return json.RawMessage(nil) } - return raw + return read.All(), nil } -func outputOf(raw json.RawMessage) string { - var r struct { - Output string `json:"output"` +// runsOn is, by module, the nodes placement puts it on, sorted. +func runsOn(placed map[string]delivery.Placement) map[string][]string { + out := map[string][]string{} + for node, p := range placed { + for _, m := range p.Modules { + out[m.Module] = append(out[m.Module], node) + } } - _ = json.Unmarshal(raw, &r) - return r.Output + for _, nodes := range out { + sort.Strings(nodes) + } + return out } -var publicDomain = regexp.MustCompile(`(?m)^\s*public domain\s+(\S+)\s*$`) +// NodesRunning is the nodes placement puts module on. +func NodesRunning(st States, module string) ([]string, error) { + placed, err := placements(st) + if err != nil { + return nil, err + } + return runsOn(placed)[module], nil +} + +// modulesTools is every placed module's own tools that replace a command, by module, from the manifest the indexer +// keeps of it. A module whose manifest cannot be read is left out and said in the error. +func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) { + var out []ModuleTools + var problems []error + modules := make([]string, 0, len(on)) + for m := range on { + modules = append(modules, m) + } + sort.Strings(modules) + for _, module := range modules { + key, err := delivery.ModulesKey(module) + if err != nil { + continue + } + raw, found, err := st.Get(ModulesRead, key) + if err != nil || !found { + if err != nil { + problems = append(problems, fmt.Errorf("%s %s: %w", ModulesRead, module, err)) + } + continue + } + m, err := delivery.DecodeModule(raw) + if err != nil || m.Manifest == "" { + continue + } + mkey, err := delivery.ManifestsKey(m.Manifest) + if err != nil { + continue + } + raw, found, err = st.Get(ManifestsRead, mkey) + if err != nil || !found { + if err != nil { + problems = append(problems, fmt.Errorf("%s of %s: %w", ManifestsRead, module, err)) + } + continue + } + kept, err := delivery.DecodeManifest(raw) + if err != nil { + problems = append(problems, fmt.Errorf("the manifest of %s: %w", module, err)) + continue + } + var man struct { + Replaces map[string][]string `json:"replaces"` + } + if json.Unmarshal([]byte(kept.JSON), &man) != nil || len(man.Replaces) == 0 { + continue + } + out = append(out, ModuleTools{Module: module, On: on[module], Replaces: man.Replaces}) + } + return out, errors.Join(problems...) +} // firstSentence is what a verb does, short: its description to the first full stop, at most 70 characters. func firstSentence(s string) string { @@ -131,52 +215,33 @@ func firstSentence(s string) string { return s } -// ReplacementsOf reads the controller's `tools` and `modules` answers: the seats' verbs first, in the -// records' order, then the modules' own tools by module and tool. -func ReplacementsOf(tools, modules json.RawMessage) []Replacement { +// ReplacementsOf is every seat verb and module tool that says it replaces a command: the seats' verbs first, in the +// seats' order, then the modules' own tools by module and tool. +func ReplacementsOf(seats []catalogue.Seat, modules []ModuleTools) []Replacement { var out []Replacement - var seats struct { - Seats []struct { - Seat string `json:"seat"` - Scope string `json:"scope"` - Tools []struct { - Name string `json:"name"` - Description string `json:"description"` - Replaces []string `json:"replaces"` - } `json:"tools"` - } `json:"seats"` - } - if json.Unmarshal(tools, &seats) == nil { - for _, s := range seats.Seats { - for _, t := range s.Tools { - if len(t.Replaces) == 0 { - continue - } - out = append(out, Replacement{Address: s.Seat + "." + t.Name, NodeScoped: s.Scope == "node", Seat: true, - Does: firstSentence(t.Description), Replaces: t.Replaces}) + for _, s := range seats { + for _, v := range s.Serves { + if len(v.Replaces) == 0 { + continue } + out = append(out, Replacement{Address: s.Name + "." + v.Name, NodeScoped: s.Scope == catalogue.ScopeNode, Seat: true, + Does: firstSentence(v.Description), Replaces: v.Replaces}) } } - var listed []struct { - Module string `json:"module"` - On []string `json:"on"` - Replaces map[string][]string `json:"replaces"` - } - if json.Unmarshal(modules, &listed) == nil { - sort.SliceStable(listed, func(i, j int) bool { return listed[i].Module < listed[j].Module }) - for _, m := range listed { - names := make([]string, 0, len(m.Replaces)) - for t := range m.Replaces { - names = append(names, t) - } - sort.Strings(names) - for _, t := range names { - if len(m.Replaces[t]) == 0 { - continue - } - out = append(out, Replacement{Address: m.Module + "." + t, NodeScoped: true, Replaces: m.Replaces[t], On: m.On, - Does: strings.ReplaceAll(strings.TrimPrefix(t, strings.ReplaceAll(m.Module, "-", "_")+"_"), "_", " ")}) + sorted := append([]ModuleTools(nil), modules...) + sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Module < sorted[j].Module }) + for _, m := range sorted { + names := make([]string, 0, len(m.Replaces)) + for t := range m.Replaces { + names = append(names, t) + } + sort.Strings(names) + for _, t := range names { + if len(m.Replaces[t]) == 0 { + continue } + out = append(out, Replacement{Address: m.Module + "." + t, NodeScoped: true, Replaces: m.Replaces[t], On: m.On, + Does: strings.ReplaceAll(strings.TrimPrefix(t, strings.ReplaceAll(m.Module, "-", "_")+"_"), "_", " ")}) } } return out @@ -190,36 +255,24 @@ func lookup(name string) []string { return addrs } -// AskMeshTools asks the controller now. A part that does not answer is left out and said in the error; -// what did answer is kept. -func AskMeshTools(ask Ask, resolve func(string) []string) (MeshTools, error) { +// ReadMeshToolsFrom reads the bus now: the seats' verbs, the placed modules' own tools, and every node placement names +// with its domains and addresses. A part that cannot be read is left out and said in the error; what was read is kept. +func ReadMeshToolsFrom(st States, seats []catalogue.Seat, resolve func(string) []string) (MeshTools, error) { var m MeshTools - tools, terr := ask("seat:mesh-controller.tools", map[string]any{}) - modules, merr := ask("seat:mesh-controller.modules", map[string]any{}) - if terr != nil && merr != nil { - return m, terr - } - m.Replacements = ReplacementsOf(verbAnswer(tools), verbAnswer(modules)) - nodesRaw, err := ask("seat:mesh-controller.nodes", map[string]any{}) + placed, err := placements(st) if err != nil { + m.Replacements = ReplacementsOf(seats, nil) return m, err } - var nodes []struct { - Name string `json:"name"` - } - _ = json.Unmarshal(verbAnswer(nodesRaw), &nodes) - for _, n := range nodes { - if n.Name == "" { - continue - } - machine := Machine{Name: n.Name, Domains: []string{n.Name + InternalSuffix}} - if shown, err := ask("seat:mesh-controller.node", map[string]any{"node": n.Name}); err == nil { - if d := publicDomain.FindStringSubmatch(outputOf(shown)); d != nil { - machine.Domains = append(machine.Domains, strings.ToLower(d[1])) - } + modules, merr := modulesTools(st, runsOn(placed)) + m.Replacements = ReplacementsOf(seats, modules) + for node, p := range placed { + machine := Machine{Name: node, Domains: []string{node + InternalSuffix}} + if d := strings.ToLower(strings.TrimSpace(p.Decided.PublicDomain)); d != "" { + machine.Domains = append(machine.Domains, d) } seen := map[string]bool{} - for _, name := range append([]string{n.Name}, machine.Domains...) { + for _, name := range append([]string{node}, machine.Domains...) { for _, a := range resolve(name) { if !seen[a] && !net.ParseIP(a).IsLoopback() { seen[a] = true @@ -231,7 +284,7 @@ func AskMeshTools(ask Ask, resolve func(string) []string) (MeshTools, error) { m.Machines = append(m.Machines, machine) } sort.Slice(m.Machines, func(i, j int) bool { return m.Machines[i].Name < m.Machines[j].Name }) - return m, nil + return m, merr } // KeepMeshTools writes what was asked to the module's state when it differs from what was kept, and @@ -281,19 +334,19 @@ func guardFiles(p Paths, m *MeshTools) *GuardFiles { return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m), WaitsDir: WaitsDirPath(p)} } -// MeshToolsEvery is how often the controller is asked again. +// MeshToolsEvery is how often the bus is read again. const MeshToolsEvery = 10 * time.Minute -// refreshMeshTools asks the controller at start and every few minutes, and renders when what it said changed. +// refreshMeshTools reads the bus at start and every few minutes, and renders when what it said changed. func refreshMeshTools(p Paths) { for { - m, err := AskMeshTools(ask, lookup) + m, err := ReadMeshToolsFrom(busStates{}, catalogue.Seats(), lookup) if err != nil { - say("asking the controller what each tool replaces: %v", err) + say("reading what each tool replaces and which machines the mesh has: %v", err) } if (err == nil || len(m.Replacements) > 0) && KeepMeshTools(p, m) { if _, err := RenderNow(p, writeManaged); err != nil { - say("rendering what the controller said of its tools: %v", err) + say("rendering what the bus said of the tools: %v", err) } else { say("the mesh's tools changed: %d replace a command, %d machines; rendered", len(m.Replacements), len(m.Machines)) } @@ -306,7 +359,7 @@ func refreshMeshTools(p Paths) { func GuardStatus(p Paths, last int) map[string]any { m := ReadMeshTools(p) d := GuardDataOf(p, m) - asked := "never: the controller has not answered yet" + asked := "never: the bus has not been read whole yet" if m != nil { asked = m.Asked } diff --git a/modules/claude-code/cmd/claude-code/testdata/rendered-by-typescript.json b/modules/claude-code/cmd/claude-code/testdata/rendered-by-typescript.json index 40e6a530..76bab54b 100644 --- a/modules/claude-code/cmd/claude-code/testdata/rendered-by-typescript.json +++ b/modules/claude-code/cmd/claude-code/testdata/rendered-by-typescript.json @@ -32,11 +32,11 @@ "withKey": { "managed-mcp.json": "{\n \"mcpServers\": {\n \"anton\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\n \"/a/b.js\"\n ],\n \"env\": {}\n },\n \"docs\": {\n \"type\": \"stdio\",\n \"command\": \"docs-mcp\",\n \"args\": [\n \"--x\"\n ]\n },\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n },\n \"search\": {\n \"type\": \"http\",\n \"url\": \"https://s.example/mcp\"\n }\n }\n}\n", "managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true,\n \"apiKeyHelper\": \"/state/api-key-helper\"\n}\n", - "CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `.` (the mesh's own verbs\n are `mesh-controller.`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `/.`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n" + "CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the placement seat (`mesh-placement.placement`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `.` (the mesh's own seats\n include `mesh-placement`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `/.`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n" }, "plain": { "managed-mcp.json": "{\n \"mcpServers\": {\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n }\n }\n}\n", "managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true\n}\n", - "CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `.` (the mesh's own verbs\n are `mesh-controller.`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `/.`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n" + "CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the placement seat (`mesh-placement.placement`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `.` (the mesh's own seats\n include `mesh-placement`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `/.`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n" } } \ No newline at end of file diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index 48f1a194..b1b0da23 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -35,7 +35,10 @@ "waits" ], "reads": [ - "claude-licence-manager.bindings" + "claude-licence-manager.bindings", + "mesh-delivery-assigner.placement", + "mesh-delivery-indexer.modules", + "mesh-delivery-indexer.manifests" ], "tools": [ "claude_code_status",