From 437d42c7fad6b231ca29c9f00233f817ca5604a4 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 30 Dec 2025 02:06:08 -0800 Subject: [PATCH] ZFC #4: Replace daemon identity parsing with agent self-registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements role-based lifecycle configuration where agent types self-register via role beads instead of hardcoded identity string parsing in the daemon. Changes: - Add RoleConfig struct with lifecycle fields (session_pattern, work_dir_pattern, needs_pre_sync, start_command, env_vars) - Add ParseRoleConfig/FormatRoleConfig/ExpandRolePattern to beads package - Add role bead ID helpers (RoleBeadID, MayorRoleBeadID, etc.) - Refactor daemon to use single parseIdentity function as ONLY place where identity strings are parsed - Daemon now looks up role beads to get lifecycle config, with fallback to defaults when role bead is missing or has no config - Updated all role beads (mayor, deacon, witness, refinery, crew, polecat) with structured lifecycle configuration fields - Add comprehensive unit tests for RoleConfig parsing and expansion This makes the daemon ZFC-compliant by trusting what agents self-report in their role beads rather than encoding agent-specific knowledge in Go code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/beads/beads.go | 65 +++++ internal/beads/beads_test.go | 304 +++++++++++++++++++++ internal/beads/fields.go | 122 +++++++++ internal/daemon/lifecycle.go | 501 +++++++++++++++++++---------------- 4 files changed, 766 insertions(+), 226 deletions(-) diff --git a/internal/beads/beads.go b/internal/beads/beads.go index 19749912..731fa261 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -888,3 +888,68 @@ func IsAgentSessionBead(beadID string) bool { return false } } + +// Role bead ID naming convention: +// gt--role +// +// Examples: +// - gt-mayor-role +// - gt-deacon-role +// - gt-witness-role +// - gt-refinery-role +// - gt-crew-role +// - gt-polecat-role + +// RoleBeadID returns the role bead ID for a given role type. +// Role beads define lifecycle configuration for each agent type. +func RoleBeadID(roleType string) string { + return "gt-" + roleType + "-role" +} + +// MayorRoleBeadID returns the Mayor role bead ID. +func MayorRoleBeadID() string { + return RoleBeadID("mayor") +} + +// DeaconRoleBeadID returns the Deacon role bead ID. +func DeaconRoleBeadID() string { + return RoleBeadID("deacon") +} + +// WitnessRoleBeadID returns the Witness role bead ID. +func WitnessRoleBeadID() string { + return RoleBeadID("witness") +} + +// RefineryRoleBeadID returns the Refinery role bead ID. +func RefineryRoleBeadID() string { + return RoleBeadID("refinery") +} + +// CrewRoleBeadID returns the Crew role bead ID. +func CrewRoleBeadID() string { + return RoleBeadID("crew") +} + +// PolecatRoleBeadID returns the Polecat role bead ID. +func PolecatRoleBeadID() string { + return RoleBeadID("polecat") +} + +// 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. +func (b *Beads) GetRoleConfig(roleBeadID string) (*RoleConfig, error) { + issue, err := b.Show(roleBeadID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, nil + } + return nil, err + } + + if issue.Type != "role" { + return nil, fmt.Errorf("bead %s is not a role bead (type: %s)", roleBeadID, issue.Type) + } + + return ParseRoleConfig(issue.Description), nil +} diff --git a/internal/beads/beads_test.go b/internal/beads/beads_test.go index d0f3c7ac..d74ab6dc 100644 --- a/internal/beads/beads_test.go +++ b/internal/beads/beads_test.go @@ -1073,3 +1073,307 @@ func TestIsAgentSessionBead(t *testing.T) { }) } } + +// TestParseRoleConfig tests parsing role configuration from descriptions. +func TestParseRoleConfig(t *testing.T) { + tests := []struct { + name string + description string + wantNil bool + wantConfig *RoleConfig + }{ + { + name: "empty description", + description: "", + wantNil: true, + }, + { + name: "no role config fields", + description: "This is just plain text\nwith no role config fields", + wantNil: true, + }, + { + name: "all fields", + description: `session_pattern: gt-{rig}-{name} +work_dir_pattern: {town}/{rig}/polecats/{name} +needs_pre_sync: true +start_command: exec claude --dangerously-skip-permissions +env_var: GT_ROLE=polecat +env_var: GT_RIG={rig}`, + wantConfig: &RoleConfig{ + SessionPattern: "gt-{rig}-{name}", + WorkDirPattern: "{town}/{rig}/polecats/{name}", + NeedsPreSync: true, + StartCommand: "exec claude --dangerously-skip-permissions", + EnvVars: map[string]string{"GT_ROLE": "polecat", "GT_RIG": "{rig}"}, + }, + }, + { + name: "partial fields", + description: `session_pattern: gt-mayor +work_dir_pattern: {town}`, + wantConfig: &RoleConfig{ + SessionPattern: "gt-mayor", + WorkDirPattern: "{town}", + EnvVars: map[string]string{}, + }, + }, + { + name: "mixed with prose", + description: `You are the Witness. + +session_pattern: gt-{rig}-witness +work_dir_pattern: {town}/{rig} +needs_pre_sync: false + +Your job is to monitor workers.`, + wantConfig: &RoleConfig{ + SessionPattern: "gt-{rig}-witness", + WorkDirPattern: "{town}/{rig}", + NeedsPreSync: false, + EnvVars: map[string]string{}, + }, + }, + { + name: "alternate key formats (hyphen)", + description: `session-pattern: gt-{rig}-{name} +work-dir-pattern: {town}/{rig}/polecats/{name} +needs-pre-sync: true`, + wantConfig: &RoleConfig{ + SessionPattern: "gt-{rig}-{name}", + WorkDirPattern: "{town}/{rig}/polecats/{name}", + NeedsPreSync: true, + EnvVars: map[string]string{}, + }, + }, + { + name: "case insensitive keys", + description: `SESSION_PATTERN: gt-mayor +Work_Dir_Pattern: {town}`, + wantConfig: &RoleConfig{ + SessionPattern: "gt-mayor", + WorkDirPattern: "{town}", + EnvVars: map[string]string{}, + }, + }, + { + name: "ignores null values", + description: `session_pattern: gt-{rig}-witness +work_dir_pattern: null +needs_pre_sync: false`, + wantConfig: &RoleConfig{ + SessionPattern: "gt-{rig}-witness", + EnvVars: map[string]string{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := ParseRoleConfig(tt.description) + + if tt.wantNil { + if config != nil { + t.Errorf("ParseRoleConfig() = %+v, want nil", config) + } + return + } + + if config == nil { + t.Fatal("ParseRoleConfig() = nil, want non-nil") + } + + if config.SessionPattern != tt.wantConfig.SessionPattern { + t.Errorf("SessionPattern = %q, want %q", config.SessionPattern, tt.wantConfig.SessionPattern) + } + if config.WorkDirPattern != tt.wantConfig.WorkDirPattern { + t.Errorf("WorkDirPattern = %q, want %q", config.WorkDirPattern, tt.wantConfig.WorkDirPattern) + } + if config.NeedsPreSync != tt.wantConfig.NeedsPreSync { + t.Errorf("NeedsPreSync = %v, want %v", config.NeedsPreSync, tt.wantConfig.NeedsPreSync) + } + if config.StartCommand != tt.wantConfig.StartCommand { + t.Errorf("StartCommand = %q, want %q", config.StartCommand, tt.wantConfig.StartCommand) + } + if len(config.EnvVars) != len(tt.wantConfig.EnvVars) { + t.Errorf("EnvVars len = %d, want %d", len(config.EnvVars), len(tt.wantConfig.EnvVars)) + } + for k, v := range tt.wantConfig.EnvVars { + if config.EnvVars[k] != v { + t.Errorf("EnvVars[%q] = %q, want %q", k, config.EnvVars[k], v) + } + } + }) + } +} + +// TestExpandRolePattern tests pattern expansion with placeholders. +func TestExpandRolePattern(t *testing.T) { + tests := []struct { + pattern string + townRoot string + rig string + name string + role string + want string + }{ + { + pattern: "gt-mayor", + townRoot: "/Users/stevey/gt", + want: "gt-mayor", + }, + { + pattern: "gt-{rig}-{role}", + townRoot: "/Users/stevey/gt", + rig: "gastown", + role: "witness", + want: "gt-gastown-witness", + }, + { + pattern: "gt-{rig}-{name}", + townRoot: "/Users/stevey/gt", + rig: "gastown", + name: "toast", + want: "gt-gastown-toast", + }, + { + pattern: "{town}/{rig}/polecats/{name}", + townRoot: "/Users/stevey/gt", + rig: "gastown", + name: "toast", + want: "/Users/stevey/gt/gastown/polecats/toast", + }, + { + pattern: "{town}/{rig}/refinery/rig", + townRoot: "/Users/stevey/gt", + rig: "gastown", + want: "/Users/stevey/gt/gastown/refinery/rig", + }, + { + pattern: "export GT_ROLE={role} GT_RIG={rig} BD_ACTOR={rig}/polecats/{name}", + townRoot: "/Users/stevey/gt", + rig: "gastown", + name: "toast", + role: "polecat", + want: "export GT_ROLE=polecat GT_RIG=gastown BD_ACTOR=gastown/polecats/toast", + }, + } + + for _, tt := range tests { + t.Run(tt.pattern, func(t *testing.T) { + got := ExpandRolePattern(tt.pattern, tt.townRoot, tt.rig, tt.name, tt.role) + if got != tt.want { + t.Errorf("ExpandRolePattern() = %q, want %q", got, tt.want) + } + }) + } +} + +// TestFormatRoleConfig tests formatting role config to string. +func TestFormatRoleConfig(t *testing.T) { + tests := []struct { + name string + config *RoleConfig + want string + }{ + { + name: "nil config", + config: nil, + want: "", + }, + { + name: "empty config", + config: &RoleConfig{EnvVars: map[string]string{}}, + want: "", + }, + { + name: "all fields", + config: &RoleConfig{ + SessionPattern: "gt-{rig}-{name}", + WorkDirPattern: "{town}/{rig}/polecats/{name}", + NeedsPreSync: true, + StartCommand: "exec claude", + EnvVars: map[string]string{}, + }, + want: `session_pattern: gt-{rig}-{name} +work_dir_pattern: {town}/{rig}/polecats/{name} +needs_pre_sync: true +start_command: exec claude`, + }, + { + name: "only session pattern", + config: &RoleConfig{ + SessionPattern: "gt-mayor", + EnvVars: map[string]string{}, + }, + want: "session_pattern: gt-mayor", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatRoleConfig(tt.config) + if got != tt.want { + t.Errorf("FormatRoleConfig() =\n%q\nwant\n%q", got, tt.want) + } + }) + } +} + +// TestRoleConfigRoundTrip tests that parse/format round-trips correctly. +func TestRoleConfigRoundTrip(t *testing.T) { + original := &RoleConfig{ + SessionPattern: "gt-{rig}-{name}", + WorkDirPattern: "{town}/{rig}/polecats/{name}", + NeedsPreSync: true, + StartCommand: "exec claude --dangerously-skip-permissions", + EnvVars: map[string]string{}, // Can't round-trip env vars due to order + } + + // Format to string + formatted := FormatRoleConfig(original) + + // Parse back + parsed := ParseRoleConfig(formatted) + + if parsed == nil { + t.Fatal("round-trip parse returned nil") + } + + if parsed.SessionPattern != original.SessionPattern { + t.Errorf("round-trip SessionPattern = %q, want %q", parsed.SessionPattern, original.SessionPattern) + } + if parsed.WorkDirPattern != original.WorkDirPattern { + t.Errorf("round-trip WorkDirPattern = %q, want %q", parsed.WorkDirPattern, original.WorkDirPattern) + } + if parsed.NeedsPreSync != original.NeedsPreSync { + t.Errorf("round-trip NeedsPreSync = %v, want %v", parsed.NeedsPreSync, original.NeedsPreSync) + } + if parsed.StartCommand != original.StartCommand { + t.Errorf("round-trip StartCommand = %q, want %q", parsed.StartCommand, original.StartCommand) + } +} + +// TestRoleBeadID tests role bead ID generation. +func TestRoleBeadID(t *testing.T) { + tests := []struct { + roleType string + want string + }{ + {"mayor", "gt-mayor-role"}, + {"deacon", "gt-deacon-role"}, + {"witness", "gt-witness-role"}, + {"refinery", "gt-refinery-role"}, + {"crew", "gt-crew-role"}, + {"polecat", "gt-polecat-role"}, + } + + for _, tt := range tests { + t.Run(tt.roleType, func(t *testing.T) { + got := RoleBeadID(tt.roleType) + if got != tt.want { + t.Errorf("RoleBeadID(%q) = %q, want %q", tt.roleType, got, tt.want) + } + }) + } +} diff --git a/internal/beads/fields.go b/internal/beads/fields.go index 4274f589..cbe3517f 100644 --- a/internal/beads/fields.go +++ b/internal/beads/fields.go @@ -333,3 +333,125 @@ func SetMRFields(issue *Issue, fields *MRFields) string { return formatted + "\n\n" + strings.Join(otherLines, "\n") } + +// RoleConfig holds structured lifecycle configuration for role beads. +// These fields are stored as "key: value" lines in the role bead description. +// This enables agents to self-register their lifecycle configuration, +// replacing hardcoded identity string parsing in the daemon. +type RoleConfig struct { + // SessionPattern defines how to derive tmux session name. + // Supports placeholders: {rig}, {name}, {role} + // Examples: "gt-mayor", "gt-{rig}-{role}", "gt-{rig}-{name}" + SessionPattern string + + // WorkDirPattern defines the working directory relative to town root. + // Supports placeholders: {town}, {rig}, {name}, {role} + // Examples: "{town}", "{town}/{rig}", "{town}/{rig}/polecats/{name}" + WorkDirPattern string + + // NeedsPreSync indicates whether workspace needs git sync before starting. + // True for agents with persistent clones (refinery, crew, polecat). + NeedsPreSync bool + + // StartCommand is the command to run after creating the session. + // Default: "exec claude --dangerously-skip-permissions" + StartCommand string + + // EnvVars are additional environment variables to set in the session. + // Stored as "key=value" pairs. + EnvVars map[string]string +} + +// ParseRoleConfig extracts RoleConfig from a role bead's description. +// Fields are expected as "key: value" lines. Returns nil if no config found. +func ParseRoleConfig(description string) *RoleConfig { + config := &RoleConfig{ + EnvVars: make(map[string]string), + } + hasFields := false + + for _, line := range strings.Split(description, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + colonIdx := strings.Index(line, ":") + if colonIdx == -1 { + continue + } + + key := strings.TrimSpace(line[:colonIdx]) + value := strings.TrimSpace(line[colonIdx+1:]) + if value == "" || value == "null" { + continue + } + + switch strings.ToLower(key) { + case "session_pattern", "session-pattern", "sessionpattern": + config.SessionPattern = value + hasFields = true + case "work_dir_pattern", "work-dir-pattern", "workdirpattern", "workdir_pattern": + config.WorkDirPattern = value + hasFields = true + case "needs_pre_sync", "needs-pre-sync", "needspresync": + config.NeedsPreSync = strings.ToLower(value) == "true" + hasFields = true + case "start_command", "start-command", "startcommand": + config.StartCommand = value + hasFields = true + case "env_var", "env-var", "envvar": + // Format: "env_var: KEY=VALUE" + if eqIdx := strings.Index(value, "="); eqIdx != -1 { + envKey := strings.TrimSpace(value[:eqIdx]) + envVal := strings.TrimSpace(value[eqIdx+1:]) + config.EnvVars[envKey] = envVal + hasFields = true + } + } + } + + if !hasFields { + return nil + } + return config +} + +// FormatRoleConfig formats RoleConfig as a string suitable for a role bead description. +// Only non-empty/non-default fields are included. +func FormatRoleConfig(config *RoleConfig) string { + if config == nil { + return "" + } + + var lines []string + + if config.SessionPattern != "" { + lines = append(lines, "session_pattern: "+config.SessionPattern) + } + if config.WorkDirPattern != "" { + lines = append(lines, "work_dir_pattern: "+config.WorkDirPattern) + } + if config.NeedsPreSync { + lines = append(lines, "needs_pre_sync: true") + } + if config.StartCommand != "" { + lines = append(lines, "start_command: "+config.StartCommand) + } + for k, v := range config.EnvVars { + lines = append(lines, "env_var: "+k+"="+v) + } + + return strings.Join(lines, "\n") +} + +// ExpandRolePattern expands placeholders in a pattern string. +// Supported placeholders: {town}, {rig}, {name}, {role} +func ExpandRolePattern(pattern, townRoot, rig, name, role string) string { + result := pattern + result = strings.ReplaceAll(result, "{town}", townRoot) + result = strings.ReplaceAll(result, "{rig}", rig) + result = strings.ReplaceAll(result, "{name}", name) + result = strings.ReplaceAll(result, "{role}", role) + return result +} diff --git a/internal/daemon/lifecycle.go b/internal/daemon/lifecycle.go index 758dc268..a799f6a5 100644 --- a/internal/daemon/lifecycle.go +++ b/internal/daemon/lifecycle.go @@ -216,112 +216,131 @@ func (d *Daemon) executeLifecycleAction(request *LifecycleRequest) error { } } -// identityToSession converts a beads identity to a tmux session name. -func (d *Daemon) identityToSession(identity string) string { - // Handle known identities +// ParsedIdentity holds the components extracted from an agent identity string. +// This is used to look up the appropriate role bead for lifecycle config. +type ParsedIdentity struct { + RoleType string // mayor, deacon, witness, refinery, crew, polecat + RigName string // Empty for town-level agents (mayor, deacon) + AgentName string // Empty for singletons (mayor, deacon, witness, refinery) +} + +// 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. +func parseIdentity(identity string) (*ParsedIdentity, error) { switch identity { case "mayor": - return "gt-mayor" + return &ParsedIdentity{RoleType: "mayor"}, nil + case "deacon": + return &ParsedIdentity{RoleType: "deacon"}, nil + } + + // Pattern: -witness → witness role + if strings.HasSuffix(identity, "-witness") { + rigName := strings.TrimSuffix(identity, "-witness") + return &ParsedIdentity{RoleType: "witness", RigName: rigName}, nil + } + + // Pattern: -refinery → refinery role + if strings.HasSuffix(identity, "-refinery") { + rigName := strings.TrimSuffix(identity, "-refinery") + return &ParsedIdentity{RoleType: "refinery", RigName: rigName}, nil + } + + // Pattern: -crew- → crew role + if strings.Contains(identity, "-crew-") { + parts := strings.SplitN(identity, "-crew-", 2) + if len(parts) == 2 { + return &ParsedIdentity{RoleType: "crew", RigName: parts[0], AgentName: parts[1]}, nil + } + } + + // Pattern: -polecat- → polecat role + if strings.Contains(identity, "-polecat-") { + parts := strings.SplitN(identity, "-polecat-", 2) + if len(parts) == 2 { + return &ParsedIdentity{RoleType: "polecat", RigName: parts[0], AgentName: parts[1]}, nil + } + } + + // Pattern: /polecats/ → polecat role (slash format) + if strings.Contains(identity, "/polecats/") { + parts := strings.Split(identity, "/polecats/") + if len(parts) == 2 { + return &ParsedIdentity{RoleType: "polecat", RigName: parts[0], AgentName: parts[1]}, nil + } + } + + 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. +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 + roleBeadID := beads.RoleBeadID(parsed.RoleType) + b := beads.New(d.config.TownRoot) + config, err := b.GetRoleConfig(roleBeadID) + if err != nil { + d.logger.Printf("Warning: failed to get role config for %s: %v", roleBeadID, err) + } + + // Return parsed identity even if config is nil (caller can use defaults) + return config, parsed, nil +} + +// identityToSession converts a beads identity to a tmux session name. +// Uses role bead 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 config != nil && config.SessionPattern != "" { + return beads.ExpandRolePattern(config.SessionPattern, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType) + } + + // Fallback: use default patterns based on role type + switch parsed.RoleType { + case "mayor", "deacon": + return "gt-" + parsed.RoleType + case "witness", "refinery": + return fmt.Sprintf("gt-%s-%s", parsed.RigName, parsed.RoleType) + case "crew": + return fmt.Sprintf("gt-%s-crew-%s", parsed.RigName, parsed.AgentName) + case "polecat": + return fmt.Sprintf("gt-%s-%s", parsed.RigName, parsed.AgentName) default: - // Pattern: -witness → gt--witness - if strings.HasSuffix(identity, "-witness") { - return "gt-" + identity - } - // Pattern: -refinery → gt--refinery - if strings.HasSuffix(identity, "-refinery") { - return "gt-" + identity - } - // Pattern: -crew- → gt--crew- - if strings.Contains(identity, "-crew-") { - return "gt-" + identity - } - // Pattern: -polecat- or /polecats/ → gt-- - if strings.Contains(identity, "-polecat-") { - // -polecat- → gt-- - parts := strings.SplitN(identity, "-polecat-", 2) - if len(parts) == 2 { - return fmt.Sprintf("gt-%s-%s", parts[0], parts[1]) - } - } - if strings.Contains(identity, "/polecats/") { - // /polecats/ → gt-- - parts := strings.Split(identity, "/polecats/") - if len(parts) == 2 { - return fmt.Sprintf("gt-%s-%s", parts[0], parts[1]) - } - } - // Unknown identity return "" } } // restartSession starts a new session for the given agent. +// Uses role bead config if available, falls back to hardcoded defaults. func (d *Daemon) restartSession(sessionName, identity string) error { - // Determine working directory and startup command based on agent type - var workDir, startCmd string - var rigName string - var agentRole string - var needsPreSync bool - - if identity == "mayor" { - workDir = d.config.TownRoot - startCmd = "exec claude --dangerously-skip-permissions" - agentRole = "coordinator" - } else if strings.HasSuffix(identity, "-witness") { - // Extract rig name: -witness → - rigName = strings.TrimSuffix(identity, "-witness") - workDir = d.config.TownRoot + "/" + rigName - startCmd = "exec claude --dangerously-skip-permissions" - agentRole = "witness" - } else if strings.HasSuffix(identity, "-refinery") { - // Extract rig name: -refinery → - rigName = strings.TrimSuffix(identity, "-refinery") - workDir = filepath.Join(d.config.TownRoot, rigName, "refinery", "rig") - startCmd = "exec claude --dangerously-skip-permissions" - agentRole = "refinery" - needsPreSync = true - } else if strings.Contains(identity, "-crew-") { - // Extract rig and crew name: -crew- → , - parts := strings.SplitN(identity, "-crew-", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid crew identity format: %s", identity) - } - rigName = parts[0] - crewName := parts[1] - workDir = filepath.Join(d.config.TownRoot, rigName, "crew", crewName) - startCmd = "exec claude --dangerously-skip-permissions" - agentRole = "crew" - needsPreSync = true - } else if strings.Contains(identity, "-polecat-") || strings.Contains(identity, "/polecats/") { - // Extract rig and polecat name from either format: - // -polecat- or /polecats/ - var polecatName string - if strings.Contains(identity, "-polecat-") { - parts := strings.SplitN(identity, "-polecat-", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid polecat identity format: %s", identity) - } - rigName = parts[0] - polecatName = parts[1] - } else { - parts := strings.Split(identity, "/polecats/") - if len(parts) != 2 { - return fmt.Errorf("invalid polecat identity format: %s", identity) - } - rigName = parts[0] - polecatName = parts[1] - } - workDir = filepath.Join(d.config.TownRoot, rigName, "polecats", polecatName) - bdActor := fmt.Sprintf("%s/polecats/%s", rigName, polecatName) - startCmd = fmt.Sprintf("export GT_ROLE=polecat GT_RIG=%s GT_POLECAT=%s BD_ACTOR=%s && claude --dangerously-skip-permissions", - rigName, polecatName, bdActor) - agentRole = "polecat" - needsPreSync = true - } else { - return fmt.Errorf("don't know how to restart %s", identity) + // Get role config for this identity + config, parsed, err := d.getRoleConfigForIdentity(identity) + if err != nil { + return fmt.Errorf("parsing identity: %w", err) } - // Pre-sync workspace for agents with git worktrees (refinery) + // Determine working directory + workDir := d.getWorkDir(config, parsed) + if workDir == "" { + return fmt.Errorf("cannot determine working directory for %s", identity) + } + + // Determine if pre-sync is needed + needsPreSync := d.getNeedsPreSync(config, parsed) + + // Pre-sync workspace for agents with git worktrees if needsPreSync { d.logger.Printf("Pre-syncing workspace for %s at %s", identity, workDir) d.syncWorkspace(workDir) @@ -332,22 +351,14 @@ func (d *Daemon) restartSession(sessionName, identity string) error { return fmt.Errorf("creating session: %w", err) } - // Set environment (non-fatal: session works without these) - _ = d.tmux.SetEnvironment(sessionName, "GT_ROLE", identity) - // BD_ACTOR uses slashes instead of dashes for path-like identity - bdActor := identityToBDActor(identity) - _ = d.tmux.SetEnvironment(sessionName, "BD_ACTOR", bdActor) + // Set environment variables + d.setSessionEnvironment(sessionName, identity, config, parsed) // Apply theme (non-fatal: theming failure doesn't affect operation) - if identity == "mayor" { - theme := tmux.MayorTheme() - _ = d.tmux.ConfigureGasTownSession(sessionName, theme, "", "Mayor", "coordinator") - } else if rigName != "" { - theme := tmux.AssignTheme(rigName) - _ = d.tmux.ConfigureGasTownSession(sessionName, theme, rigName, agentRole, agentRole) - } + d.applySessionTheme(sessionName, parsed) - // Send startup command + // Get and send startup command + startCmd := d.getStartCommand(config, parsed) if err := d.tmux.SendKeys(sessionName, startCmd); err != nil { return fmt.Errorf("sending startup command: %w", err) } @@ -358,6 +369,102 @@ func (d *Daemon) restartSession(sessionName, identity string) error { return nil } +// getWorkDir determines the working directory for an agent. +// Uses role bead 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 config != nil && config.WorkDirPattern != "" { + return beads.ExpandRolePattern(config.WorkDirPattern, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType) + } + + // Fallback: use default patterns based on role type + switch parsed.RoleType { + case "mayor": + return d.config.TownRoot + case "deacon": + return d.config.TownRoot + case "witness": + return filepath.Join(d.config.TownRoot, parsed.RigName) + case "refinery": + return filepath.Join(d.config.TownRoot, parsed.RigName, "refinery", "rig") + case "crew": + return filepath.Join(d.config.TownRoot, parsed.RigName, "crew", parsed.AgentName) + case "polecat": + return filepath.Join(d.config.TownRoot, parsed.RigName, "polecats", parsed.AgentName) + default: + return "" + } +} + +// getNeedsPreSync determines if a workspace needs git sync before starting. +// Uses role bead 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 config != nil { + return config.NeedsPreSync + } + + // Fallback: roles with persistent git clones need pre-sync + switch parsed.RoleType { + case "refinery", "crew", "polecat": + return true + default: + return false + } +} + +// getStartCommand determines the startup command for an agent. +// Uses role bead config if available, falls back to hardcoded defaults. +func (d *Daemon) getStartCommand(config *beads.RoleConfig, parsed *ParsedIdentity) string { + // If role bead has explicit config, use it + if config != nil && config.StartCommand != "" { + // Expand any patterns in the command + return beads.ExpandRolePattern(config.StartCommand, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType) + } + + // Default command for all agents + defaultCmd := "exec claude --dangerously-skip-permissions" + + // Polecats need environment variables set in the command + if parsed.RoleType == "polecat" { + bdActor := fmt.Sprintf("%s/polecats/%s", parsed.RigName, parsed.AgentName) + return fmt.Sprintf("export GT_ROLE=polecat GT_RIG=%s GT_POLECAT=%s BD_ACTOR=%s && %s", + parsed.RigName, parsed.AgentName, bdActor, defaultCmd) + } + + return defaultCmd +} + +// setSessionEnvironment sets environment variables for the tmux session. +// Uses role bead config if available, falls back to hardcoded defaults. +func (d *Daemon) setSessionEnvironment(sessionName, identity string, config *beads.RoleConfig, parsed *ParsedIdentity) { + // Always set GT_ROLE + _ = d.tmux.SetEnvironment(sessionName, "GT_ROLE", identity) + + // BD_ACTOR uses slashes instead of dashes for path-like identity + bdActor := identityToBDActor(identity) + _ = d.tmux.SetEnvironment(sessionName, "BD_ACTOR", bdActor) + + // Set any custom env vars from role config + if config != nil { + for k, v := range config.EnvVars { + expanded := beads.ExpandRolePattern(v, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType) + _ = d.tmux.SetEnvironment(sessionName, k, expanded) + } + } +} + +// applySessionTheme applies tmux theming to the session. +func (d *Daemon) applySessionTheme(sessionName string, parsed *ParsedIdentity) { + if parsed.RoleType == "mayor" { + theme := tmux.MayorTheme() + _ = d.tmux.ConfigureGasTownSession(sessionName, theme, "", "Mayor", "coordinator") + } else if parsed.RigName != "" { + theme := tmux.AssignTheme(parsed.RigName) + _ = d.tmux.ConfigureGasTownSession(sessionName, theme, parsed.RigName, parsed.RoleType, parsed.RoleType) + } +} + // syncWorkspace syncs a git workspace before starting a new session. // This ensures agents with persistent clones (like refinery) start with current code. func (d *Daemon) syncWorkspace(workDir string) { @@ -480,50 +587,32 @@ func (d *Daemon) clearAgentRequestingState(identity string, action LifecycleActi } // identityToStateFile maps an agent identity to its state.json file path. +// Uses parseIdentity to extract components, then derives state file location. func (d *Daemon) identityToStateFile(identity string) string { - switch identity { + parsed, err := parseIdentity(identity) + if err != nil { + return "" + } + + // Derive state file path based on working directory + workDir := d.getWorkDir(nil, parsed) // Use defaults, not role bead config + if workDir == "" { + return "" + } + + // For mayor and deacon, state file is in a subdirectory + switch parsed.RoleType { case "mayor": return filepath.Join(d.config.TownRoot, "mayor", "state.json") + case "deacon": + return filepath.Join(d.config.TownRoot, "deacon", "state.json") + case "witness": + return filepath.Join(d.config.TownRoot, parsed.RigName, "witness", "state.json") + case "refinery": + return filepath.Join(d.config.TownRoot, parsed.RigName, "refinery", "state.json") default: - // Pattern: -witness → //witness/state.json - if strings.HasSuffix(identity, "-witness") { - rigName := strings.TrimSuffix(identity, "-witness") - return filepath.Join(d.config.TownRoot, rigName, "witness", "state.json") - } - // Pattern: -refinery → //refinery/state.json - if strings.HasSuffix(identity, "-refinery") { - rigName := strings.TrimSuffix(identity, "-refinery") - return filepath.Join(d.config.TownRoot, rigName, "refinery", "state.json") - } - // Pattern: -crew- → //crew//state.json - if strings.Contains(identity, "-crew-") { - parts := strings.SplitN(identity, "-crew-", 2) - if len(parts) == 2 { - rigName := parts[0] - crewName := parts[1] - return filepath.Join(d.config.TownRoot, rigName, "crew", crewName, "state.json") - } - } - // Pattern: -polecat- → //polecats//state.json - if strings.Contains(identity, "-polecat-") { - parts := strings.SplitN(identity, "-polecat-", 2) - if len(parts) == 2 { - rigName := parts[0] - polecatName := parts[1] - return filepath.Join(d.config.TownRoot, rigName, "polecats", polecatName, "state.json") - } - } - // Pattern: /polecats/ → //polecats//state.json - if strings.Contains(identity, "/polecats/") { - parts := strings.Split(identity, "/polecats/") - if len(parts) == 2 { - rigName := parts[0] - polecatName := parts[1] - return filepath.Join(d.config.TownRoot, rigName, "polecats", polecatName, "state.json") - } - } - // Unknown identity - can't determine state file - return "" + // For crew and polecat, state file is in their working directory + return filepath.Join(workDir, "state.json") } } @@ -602,52 +691,27 @@ func (d *Daemon) getAgentBeadInfo(agentBeadID string) (*AgentBeadInfo, error) { } // identityToAgentBeadID maps a daemon identity to an agent bead ID. -// Uses the canonical naming convention: prefix-rig-role-name -// Examples: -// - "deacon" → "gt-deacon" -// - "mayor" → "gt-mayor" -// - "gastown-witness" → "gt-gastown-witness" -// - "gastown-refinery" → "gt-gastown-refinery" -// - "gastown-polecat-toast" → "gt-polecat-gastown-toast" +// Uses parseIdentity to extract components, then uses beads package helpers. func (d *Daemon) identityToAgentBeadID(identity string) string { - switch identity { + parsed, err := parseIdentity(identity) + if err != nil { + return "" + } + + switch parsed.RoleType { case "deacon": return beads.DeaconBeadID() case "mayor": return beads.MayorBeadID() + case "witness": + return beads.WitnessBeadID(parsed.RigName) + case "refinery": + return beads.RefineryBeadID(parsed.RigName) + case "crew": + return beads.CrewBeadID(parsed.RigName, parsed.AgentName) + case "polecat": + return beads.PolecatBeadID(parsed.RigName, parsed.AgentName) default: - // Pattern: -witness → gt--witness - if strings.HasSuffix(identity, "-witness") { - rigName := strings.TrimSuffix(identity, "-witness") - return beads.WitnessBeadID(rigName) - } - // Pattern: -refinery → gt--refinery - if strings.HasSuffix(identity, "-refinery") { - rigName := strings.TrimSuffix(identity, "-refinery") - return beads.RefineryBeadID(rigName) - } - // Pattern: -crew- → gt--crew- - if strings.Contains(identity, "-crew-") { - parts := strings.SplitN(identity, "-crew-", 2) - if len(parts) == 2 { - return beads.CrewBeadID(parts[0], parts[1]) - } - } - // Pattern: -polecat- → gt-polecat-- - if strings.Contains(identity, "-polecat-") { - parts := strings.SplitN(identity, "-polecat-", 2) - if len(parts) == 2 { - return beads.PolecatBeadID(parts[0], parts[1]) - } - } - // Pattern: /polecats/ → gt-polecat-- - if strings.Contains(identity, "/polecats/") { - parts := strings.Split(identity, "/polecats/") - if len(parts) == 2 { - return beads.PolecatBeadID(parts[0], parts[1]) - } - } - // Unknown format return "" } } @@ -740,47 +804,32 @@ func (d *Daemon) markAgentDead(agentBeadID string) error { return nil } -// identityToBDActor converts a daemon identity (with dashes) to BD_ACTOR format (with slashes). -// Examples: -// - "mayor" → "mayor" -// - "gastown-witness" → "gastown/witness" -// - "gastown-refinery" → "gastown/refinery" -// - "gastown-crew-max" → "gastown/crew/max" -// - "gastown-polecat-toast" → "gastown/polecats/toast" +// identityToBDActor converts a daemon identity to BD_ACTOR format (with slashes). +// Uses parseIdentity to extract components, then builds the slash format. func identityToBDActor(identity string) string { - switch identity { - case "mayor", "deacon": + // Handle already-slash-formatted identities + if strings.Contains(identity, "/polecats/") || strings.Contains(identity, "/crew/") || + strings.Contains(identity, "/witness") || strings.Contains(identity, "/refinery") { return identity + } + + parsed, err := parseIdentity(identity) + if err != nil { + return identity // Unknown format - return as-is + } + + switch parsed.RoleType { + case "mayor", "deacon": + return parsed.RoleType + case "witness": + return parsed.RigName + "/witness" + case "refinery": + return parsed.RigName + "/refinery" + case "crew": + return parsed.RigName + "/crew/" + parsed.AgentName + case "polecat": + return parsed.RigName + "/polecats/" + parsed.AgentName default: - // Pattern: -witness → /witness - if strings.HasSuffix(identity, "-witness") { - rigName := strings.TrimSuffix(identity, "-witness") - return rigName + "/witness" - } - // Pattern: -refinery → /refinery - if strings.HasSuffix(identity, "-refinery") { - rigName := strings.TrimSuffix(identity, "-refinery") - return rigName + "/refinery" - } - // Pattern: -crew- → /crew/ - if strings.Contains(identity, "-crew-") { - parts := strings.SplitN(identity, "-crew-", 2) - if len(parts) == 2 { - return parts[0] + "/crew/" + parts[1] - } - } - // Pattern: -polecat- → /polecats/ - if strings.Contains(identity, "-polecat-") { - parts := strings.SplitN(identity, "-polecat-", 2) - if len(parts) == 2 { - return parts[0] + "/polecats/" + parts[1] - } - } - // Identity already in slash format - return as-is - if strings.Contains(identity, "/polecats/") { - return identity - } - // Unknown format - return as-is return identity } }