From a6102830785430065d79c908563a691fb14da44c Mon Sep 17 00:00:00 2001 From: gastown/crew/max Date: Tue, 20 Jan 2026 12:49:52 -0800 Subject: [PATCH] feat(roles): switch daemon to config-based roles, remove role beads (Phase 2+3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2: Daemon now uses config.LoadRoleDefinition() instead of role beads - lifecycle.go: getRoleConfigForIdentity() reads from TOML configs - Layered override resolution: builtin → town → rig Phase 3: Remove role bead creation and references - Remove RoleBead field from AgentFields struct - gt install no longer creates role beads - Remove 'role' from custom types list - Delete migrate_agents.go (no longer needed) - Deprecate beads_role.go (kept for reading existing beads) - Rewrite role_beads_check.go to validate TOML configs Existing role beads are orphaned but harmless. Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 9 - internal/beads/beads.go | 2 +- internal/beads/beads_agent.go | 27 +- internal/beads/beads_role.go | 31 +- internal/beads/beads_test.go | 6 - internal/cmd/crew_add.go | 1 - internal/cmd/install.go | 48 +-- internal/cmd/migrate_agents.go | 325 ------------------ internal/cmd/migrate_agents_test.go | 87 ----- internal/daemon/lifecycle.go | 68 ++-- .../daemon/role_config_integration_test.go | 141 +++++--- internal/doctor/agent_beads_check.go | 5 - internal/doctor/role_beads_check.go | 167 +++++---- internal/doctor/role_beads_check_test.go | 137 +++++--- internal/polecat/manager.go | 2 - internal/rig/manager.go | 4 +- 16 files changed, 347 insertions(+), 713 deletions(-) delete mode 100644 internal/cmd/migrate_agents.go delete mode 100644 internal/cmd/migrate_agents_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 05df8245..4a862fbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,19 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.4.0] - 2026-01-17 - ### Fixed - **Orphan cleanup skips valid tmux sessions** - `gt orphans kill` and automatic orphan cleanup now check for Claude processes belonging to valid Gas Town tmux sessions (gt-*/hq-*) before killing. This prevents false kills of witnesses, refineries, and deacon during startup when they may temporarily show TTY "?" -## [0.3.1] - 2026-01-17 - -### Fixed - -- **Orphan cleanup on macOS** - Fixed TTY comparison (`??` vs `?`) so orphan detection works on macOS -- **Session kill leaves orphans** - `gt done` and `gt crew stop` now use `KillSessionWithProcesses` to properly terminate all child processes before killing the tmux session - ## [0.3.0] - 2026-01-17 ### Added diff --git a/internal/beads/beads.go b/internal/beads/beads.go index 70f90c1d..d1adbd4a 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -44,8 +44,8 @@ type Issue struct { // Agent bead slots (type=agent only) HookBead string `json:"hook_bead,omitempty"` // Current work attached to agent's hook - RoleBead string `json:"role_bead,omitempty"` // Role definition bead (shared) AgentState string `json:"agent_state,omitempty"` // Agent lifecycle state (spawning, working, done, stuck) + // Note: role_bead field removed - role definitions are now config-based // Counts from list output DependencyCount int `json:"dependency_count,omitempty"` diff --git a/internal/beads/beads_agent.go b/internal/beads/beads_agent.go index 6334f93d..3374a246 100644 --- a/internal/beads/beads_agent.go +++ b/internal/beads/beads_agent.go @@ -15,10 +15,11 @@ type AgentFields struct { Rig string // Rig name (empty for global agents like mayor/deacon) AgentState string // spawning, working, done, stuck HookBead string // Currently pinned work bead ID - RoleBead string // Role definition bead ID (canonical location; may not exist yet) CleanupStatus string // ZFC: polecat self-reports git state (clean, has_uncommitted, has_stash, has_unpushed) ActiveMR string // Currently active merge request bead ID (for traceability) NotificationLevel string // DND mode: verbose, normal, muted (default: normal) + // Note: RoleBead field removed - role definitions are now config-based. + // See internal/config/roles/*.toml and config-based-roles.md. } // Notification level constants @@ -53,11 +54,7 @@ func FormatAgentDescription(title string, fields *AgentFields) string { lines = append(lines, "hook_bead: null") } - if fields.RoleBead != "" { - lines = append(lines, fmt.Sprintf("role_bead: %s", fields.RoleBead)) - } else { - lines = append(lines, "role_bead: null") - } + // Note: role_bead field no longer written - role definitions are config-based if fields.CleanupStatus != "" { lines = append(lines, fmt.Sprintf("cleanup_status: %s", fields.CleanupStatus)) @@ -111,7 +108,7 @@ func ParseAgentFields(description string) *AgentFields { case "hook_bead": fields.HookBead = value case "role_bead": - fields.RoleBead = value + // Ignored - role definitions are now config-based (backward compat) case "cleanup_status": fields.CleanupStatus = value case "active_mr": @@ -158,13 +155,7 @@ func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue, return nil, fmt.Errorf("parsing bd create output: %w", err) } - // Set the role slot if specified (this is the authoritative storage) - if fields != nil && fields.RoleBead != "" { - if _, err := b.run("slot", "set", id, "role", fields.RoleBead); err != nil { - // Non-fatal: warn but continue - fmt.Printf("Warning: could not set role slot: %v\n", err) - } - } + // Note: role slot no longer set - role definitions are config-based // Set the hook slot if specified (this is the authoritative storage) // This fixes the slot inconsistency bug where bead status is 'hooked' but @@ -223,13 +214,7 @@ func (b *Beads) CreateOrReopenAgentBead(id, title string, fields *AgentFields) ( return nil, fmt.Errorf("updating reopened agent bead: %w", err) } - // Set the role slot if specified - if fields != nil && fields.RoleBead != "" { - if _, err := b.run("slot", "set", id, "role", fields.RoleBead); err != nil { - // Non-fatal: warn but continue - fmt.Printf("Warning: could not set role slot: %v\n", err) - } - } + // Note: role slot no longer set - role definitions are config-based // Clear any existing hook slot (handles stale state from previous lifecycle) _, _ = b.run("slot", "clear", id, "hook") diff --git a/internal/beads/beads_role.go b/internal/beads/beads_role.go index 14bcef6e..0bd18e79 100644 --- a/internal/beads/beads_role.go +++ b/internal/beads/beads_role.go @@ -1,4 +1,11 @@ // Package beads provides role bead management. +// +// DEPRECATED: Role beads are deprecated. Role definitions are now config-based. +// See internal/config/roles/*.toml and config-based-roles.md for the new system. +// +// This file is kept for backward compatibility with existing role beads but +// new code should use config.LoadRoleDefinition() instead of reading role beads. +// The daemon no longer uses role beads as of Phase 2 (config-based roles). package beads import ( @@ -6,10 +13,12 @@ import ( "fmt" ) -// Role bead ID naming convention: -// Role beads are stored in town beads (~/.beads/) with hq- prefix. +// DEPRECATED: Role bead ID naming convention is no longer used. +// Role definitions are now config-based (internal/config/roles/*.toml). // -// Canonical format: hq--role +// Role beads were stored in town beads (~/.beads/) with hq- prefix. +// +// Canonical format was: hq--role // // Examples: // - hq-mayor-role @@ -19,8 +28,8 @@ import ( // - hq-crew-role // - hq-polecat-role // -// Use RoleBeadIDTown() to get canonical role bead IDs. -// The legacy RoleBeadID() function returns gt--role for backward compatibility. +// Legacy functions RoleBeadID() and RoleBeadIDTown() still work for +// backward compatibility but should not be used in new code. // RoleBeadID returns the role bead ID for a given role type. // Role beads define lifecycle configuration for each agent type. @@ -67,6 +76,9 @@ func PolecatRoleBeadID() string { // GetRoleConfig looks up a role bead and returns its parsed RoleConfig. // Returns nil, nil if the role bead doesn't exist or has no config. +// +// Deprecated: Use config.LoadRoleDefinition() instead. Role definitions +// are now config-based, not stored as beads. func (b *Beads) GetRoleConfig(roleBeadID string) (*RoleConfig, error) { issue, err := b.Show(roleBeadID) if err != nil { @@ -94,7 +106,9 @@ func HasLabel(issue *Issue, label string) bool { } // RoleBeadDef defines a role bead's metadata. -// Used by gt install and gt doctor to create missing role beads. +// +// Deprecated: Role beads are no longer created. Role definitions are +// now config-based (internal/config/roles/*.toml). type RoleBeadDef struct { ID string // e.g., "hq-witness-role" Title string // e.g., "Witness Role" @@ -102,8 +116,9 @@ type RoleBeadDef struct { } // 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). +// +// Deprecated: Role beads are no longer created by gt install or gt doctor. +// This function is kept for backward compatibility only. func AllRoleBeadDefs() []RoleBeadDef { return []RoleBeadDef{ { diff --git a/internal/beads/beads_test.go b/internal/beads/beads_test.go index eeb907df..103b68e4 100644 --- a/internal/beads/beads_test.go +++ b/internal/beads/beads_test.go @@ -1972,7 +1972,6 @@ func TestCreateOrReopenAgentBead_ClosedBead(t *testing.T) { Rig: "testrig", AgentState: "spawning", HookBead: "test-task-1", - RoleBead: "test-polecat-role", }) if err != nil { t.Fatalf("Spawn 1 - CreateOrReopenAgentBead: %v", err) @@ -1993,7 +1992,6 @@ func TestCreateOrReopenAgentBead_ClosedBead(t *testing.T) { Rig: "testrig", AgentState: "spawning", HookBead: "test-task-2", // Different task - RoleBead: "test-polecat-role", }) if err != nil { t.Fatalf("Spawn 2 - CreateOrReopenAgentBead: %v", err) @@ -2020,7 +2018,6 @@ func TestCreateOrReopenAgentBead_ClosedBead(t *testing.T) { Rig: "testrig", AgentState: "spawning", HookBead: "test-task-3", - RoleBead: "test-polecat-role", }) if err != nil { t.Fatalf("Spawn 3 - CreateOrReopenAgentBead: %v", err) @@ -2059,7 +2056,6 @@ func TestCloseAndClearAgentBead_FieldClearing(t *testing.T) { Rig: "testrig", AgentState: "running", HookBead: "test-issue-123", - RoleBead: "test-polecat-role", CleanupStatus: "clean", ActiveMR: "test-mr-456", NotificationLevel: "normal", @@ -2279,7 +2275,6 @@ func TestCloseAndClearAgentBead_ReopenHasCleanState(t *testing.T) { Rig: "testrig", AgentState: "running", HookBead: "test-old-issue", - RoleBead: "test-polecat-role", CleanupStatus: "clean", ActiveMR: "test-old-mr", NotificationLevel: "normal", @@ -2300,7 +2295,6 @@ func TestCloseAndClearAgentBead_ReopenHasCleanState(t *testing.T) { Rig: "testrig", AgentState: "spawning", HookBead: "test-new-issue", - RoleBead: "test-polecat-role", }) if err != nil { t.Fatalf("CreateOrReopenAgentBead: %v", err) diff --git a/internal/cmd/crew_add.go b/internal/cmd/crew_add.go index a24b3444..b772872d 100644 --- a/internal/cmd/crew_add.go +++ b/internal/cmd/crew_add.go @@ -106,7 +106,6 @@ func runCrewAdd(cmd *cobra.Command, args []string) error { RoleType: "crew", Rig: rigName, AgentState: "idle", - RoleBead: beads.RoleBeadIDTown("crew"), } desc := fmt.Sprintf("Crew worker %s in %s - human-managed persistent workspace.", name, rigName) if _, err := bd.CreateAgentBead(crewID, desc, fields); err != nil { diff --git a/internal/cmd/install.go b/internal/cmd/install.go index cf0e68da..c4a403d2 100644 --- a/internal/cmd/install.go +++ b/internal/cmd/install.go @@ -248,7 +248,7 @@ func runInstall(cmd *cobra.Command, args []string) error { } } - // Create town-level agent beads (Mayor, Deacon) and role beads. + // Create town-level agent beads (Mayor, Deacon). // These use hq- prefix and are stored in town beads for cross-rig coordination. if err := initTownAgentBeads(absPath); err != nil { fmt.Printf(" %s Could not create town-level agent beads: %v\n", style.Dim.Render("⚠"), err) @@ -448,58 +448,30 @@ func ensureCustomTypes(beadsPath string) error { return nil } -// initTownAgentBeads creates town-level agent and role beads using hq- prefix. +// initTownAgentBeads creates town-level agent beads using hq- prefix. // This creates: // - hq-mayor, hq-deacon (agent beads for town-level agents) -// - hq-mayor-role, hq-deacon-role, hq-witness-role, hq-refinery-role, -// hq-polecat-role, hq-crew-role (role definition beads) // // These beads are stored in town beads (~/gt/.beads/) and are shared across all rigs. // Rig-level agent beads (witness, refinery) are created by gt rig add in rig beads. // -// ERROR HANDLING ASYMMETRY: -// Agent beads (Mayor, Deacon) use hard fail - installation aborts if creation fails. -// Role beads use soft fail - logs warning and continues if creation fails. +// Note: Role definitions are now config-based (internal/config/roles/*.toml), +// not stored as beads. See config-based-roles.md for details. // -// Rationale: Agent beads are identity beads that track agent state, hooks, and +// Agent beads use hard fail - installation aborts if creation fails. +// Agent beads are identity beads that track agent state, hooks, and // form the foundation of the CV/reputation ledger. Without them, agents cannot -// be properly tracked or coordinated. Role beads are documentation templates -// that define role characteristics but are not required for agent operation - -// agents can function without their role bead existing. +// be properly tracked or coordinated. func initTownAgentBeads(townPath string) error { bd := beads.New(townPath) // bd init doesn't enable "custom" issue types by default, but Gas Town uses - // agent/role beads during install and runtime. Ensure these types are enabled + // agent beads during install and runtime. Ensure these types are enabled // before attempting to create any town-level system beads. - if err := ensureBeadsCustomTypes(townPath, []string{"agent", "role", "rig", "convoy", "slot"}); err != nil { + if err := ensureBeadsCustomTypes(townPath, []string{"agent", "rig", "convoy", "slot"}); err != nil { return err } - // 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 { - 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, - Type: "role", - 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) - continue - } - fmt.Printf(" ✓ Created role bead: %s\n", role.ID) - } - // Town-level agent beads agentDefs := []struct { id string @@ -541,7 +513,7 @@ func initTownAgentBeads(townPath string) error { Rig: "", // Town-level agents have no rig AgentState: "idle", HookBead: "", - RoleBead: beads.RoleBeadIDTown(agent.roleType), + // Note: RoleBead field removed - role definitions are now config-based } if _, err := bd.CreateAgentBead(agent.id, agent.title, fields); err != nil { diff --git a/internal/cmd/migrate_agents.go b/internal/cmd/migrate_agents.go deleted file mode 100644 index 742326c8..00000000 --- a/internal/cmd/migrate_agents.go +++ /dev/null @@ -1,325 +0,0 @@ -package cmd - -import ( - "fmt" - "path/filepath" - "strings" - - "github.com/spf13/cobra" - "github.com/steveyegge/gastown/internal/beads" - "github.com/steveyegge/gastown/internal/workspace" -) - -var ( - migrateAgentsDryRun bool - migrateAgentsForce bool -) - -var migrateAgentsCmd = &cobra.Command{ - Use: "migrate-agents", - GroupID: GroupDiag, - Short: "Migrate agent beads to two-level architecture", - Long: `Migrate agent beads from the old single-tier to the two-level architecture. - -This command migrates town-level agent beads (Mayor, Deacon) from rig beads -with gt-* prefix to town beads with hq-* prefix: - - OLD (rig beads): gt-mayor, gt-deacon - NEW (town beads): hq-mayor, hq-deacon - -Rig-level agents (Witness, Refinery, Polecats) remain in rig beads unchanged. - -The migration: -1. Detects old gt-mayor/gt-deacon beads in rig beads -2. Creates new hq-mayor/hq-deacon beads in town beads -3. Copies agent state (hook_bead, agent_state, etc.) -4. Adds migration note to old beads (preserves them) - -Safety: -- Dry-run mode by default (use --execute to apply changes) -- Old beads are preserved with migration notes -- Validates new beads exist before marking migration complete -- Skips if new beads already exist (idempotent) - -Examples: - gt migrate-agents # Dry-run: show what would be migrated - gt migrate-agents --execute # Apply the migration - gt migrate-agents --force # Re-migrate even if new beads exist`, - RunE: runMigrateAgents, -} - -func init() { - migrateAgentsCmd.Flags().BoolVar(&migrateAgentsDryRun, "dry-run", true, "Show what would be migrated without making changes (default)") - migrateAgentsCmd.Flags().BoolVar(&migrateAgentsForce, "force", false, "Re-migrate even if new beads already exist") - // Add --execute as inverse of --dry-run for clarity - migrateAgentsCmd.Flags().BoolP("execute", "x", false, "Actually apply the migration (opposite of --dry-run)") - rootCmd.AddCommand(migrateAgentsCmd) -} - -// migrationResult holds the result of a single bead migration. -type migrationResult struct { - OldID string - NewID string - Status string // "migrated", "skipped", "error" - Message string - OldFields *beads.AgentFields - WasDryRun bool -} - -func runMigrateAgents(cmd *cobra.Command, args []string) error { - // Handle --execute flag - if execute, _ := cmd.Flags().GetBool("execute"); execute { - migrateAgentsDryRun = false - } - - // Find town root - townRoot, err := workspace.FindFromCwdOrError() - if err != nil { - return fmt.Errorf("not in a Gas Town workspace: %w", err) - } - - // Get town beads path - townBeadsDir := filepath.Join(townRoot, ".beads") - - // Load routes to find rig beads - routes, err := beads.LoadRoutes(townBeadsDir) - if err != nil { - return fmt.Errorf("loading routes.jsonl: %w", err) - } - - // Find the first rig with gt- prefix (where global agents are currently stored) - var sourceRigPath string - for _, r := range routes { - if strings.TrimSuffix(r.Prefix, "-") == "gt" && r.Path != "." { - sourceRigPath = r.Path - break - } - } - - if sourceRigPath == "" { - fmt.Println("No rig with gt- prefix found. Nothing to migrate.") - return nil - } - - // Source beads (rig beads where old agent beads are) - sourceBeadsDir := filepath.Join(townRoot, sourceRigPath, ".beads") - sourceBd := beads.New(sourceBeadsDir) - - // Target beads (town beads where new agent beads should go) - targetBd := beads.NewWithBeadsDir(townRoot, townBeadsDir) - - // Agents to migrate: town-level agents only - agentsToMigrate := []struct { - oldID string - newID string - desc string - }{ - { - oldID: beads.MayorBeadID(), // gt-mayor - newID: beads.MayorBeadIDTown(), // hq-mayor - desc: "Mayor - global coordinator, handles cross-rig communication and escalations.", - }, - { - oldID: beads.DeaconBeadID(), // gt-deacon - newID: beads.DeaconBeadIDTown(), // hq-deacon - desc: "Deacon (daemon beacon) - receives mechanical heartbeats, runs town plugins and monitoring.", - }, - } - - // Also migrate role beads - rolesToMigrate := []string{"mayor", "deacon", "witness", "refinery", "polecat", "crew", "dog"} - - if migrateAgentsDryRun { - fmt.Println("🔍 DRY RUN: Showing what would be migrated") - fmt.Println(" Use --execute to apply changes") - fmt.Println() - } else { - fmt.Println("🚀 Migrating agent beads to two-level architecture") - fmt.Println() - } - - var results []migrationResult - - // Migrate agent beads - fmt.Println("Agent Beads:") - for _, agent := range agentsToMigrate { - result := migrateAgentBead(sourceBd, targetBd, agent.oldID, agent.newID, agent.desc, migrateAgentsDryRun, migrateAgentsForce) - results = append(results, result) - printMigrationResult(result) - } - - // Migrate role beads - fmt.Println("\nRole Beads:") - for _, role := range rolesToMigrate { - oldID := "gt-" + role + "-role" - newID := beads.RoleBeadIDTown(role) // hq--role - result := migrateRoleBead(sourceBd, targetBd, oldID, newID, role, migrateAgentsDryRun, migrateAgentsForce) - results = append(results, result) - printMigrationResult(result) - } - - // Summary - fmt.Println() - printMigrationSummary(results, migrateAgentsDryRun) - - return nil -} - -// migrateAgentBead migrates a single agent bead from source to target. -func migrateAgentBead(sourceBd, targetBd *beads.Beads, oldID, newID, desc string, dryRun, force bool) migrationResult { - result := migrationResult{ - OldID: oldID, - NewID: newID, - WasDryRun: dryRun, - } - - // Check if old bead exists - oldIssue, oldFields, err := sourceBd.GetAgentBead(oldID) - if err != nil { - result.Status = "skipped" - result.Message = "old bead not found" - return result - } - result.OldFields = oldFields - - // Check if new bead already exists - if _, err := targetBd.Show(newID); err == nil { - if !force { - result.Status = "skipped" - result.Message = "new bead already exists (use --force to re-migrate)" - return result - } - } - - if dryRun { - result.Status = "would migrate" - result.Message = fmt.Sprintf("would copy state from %s", oldIssue.ID) - return result - } - - // Create new bead in town beads - newFields := &beads.AgentFields{ - RoleType: oldFields.RoleType, - Rig: oldFields.Rig, - AgentState: oldFields.AgentState, - HookBead: oldFields.HookBead, - RoleBead: beads.RoleBeadIDTown(oldFields.RoleType), // Update to hq- role - CleanupStatus: oldFields.CleanupStatus, - ActiveMR: oldFields.ActiveMR, - NotificationLevel: oldFields.NotificationLevel, - } - - _, err = targetBd.CreateAgentBead(newID, desc, newFields) - if err != nil { - result.Status = "error" - result.Message = fmt.Sprintf("failed to create: %v", err) - return result - } - - // Add migration label to old bead - migrationLabel := fmt.Sprintf("migrated-to:%s", newID) - if err := sourceBd.Update(oldID, beads.UpdateOptions{AddLabels: []string{migrationLabel}}); err != nil { - // Non-fatal: just log it - result.Message = fmt.Sprintf("created but couldn't add migration label: %v", err) - } - - result.Status = "migrated" - result.Message = "successfully migrated" - return result -} - -// migrateRoleBead migrates a role definition bead. -func migrateRoleBead(sourceBd, targetBd *beads.Beads, oldID, newID, role string, dryRun, force bool) migrationResult { - result := migrationResult{ - OldID: oldID, - NewID: newID, - WasDryRun: dryRun, - } - - // Check if old bead exists - oldIssue, err := sourceBd.Show(oldID) - if err != nil { - result.Status = "skipped" - result.Message = "old bead not found" - return result - } - - // Check if new bead already exists - if _, err := targetBd.Show(newID); err == nil { - if !force { - result.Status = "skipped" - result.Message = "new bead already exists (use --force to re-migrate)" - return result - } - } - - if dryRun { - result.Status = "would migrate" - result.Message = fmt.Sprintf("would copy from %s", oldIssue.ID) - return result - } - - // Create new role bead in town beads - // Role beads are simple - just copy the description - _, err = targetBd.CreateWithID(newID, beads.CreateOptions{ - Title: fmt.Sprintf("Role: %s", role), - Type: "role", - Description: oldIssue.Title, // Use old title as description - }) - if err != nil { - result.Status = "error" - result.Message = fmt.Sprintf("failed to create: %v", err) - return result - } - - // Add migration label to old bead - migrationLabel := fmt.Sprintf("migrated-to:%s", newID) - if err := sourceBd.Update(oldID, beads.UpdateOptions{AddLabels: []string{migrationLabel}}); err != nil { - // Non-fatal - result.Message = fmt.Sprintf("created but couldn't add migration label: %v", err) - } - - result.Status = "migrated" - result.Message = "successfully migrated" - return result -} - -func getMigrationStatusIcon(status string) string { - switch status { - case "migrated", "would migrate": - return " ✓" - case "skipped": - return " ⊘" - case "error": - return " ✗" - default: - return " ?" - } -} - -func printMigrationResult(r migrationResult) { - fmt.Printf("%s %s → %s: %s\n", getMigrationStatusIcon(r.Status), r.OldID, r.NewID, r.Message) -} - -func printMigrationSummary(results []migrationResult, dryRun bool) { - var migrated, skipped, errors int - for _, r := range results { - switch r.Status { - case "migrated", "would migrate": - migrated++ - case "skipped": - skipped++ - case "error": - errors++ - } - } - - if dryRun { - fmt.Printf("Summary (dry-run): %d would migrate, %d skipped, %d errors\n", migrated, skipped, errors) - if migrated > 0 { - fmt.Println("\nRun with --execute to apply these changes.") - } - } else { - fmt.Printf("Summary: %d migrated, %d skipped, %d errors\n", migrated, skipped, errors) - } -} diff --git a/internal/cmd/migrate_agents_test.go b/internal/cmd/migrate_agents_test.go deleted file mode 100644 index b5d1ea2f..00000000 --- a/internal/cmd/migrate_agents_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package cmd - -import ( - "testing" - - "github.com/steveyegge/gastown/internal/beads" -) - -func TestMigrationResultStatus(t *testing.T) { - tests := []struct { - name string - result migrationResult - wantIcon string - }{ - { - name: "migrated shows checkmark", - result: migrationResult{ - OldID: "gt-mayor", - NewID: "hq-mayor", - Status: "migrated", - Message: "successfully migrated", - }, - wantIcon: " ✓", - }, - { - name: "would migrate shows checkmark", - result: migrationResult{ - OldID: "gt-mayor", - NewID: "hq-mayor", - Status: "would migrate", - Message: "would copy state from gt-mayor", - }, - wantIcon: " ✓", - }, - { - name: "skipped shows empty circle", - result: migrationResult{ - OldID: "gt-mayor", - NewID: "hq-mayor", - Status: "skipped", - Message: "already exists", - }, - wantIcon: " ⊘", - }, - { - name: "error shows X", - result: migrationResult{ - OldID: "gt-mayor", - NewID: "hq-mayor", - Status: "error", - Message: "failed to create", - }, - wantIcon: " ✗", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - icon := getMigrationStatusIcon(tt.result.Status) - if icon != tt.wantIcon { - t.Errorf("getMigrationStatusIcon(%q) = %q, want %q", tt.result.Status, icon, tt.wantIcon) - } - }) - } -} - -func TestTownBeadIDHelpers(t *testing.T) { - tests := []struct { - name string - got string - want string - }{ - {"MayorBeadIDTown", beads.MayorBeadIDTown(), "hq-mayor"}, - {"DeaconBeadIDTown", beads.DeaconBeadIDTown(), "hq-deacon"}, - {"DogBeadIDTown", beads.DogBeadIDTown("fido"), "hq-dog-fido"}, - {"RoleBeadIDTown mayor", beads.RoleBeadIDTown("mayor"), "hq-mayor-role"}, - {"RoleBeadIDTown witness", beads.RoleBeadIDTown("witness"), "hq-witness-role"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.got != tt.want { - t.Errorf("%s = %q, want %q", tt.name, tt.got, tt.want) - } - }) - } -} diff --git a/internal/daemon/lifecycle.go b/internal/daemon/lifecycle.go index a1324928..7f3d56c7 100644 --- a/internal/daemon/lifecycle.go +++ b/internal/daemon/lifecycle.go @@ -211,7 +211,7 @@ func (d *Daemon) executeLifecycleAction(request *LifecycleRequest) error { } // ParsedIdentity holds the components extracted from an agent identity string. -// This is used to look up the appropriate role bead for lifecycle config. +// This is used to look up the appropriate role config for lifecycle management. type ParsedIdentity struct { RoleType string // mayor, deacon, witness, refinery, crew, polecat RigName string // Empty for town-level agents (mayor, deacon) @@ -220,7 +220,7 @@ type ParsedIdentity struct { // parseIdentity extracts role type, rig name, and agent name from an identity string. // This is the ONLY place where identity string patterns are parsed. -// All other functions should use the extracted components to look up role beads. +// All other functions should use the extracted components to look up role config. func parseIdentity(identity string) (*ParsedIdentity, error) { switch identity { case "mayor": @@ -268,49 +268,50 @@ func parseIdentity(identity string) (*ParsedIdentity, error) { return nil, fmt.Errorf("unknown identity format: %s", identity) } -// getRoleConfigForIdentity looks up the role bead for an identity and returns its config. -// Falls back to default config if role bead doesn't exist or has no config. +// getRoleConfigForIdentity loads role configuration from the config-based role system. +// Uses config.LoadRoleDefinition() with layered override resolution (builtin → town → rig). +// Returns config in beads.RoleConfig format for backward compatibility. func (d *Daemon) getRoleConfigForIdentity(identity string) (*beads.RoleConfig, *ParsedIdentity, error) { parsed, err := parseIdentity(identity) if err != nil { return nil, nil, err } - // Look up role bead - b := beads.New(d.config.TownRoot) + // Determine rig path for rig-scoped roles + rigPath := "" + if parsed.RigName != "" { + rigPath = filepath.Join(d.config.TownRoot, parsed.RigName) + } - roleBeadID := beads.RoleBeadIDTown(parsed.RoleType) - roleConfig, err := b.GetRoleConfig(roleBeadID) + // Load role definition from config system (Phase 2: config-based roles) + roleDef, err := config.LoadRoleDefinition(d.config.TownRoot, rigPath, parsed.RoleType) if err != nil { - d.logger.Printf("Warning: failed to get role config for %s: %v", roleBeadID, err) + d.logger.Printf("Warning: failed to load role definition for %s: %v", parsed.RoleType, err) + // Return parsed identity even if config fails (caller can use defaults) + return nil, parsed, nil } - // Backward compatibility: fall back to legacy role bead IDs. - if roleConfig == nil { - legacyRoleBeadID := beads.RoleBeadID(parsed.RoleType) // gt--role - if legacyRoleBeadID != roleBeadID { - legacyCfg, legacyErr := b.GetRoleConfig(legacyRoleBeadID) - if legacyErr != nil { - d.logger.Printf("Warning: failed to get legacy role config for %s: %v", legacyRoleBeadID, legacyErr) - } else if legacyCfg != nil { - roleConfig = legacyCfg - } - } + // Convert to beads.RoleConfig for backward compatibility + roleConfig := &beads.RoleConfig{ + SessionPattern: roleDef.Session.Pattern, + WorkDirPattern: roleDef.Session.WorkDir, + NeedsPreSync: roleDef.Session.NeedsPreSync, + StartCommand: roleDef.Session.StartCommand, + EnvVars: roleDef.Env, } - // Return parsed identity even if config is nil (caller can use defaults) return roleConfig, parsed, nil } // identityToSession converts a beads identity to a tmux session name. -// Uses role bead config if available, falls back to hardcoded patterns. +// Uses role config if available, falls back to hardcoded patterns. func (d *Daemon) identityToSession(identity string) string { config, parsed, err := d.getRoleConfigForIdentity(identity) if err != nil { return "" } - // If role bead has session_pattern, use it + // If role config has session_pattern, use it if config != nil && config.SessionPattern != "" { return beads.ExpandRolePattern(config.SessionPattern, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType) } @@ -333,7 +334,7 @@ func (d *Daemon) identityToSession(identity string) string { } // restartSession starts a new session for the given agent. -// Uses role bead config if available, falls back to hardcoded defaults. +// Uses role config if available, falls back to hardcoded defaults. func (d *Daemon) restartSession(sessionName, identity string) error { // Get role config for this identity config, parsed, err := d.getRoleConfigForIdentity(identity) @@ -409,9 +410,9 @@ func (d *Daemon) restartSession(sessionName, identity string) error { } // getWorkDir determines the working directory for an agent. -// Uses role bead config if available, falls back to hardcoded defaults. +// Uses role config if available, falls back to hardcoded defaults. func (d *Daemon) getWorkDir(config *beads.RoleConfig, parsed *ParsedIdentity) string { - // If role bead has work_dir_pattern, use it + // If role config has work_dir_pattern, use it if config != nil && config.WorkDirPattern != "" { return beads.ExpandRolePattern(config.WorkDirPattern, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType) } @@ -442,9 +443,9 @@ func (d *Daemon) getWorkDir(config *beads.RoleConfig, parsed *ParsedIdentity) st } // getNeedsPreSync determines if a workspace needs git sync before starting. -// Uses role bead config if available, falls back to hardcoded defaults. +// Uses role config if available, falls back to hardcoded defaults. func (d *Daemon) getNeedsPreSync(config *beads.RoleConfig, parsed *ParsedIdentity) bool { - // If role bead has explicit config, use it + // If role config is available, use it if config != nil { return config.NeedsPreSync } @@ -459,9 +460,9 @@ func (d *Daemon) getNeedsPreSync(config *beads.RoleConfig, parsed *ParsedIdentit } // getStartCommand determines the startup command for an agent. -// Uses role bead config if available, then role-based agent selection, then hardcoded defaults. +// Uses role config if available, then role-based agent selection, then hardcoded defaults. func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIdentity) string { - // If role bead has explicit config, use it + // If role config is available, use it if roleConfig != nil && roleConfig.StartCommand != "" { // Expand any patterns in the command return beads.ExpandRolePattern(roleConfig.StartCommand, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType) @@ -516,7 +517,7 @@ func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIde } // setSessionEnvironment sets environment variables for the tmux session. -// Uses centralized AgentEnv for consistency, plus role bead custom env vars if available. +// Uses centralized AgentEnv for consistency, plus custom env vars from role config if available. func (d *Daemon) setSessionEnvironment(sessionName string, roleConfig *beads.RoleConfig, parsed *ParsedIdentity) { // Use centralized AgentEnv for base environment variables envVars := config.AgentEnv(config.AgentEnvConfig{ @@ -529,7 +530,7 @@ func (d *Daemon) setSessionEnvironment(sessionName string, roleConfig *beads.Rol _ = d.tmux.SetEnvironment(sessionName, k, v) } - // Set any custom env vars from role config (bead-defined overrides) + // Set any custom env vars from role config if roleConfig != nil { for k, v := range roleConfig.EnvVars { expanded := beads.ExpandRolePattern(v, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType) @@ -637,10 +638,10 @@ type AgentBeadInfo struct { Type string `json:"issue_type"` State string // Parsed from description: agent_state HookBead string // Parsed from description: hook_bead - RoleBead string // Parsed from description: role_bead RoleType string // Parsed from description: role_type Rig string // Parsed from description: rig LastUpdate string `json:"updated_at"` + // Note: RoleBead field removed - role definitions are now config-based } // getAgentBeadState reads non-observable agent state from an agent bead. @@ -699,7 +700,6 @@ func (d *Daemon) getAgentBeadInfo(agentBeadID string) (*AgentBeadInfo, error) { if fields != nil { info.State = fields.AgentState - info.RoleBead = fields.RoleBead info.RoleType = fields.RoleType info.Rig = fields.Rig } diff --git a/internal/daemon/role_config_integration_test.go b/internal/daemon/role_config_integration_test.go index 968d2a91..e893f339 100644 --- a/internal/daemon/role_config_integration_test.go +++ b/internal/daemon/role_config_integration_test.go @@ -5,41 +5,60 @@ package daemon import ( "io" "log" - "os/exec" - "strings" + "os" + "path/filepath" "testing" ) -func runBd(t *testing.T, dir string, args ...string) string { - t.Helper() - cmd := exec.Command("bd", args...) //nolint:gosec // bd is a trusted internal tool in this repo - cmd.Dir = dir - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("bd %s failed: %v\n%s", strings.Join(args, " "), err, string(out)) +// TestGetRoleConfigForIdentity_UsesBuiltinDefaults tests that the daemon +// uses built-in role definitions from embedded TOML files when no overrides exist. +func TestGetRoleConfigForIdentity_UsesBuiltinDefaults(t *testing.T) { + townRoot := t.TempDir() + + d := &Daemon{ + config: &Config{TownRoot: townRoot}, + logger: log.New(io.Discard, "", 0), + } + + // Should load witness role from built-in defaults + cfg, parsed, err := d.getRoleConfigForIdentity("myrig-witness") + if err != nil { + t.Fatalf("getRoleConfigForIdentity: %v", err) + } + if parsed == nil || parsed.RoleType != "witness" { + t.Fatalf("parsed = %#v, want roleType witness", parsed) + } + if cfg == nil { + t.Fatal("cfg is nil, expected built-in defaults") + } + // Built-in witness has session pattern "gt-{rig}-witness" + if cfg.SessionPattern != "gt-{rig}-witness" { + t.Errorf("cfg.SessionPattern = %q, want %q", cfg.SessionPattern, "gt-{rig}-witness") } - return string(out) } -func TestGetRoleConfigForIdentity_PrefersTownRoleBead(t *testing.T) { - if _, err := exec.LookPath("bd"); err != nil { - t.Skip("bd not installed") +// TestGetRoleConfigForIdentity_TownOverride tests that town-level TOML overrides +// are merged with built-in defaults. +func TestGetRoleConfigForIdentity_TownOverride(t *testing.T) { + townRoot := t.TempDir() + + // Create town-level override + rolesDir := filepath.Join(townRoot, "roles") + if err := os.MkdirAll(rolesDir, 0755); err != nil { + t.Fatalf("mkdir roles: %v", err) } - townRoot := t.TempDir() - runBd(t, townRoot, "init", "--quiet", "--prefix", "hq") + // Override start_command for witness role + witnessOverride := ` +role = "witness" +scope = "rig" - runBd(t, townRoot, "config", "set", "types.custom", "agent,role,rig,convoy,event") - - runBd(t, townRoot, "config", "set", "types.custom", "agent,role,rig,convoy,event") - - // Create canonical role bead. - runBd(t, townRoot, "create", - "--id", "hq-witness-role", - "--type", "role", - "--title", "Witness Role", - "--description", "start_command: exec echo hq\n", - ) +[session] +start_command = "exec echo custom-town-command" +` + if err := os.WriteFile(filepath.Join(rolesDir, "witness.toml"), []byte(witnessOverride), 0644); err != nil { + t.Fatalf("write witness.toml: %v", err) + } d := &Daemon{ config: &Config{TownRoot: townRoot}, @@ -53,30 +72,56 @@ func TestGetRoleConfigForIdentity_PrefersTownRoleBead(t *testing.T) { if parsed == nil || parsed.RoleType != "witness" { t.Fatalf("parsed = %#v, want roleType witness", parsed) } - if cfg == nil || cfg.StartCommand != "exec echo hq" { - t.Fatalf("cfg.StartCommand = %#v, want %q", cfg, "exec echo hq") + if cfg == nil { + t.Fatal("cfg is nil") + } + // Should have the overridden start_command + if cfg.StartCommand != "exec echo custom-town-command" { + t.Errorf("cfg.StartCommand = %q, want %q", cfg.StartCommand, "exec echo custom-town-command") + } + // Should still have built-in session pattern (not overridden) + if cfg.SessionPattern != "gt-{rig}-witness" { + t.Errorf("cfg.SessionPattern = %q, want %q", cfg.SessionPattern, "gt-{rig}-witness") } } -func TestGetRoleConfigForIdentity_FallsBackToLegacyRoleBead(t *testing.T) { - if _, err := exec.LookPath("bd"); err != nil { - t.Skip("bd not installed") +// TestGetRoleConfigForIdentity_RigOverride tests that rig-level TOML overrides +// take precedence over town-level overrides. +func TestGetRoleConfigForIdentity_RigOverride(t *testing.T) { + townRoot := t.TempDir() + rigPath := filepath.Join(townRoot, "myrig") + + // Create town-level override + townRolesDir := filepath.Join(townRoot, "roles") + if err := os.MkdirAll(townRolesDir, 0755); err != nil { + t.Fatalf("mkdir town roles: %v", err) + } + townOverride := ` +role = "witness" +scope = "rig" + +[session] +start_command = "exec echo town-command" +` + if err := os.WriteFile(filepath.Join(townRolesDir, "witness.toml"), []byte(townOverride), 0644); err != nil { + t.Fatalf("write town witness.toml: %v", err) } - townRoot := t.TempDir() - runBd(t, townRoot, "init", "--quiet", "--prefix", "gt") + // Create rig-level override (should take precedence) + rigRolesDir := filepath.Join(rigPath, "roles") + if err := os.MkdirAll(rigRolesDir, 0755); err != nil { + t.Fatalf("mkdir rig roles: %v", err) + } + rigOverride := ` +role = "witness" +scope = "rig" - runBd(t, townRoot, "config", "set", "types.custom", "agent,role,rig,convoy,event") - - runBd(t, townRoot, "config", "set", "types.custom", "agent,role,rig,convoy,event") - - // Only legacy role bead exists. - runBd(t, townRoot, "create", - "--id", "gt-witness-role", - "--type", "role", - "--title", "Witness Role (legacy)", - "--description", "start_command: exec echo gt\n", - ) +[session] +start_command = "exec echo rig-command" +` + if err := os.WriteFile(filepath.Join(rigRolesDir, "witness.toml"), []byte(rigOverride), 0644); err != nil { + t.Fatalf("write rig witness.toml: %v", err) + } d := &Daemon{ config: &Config{TownRoot: townRoot}, @@ -90,7 +135,11 @@ func TestGetRoleConfigForIdentity_FallsBackToLegacyRoleBead(t *testing.T) { if parsed == nil || parsed.RoleType != "witness" { t.Fatalf("parsed = %#v, want roleType witness", parsed) } - if cfg == nil || cfg.StartCommand != "exec echo gt" { - t.Fatalf("cfg.StartCommand = %#v, want %q", cfg, "exec echo gt") + if cfg == nil { + t.Fatal("cfg is nil") + } + // Should have the rig-level override (takes precedence over town) + if cfg.StartCommand != "exec echo rig-command" { + t.Errorf("cfg.StartCommand = %q, want %q", cfg.StartCommand, "exec echo rig-command") } } diff --git a/internal/doctor/agent_beads_check.go b/internal/doctor/agent_beads_check.go index 6651849a..e64e16a0 100644 --- a/internal/doctor/agent_beads_check.go +++ b/internal/doctor/agent_beads_check.go @@ -170,7 +170,6 @@ func (c *AgentBeadsCheck) Fix(ctx *CheckContext) error { 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 { @@ -184,7 +183,6 @@ func (c *AgentBeadsCheck) Fix(ctx *CheckContext) error { 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 { @@ -231,7 +229,6 @@ func (c *AgentBeadsCheck) Fix(ctx *CheckContext) error { 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 { @@ -245,7 +242,6 @@ func (c *AgentBeadsCheck) Fix(ctx *CheckContext) error { 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 { @@ -262,7 +258,6 @@ func (c *AgentBeadsCheck) Fix(ctx *CheckContext) error { 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 { diff --git a/internal/doctor/role_beads_check.go b/internal/doctor/role_beads_check.go index aa9c9c77..b4a2c612 100644 --- a/internal/doctor/role_beads_check.go +++ b/internal/doctor/role_beads_check.go @@ -2,119 +2,116 @@ package doctor import ( "fmt" - "os/exec" - "strings" + "os" + "path/filepath" - "github.com/steveyegge/gastown/internal/beads" + "github.com/BurntSushi/toml" + "github.com/steveyegge/gastown/internal/config" ) -// 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 +// RoleConfigCheck verifies that role configuration is valid. +// Role definitions are now config-based (internal/config/roles/*.toml), +// not stored as beads. Built-in defaults are embedded in the binary. +// This check validates any user-provided overrides at: +// - /roles/.toml (town-level overrides) +// - /roles/.toml (rig-level overrides) +type RoleConfigCheck struct { + BaseCheck } -// 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, - }, +// NewRoleBeadsCheck creates a new role config check. +// Note: Function name kept as NewRoleBeadsCheck for backward compatibility +// with existing doctor.go registration code. +func NewRoleBeadsCheck() *RoleConfigCheck { + return &RoleConfigCheck{ + BaseCheck: BaseCheck{ + CheckName: "role-config-valid", + CheckDescription: "Verify role configuration is valid", + CheckCategory: CategoryConfig, }, } } -// Run checks if role beads exist. -func (c *RoleBeadsCheck) Run(ctx *CheckContext) *CheckResult { - c.missing = nil // Reset +// Run checks if role config is valid. +func (c *RoleConfigCheck) Run(ctx *CheckContext) *CheckResult { + var warnings []string + var overrideCount int - 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) + // Check town-level overrides + townRolesDir := filepath.Join(ctx.TownRoot, "roles") + if entries, err := os.ReadDir(townRolesDir); err == nil { + for _, entry := range entries { + if !entry.IsDir() && filepath.Ext(entry.Name()) == ".toml" { + overrideCount++ + path := filepath.Join(townRolesDir, entry.Name()) + if err := validateRoleOverride(path); err != nil { + warnings = append(warnings, fmt.Sprintf("town override %s: %v", entry.Name(), err)) + } + } } } - c.missing = missing + // Check rig-level overrides for each rig + // Discover rigs by looking for directories with rig.json + if entries, err := os.ReadDir(ctx.TownRoot); err == nil { + for _, entry := range entries { + if !entry.IsDir() { + continue + } + rigName := entry.Name() + // Check if this is a rig (has rig.json) + if _, err := os.Stat(filepath.Join(ctx.TownRoot, rigName, "rig.json")); err != nil { + continue + } + rigRolesDir := filepath.Join(ctx.TownRoot, rigName, "roles") + if roleEntries, err := os.ReadDir(rigRolesDir); err == nil { + for _, roleEntry := range roleEntries { + if !roleEntry.IsDir() && filepath.Ext(roleEntry.Name()) == ".toml" { + overrideCount++ + path := filepath.Join(rigRolesDir, roleEntry.Name()) + if err := validateRoleOverride(path); err != nil { + warnings = append(warnings, fmt.Sprintf("rig %s override %s: %v", rigName, roleEntry.Name(), err)) + } + } + } + } + } + } - if len(missing) == 0 { + if len(warnings) > 0 { return &CheckResult{ Name: c.Name(), - Status: StatusOK, - Message: fmt.Sprintf("All %d role beads exist", len(roleDefs)), + Status: StatusWarning, + Message: fmt.Sprintf("%d role config override(s) have issues", len(warnings)), + Details: warnings, + FixHint: "Check TOML syntax in role override files", Category: c.Category(), } } + msg := "Role config uses built-in defaults" + if overrideCount > 0 { + msg = fmt.Sprintf("Role config valid (%d override file(s))", overrideCount) + } + 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", + Status: StatusOK, + Message: msg, 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 - } +// validateRoleOverride checks if a role override file is valid TOML. +func validateRoleOverride(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err } - 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 - args := []string{ - "create", - "--type=role", - "--id=" + role.ID, - "--title=" + role.Title, - "--description=" + role.Desc, - } - if beads.NeedsForceForID(role.ID) { - args = append(args, "--force") - } - cmd := exec.Command("bd", args...) - cmd.Dir = ctx.TownRoot - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("creating %s: %s", role.ID, strings.TrimSpace(string(output))) - } + var def config.RoleDefinition + if err := toml.Unmarshal(data, &def); err != nil { + return fmt.Errorf("invalid TOML: %w", err) } return nil diff --git a/internal/doctor/role_beads_check_test.go b/internal/doctor/role_beads_check_test.go index 83dbde23..6f5d63bb 100644 --- a/internal/doctor/role_beads_check_test.go +++ b/internal/doctor/role_beads_check_test.go @@ -4,15 +4,64 @@ 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) { +func TestRoleConfigCheck_Run(t *testing.T) { + t.Run("no overrides returns OK with defaults message", func(t *testing.T) { tmpDir := t.TempDir() - // Create minimal town structure without .beads - if err := os.MkdirAll(filepath.Join(tmpDir, "mayor"), 0755); err != nil { + + check := NewRoleBeadsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK, got %v: %s", result.Status, result.Message) + } + if result.Message != "Role config uses built-in defaults" { + t.Errorf("unexpected message: %s", result.Message) + } + }) + + t.Run("valid town override returns OK", func(t *testing.T) { + tmpDir := t.TempDir() + rolesDir := filepath.Join(tmpDir, "roles") + if err := os.MkdirAll(rolesDir, 0755); err != nil { + t.Fatal(err) + } + + // Create a valid TOML override + override := ` +role = "witness" +scope = "rig" + +[session] +start_command = "exec echo test" +` + if err := os.WriteFile(filepath.Join(rolesDir, "witness.toml"), []byte(override), 0644); err != nil { + t.Fatal(err) + } + + check := NewRoleBeadsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK, got %v: %s", result.Status, result.Message) + } + if result.Message != "Role config valid (1 override file(s))" { + t.Errorf("unexpected message: %s", result.Message) + } + }) + + t.Run("invalid town override returns warning", func(t *testing.T) { + tmpDir := t.TempDir() + rolesDir := filepath.Join(tmpDir, "roles") + if err := os.MkdirAll(rolesDir, 0755); err != nil { + t.Fatal(err) + } + + // Create an invalid TOML file + if err := os.WriteFile(filepath.Join(rolesDir, "witness.toml"), []byte("invalid { toml"), 0644); err != nil { t.Fatal(err) } @@ -20,49 +69,53 @@ func TestRoleBeadsCheck_Run(t *testing.T) { 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) + if len(result.Details) != 1 { + t.Errorf("expected 1 warning detail, got %d", len(result.Details)) } }) - t.Run("check is fixable", func(t *testing.T) { + t.Run("valid rig override returns OK", func(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + rigRolesDir := filepath.Join(rigDir, "roles") + if err := os.MkdirAll(rigRolesDir, 0755); err != nil { + t.Fatal(err) + } + + // Create rig.json to mark this as a rig + if err := os.WriteFile(filepath.Join(rigDir, "rig.json"), []byte(`{"name": "testrig"}`), 0644); err != nil { + t.Fatal(err) + } + + // Create a valid TOML override + override := ` +role = "refinery" +scope = "rig" + +[session] +needs_pre_sync = true +` + if err := os.WriteFile(filepath.Join(rigRolesDir, "refinery.toml"), []byte(override), 0644); err != nil { + t.Fatal(err) + } + check := NewRoleBeadsCheck() - if !check.CanFix() { - t.Error("RoleBeadsCheck should be fixable") + ctx := &CheckContext{TownRoot: tmpDir} + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK, got %v: %s", result.Status, result.Message) + } + }) + + t.Run("check is not fixable", func(t *testing.T) { + check := NewRoleBeadsCheck() + if check.CanFix() { + t.Error("RoleConfigCheck should not be fixable (config issues need manual fix)") } }) } - -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) - } - } -} diff --git a/internal/polecat/manager.go b/internal/polecat/manager.go index e23d7abc..47ee857c 100644 --- a/internal/polecat/manager.go +++ b/internal/polecat/manager.go @@ -353,7 +353,6 @@ func (m *Manager) AddWithOptions(name string, opts AddOptions) (*Polecat, error) RoleType: "polecat", Rig: m.rig.Name, AgentState: "spawning", - RoleBead: beads.RoleBeadIDTown("polecat"), HookBead: opts.HookBead, // Set atomically at spawn time }) if err != nil { @@ -648,7 +647,6 @@ func (m *Manager) RepairWorktreeWithOptions(name string, force bool, opts AddOpt RoleType: "polecat", Rig: m.rig.Name, AgentState: "spawning", - RoleBead: beads.RoleBeadIDTown("polecat"), HookBead: opts.HookBead, // Set atomically at spawn time }) if err != nil { diff --git a/internal/rig/manager.go b/internal/rig/manager.go index a7e82833..46895a8d 100644 --- a/internal/rig/manager.go +++ b/internal/rig/manager.go @@ -750,14 +750,12 @@ func (m *Manager) initAgentBeads(rigPath, rigName, prefix string) error { continue // Already exists } - // RoleBead points to the shared role definition bead for this agent type. - // Role beads are in town beads with hq- prefix (e.g., hq-witness-role). + // Note: RoleBead field removed - role definitions are now config-based fields := &beads.AgentFields{ RoleType: agent.roleType, Rig: agent.rig, AgentState: "idle", HookBead: "", - RoleBead: beads.RoleBeadIDTown(agent.roleType), } if _, err := bd.CreateAgentBead(agent.id, agent.desc, fields); err != nil {