diff --git a/internal/beads/beads_role.go b/internal/beads/beads_role.go index 6a5bdbd9..14bcef6e 100644 --- a/internal/beads/beads_role.go +++ b/internal/beads/beads_role.go @@ -92,3 +92,54 @@ func HasLabel(issue *Issue, label string) bool { } return false } + +// RoleBeadDef defines a role bead's metadata. +// Used by gt install and gt doctor to create missing role beads. +type RoleBeadDef struct { + ID string // e.g., "hq-witness-role" + Title string // e.g., "Witness Role" + Desc string // Description of the role +} + +// AllRoleBeadDefs returns all role bead definitions. +// This is the single source of truth for role beads used by both +// gt install (initial creation) and gt doctor --fix (repair). +func AllRoleBeadDefs() []RoleBeadDef { + return []RoleBeadDef{ + { + ID: MayorRoleBeadIDTown(), + Title: "Mayor Role", + Desc: "Role definition for Mayor agents. Global coordinator for cross-rig work.", + }, + { + ID: DeaconRoleBeadIDTown(), + Title: "Deacon Role", + Desc: "Role definition for Deacon agents. Daemon beacon for heartbeats and monitoring.", + }, + { + ID: DogRoleBeadIDTown(), + Title: "Dog Role", + Desc: "Role definition for Dog agents. Town-level workers for cross-rig tasks.", + }, + { + ID: WitnessRoleBeadIDTown(), + Title: "Witness Role", + Desc: "Role definition for Witness agents. Per-rig worker monitor with progressive nudging.", + }, + { + ID: RefineryRoleBeadIDTown(), + Title: "Refinery Role", + Desc: "Role definition for Refinery agents. Merge queue processor with verification gates.", + }, + { + ID: PolecatRoleBeadIDTown(), + Title: "Polecat Role", + Desc: "Role definition for Polecat agents. Ephemeral workers for batch work dispatch.", + }, + { + ID: CrewRoleBeadIDTown(), + Title: "Crew Role", + Desc: "Role definition for Crew agents. Persistent user-managed workspaces.", + }, + } +} diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index a92e2497..07375382 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -153,6 +153,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { d.Register(doctor.NewPatrolRolesHavePromptsCheck()) d.Register(doctor.NewAgentBeadsCheck()) d.Register(doctor.NewRigBeadsCheck()) + d.Register(doctor.NewRoleBeadsCheck()) // NOTE: StaleAttachmentsCheck removed - staleness detection belongs in Deacon molecule diff --git a/internal/cmd/install.go b/internal/cmd/install.go index fe3fb595..5b2d6143 100644 --- a/internal/cmd/install.go +++ b/internal/cmd/install.go @@ -454,70 +454,28 @@ func initTownAgentBeads(townPath string) error { return err } - // Role beads (global templates) - roleDefs := []struct { - id string - title string - desc string - }{ - { - id: beads.MayorRoleBeadIDTown(), - title: "Mayor Role", - desc: "Role definition for Mayor agents. Global coordinator for cross-rig work.", - }, - { - id: beads.DeaconRoleBeadIDTown(), - title: "Deacon Role", - desc: "Role definition for Deacon agents. Daemon beacon for heartbeats and monitoring.", - }, - { - id: beads.DogRoleBeadIDTown(), - title: "Dog Role", - desc: "Role definition for Dog agents. Town-level workers for cross-rig tasks.", - }, - { - id: beads.WitnessRoleBeadIDTown(), - title: "Witness Role", - desc: "Role definition for Witness agents. Per-rig worker monitor with progressive nudging.", - }, - { - id: beads.RefineryRoleBeadIDTown(), - title: "Refinery Role", - desc: "Role definition for Refinery agents. Merge queue processor with verification gates.", - }, - { - id: beads.PolecatRoleBeadIDTown(), - title: "Polecat Role", - desc: "Role definition for Polecat agents. Ephemeral workers for batch work dispatch.", - }, - { - id: beads.CrewRoleBeadIDTown(), - title: "Crew Role", - desc: "Role definition for Crew agents. Persistent user-managed workspaces.", - }, - } - - for _, role := range roleDefs { + // Role beads (global templates) - use shared definitions from beads package + for _, role := range beads.AllRoleBeadDefs() { // Check if already exists - if _, err := bd.Show(role.id); err == nil { + if _, err := bd.Show(role.ID); err == nil { continue // Already exists } // Create role bead using the beads API // CreateWithID with Type: "role" automatically adds gt:role label - _, err := bd.CreateWithID(role.id, beads.CreateOptions{ - Title: role.title, + _, err := bd.CreateWithID(role.ID, beads.CreateOptions{ + Title: role.Title, Type: "role", - Description: role.desc, + Description: role.Desc, Priority: -1, // No priority }) if err != nil { // Log but continue - role beads are optional fmt.Printf(" %s Could not create role bead %s: %v\n", - style.Dim.Render("⚠"), role.id, err) + style.Dim.Render("⚠"), role.ID, err) continue } - fmt.Printf(" ✓ Created role bead: %s\n", role.id) + fmt.Printf(" ✓ Created role bead: %s\n", role.ID) } // Town-level agent beads diff --git a/internal/doctor/role_beads_check.go b/internal/doctor/role_beads_check.go new file mode 100644 index 00000000..af06b545 --- /dev/null +++ b/internal/doctor/role_beads_check.go @@ -0,0 +1,116 @@ +package doctor + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/steveyegge/gastown/internal/beads" +) + +// RoleBeadsCheck verifies that role definition beads exist. +// Role beads are templates that define role characteristics and lifecycle hooks. +// They are stored in town beads (~/.beads/) with hq- prefix: +// - hq-mayor-role, hq-deacon-role, hq-dog-role +// - hq-witness-role, hq-refinery-role, hq-polecat-role, hq-crew-role +// +// Role beads are created by gt install, but creation may fail silently. +// Without role beads, agents fall back to defaults which may differ from +// user expectations. +type RoleBeadsCheck struct { + FixableCheck + missing []string // Track missing role beads for fix +} + +// NewRoleBeadsCheck creates a new role beads check. +func NewRoleBeadsCheck() *RoleBeadsCheck { + return &RoleBeadsCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "role-beads-exist", + CheckDescription: "Verify role definition beads exist", + CheckCategory: CategoryConfig, + }, + }, + } +} + +// Run checks if role beads exist. +func (c *RoleBeadsCheck) Run(ctx *CheckContext) *CheckResult { + c.missing = nil // Reset + + townBeadsPath := beads.GetTownBeadsPath(ctx.TownRoot) + bd := beads.New(townBeadsPath) + + var missing []string + roleDefs := beads.AllRoleBeadDefs() + + for _, role := range roleDefs { + if _, err := bd.Show(role.ID); err != nil { + missing = append(missing, role.ID) + } + } + + c.missing = missing + + if len(missing) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: fmt.Sprintf("All %d role beads exist", len(roleDefs)), + Category: c.Category(), + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, // Warning, not error - agents work without role beads + Message: fmt.Sprintf("%d role bead(s) missing (agents will use defaults)", len(missing)), + Details: missing, + FixHint: "Run 'gt doctor --fix' to create missing role beads", + Category: c.Category(), + } +} + +// Fix creates missing role beads. +func (c *RoleBeadsCheck) Fix(ctx *CheckContext) error { + // Re-run check to populate missing if needed + if c.missing == nil { + result := c.Run(ctx) + if result.Status == StatusOK { + return nil // Nothing to fix + } + } + + if len(c.missing) == 0 { + return nil + } + + // Build lookup map for role definitions + roleDefMap := make(map[string]beads.RoleBeadDef) + for _, role := range beads.AllRoleBeadDefs() { + roleDefMap[role.ID] = role + } + + // Create missing role beads + for _, id := range c.missing { + role, ok := roleDefMap[id] + if !ok { + continue // Shouldn't happen + } + + // Create role bead using bd create --type=role + cmd := exec.Command("bd", "create", + "--type=role", + "--id="+role.ID, + "--title="+role.Title, + "--description="+role.Desc, + ) + cmd.Dir = ctx.TownRoot + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("creating %s: %s", role.ID, strings.TrimSpace(string(output))) + } + } + + return nil +} diff --git a/internal/doctor/role_beads_check_test.go b/internal/doctor/role_beads_check_test.go new file mode 100644 index 00000000..83dbde23 --- /dev/null +++ b/internal/doctor/role_beads_check_test.go @@ -0,0 +1,68 @@ +package doctor + +import ( + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/gastown/internal/beads" +) + +func TestRoleBeadsCheck_Run(t *testing.T) { + t.Run("no town beads returns warning", func(t *testing.T) { + tmpDir := t.TempDir() + // Create minimal town structure without .beads + if err := os.MkdirAll(filepath.Join(tmpDir, "mayor"), 0755); err != nil { + t.Fatal(err) + } + + check := NewRoleBeadsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + result := check.Run(ctx) + + // Without .beads directory, all role beads are "missing" + expectedCount := len(beads.AllRoleBeadDefs()) + if result.Status != StatusWarning { + t.Errorf("expected StatusWarning, got %v: %s", result.Status, result.Message) + } + if len(result.Details) != expectedCount { + t.Errorf("expected %d missing role beads, got %d: %v", expectedCount, len(result.Details), result.Details) + } + }) + + t.Run("check is fixable", func(t *testing.T) { + check := NewRoleBeadsCheck() + if !check.CanFix() { + t.Error("RoleBeadsCheck should be fixable") + } + }) +} + +func TestRoleBeadsCheck_usesSharedDefs(t *testing.T) { + // Verify the check uses beads.AllRoleBeadDefs() + roleDefs := beads.AllRoleBeadDefs() + + if len(roleDefs) < 7 { + t.Errorf("expected at least 7 role beads, got %d", len(roleDefs)) + } + + // Verify key roles are present + expectedIDs := map[string]bool{ + "hq-mayor-role": false, + "hq-deacon-role": false, + "hq-witness-role": false, + "hq-refinery-role": false, + } + + for _, role := range roleDefs { + if _, exists := expectedIDs[role.ID]; exists { + expectedIDs[role.ID] = true + } + } + + for id, found := range expectedIDs { + if !found { + t.Errorf("expected role %s not found in AllRoleBeadDefs()", id) + } + } +}