package doctor import ( "fmt" "os" "path/filepath" "strings" "github.com/steveyegge/gastown/internal/beads" ) // AgentBeadsCheck verifies that agent beads exist for all agents. // This includes: // - Global agents (deacon, mayor) - stored in town beads with hq- prefix // - Per-rig agents (witness, refinery) - stored in each rig's beads // - Crew workers - stored in each rig's beads // // Agent beads are created by gt rig add (see gt-h3hak, gt-pinkq) and gt crew add. // Each rig uses its configured prefix (e.g., "gt-" for gastown, "bd-" for beads). type AgentBeadsCheck struct { FixableCheck } // NewAgentBeadsCheck creates a new agent beads check. func NewAgentBeadsCheck() *AgentBeadsCheck { return &AgentBeadsCheck{ FixableCheck: FixableCheck{ BaseCheck: BaseCheck{ CheckName: "agent-beads-exist", CheckDescription: "Verify agent beads exist for all agents", CheckCategory: CategoryRig, }, }, } } // rigInfo holds the rig name and its beads path from routes. type rigInfo struct { name string // rig name (first component of path) beadsPath string // full path to beads directory relative to town root } // Run checks if agent beads exist for all expected agents. func (c *AgentBeadsCheck) Run(ctx *CheckContext) *CheckResult { // Load routes to get prefixes (routes.jsonl is source of truth for prefixes) beadsDir := filepath.Join(ctx.TownRoot, ".beads") routes, err := beads.LoadRoutes(beadsDir) if err != nil { return &CheckResult{ Name: c.Name(), Status: StatusWarning, Message: "Could not load routes.jsonl", } } // Build prefix -> rigInfo map from routes // Routes have format: prefix "gt-" -> path "gastown/mayor/rig" or "my-saas" prefixToRig := make(map[string]rigInfo) // prefix (without hyphen) -> rigInfo for _, r := range routes { // Extract rig name from path (first component) parts := strings.Split(r.Path, "/") if len(parts) >= 1 && parts[0] != "." { rigName := parts[0] prefix := strings.TrimSuffix(r.Prefix, "-") prefixToRig[prefix] = rigInfo{ name: rigName, beadsPath: r.Path, // Use the full route path } } } var missing []string var checked int // Check global agents (Mayor, Deacon) in town beads // These use hq- prefix and are stored in ~/gt/.beads/ townBeadsPath := beads.GetTownBeadsPath(ctx.TownRoot) townBd := beads.New(townBeadsPath) deaconID := beads.DeaconBeadIDTown() mayorID := beads.MayorBeadIDTown() if _, err := townBd.Show(deaconID); err != nil { missing = append(missing, deaconID) } checked++ if _, err := townBd.Show(mayorID); err != nil { missing = append(missing, mayorID) } checked++ if len(prefixToRig) == 0 { // No rigs to check, but we still checked global agents if len(missing) == 0 { return &CheckResult{ Name: c.Name(), Status: StatusOK, Message: fmt.Sprintf("All %d agent beads exist", checked), } } return &CheckResult{ Name: c.Name(), Status: StatusError, Message: fmt.Sprintf("%d agent bead(s) missing", len(missing)), Details: missing, FixHint: "Run 'gt doctor --fix' to create missing agent beads", } } // Check each rig for its agents for prefix, info := range prefixToRig { // Get beads client for this rig using the route path directly rigBeadsPath := filepath.Join(ctx.TownRoot, info.beadsPath) bd := beads.New(rigBeadsPath) rigName := info.name // Check rig-specific agents (using canonical naming: prefix-rig-role-name) witnessID := beads.WitnessBeadIDWithPrefix(prefix, rigName) refineryID := beads.RefineryBeadIDWithPrefix(prefix, rigName) if _, err := bd.Show(witnessID); err != nil { missing = append(missing, witnessID) } checked++ if _, err := bd.Show(refineryID); err != nil { missing = append(missing, refineryID) } checked++ // Check crew worker agents crewWorkers := listCrewWorkers(ctx.TownRoot, rigName) for _, workerName := range crewWorkers { crewID := beads.CrewBeadIDWithPrefix(prefix, rigName, workerName) if _, err := bd.Show(crewID); err != nil { missing = append(missing, crewID) } checked++ } } if len(missing) == 0 { return &CheckResult{ Name: c.Name(), Status: StatusOK, Message: fmt.Sprintf("All %d agent beads exist", checked), } } return &CheckResult{ Name: c.Name(), Status: StatusError, Message: fmt.Sprintf("%d agent bead(s) missing", len(missing)), Details: missing, FixHint: "Run 'gt doctor --fix' to create missing agent beads", } } // Fix creates missing agent beads. func (c *AgentBeadsCheck) Fix(ctx *CheckContext) error { // Create global agents (Mayor, Deacon) in town beads // These use hq- prefix and are stored in ~/gt/.beads/ townBeadsPath := beads.GetTownBeadsPath(ctx.TownRoot) townBd := beads.New(townBeadsPath) deaconID := beads.DeaconBeadIDTown() if _, err := townBd.Show(deaconID); err != nil { fields := &beads.AgentFields{ RoleType: "deacon", Rig: "", AgentState: "idle", RoleBead: beads.DeaconRoleBeadIDTown(), } desc := "Deacon (daemon beacon) - receives mechanical heartbeats, runs town plugins and monitoring." if _, err := townBd.CreateAgentBead(deaconID, desc, fields); err != nil { return fmt.Errorf("creating %s: %w", deaconID, err) } } mayorID := beads.MayorBeadIDTown() if _, err := townBd.Show(mayorID); err != nil { fields := &beads.AgentFields{ RoleType: "mayor", Rig: "", AgentState: "idle", RoleBead: beads.MayorRoleBeadIDTown(), } desc := "Mayor - global coordinator, handles cross-rig communication and escalations." if _, err := townBd.CreateAgentBead(mayorID, desc, fields); err != nil { return fmt.Errorf("creating %s: %w", mayorID, err) } } // Load routes to get prefixes for rig-level agents beadsDir := filepath.Join(ctx.TownRoot, ".beads") routes, err := beads.LoadRoutes(beadsDir) if err != nil { return fmt.Errorf("loading routes.jsonl: %w", err) } // Build prefix -> rigInfo map from routes prefixToRig := make(map[string]rigInfo) for _, r := range routes { parts := strings.Split(r.Path, "/") if len(parts) >= 1 && parts[0] != "." { rigName := parts[0] prefix := strings.TrimSuffix(r.Prefix, "-") prefixToRig[prefix] = rigInfo{ name: rigName, beadsPath: r.Path, // Use the full route path } } } if len(prefixToRig) == 0 { return nil // No rigs to process } // Create missing agents for each rig for prefix, info := range prefixToRig { // Use the route path directly instead of hardcoding /mayor/rig rigBeadsPath := filepath.Join(ctx.TownRoot, info.beadsPath) bd := beads.New(rigBeadsPath) rigName := info.name // Create rig-specific agents if missing (using canonical naming: prefix-rig-role-name) witnessID := beads.WitnessBeadIDWithPrefix(prefix, rigName) if _, err := bd.Show(witnessID); err != nil { fields := &beads.AgentFields{ RoleType: "witness", Rig: rigName, AgentState: "idle", RoleBead: beads.RoleBeadIDTown("witness"), } desc := fmt.Sprintf("Witness for %s - monitors polecat health and progress.", rigName) if _, err := bd.CreateAgentBead(witnessID, desc, fields); err != nil { return fmt.Errorf("creating %s: %w", witnessID, err) } } refineryID := beads.RefineryBeadIDWithPrefix(prefix, rigName) if _, err := bd.Show(refineryID); err != nil { fields := &beads.AgentFields{ RoleType: "refinery", Rig: rigName, AgentState: "idle", RoleBead: beads.RoleBeadIDTown("refinery"), } desc := fmt.Sprintf("Refinery for %s - processes merge queue.", rigName) if _, err := bd.CreateAgentBead(refineryID, desc, fields); err != nil { return fmt.Errorf("creating %s: %w", refineryID, err) } } // Create crew worker agents if missing crewWorkers := listCrewWorkers(ctx.TownRoot, rigName) for _, workerName := range crewWorkers { crewID := beads.CrewBeadIDWithPrefix(prefix, rigName, workerName) if _, err := bd.Show(crewID); err != nil { fields := &beads.AgentFields{ RoleType: "crew", Rig: rigName, AgentState: "idle", RoleBead: beads.RoleBeadIDTown("crew"), } desc := fmt.Sprintf("Crew worker %s in %s - human-managed persistent workspace.", workerName, rigName) if _, err := bd.CreateAgentBead(crewID, desc, fields); err != nil { return fmt.Errorf("creating %s: %w", crewID, err) } } } } return nil } // listCrewWorkers returns the names of all crew workers in a rig. func listCrewWorkers(townRoot, rigName string) []string { crewDir := filepath.Join(townRoot, rigName, "crew") entries, err := os.ReadDir(crewDir) if err != nil { return nil // No crew directory or can't read it } var workers []string for _, entry := range entries { if entry.IsDir() && !strings.HasPrefix(entry.Name(), ".") { workers = append(workers, entry.Name()) } } return workers }