diff --git a/modules/claude-code/cmd/claude-code/config_test.go b/modules/claude-code/cmd/claude-code/config_test.go index 3c219d049..fde4a3912 100644 --- a/modules/claude-code/cmd/claude-code/config_test.go +++ b/modules/claude-code/cmd/claude-code/config_test.go @@ -275,11 +275,11 @@ func TestTheManifestListsEveryToolServed(t *testing.T) { Consumes []string `json:"consumes"` } _ = json.Unmarshal(raw, &m) - // The hand-act seat's warranted and hand-act, and the owners' read tools of the three followed states; no verb of + // The hand-act seat's warranted and hand-act, the seat mesh-catalog's seat list (ADR 0635 §1), and the owners' read tools of the three followed states; no verb of // the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed. - if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", ModulesTool, ManifestsTool, PlacementTool}) || + if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, PlacementTool}) || !slices.Equal(m.Reads, []string{"claude-licence-manager.bindings"}) || - !slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.placement"}) { + !slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.placement", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed"}) { t.Errorf("invokes = %v, reads = %v, consumes = %v", m.Invokes, m.Reads, m.Consumes) } if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) || diff --git a/modules/claude-code/cmd/claude-code/guard_test.go b/modules/claude-code/cmd/claude-code/guard_test.go index 7acf464d2..88724f165 100644 --- a/modules/claude-code/cmd/claude-code/guard_test.go +++ b/modules/claude-code/cmd/claude-code/guard_test.go @@ -9,20 +9,20 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-contracts/go/seats" + "git.novox.be/novox/mesh-sdk/go/seatdef" ) // meshForTheGuard is a mesh of two machines and the verbs that replace what an agent runs over ssh, as the // seats' contract and the modules' manifests say them. func meshForTheGuard() GuardData { - defined := []seats.Seat{ - {Name: "mesh-placement", Scope: "mesh", Serves: []seats.Verb{ + defined := []seatdef.Definition{ + {Seat: "mesh-module-assigner", Scope: "mesh", Verbs: []seatdef.Verb{ {Name: "placement", Description: "Read placement: one node's key.", Replaces: []string{"hostnamectl", "uptime"}}}}, - {Name: "node-service-manager", Scope: "node", Serves: []seats.Verb{ + {Seat: "node-service-manager", Scope: "node", Verbs: []seatdef.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: "node", Serves: []seats.Verb{ + {Seat: "node-hostname", Scope: "node", Verbs: []seatdef.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"}}}, @@ -80,14 +80,14 @@ func TestSSHToAMeshMachineIsRefusedNamingTheTool(t *testing.T) { "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-placement.placement", + "ssh a uptime": "mesh-module-assigner.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-placement.placement", + "for n in anchor laptop; do ssh $n uptime; done; ssh anchor uptime": "mesh-module-assigner.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/handacts.go b/modules/claude-code/cmd/claude-code/handacts.go new file mode 100644 index 000000000..6ec7e849f --- /dev/null +++ b/modules/claude-code/cmd/claude-code/handacts.go @@ -0,0 +1,16 @@ +package main + +import ( + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// handActs records the hand acts of the views reader through the seat mesh-hand-acts's client. An act the seat does not +// take is an error, and the reader then does nothing (the views package's rule). +type handActs struct{} + +func (handActs) RecordHandAct(a views.HandAct) error { + _, err := meshhandactsclient.Recorder{Ask: stdio.Ask}.Record(meshhandactsclient.Request{What: a.What, Why: a.Why, Cause: a.Cause, Verb: a.Verb}) + return err +} diff --git a/modules/claude-code/cmd/claude-code/insteadof_test.go b/modules/claude-code/cmd/claude-code/insteadof_test.go index 8e5815001..ca03e95e3 100644 --- a/modules/claude-code/cmd/claude-code/insteadof_test.go +++ b/modules/claude-code/cmd/claude-code/insteadof_test.go @@ -8,12 +8,14 @@ import ( "fmt" "os" "reflect" + "slices" "strings" "testing" "time" - "git.novox.be/novox/mesh-contracts/go/seats" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/seatdef" + "git.novox.be/novox/mesh-sdk/go/seatlist" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -37,19 +39,19 @@ func (b aBus) Get(read, key string) ([]byte, bool, error) { } // someSeats are two seats as the seats' contract says them: one held once for the mesh, one on every machine. -func someSeats() []seats.Seat { - return []seats.Seat{ - {Name: "mesh-placement", Scope: "mesh", Serves: []seats.Verb{ +func someSeats() []seatdef.Definition { + return []seatdef.Definition{ + {Seat: "mesh-module-assigner", Scope: "mesh", Verbs: []seatdef.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: "node", Serves: []seats.Verb{ + {Seat: "node-service-manager", Scope: "node", Verbs: []seatdef.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. +// saying what its tool replaces, as the seat mesh-module-assigner and the indexer keep them on the bus. func aMesh(t *testing.T) aBus { t.Helper() b := aBus{PlacementRead: {}, ModulesRead: {}, ManifestsRead: {}} @@ -100,9 +102,30 @@ func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) { for _, r := range m.Replacements { addresses = append(addresses, r.Address) } - if got := strings.Join(addresses, ","); got != "mesh-placement.placement,node-service-manager.status,node-service-manager.journal,docker.docker_logs" { + if got := strings.Join(addresses, ","); got != "mesh-module-assigner.placement,node-service-manager.status,node-service-manager.journal,docker.docker_logs" { t.Errorf("replacements: %s", got) } + t.Run("the seats are the seat list read from the bus", func(t *testing.T) { + // A table made from what the seat mesh-catalog answers (ADR 0635 §1): no compiled list, so before the reader + // has started nothing replaces a command, and after it the seats' verbs are the seat's own. + seatListMu.Lock() + seatList = nil + seatListMu.Unlock() + if got := definedSeats(); len(got) != 0 { + t.Fatalf("seats before the reader started: %v", got) + } + startSeatList(listBus{"mesh-module-assigner": someSeats()[0], "node-service-manager": someSeats()[1]}) + defer func() { seatListMu.Lock(); seatList = nil; seatListMu.Unlock() }() + got, err := ReadMeshToolsFrom(aMesh(t), definedSeats(), resolving) + var addrs []string + for _, r := range got.Replacements { + addrs = append(addrs, r.Address) + } + // The foundation's bootstrapped seats are held beside the two the seat answered, in reading order. + if err != nil || !slices.Contains(addrs, "mesh-module-assigner.placement") || !slices.Contains(addrs, "node-service-manager.journal") { + t.Fatalf("replacements from the bus: %v, %v", addrs, err) + } + }) if len(m.Machines) != 2 || strings.Join(m.Machines[0].Domains, ",") != "anchor.internal,anchor.example" || strings.Join(m.Machines[0].Addresses, ",") != "10.10.0.1,192.0.2.10" || strings.Join(m.Machines[1].Addresses, ",") != "10.10.0.4" { t.Errorf("machines: %+v", m.Machines) @@ -120,7 +143,7 @@ func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) { for _, want := range []string{ "| `/node-service-manager.` | `status` — `systemctl status`, `systemctl is-active` · `journal` — `journalctl` |", "| `/docker.` | `docker_logs` — `docker logs` |", - "| `mesh-placement.` | `placement` — `hostnamectl`, `uptime` |", + "| `mesh-module-assigner.` | `placement` — `hostnamectl`, `uptime` |", "Never `ssh` to a mesh machine", } { if !strings.Contains(table, want) { @@ -128,14 +151,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-placement.`") { + strings.Index(table, "docker.") > strings.Index(table, "`mesh-module-assigner.`") { 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-placement.placement": 1} + m.Refused = map[string]int{"node-service-manager.journal": 3, "mesh-module-assigner.placement": 1} ranked := InsteadOf(&m) if !strings.Contains(ranked, "| `/node-service-manager.` | `journal` — `journalctl` · `status`") || - strings.Index(ranked, "`mesh-placement.`") > strings.Index(ranked, "`/docker.`") { + strings.Index(ranked, "`mesh-module-assigner.`") > strings.Index(ranked, "`/docker.`") { t.Errorf("not ranked by the guard's record:\n%s", ranked) } m.Refused = nil @@ -182,7 +205,7 @@ func TestTheMeshsToolsAreKeptOnlyWhenTheyChange(t *testing.T) { } } -// Where a module runs is read from the seat mesh-placement's placement (issue 1126 B13), never the controller's answer. +// Where a module runs is read from the seat mesh-module-assigner'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" { @@ -216,16 +239,16 @@ func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) { t.Fatal(err) } } - want, err := ReadMeshToolsFrom(bus, seats.List(), resolving) + want, err := ReadMeshToolsFrom(bus, someSeats(), resolving) if err != nil { t.Fatal(err) } - got, err := ReadMeshToolsFrom(viewStates{r}, seats.List(), resolving) + got, err := ReadMeshToolsFrom(viewStates{r}, someSeats(), resolving) if err != nil || !reflect.DeepEqual(got, want) || len(got.Replacements) == 0 { t.Fatalf("from the views: %+v, %v\nfrom the state: %+v", got, err, want) } // A reader not yet answered says so, and reads nothing. - if _, err := ReadMeshToolsFrom(viewStates{newMeshViews()}, seats.List(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") { + if _, err := ReadMeshToolsFrom(viewStates{newMeshViews()}, someSeats(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") { t.Fatalf("an unanswered first view: %v", err) } } @@ -233,7 +256,7 @@ func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) { // 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), seats.List(), resolving) + m, err := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving) if err != nil { t.Fatal(err) } @@ -245,3 +268,22 @@ func TestTheRenderedInstructionsNameNoControllerAddress(t *testing.T) { t.Errorf("the instructions name the controller:\n%s", md) } } + +// listBus answers the seat mesh-catalog's seat list from the definitions it is given. +type listBus map[string]seatdef.Definition + +func (b listBus) Ask(key string, body any) (json.RawMessage, error) { + if q, _ := body.(map[string]any); q != nil && q["seat"] != nil { + d := b[q["seat"].(string)] + d.Format, d.Grain, d.Decision = seatdef.Format, seatdef.SeatList, "ADR 0635" + raw, _ := json.Marshal(d) + return json.Marshal(seatlist.Answer{Seat: d.Seat, Found: true, Entry: &seatlist.Entry{Seat: d.Seat, Source: "trunk", Digest: "d-" + d.Seat, Definition: raw}}) + } + var l seatlist.Listing + for n := range b { + l.Seats = append(l.Seats, seatlist.Entry{Seat: n, Source: "trunk", Digest: "d-" + n}) + } + return json.Marshal(l) +} + +func (listBus) Subscribe(string, func(string, json.RawMessage) error) error { return nil } diff --git a/modules/claude-code/cmd/claude-code/instructions.go b/modules/claude-code/cmd/claude-code/instructions.go index 0fc2eb1a4..678d9e40a 100644 --- a/modules/claude-code/cmd/claude-code/instructions.go +++ b/modules/claude-code/cmd/claude-code/instructions.go @@ -9,5 +9,5 @@ func instructionsText(node, role string) string { node + "`\n- **Role:** " + role + - "\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" + "\n- The other nodes, their roles and what runs where: ask the placement seat (`mesh-module-assigner.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-module-assigner`, `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 d51e1a616..2884cd16e 100644 --- a/modules/claude-code/cmd/claude-code/main.go +++ b/modules/claude-code/cmd/claude-code/main.go @@ -23,11 +23,10 @@ import ( "time" occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" - "git.novox.be/novox/mesh-contracts/go/seats" oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/handacts" + "git.novox.be/novox/mesh-sdk/go/seatlist" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -230,7 +229,7 @@ var meshChanged = make(chan struct{}, 1) // newMeshViews builds the reader of those three states; Start asks them. func newMeshViews() *views.Reader { v := &views.Reader{Module: Module, Logf: func(f string, a ...any) { say(f, a...) }, Findings: views.OwnFindings{}, - Acts: handacts.Recorder{Ask: stdio.Ask}, + Acts: handActs{}, Changed: func(string, string, json.RawMessage, bool) { select { case meshChanged <- struct{}{}: @@ -319,7 +318,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c 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 := ReadMeshToolsFrom(viewStates{meshViews}, seats.List(), lookup); err != nil { + if m, err := ReadMeshToolsFrom(viewStates{meshViews}, definedSeats(), lookup); err != nil { answer["asked"] = "the bus could not be read in full (" + err.Error() + "); what was kept is rendered" } else { KeepMeshTools(p, m) @@ -406,6 +405,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c } out = append(out, judgeTool(judge), judgedTool(judge)) out = append(out, meshViews.Tools("claude_code")...) + out = append(out, seatListTools()...) return append(out, configTools(p, config, configView)...) } @@ -659,3 +659,24 @@ func refusals(n int) string { } return fmt.Sprintf(" (after %d refusal(s))", n) } + +// seatListTools are the seat list reader's two tools: what the list holds and what it waits for, and a read now. The +// reader starts once the runtime serves, after the tools are listed, so each tool finds it when it is called. +func seatListTools() []stdio.Tool { + names := []string{"claude_code_seat_list", "claude_code_seat_list_again"} + callers := stdio.Callers{"mesh-healers"} + shape := seatlist.Tools(&seatlist.Reader{}, names[0], names[1], callers) + for i := range shape { + run := i + shape[i].Run = func(args map[string]any) (any, error) { + seatListMu.Lock() + r := seatList + seatListMu.Unlock() + if r == nil { + return nil, errors.New("the seat list reader has not started yet: the runtime has not served this module") + } + return seatlist.Tools(r, names[0], names[1], callers)[run].Run(args) + } + } + return shape +} diff --git a/modules/claude-code/cmd/claude-code/mesh.go b/modules/claude-code/cmd/claude-code/mesh.go index 380cf1366..7ccb6e5e7 100644 --- a/modules/claude-code/cmd/claude-code/mesh.go +++ b/modules/claude-code/cmd/claude-code/mesh.go @@ -7,7 +7,7 @@ package main // 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-contracts), 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 +// machines and where each module runs from the seat mesh-module-assigner'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. @@ -21,10 +21,13 @@ import ( "path/filepath" "sort" "strings" + "sync" "time" - "git.novox.be/novox/mesh-contracts/go/seats" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/seatdef" + "git.novox.be/novox/mesh-sdk/go/seatlist" ) // Replacement is one verb or tool and the shell commands it is the mesh's way to do. @@ -104,7 +107,7 @@ type ModuleTools struct { Replaces map[string][]string } -// placements is every node's placement, from the seat mesh-placement's state. +// placements is every node's placement, from the seat mesh-module-assigner's state. func placements(st States) (map[string]delivery.Placement, error) { keys, err := st.Keys(PlacementRead) if err != nil { @@ -217,14 +220,14 @@ func firstSentence(s string) string { // 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(defined []seats.Seat, modules []ModuleTools) []Replacement { +func ReplacementsOf(defined []seatdef.Definition, modules []ModuleTools) []Replacement { var out []Replacement for _, s := range defined { - for _, v := range s.Serves { + for _, v := range s.Verbs { if len(v.Replaces) == 0 { continue } - out = append(out, Replacement{Address: s.Name + "." + v.Name, NodeScoped: s.Scope == "node", Seat: true, + out = append(out, Replacement{Address: s.Seat + "." + v.Name, NodeScoped: s.Scope == "node", Seat: true, Does: firstSentence(v.Description), Replaces: v.Replaces}) } } @@ -257,7 +260,7 @@ func lookup(name string) []string { // 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, defined []seats.Seat, resolve func(string) []string) (MeshTools, error) { +func ReadMeshToolsFrom(st States, defined []seatdef.Definition, resolve func(string) []string) (MeshTools, error) { var m MeshTools placed, err := placements(st) if err != nil { @@ -349,8 +352,9 @@ func refreshMeshTools(p Paths) { case <-time.After(MeshToolsFirst): say("the first views of the tools and the placement were not answered in %s; reading what is held", MeshToolsFirst) } + startSeatList(seatlist.StdioBus()) for { - m, err := ReadMeshToolsFrom(viewStates{meshViews}, seats.List(), lookup) + m, err := ReadMeshToolsFrom(viewStates{meshViews}, definedSeats(), lookup) if err != nil { say("reading what each tool replaces and which machines the mesh has: %v", err) } @@ -403,3 +407,46 @@ func GuardStatus(p Paths, last int) map[string]any { "record": ReadGuardLog(d.Log, last), } } + +// seatList is the seat list this module holds: the definitions of the seats, read from the seat mesh-catalog and +// followed on its events (novox/hq ADR 0635 §1, ADR 0636), the foundation's bootstrapped ones until it answers. +var seatList *seatlist.Reader + +// startSeatList starts the reader once the runtime serves; a change of the list is told like any followed state, so the +// instructions' table is read again. +func startSeatList(bus seatlist.Bus) { + r, err := seatlist.Start(seatlist.Config{Bus: bus, Bootstrap: seatlist.Bootstrapped(), + OnChange: func([]catalogue.Seat) { + select { + case meshChanged <- struct{}{}: + default: + } + }}) + if err != nil { + say("following the seat list: %v", err) + } + seatListMu.Lock() + seatList = r + seatListMu.Unlock() +} + +var seatListMu sync.Mutex + +// definedSeats are the held definitions in reading order, mesh seats first, then node seats, each by name; empty until +// the reader has started. +func definedSeats() []seatdef.Definition { + seatListMu.Lock() + r := seatList + seatListMu.Unlock() + if r == nil { + return nil + } + held := r.Definitions() + var out []seatdef.Definition + for _, s := range r.Seats() { + if d, ok := held[s.Name]; ok { + out = append(out, d) + } + } + return out +} 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 76bab54b4..fdf0b29ef 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 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" + "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-module-assigner.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-module-assigner`, `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 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" + "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-module-assigner.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-module-assigner`, `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/go.mod b/modules/claude-code/go.mod index c15c01347..a1b7dbb87 100644 --- a/modules/claude-code/go.mod +++ b/modules/claude-code/go.mod @@ -5,6 +5,7 @@ go 1.26.0 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f require ( + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 gopkg.in/yaml.v3 v3.0.1 diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index 3ecf0dfab..25f69bc23 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -25,6 +25,7 @@ "invokes": [ "seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", + "seat:mesh-catalog.seat-definitions", "mesh-delivery-indexer.indexer_read_modules", "mesh-delivery-indexer.indexer_read_manifests", "mesh-delivery-assigner.assigner_read_placement" @@ -32,7 +33,9 @@ "consumes": [ "mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", - "mesh-delivery-assigner.views.placement" + "mesh-delivery-assigner.views.placement", + "mesh-catalog.seat-definition-changed", + "mesh-catalog.seat-definition-removed" ], "state": [ "servers", @@ -101,7 +104,9 @@ "claude_code_judge", "claude_code_judged", "claude_code_first_views", - "claude_code_first_view_again" + "claude_code_first_view_again", + "claude_code_seat_list", + "claude_code_seat_list_again" ], "data": { "own": [