Files
gastown/internal/doctor/agent_beads_check.go
Steve Yegge 990d9820a0 feat: Create crew agent beads in doctor --fix and crew add
- doctor/agent_beads_check.go: Check and create agent beads for all crew workers
  - New listCrewWorkers() helper finds crew directories in each rig
  - Run() checks for missing crew agent beads
  - Fix() creates missing crew agent beads with proper fields

- cmd/crew_add.go: Create agent bead when adding a crew worker
  - Creates gt-crew-<rig>-<name> agent bead after workspace creation
  - Non-fatal if bead creation fails (warns but continues)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 10:07:01 -08:00

320 lines
9.2 KiB
Go

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 first rig's beads
// - 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.
//
// NOTE: Currently, the beads library validates that agent IDs must start
// with 'gt-'. Rigs with different prefixes (like 'bd-') cannot have agent
// beads created until that validation is fixed in the beads repo.
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",
},
},
}
}
// 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 -> rigName map from routes
// Routes have format: prefix "gt-" -> path "gastown/mayor/rig"
prefixToRig := make(map[string]string) // prefix (without hyphen) -> rigName
for _, r := range routes {
// Extract rig name from path like "gastown/mayor/rig"
parts := strings.Split(r.Path, "/")
if len(parts) >= 1 && parts[0] != "." {
rigName := parts[0]
prefix := strings.TrimSuffix(r.Prefix, "-")
prefixToRig[prefix] = rigName
}
}
if len(prefixToRig) == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No rigs with beads routes (agent beads created on rig add)",
}
}
var missing []string
var checked int
// Find the first rig (by name, alphabetically) for global agents
// Only consider gt-prefix rigs since other prefixes can't have agent beads yet
var firstRigName string
var firstPrefix string
for prefix, rigName := range prefixToRig {
if prefix != "gt" {
continue // Skip non-gt prefixes for first rig selection
}
if firstRigName == "" || rigName < firstRigName {
firstRigName = rigName
firstPrefix = prefix
}
}
// Check each rig for its agents
var skipped []string
for prefix, rigName := range prefixToRig {
// Skip non-gt prefixes - beads library currently requires gt- prefix for agents
// TODO: Remove this once beads validation is fixed to accept any prefix
if prefix != "gt" {
skipped = append(skipped, fmt.Sprintf("%s (%s-*)", rigName, prefix))
continue
}
// Get beads client for this rig
rigBeadsPath := filepath.Join(ctx.TownRoot, rigName, "mayor", "rig")
bd := beads.New(rigBeadsPath)
// Check rig-specific agents
witnessID := fmt.Sprintf("%s-witness-%s", prefix, rigName)
refineryID := fmt.Sprintf("%s-refinery-%s", 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 := fmt.Sprintf("%s-crew-%s-%s", prefix, rigName, workerName)
if _, err := bd.Show(crewID); err != nil {
missing = append(missing, crewID)
}
checked++
}
// Check global agents in first rig
if rigName == firstRigName {
deaconID := firstPrefix + "-deacon"
mayorID := firstPrefix + "-mayor"
if _, err := bd.Show(deaconID); err != nil {
missing = append(missing, deaconID)
}
checked++
if _, err := bd.Show(mayorID); err != nil {
missing = append(missing, mayorID)
}
checked++
}
}
if len(missing) == 0 {
msg := fmt.Sprintf("All %d agent beads exist", checked)
var details []string
if len(skipped) > 0 {
details = append(details, fmt.Sprintf("Skipped %d rig(s) with non-gt prefix (beads library limitation): %s",
len(skipped), strings.Join(skipped, ", ")))
}
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: msg,
Details: details,
}
}
details := missing
if len(skipped) > 0 {
details = append(details, fmt.Sprintf("Skipped %d rig(s) with non-gt prefix: %s",
len(skipped), strings.Join(skipped, ", ")))
}
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: fmt.Sprintf("%d agent bead(s) missing", len(missing)),
Details: details,
FixHint: "Run 'gt doctor --fix' to create missing agent beads",
}
}
// Fix creates missing agent beads.
func (c *AgentBeadsCheck) Fix(ctx *CheckContext) error {
// Load routes to get prefixes (same as Run)
beadsDir := filepath.Join(ctx.TownRoot, ".beads")
routes, err := beads.LoadRoutes(beadsDir)
if err != nil {
return fmt.Errorf("loading routes.jsonl: %w", err)
}
// Build prefix -> rigName map from routes
prefixToRig := make(map[string]string)
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] = rigName
}
}
if len(prefixToRig) == 0 {
return nil // Nothing to fix
}
// Find the first rig for global agents (only gt-prefix rigs)
var firstRigName string
var firstPrefix string
for prefix, rigName := range prefixToRig {
if prefix != "gt" {
continue
}
if firstRigName == "" || rigName < firstRigName {
firstRigName = rigName
firstPrefix = prefix
}
}
// Create missing agents for each rig
for prefix, rigName := range prefixToRig {
// Skip non-gt prefixes - beads library currently requires gt- prefix for agents
if prefix != "gt" {
continue
}
rigBeadsPath := filepath.Join(ctx.TownRoot, rigName, "mayor", "rig")
bd := beads.New(rigBeadsPath)
// Create rig-specific agents if missing
witnessID := fmt.Sprintf("%s-witness-%s", prefix, rigName)
if _, err := bd.Show(witnessID); err != nil {
fields := &beads.AgentFields{
RoleType: "witness",
Rig: rigName,
AgentState: "idle",
RoleBead: witnessID + "-role",
}
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 := fmt.Sprintf("%s-refinery-%s", prefix, rigName)
if _, err := bd.Show(refineryID); err != nil {
fields := &beads.AgentFields{
RoleType: "refinery",
Rig: rigName,
AgentState: "idle",
RoleBead: refineryID + "-role",
}
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 := fmt.Sprintf("%s-crew-%s-%s", prefix, rigName, workerName)
if _, err := bd.Show(crewID); err != nil {
fields := &beads.AgentFields{
RoleType: "crew",
Rig: rigName,
AgentState: "idle",
RoleBead: crewID + "-role",
}
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)
}
}
}
// Create global agents in first rig if missing
if rigName == firstRigName {
deaconID := firstPrefix + "-deacon"
if _, err := bd.Show(deaconID); err != nil {
fields := &beads.AgentFields{
RoleType: "deacon",
Rig: "",
AgentState: "idle",
RoleBead: deaconID + "-role",
}
desc := "Deacon (daemon beacon) - receives mechanical heartbeats, runs town plugins and monitoring."
if _, err := bd.CreateAgentBead(deaconID, desc, fields); err != nil {
return fmt.Errorf("creating %s: %w", deaconID, err)
}
}
mayorID := firstPrefix + "-mayor"
if _, err := bd.Show(mayorID); err != nil {
fields := &beads.AgentFields{
RoleType: "mayor",
Rig: "",
AgentState: "idle",
RoleBead: mayorID + "-role",
}
desc := "Mayor - global coordinator, handles cross-rig communication and escalations."
if _, err := bd.CreateAgentBead(mayorID, desc, fields); err != nil {
return fmt.Errorf("creating %s: %w", mayorID, 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
}