From b91dd43697917b396e4dab00ab8ded388f114bab Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Thu, 1 Jan 2026 18:07:46 -0800 Subject: [PATCH] fix: use rig prefixes for agent bead IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align rig-scoped agent beads with route prefixes so crew add/prime/status resolve the same IDs across rigs. Add tests that assert rig-prefixed agent IDs in prime and status. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/crew_add.go | 3 +- internal/cmd/prime.go | 16 +++--- internal/cmd/prime_test.go | 97 +++++++++++++++++++++++++++++++++++++ internal/cmd/status.go | 86 +++++++++++++++++++------------- internal/cmd/status_test.go | 97 +++++++++++++++++++++++++++++++++++++ 5 files changed, 257 insertions(+), 42 deletions(-) create mode 100644 internal/cmd/prime_test.go create mode 100644 internal/cmd/status_test.go diff --git a/internal/cmd/crew_add.go b/internal/cmd/crew_add.go index 754a7b59..7d5e88a8 100644 --- a/internal/cmd/crew_add.go +++ b/internal/cmd/crew_add.go @@ -100,7 +100,8 @@ func runCrewAdd(cmd *cobra.Command, args []string) error { fmt.Printf(" Branch: %s\n", worker.Branch) // Create agent bead for the crew worker - crewID := beads.CrewBeadID(rigName, name) + prefix := beads.GetPrefixForRig(townRoot, rigName) + crewID := beads.CrewBeadIDWithPrefix(prefix, rigName, name) if _, err := bd.Show(crewID); err != nil { // Agent bead doesn't exist, create it fields := &beads.AgentFields{ diff --git a/internal/cmd/prime.go b/internal/cmd/prime.go index 3a434587..fa6bea16 100644 --- a/internal/cmd/prime.go +++ b/internal/cmd/prime.go @@ -1195,9 +1195,7 @@ func getAgentFields(ctx RoleContext, state string) *beads.AgentFields { } // getAgentBeadID returns the agent bead ID for the current role. -// Uses canonical naming: gt-rig-role-name -// Agent beads always use "gt-" prefix (required by beads validation). -// Only issue beads use rig-specific prefixes. +// Rig-scoped agents use the rig's configured prefix; town agents remain gt-. // Returns empty string for unknown roles. func getAgentBeadID(ctx RoleContext) string { switch ctx.Role { @@ -1207,22 +1205,26 @@ func getAgentBeadID(ctx RoleContext) string { return beads.DeaconBeadID() case RoleWitness: if ctx.Rig != "" { - return beads.WitnessBeadID(ctx.Rig) + prefix := beads.GetPrefixForRig(ctx.TownRoot, ctx.Rig) + return beads.WitnessBeadIDWithPrefix(prefix, ctx.Rig) } return "" case RoleRefinery: if ctx.Rig != "" { - return beads.RefineryBeadID(ctx.Rig) + prefix := beads.GetPrefixForRig(ctx.TownRoot, ctx.Rig) + return beads.RefineryBeadIDWithPrefix(prefix, ctx.Rig) } return "" case RolePolecat: if ctx.Rig != "" && ctx.Polecat != "" { - return beads.PolecatBeadID(ctx.Rig, ctx.Polecat) + prefix := beads.GetPrefixForRig(ctx.TownRoot, ctx.Rig) + return beads.PolecatBeadIDWithPrefix(prefix, ctx.Rig, ctx.Polecat) } return "" case RoleCrew: if ctx.Rig != "" && ctx.Polecat != "" { - return beads.CrewBeadID(ctx.Rig, ctx.Polecat) + prefix := beads.GetPrefixForRig(ctx.TownRoot, ctx.Rig) + return beads.CrewBeadIDWithPrefix(prefix, ctx.Rig, ctx.Polecat) } return "" default: diff --git a/internal/cmd/prime_test.go b/internal/cmd/prime_test.go new file mode 100644 index 00000000..6560b189 --- /dev/null +++ b/internal/cmd/prime_test.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/gastown/internal/beads" +) + +func writeTestRoutes(t *testing.T, townRoot string, routes []beads.Route) { + t.Helper() + beadsDir := filepath.Join(townRoot, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("create beads dir: %v", err) + } + if err := beads.WriteRoutes(beadsDir, routes); err != nil { + t.Fatalf("write routes: %v", err) + } +} + +func TestGetAgentBeadID_UsesRigPrefix(t *testing.T) { + townRoot := t.TempDir() + writeTestRoutes(t, townRoot, []beads.Route{ + {Prefix: "bd-", Path: "beads/mayor/rig"}, + }) + + cases := []struct { + name string + ctx RoleContext + want string + }{ + { + name: "mayor", + ctx: RoleContext{ + Role: RoleMayor, + TownRoot: townRoot, + }, + want: "gt-mayor", + }, + { + name: "deacon", + ctx: RoleContext{ + Role: RoleDeacon, + TownRoot: townRoot, + }, + want: "gt-deacon", + }, + { + name: "witness", + ctx: RoleContext{ + Role: RoleWitness, + Rig: "beads", + TownRoot: townRoot, + }, + want: "bd-beads-witness", + }, + { + name: "refinery", + ctx: RoleContext{ + Role: RoleRefinery, + Rig: "beads", + TownRoot: townRoot, + }, + want: "bd-beads-refinery", + }, + { + name: "polecat", + ctx: RoleContext{ + Role: RolePolecat, + Rig: "beads", + Polecat: "lex", + TownRoot: townRoot, + }, + want: "bd-beads-polecat-lex", + }, + { + name: "crew", + ctx: RoleContext{ + Role: RoleCrew, + Rig: "beads", + Polecat: "lex", + TownRoot: townRoot, + }, + want: "bd-beads-crew-lex", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := getAgentBeadID(tc.ctx) + if got != tc.want { + t.Fatalf("getAgentBeadID() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/cmd/status.go b/internal/cmd/status.go index 3d12376a..a096d33f 100644 --- a/internal/cmd/status.go +++ b/internal/cmd/status.go @@ -163,27 +163,42 @@ func runStatus(cmd *cobra.Command, args []string) error { return fmt.Errorf("discovering rigs: %w", err) } - // Create beads instance for agent bead lookups (gastown rig holds gt- prefix beads) - gastownBeadsPath := filepath.Join(townRoot, "gastown", "mayor", "rig") - agentBeads := beads.New(gastownBeadsPath) - - // Pre-fetch all agent beads in a single query for performance - allAgentBeads, _ := agentBeads.ListAgentBeads() - if allAgentBeads == nil { - allAgentBeads = make(map[string]*beads.Issue) - } - - // Pre-fetch all hook beads (referenced in agent beads) in a single query - // Use the HookBead field from the database column, not parsed from description. - var allHookIDs []string - for _, issue := range allAgentBeads { - if issue.HookBead != "" { - allHookIDs = append(allHookIDs, issue.HookBead) + // Pre-fetch agent beads across all rig-specific beads DBs. + allAgentBeads := make(map[string]*beads.Issue) + allHookBeads := make(map[string]*beads.Issue) + for _, r := range rigs { + rigBeadsPath := filepath.Join(r.Path, "mayor", "rig") + rigBeads := beads.New(rigBeadsPath) + rigAgentBeads, _ := rigBeads.ListAgentBeads() + if rigAgentBeads == nil { + continue + } + for id, issue := range rigAgentBeads { + allAgentBeads[id] = issue + } + + var hookIDs []string + for _, issue := range rigAgentBeads { + // Use the HookBead field from the database column; fall back for legacy beads. + hookID := issue.HookBead + if hookID == "" { + fields := beads.ParseAgentFields(issue.Description) + if fields != nil { + hookID = fields.HookBead + } + } + if hookID != "" { + hookIDs = append(hookIDs, hookID) + } + } + + if len(hookIDs) == 0 { + continue + } + hookBeads, _ := rigBeads.ShowMultiple(hookIDs) + for id, issue := range hookBeads { + allHookBeads[id] = issue } - } - allHookBeads, _ := agentBeads.ShowMultiple(allHookIDs) - if allHookBeads == nil { - allHookBeads = make(map[string]*beads.Issue) } // Create mail router for inbox lookups @@ -336,7 +351,7 @@ func outputStatusText(status TownStatus) error { icon = roleIcons[agent.Name] } fmt.Printf("%s %s\n", icon, style.Bold.Render(capitalizeFirst(agent.Name))) - renderAgentDetails(agent, " ", nil) + renderAgentDetails(agent, " ", nil, status.Location) fmt.Println() } @@ -369,7 +384,7 @@ func outputStatusText(status TownStatus) error { if len(witnesses) > 0 { fmt.Printf("%s %s\n", roleIcons["witness"], style.Bold.Render("Witness")) for _, agent := range witnesses { - renderAgentDetails(agent, " ", r.Hooks) + renderAgentDetails(agent, " ", r.Hooks, status.Location) } fmt.Println() } @@ -378,7 +393,7 @@ func outputStatusText(status TownStatus) error { if len(refineries) > 0 { fmt.Printf("%s %s\n", roleIcons["refinery"], style.Bold.Render("Refinery")) for _, agent := range refineries { - renderAgentDetails(agent, " ", r.Hooks) + renderAgentDetails(agent, " ", r.Hooks, status.Location) } // MQ summary (shown under refinery) if r.MQ != nil { @@ -416,7 +431,7 @@ func outputStatusText(status TownStatus) error { if len(crews) > 0 { fmt.Printf("%s %s (%d)\n", roleIcons["crew"], style.Bold.Render("Crew"), len(crews)) for _, agent := range crews { - renderAgentDetails(agent, " ", r.Hooks) + renderAgentDetails(agent, " ", r.Hooks, status.Location) } fmt.Println() } @@ -425,7 +440,7 @@ func outputStatusText(status TownStatus) error { if len(polecats) > 0 { fmt.Printf("%s %s (%d)\n", roleIcons["polecat"], style.Bold.Render("Polecats"), len(polecats)) for _, agent := range polecats { - renderAgentDetails(agent, " ", r.Hooks) + renderAgentDetails(agent, " ", r.Hooks, status.Location) } fmt.Println() } @@ -440,7 +455,7 @@ func outputStatusText(status TownStatus) error { } // renderAgentDetails renders full agent bead details -func renderAgentDetails(agent AgentRuntime, indent string, hooks []AgentHookInfo) { +func renderAgentDetails(agent AgentRuntime, indent string, hooks []AgentHookInfo, townRoot string) { // Line 1: Agent bead ID + status statusStr := style.Success.Render("running") if !agent.Running { @@ -463,15 +478,16 @@ func renderAgentDetails(agent AgentRuntime, indent string, hooks []AgentHookInfo agentBeadID = beads.AgentBeadID("", parts[0], "") } else if len(parts) >= 2 { rig := parts[0] + prefix := beads.GetPrefixForRig(townRoot, rig) if parts[1] == "crew" && len(parts) >= 3 { - agentBeadID = beads.CrewBeadID(rig, parts[2]) + agentBeadID = beads.CrewBeadIDWithPrefix(prefix, rig, parts[2]) } else if parts[1] == "witness" { - agentBeadID = beads.WitnessBeadID(rig) + agentBeadID = beads.WitnessBeadIDWithPrefix(prefix, rig) } else if parts[1] == "refinery" { - agentBeadID = beads.RefineryBeadID(rig) + agentBeadID = beads.RefineryBeadIDWithPrefix(prefix, rig) } else if len(parts) == 2 { // polecat: rig/name - agentBeadID = beads.PolecatBeadID(rig, parts[1]) + agentBeadID = beads.PolecatBeadIDWithPrefix(prefix, rig, parts[1]) } } } @@ -695,6 +711,8 @@ type agentDef struct { func discoverRigAgents(allSessions map[string]bool, r *rig.Rig, crews []string, allAgentBeads map[string]*beads.Issue, allHookBeads map[string]*beads.Issue, mailRouter *mail.Router, skipMail bool) []AgentRuntime { // Build list of all agents to discover var defs []agentDef + townRoot := filepath.Dir(r.Path) + prefix := beads.GetPrefixForRig(townRoot, r.Name) // Witness if r.HasWitness { @@ -703,7 +721,7 @@ func discoverRigAgents(allSessions map[string]bool, r *rig.Rig, crews []string, address: r.Name + "/witness", session: witnessSessionName(r.Name), role: "witness", - beadID: beads.WitnessBeadID(r.Name), + beadID: beads.WitnessBeadIDWithPrefix(prefix, r.Name), }) } @@ -714,7 +732,7 @@ func discoverRigAgents(allSessions map[string]bool, r *rig.Rig, crews []string, address: r.Name + "/refinery", session: fmt.Sprintf("gt-%s-refinery", r.Name), role: "refinery", - beadID: beads.RefineryBeadID(r.Name), + beadID: beads.RefineryBeadIDWithPrefix(prefix, r.Name), }) } @@ -725,7 +743,7 @@ func discoverRigAgents(allSessions map[string]bool, r *rig.Rig, crews []string, address: r.Name + "/" + name, session: fmt.Sprintf("gt-%s-%s", r.Name, name), role: "polecat", - beadID: beads.PolecatBeadID(r.Name, name), + beadID: beads.PolecatBeadIDWithPrefix(prefix, r.Name, name), }) } @@ -736,7 +754,7 @@ func discoverRigAgents(allSessions map[string]bool, r *rig.Rig, crews []string, address: r.Name + "/crew/" + name, session: crewSessionName(r.Name, name), role: "crew", - beadID: beads.CrewBeadID(r.Name, name), + beadID: beads.CrewBeadIDWithPrefix(prefix, r.Name, name), }) } diff --git a/internal/cmd/status_test.go b/internal/cmd/status_test.go new file mode 100644 index 00000000..11ef2b7f --- /dev/null +++ b/internal/cmd/status_test.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/rig" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("create pipe: %v", err) + } + os.Stdout = w + + fn() + + _ = w.Close() + os.Stdout = old + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("read stdout: %v", err) + } + _ = r.Close() + + return buf.String() +} + +func TestDiscoverRigAgents_UsesRigPrefix(t *testing.T) { + townRoot := t.TempDir() + writeTestRoutes(t, townRoot, []beads.Route{ + {Prefix: "bd-", Path: "beads/mayor/rig"}, + }) + + r := &rig.Rig{ + Name: "beads", + Path: filepath.Join(townRoot, "beads"), + HasWitness: true, + } + + allAgentBeads := map[string]*beads.Issue{ + "bd-beads-witness": { + ID: "bd-beads-witness", + AgentState: "running", + HookBead: "bd-hook", + }, + } + allHookBeads := map[string]*beads.Issue{ + "bd-hook": {ID: "bd-hook", Title: "Pinned"}, + } + + agents := discoverRigAgents(map[string]bool{}, r, nil, allAgentBeads, allHookBeads, nil, true) + if len(agents) != 1 { + t.Fatalf("discoverRigAgents() returned %d agents, want 1", len(agents)) + } + + if agents[0].State != "running" { + t.Fatalf("agent state = %q, want %q", agents[0].State, "running") + } + if !agents[0].HasWork { + t.Fatalf("agent HasWork = false, want true") + } + if agents[0].WorkTitle != "Pinned" { + t.Fatalf("agent WorkTitle = %q, want %q", agents[0].WorkTitle, "Pinned") + } +} + +func TestRenderAgentDetails_UsesRigPrefix(t *testing.T) { + townRoot := t.TempDir() + writeTestRoutes(t, townRoot, []beads.Route{ + {Prefix: "bd-", Path: "beads/mayor/rig"}, + }) + + agent := AgentRuntime{ + Name: "witness", + Address: "beads/witness", + Role: "witness", + Running: true, + } + + output := captureStdout(t, func() { + renderAgentDetails(agent, "", nil, townRoot) + }) + + if !strings.Contains(output, "bd-beads-witness") { + t.Fatalf("output %q does not contain rig-prefixed bead ID", output) + } +}