feat(roles): switch daemon to config-based roles, remove role beads (Phase 2+3)

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 <noreply@anthropic.com>
This commit is contained in:
gastown/crew/max
2026-01-20 12:49:52 -08:00
committed by Steve Yegge
parent 544cacf36d
commit a610283078
16 changed files with 347 additions and 713 deletions

View File

@@ -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"`

View File

@@ -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")

View File

@@ -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
// Role beads were stored in town beads (~/.beads/) with hq- prefix.
//
// Canonical format was: hq-<role>-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>-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{
{

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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>-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)
}
}

View File

@@ -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)
}
})
}
}

View File

@@ -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>-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
}

View File

@@ -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")
}
}

View File

@@ -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 {

View File

@@ -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:
// - <town>/roles/<role>.toml (town-level overrides)
// - <rig>/roles/<role>.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

View File

@@ -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)
}
}
}

View File

@@ -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 {

View File

@@ -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 {