From 0ad427e4a890d3f972132f7a0461c617cf0bb9f4 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Fri, 26 Dec 2025 18:33:22 -0800 Subject: [PATCH] feat: Remove gt spawn completely - gt sling is THE command (gt-1py3y) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fully remove gt spawn from the codebase: - Delete spawn.go, create polecat_spawn.go with just sling helpers - Remove all gt spawn references from docs and CLAUDE.md - Update code comments to reference gt sling gt sling now handles ALL work dispatch: - Existing agents: gt sling mayor/crew/witness - Auto-spawn: gt sling - No-tmux: gt sling --naked - With args: gt sling --args "..." 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 2 +- docs/architecture.md | 6 +- docs/bootstrap.md | 2 +- docs/cross-project-deps.md | 6 +- docs/deacon-plugins.md | 2 +- docs/design/account-management.md | 8 +- docs/prompts.md | 2 +- docs/vision.md | 2 +- internal/cmd/gitinit.go | 4 +- internal/cmd/polecat.go | 4 +- internal/cmd/polecat_spawn.go | 207 ++++++ internal/cmd/sling.go | 2 +- internal/cmd/spawn.go | 1095 ----------------------------- 13 files changed, 227 insertions(+), 1115 deletions(-) create mode 100644 internal/cmd/polecat_spawn.go delete mode 100644 internal/cmd/spawn.go diff --git a/CLAUDE.md b/CLAUDE.md index 0f121918..64028b1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ gastown/ ← This rig - `gt rigs` - List all rigs ### Work Management -- `gt spawn --issue ` - Spawn polecat for issue +- `gt sling ` - Assign work to polecat in rig - `bd update --status=in_progress` - Claim work ## Development diff --git a/docs/architecture.md b/docs/architecture.md index 63b86bd0..8e848273 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -699,7 +699,7 @@ pending → in_progress → completed When a molecule is attached to an issue: ```bash -gt spawn --issue gt-xyz --molecule mol-shiny +gt sling gt-xyz --molecule mol-shiny ``` 1. Molecule is validated (steps, dependencies) @@ -756,7 +756,7 @@ bd mol show mol-shiny bd mol bond mol-shiny --var feature_name="auth" # Spawn polecat with molecule -gt spawn --issue gt-xyz --molecule mol-shiny +gt sling gt-xyz --molecule mol-shiny ``` ### Why Molecules? @@ -1957,7 +1957,7 @@ gt capture "" # Run command in polecat session ### Session Management ```bash -gt spawn --issue --molecule mol-shiny # Spawn polecat with workflow +gt sling --molecule mol-shiny # Spawn polecat with workflow gt handoff # Polecat requests shutdown (run when done) gt session stop

# Kill polecat session (Witness uses this) ``` diff --git a/docs/bootstrap.md b/docs/bootstrap.md index 6221d768..5543ca44 100644 --- a/docs/bootstrap.md +++ b/docs/bootstrap.md @@ -221,4 +221,4 @@ echo "Bootstrap complete!" After bootstrapping: 1. Start a Mayor session: `gt mayor attach` 2. Check for work: `bd ready` -3. Spawn workers with molecules: `gt spawn --issue --molecule mol-shiny` +3. Spawn workers with molecules: `gt sling --molecule mol-shiny` diff --git a/docs/cross-project-deps.md b/docs/cross-project-deps.md index 0d5b4f40..6806f300 100644 --- a/docs/cross-project-deps.md +++ b/docs/cross-project-deps.md @@ -127,7 +127,7 @@ bd list --has-external-block **Manual (launch):** ```bash -gt spawn --continue gt-mol-root +gt sling gt-mol-root # Spawns polecat, which reads handoff mail and continues ``` @@ -153,7 +153,7 @@ Deacon patrol checks parked molecules: ### Phase 2: Gas Town Integration (gt-* issues) 1. **gt park command**: Set blocked_by, clear assignee, handoff, shutdown -2. **gt spawn --continue**: Resume parked molecule +2. **gt sling**: Resume parked molecule 3. **Patrol step**: Check parked molecules for unblocked ### Phase 3: Automation (future) @@ -213,7 +213,7 @@ gt park --step=gt-mol.3 --waiting="beads:mol-run-assignee" # Polecat shutting down. # Later, after beads ships: -gt spawn --continue gt-mol-root +gt sling gt-mol-root # Resuming molecule gt-mol-root... # Reading handoff context... # Continuing from step gt-mol.3 diff --git a/docs/deacon-plugins.md b/docs/deacon-plugins.md index 55d27e63..f71e1ef2 100644 --- a/docs/deacon-plugins.md +++ b/docs/deacon-plugins.md @@ -267,7 +267,7 @@ Execute registered plugins whose gates are open. ## Limitations - **No polecat spawning**: Plugins cannot spawn polecats. If a plugin tries - to use `gt spawn`, behavior is undefined. This may change in the future. + to use `gt sling`, behavior is undefined. This may change in the future. - **No cross-plugin dependencies**: Plugins don't declare dependencies on each other. If ordering matters, mark both as `parallel: false`. diff --git a/docs/design/account-management.md b/docs/design/account-management.md index 3a287085..ce744f80 100644 --- a/docs/design/account-management.md +++ b/docs/design/account-management.md @@ -63,7 +63,7 @@ Highest priority override. Set this to use a specific account: ```bash export GT_ACCOUNT=yegge -gt spawn gastown # Uses yegge account +gt sling gastown # Uses yegge account ``` ### Command Interface @@ -100,13 +100,13 @@ gt account status ```bash # Override for a specific spawn -gt spawn --account=yegge gastown +gt sling gastown --account=yegge # Override for crew attach gt crew at --account=ghosttrack max # With env var (highest precedence) -GT_ACCOUNT=yegge gt spawn gastown +GT_ACCOUNT=yegge gt sling gastown ``` ### Implementation Details @@ -119,7 +119,7 @@ GT_ACCOUNT=yegge gt spawn gastown #### How Spawning Works -When `gt spawn` or `gt crew at` runs Claude Code: +When `gt sling` or `gt crew at` runs Claude Code: ```go func resolveAccountConfigDir() string { diff --git a/docs/prompts.md b/docs/prompts.md index c549cf36..21e0fa3c 100644 --- a/docs/prompts.md +++ b/docs/prompts.md @@ -426,7 +426,7 @@ Unlike polecats, crew workers have no Witness oversight: ### Phase 3: Spawn & Lifecycle 1. Port spawn injection prompts 2. Add lifecycle templates (nudge, escalation) -3. Integrate with `gt spawn` command +3. Integrate with `gt sling` command ### Phase 4: CLI & Validation 1. Implement `gt prime` with role detection diff --git a/docs/vision.md b/docs/vision.md index a06fb91b..676f8ec0 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -361,7 +361,7 @@ Putting it all together: ``` 1. Human files issue in Beads -2. Mayor dispatches: gt spawn --issue +2. Mayor dispatches: gt sling --issue 3. Polecat created with: - Fresh worktree - mol-polecat-work pinned to hook diff --git a/internal/cmd/gitinit.go b/internal/cmd/gitinit.go index 12bc6e29..4ef74393 100644 --- a/internal/cmd/gitinit.go +++ b/internal/cmd/gitinit.go @@ -29,7 +29,7 @@ This command: 3. Optionally creates a GitHub repository The .gitignore excludes: - - Polecat worktrees and rig clones (recreated with 'gt spawn' or 'gt rig add') + - Polecat worktrees and rig clones (recreated with 'gt sling' or 'gt rig add') - Runtime state files (state.json, *.lock) - OS and editor files @@ -64,7 +64,7 @@ const HQGitignore = `# Gas Town HQ .gitignore **/registry.json # ============================================================================= -# Rig git worktrees (recreate with 'gt spawn' or 'gt rig add') +# Rig git worktrees (recreate with 'gt sling' or 'gt rig add') # ============================================================================= # Polecats - worker worktrees diff --git a/internal/cmd/polecat.go b/internal/cmd/polecat.go index 1931cece..9ee2f804 100644 --- a/internal/cmd/polecat.go +++ b/internal/cmd/polecat.go @@ -94,7 +94,7 @@ var polecatWakeCmd = &cobra.Command{ Long: `Resume a polecat to working state. DEPRECATED: In the transient model, polecats are created fresh for each task -via 'gt spawn'. This command is kept for backward compatibility. +via 'gt sling'. This command is kept for backward compatibility. Transitions: done → working @@ -509,7 +509,7 @@ func runPolecatRemove(cmd *cobra.Command, args []string) error { } func runPolecatWake(cmd *cobra.Command, args []string) error { - fmt.Println(style.Warning.Render("DEPRECATED: Use 'gt spawn' to create fresh polecats instead")) + fmt.Println(style.Warning.Render("DEPRECATED: Use 'gt sling' to create fresh polecats instead")) fmt.Println() rigName, polecatName, err := parseAddress(args[0]) diff --git a/internal/cmd/polecat_spawn.go b/internal/cmd/polecat_spawn.go new file mode 100644 index 00000000..572043ef --- /dev/null +++ b/internal/cmd/polecat_spawn.go @@ -0,0 +1,207 @@ +// Package cmd provides polecat spawning utilities for gt sling. +package cmd + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/steveyegge/gastown/internal/config" + "github.com/steveyegge/gastown/internal/constants" + "github.com/steveyegge/gastown/internal/git" + "github.com/steveyegge/gastown/internal/polecat" + "github.com/steveyegge/gastown/internal/rig" + "github.com/steveyegge/gastown/internal/session" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/tmux" + "github.com/steveyegge/gastown/internal/workspace" +) + +// SpawnedPolecatInfo contains info about a spawned polecat session. +type SpawnedPolecatInfo struct { + RigName string // Rig name (e.g., "gastown") + PolecatName string // Polecat name (e.g., "Toast") + ClonePath string // Path to polecat's git worktree + SessionName string // Tmux session name (e.g., "gt-gastown-p-Toast") + Pane string // Tmux pane ID +} + +// AgentID returns the agent identifier (e.g., "gastown/polecats/Toast") +func (s *SpawnedPolecatInfo) AgentID() string { + return fmt.Sprintf("%s/polecats/%s", s.RigName, s.PolecatName) +} + +// SlingSpawnOptions contains options for spawning a polecat via sling. +type SlingSpawnOptions struct { + Force bool // Force spawn even if polecat has uncommitted work + Naked bool // No-tmux mode: skip session creation + Account string // Claude Code account handle to use + Create bool // Create polecat if it doesn't exist (currently always true for sling) +} + +// SpawnPolecatForSling creates a fresh polecat and optionally starts its session. +// This is used by gt sling when the target is a rig name. +// The caller (sling) handles hook attachment and nudging. +func SpawnPolecatForSling(rigName string, opts SlingSpawnOptions) (*SpawnedPolecatInfo, error) { + // Find workspace + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return nil, fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Load rig config + rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") + rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) + if err != nil { + rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} + } + + g := git.NewGit(townRoot) + rigMgr := rig.NewManager(townRoot, rigsConfig, g) + r, err := rigMgr.GetRig(rigName) + if err != nil { + return nil, fmt.Errorf("rig '%s' not found", rigName) + } + + // Get polecat manager + polecatGit := git.NewGit(r.Path) + polecatMgr := polecat.NewManager(r, polecatGit) + + // Allocate a new polecat name + polecatName, err := polecatMgr.AllocateName() + if err != nil { + return nil, fmt.Errorf("allocating polecat name: %w", err) + } + fmt.Printf("Allocated polecat: %s\n", polecatName) + + // Check if polecat already exists (shouldn't, since we allocated fresh) + existingPolecat, err := polecatMgr.Get(polecatName) + if err == nil { + // Exists - recreate with fresh worktree + // Check for uncommitted work first + if !opts.Force { + pGit := git.NewGit(existingPolecat.ClonePath) + workStatus, checkErr := pGit.CheckUncommittedWork() + if checkErr == nil && !workStatus.Clean() { + return nil, fmt.Errorf("polecat '%s' has uncommitted work: %s\nUse --force to proceed anyway", + polecatName, workStatus.String()) + } + } + fmt.Printf("Recreating polecat %s with fresh worktree...\n", polecatName) + if _, err = polecatMgr.Recreate(polecatName, opts.Force); err != nil { + return nil, fmt.Errorf("recreating polecat: %w", err) + } + } else if err == polecat.ErrPolecatNotFound { + // Create new polecat + fmt.Printf("Creating polecat %s...\n", polecatName) + if _, err = polecatMgr.Add(polecatName); err != nil { + return nil, fmt.Errorf("creating polecat: %w", err) + } + } else { + return nil, fmt.Errorf("getting polecat: %w", err) + } + + // Get polecat object for path info + polecatObj, err := polecatMgr.Get(polecatName) + if err != nil { + return nil, fmt.Errorf("getting polecat after creation: %w", err) + } + + // Handle naked mode (no-tmux) + if opts.Naked { + fmt.Println() + fmt.Printf("%s\n", style.Bold.Render("🔧 NO-TMUX MODE (--naked)")) + fmt.Printf("Polecat created. Agent must be started manually.\n\n") + fmt.Printf("To start the agent:\n") + fmt.Printf(" cd %s\n", polecatObj.ClonePath) + fmt.Printf(" claude # Or: claude-code\n\n") + fmt.Printf("Agent will discover work via gt prime on startup.\n") + + return &SpawnedPolecatInfo{ + RigName: rigName, + PolecatName: polecatName, + ClonePath: polecatObj.ClonePath, + SessionName: "", // No session in naked mode + Pane: "", // No pane in naked mode + }, nil + } + + // Resolve account for Claude config + accountsPath := constants.MayorAccountsPath(townRoot) + claudeConfigDir, accountHandle, err := config.ResolveAccountConfigDir(accountsPath, opts.Account) + if err != nil { + return nil, fmt.Errorf("resolving account: %w", err) + } + if accountHandle != "" { + fmt.Printf("Using account: %s\n", accountHandle) + } + + // Start session + t := tmux.NewTmux() + sessMgr := session.NewManager(t, r) + + // Check if already running + running, _ := sessMgr.IsRunning(polecatName) + if !running { + fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName) + startOpts := session.StartOptions{ + ClaudeConfigDir: claudeConfigDir, + } + if err := sessMgr.Start(polecatName, startOpts); err != nil { + return nil, fmt.Errorf("starting session: %w", err) + } + } + + // Get session name and pane + sessionName := sessMgr.SessionName(polecatName) + pane, err := getSessionPane(sessionName) + if err != nil { + return nil, fmt.Errorf("getting pane for %s: %w", sessionName, err) + } + + fmt.Printf("%s Polecat %s spawned\n", style.Bold.Render("✓"), polecatName) + + return &SpawnedPolecatInfo{ + RigName: rigName, + PolecatName: polecatName, + ClonePath: polecatObj.ClonePath, + SessionName: sessionName, + Pane: pane, + }, nil +} + +// IsRigName checks if a target string is a rig name (not a role or path). +// Returns the rig name and true if it's a valid rig. +func IsRigName(target string) (string, bool) { + // If it contains a slash, it's a path format (rig/role or rig/crew/name) + if strings.Contains(target, "/") { + return "", false + } + + // Check known non-rig role names + switch strings.ToLower(target) { + case "mayor", "may", "deacon", "dea", "crew", "witness", "wit", "refinery", "ref": + return "", false + } + + // Try to load as a rig + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return "", false + } + + rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") + rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) + if err != nil { + return "", false + } + + g := git.NewGit(townRoot) + rigMgr := rig.NewManager(townRoot, rigsConfig, g) + _, err = rigMgr.GetRig(target) + if err != nil { + return "", false + } + + return target, true +} diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index 0fb13e5b..fcf7d0eb 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -72,7 +72,7 @@ var ( slingVars []string // --var flag: formula variables (key=value) slingArgs string // --args flag: natural language instructions for executor - // Flags migrated from gt spawn for unified work assignment + // Flags migrated for polecat spawning (used by sling for work assignment slingNaked bool // --naked: no-tmux mode (skip session creation) slingCreate bool // --create: create polecat if it doesn't exist slingMolecule string // --molecule: workflow to instantiate on the bead diff --git a/internal/cmd/spawn.go b/internal/cmd/spawn.go deleted file mode 100644 index e4246241..00000000 --- a/internal/cmd/spawn.go +++ /dev/null @@ -1,1095 +0,0 @@ -package cmd - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "github.com/spf13/cobra" - "github.com/steveyegge/gastown/internal/config" - "github.com/steveyegge/gastown/internal/constants" - "github.com/steveyegge/gastown/internal/git" - "github.com/steveyegge/gastown/internal/mail" - "github.com/steveyegge/gastown/internal/polecat" - "github.com/steveyegge/gastown/internal/refinery" - "github.com/steveyegge/gastown/internal/rig" - "github.com/steveyegge/gastown/internal/session" - "github.com/steveyegge/gastown/internal/style" - "github.com/steveyegge/gastown/internal/tmux" - "github.com/steveyegge/gastown/internal/witness" - "github.com/steveyegge/gastown/internal/workspace" -) - -// Spawn command flags -var ( - spawnIssue string - spawnMessage string - spawnCreate bool - spawnNoStart bool - spawnNaked bool - spawnPolecat string - spawnRig string - spawnMolecule string - spawnForce bool - spawnAccount string - - // spawn pending flags - spawnPendingLines int -) - -var spawnCmd = &cobra.Command{ - Use: "spawn [rig/polecat | rig]", - Aliases: []string{"sp"}, - GroupID: GroupWork, - Short: "[DEPRECATED] Use 'gt sling' instead - spawn a polecat with work", - Long: `Spawn a polecat with a work assignment. - -Use 'gt spawn pending' to view spawns waiting to be triggered. - -Assigns an issue or task to a polecat and starts a session. If no polecat -is specified, auto-selects an idle polecat in the rig. - -Issue-based spawns automatically use mol-polecat-work for structured workflow -with crash recovery checkpoints. Use --molecule to override with a different -molecule, or -m/--message for free-form tasks without a molecule. - -SIMPLER ALTERNATIVE: For quick polecat spawns, use 'gt sling': - gt sling # Auto-spawns polecat, hooks work, starts immediately - -NO-TMUX MODE: Use --naked to assign work without creating a tmux session. -Agent must be started manually, but discovers work via gt prime on startup. - -Examples: - gt spawn gastown/Toast --issue gt-abc # uses mol-polecat-work - gt spawn gastown --issue gt-def # auto-select polecat - gt spawn gastown/Nux -m "Fix the tests" # free-form task (no molecule) - gt spawn gastown/Capable --issue gt-xyz --create # create if missing - gt spawn gastown/Toast --issue gt-abc --naked # no-tmux mode - - # Flag-based selection (rig inferred from current directory): - gt spawn --issue gt-xyz --polecat Angharad - gt spawn --issue gt-abc --rig gastown --polecat Toast - - # With custom molecule workflow: - gt spawn --issue gt-abc --molecule mol-engineer-box`, - Args: cobra.MaximumNArgs(1), - RunE: runSpawn, -} - -var spawnPendingCmd = &cobra.Command{ - Use: "pending [session-to-clear]", - Short: "List pending spawns with captured output (for AI observation)", - Long: `List pending polecat spawns with their terminal output for AI analysis. - -This shows spawns waiting to be triggered (Claude is still initializing). -The terminal output helps determine if Claude is ready. - -Workflow: -1. Run 'gt spawn pending' to see pending spawns and their output -2. Analyze the output to determine if Claude is ready (look for "> " prompt) -3. Run 'gt nudge "Begin."' to trigger ready polecats -4. Run 'gt spawn pending ' to clear from pending list - -Examples: - gt spawn pending # List all pending with output - gt spawn pending gastown/p-abc123 # Clear specific session from pending`, - Args: cobra.MaximumNArgs(1), - RunE: runSpawnPending, -} - -func init() { - spawnCmd.Flags().StringVar(&spawnIssue, "issue", "", "Beads issue ID to assign") - spawnCmd.Flags().StringVarP(&spawnMessage, "message", "m", "", "Free-form task description") - spawnCmd.Flags().BoolVar(&spawnCreate, "create", false, "Create polecat if it doesn't exist") - spawnCmd.Flags().BoolVar(&spawnNoStart, "no-start", false, "Assign work but don't start session") - spawnCmd.Flags().BoolVar(&spawnNaked, "naked", false, "No-tmux mode: assign work via mail only, skip tmux session (agent starts manually)") - spawnCmd.Flags().StringVar(&spawnPolecat, "polecat", "", "Polecat name (alternative to positional arg)") - spawnCmd.Flags().StringVar(&spawnRig, "rig", "", "Rig name (defaults to current directory's rig)") - spawnCmd.Flags().StringVar(&spawnMolecule, "molecule", "", "Molecule ID to instantiate on the issue") - spawnCmd.Flags().BoolVar(&spawnForce, "force", false, "Force spawn even if polecat has unread mail") - spawnCmd.Flags().StringVar(&spawnAccount, "account", "", "Claude Code account handle to use (overrides default)") - - // spawn pending flags - spawnPendingCmd.Flags().IntVarP(&spawnPendingLines, "lines", "n", 15, - "Number of terminal lines to capture per session") - - spawnCmd.AddCommand(spawnPendingCmd) - rootCmd.AddCommand(spawnCmd) -} - -// BeadsIssue represents a beads issue from JSON output. -type BeadsIssue struct { - ID string `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - Priority int `json:"priority"` - Type string `json:"issue_type"` - Status string `json:"status"` -} - -func runSpawn(cmd *cobra.Command, args []string) error { - // Deprecation warning - prefer gt sling - fmt.Println(style.Warning.Render("DEPRECATED: 'gt spawn' is deprecated. Use 'gt sling' instead:")) - fmt.Println(style.Dim.Render(" gt sling # Auto-spawn polecat")) - fmt.Println(style.Dim.Render(" gt sling --naked # No-tmux mode")) - fmt.Println(style.Dim.Render(" gt sling --args '...' # With natural language args")) - fmt.Println() - - if spawnIssue == "" && spawnMessage == "" { - return fmt.Errorf("must specify --issue or -m/--message") - } - - // --molecule requires --issue - if spawnMolecule != "" && spawnIssue == "" { - return fmt.Errorf("--molecule requires --issue to be specified") - } - - // Auto-use mol-polecat-work for issue-based spawns (Phase 3: Polecat Work Cycle) - // This gives polecats a structured workflow with checkpoints for crash recovery. - // Can be overridden with explicit --molecule flag. - if spawnIssue != "" && spawnMolecule == "" { - spawnMolecule = "mol-polecat-work" - } - - // Find workspace first (needed for rig inference) - townRoot, err := workspace.FindFromCwdOrError() - if err != nil { - return fmt.Errorf("not in a Gas Town workspace: %w", err) - } - - var rigName, polecatName string - - // Determine rig and polecat from positional arg or flags - if len(args) > 0 { - // Parse address: rig/polecat or just rig - rigName, polecatName, err = parseSpawnAddress(args[0]) - if err != nil { - return err - } - } else { - // No positional arg - use flags - polecatName = spawnPolecat - rigName = spawnRig - - // If no --rig flag, infer from current directory - if rigName == "" { - rigName, err = inferRigFromCwd(townRoot) - if err != nil { - return fmt.Errorf("cannot determine rig: %w\nUse --rig to specify explicitly or provide rig/polecat as positional arg", err) - } - } - } - - rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") - rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) - if err != nil { - rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} - } - - g := git.NewGit(townRoot) - rigMgr := rig.NewManager(townRoot, rigsConfig, g) - r, err := rigMgr.GetRig(rigName) - if err != nil { - return fmt.Errorf("rig '%s' not found", rigName) - } - - // Get polecat manager - polecatGit := git.NewGit(r.Path) - polecatMgr := polecat.NewManager(r, polecatGit) - - // Router for mail operations (used for checking inbox and sending assignments) - router := mail.NewRouter(r.Path) - - // Auto-select polecat if not specified - if polecatName == "" { - polecatName, err = selectIdlePolecat(polecatMgr, r) - if err != nil { - // If --create is set, allocate a name from the pool - if spawnCreate { - polecatName, err = polecatMgr.AllocateName() - if err != nil { - return fmt.Errorf("allocating polecat name: %w", err) - } - fmt.Printf("Allocated polecat name: %s\n", polecatName) - } else { - return fmt.Errorf("auto-select polecat: %w", err) - } - } else { - fmt.Printf("Auto-selected polecat: %s\n", polecatName) - } - } - - // Address for this polecat (used for mail operations) - polecatAddress := fmt.Sprintf("%s/%s", rigName, polecatName) - - // Check if polecat exists - existingPolecat, err := polecatMgr.Get(polecatName) - polecatExists := err == nil - - if polecatExists { - // Polecat exists - we'll recreate it fresh after safety checks - - // Check if polecat is currently working (cannot interrupt active work) - if existingPolecat.State == polecat.StateWorking { - return fmt.Errorf("polecat '%s' is already working on %s", polecatName, existingPolecat.Issue) - } - - // Check for uncommitted work (safety check before recreating) - pGit := git.NewGit(existingPolecat.ClonePath) - workStatus, checkErr := pGit.CheckUncommittedWork() - if checkErr == nil && !workStatus.Clean() { - fmt.Printf("\n%s Polecat has uncommitted work:\n", style.Warning.Render("⚠")) - if workStatus.HasUncommittedChanges { - fmt.Printf(" • %d uncommitted change(s)\n", len(workStatus.ModifiedFiles)+len(workStatus.UntrackedFiles)) - } - if workStatus.StashCount > 0 { - fmt.Printf(" • %d stash(es)\n", workStatus.StashCount) - } - if workStatus.UnpushedCommits > 0 { - fmt.Printf(" • %d unpushed commit(s)\n", workStatus.UnpushedCommits) - } - fmt.Println() - if !spawnForce { - return fmt.Errorf("polecat '%s' has uncommitted work (%s)\nCommit or stash changes before spawning, or use --force to proceed anyway", - polecatName, workStatus.String()) - } - fmt.Printf("%s Proceeding with --force (uncommitted work will be lost)\n", - style.Dim.Render("Warning:")) - } - - // Check for unread mail (indicates existing unstarted work) - mailbox, mailErr := router.GetMailbox(polecatAddress) - if mailErr == nil { - _, unread, _ := mailbox.Count() - if unread > 0 && !spawnForce { - return fmt.Errorf("polecat '%s' has %d unread message(s) in inbox (possible existing work assignment)\nUse --force to override, or let the polecat process its inbox first", - polecatName, unread) - } else if unread > 0 { - fmt.Printf("%s Polecat has %d unread message(s), proceeding with --force\n", - style.Dim.Render("Warning:"), unread) - } - } - - // Recreate the polecat with a fresh worktree (latest code from main) - fmt.Printf("Recreating polecat %s with fresh worktree...\n", polecatName) - if _, err = polecatMgr.Recreate(polecatName, spawnForce); err != nil { - return fmt.Errorf("recreating polecat: %w", err) - } - fmt.Printf("%s Fresh worktree created\n", style.Bold.Render("✓")) - } else if err == polecat.ErrPolecatNotFound { - // Polecat doesn't exist - create new one - if !spawnCreate { - return fmt.Errorf("polecat '%s' not found (use --create to create)", polecatName) - } - fmt.Printf("Creating polecat %s...\n", polecatName) - if _, err = polecatMgr.Add(polecatName); err != nil { - return fmt.Errorf("creating polecat: %w", err) - } - } else { - return fmt.Errorf("getting polecat: %w", err) - } - - // Get the polecat object to access its worktree path for hook file - polecatObj, err := polecatMgr.Get(polecatName) - if err != nil { - return fmt.Errorf("getting polecat after creation: %w", err) - } - - // Beads operations use rig-level beads (at rig root, not mayor/rig) - beadsPath := r.Path - - // Sync beads to ensure fresh state before spawn operations - if err := syncBeads(beadsPath, true); err != nil { - // Non-fatal - continue with possibly stale beads - fmt.Printf("%s beads sync: %v\n", style.Dim.Render("Warning:"), err) - } - - // Track molecule context for work assignment mail - var moleculeCtx *MoleculeContext - - // Handle molecule instantiation if specified - if spawnMolecule != "" { - // Molecule instantiation uses three separate bd commands: - // 1. bd pour - creates issues from proto template - // 2. bd update - sets status to in_progress (claims work) - // 3. bd pin - pins root for session recovery - // This keeps bd as pure data operations and gt as orchestration. - fmt.Printf("Running molecule %s on %s...\n", spawnMolecule, spawnIssue) - - // Step 1: Pour the molecule (create issues from template) - pourCmd := exec.Command("bd", "--no-daemon", "pour", spawnMolecule, - "--var", "issue="+spawnIssue, "--json") - pourCmd.Dir = beadsPath - - var pourStdout, pourStderr bytes.Buffer - pourCmd.Stdout = &pourStdout - pourCmd.Stderr = &pourStderr - - if err := pourCmd.Run(); err != nil { - errMsg := strings.TrimSpace(pourStderr.String()) - if errMsg != "" { - return fmt.Errorf("pouring molecule: %s", errMsg) - } - return fmt.Errorf("pouring molecule: %w", err) - } - - // Parse pour output to get root ID - var pourResult struct { - NewEpicID string `json:"new_epic_id"` - IDMapping map[string]string `json:"id_mapping"` - Created int `json:"created"` - } - if err := json.Unmarshal(pourStdout.Bytes(), &pourResult); err != nil { - return fmt.Errorf("parsing pour result: %w", err) - } - - rootID := pourResult.NewEpicID - fmt.Printf("%s Molecule poured: %s (%d steps)\n", - style.Bold.Render("✓"), rootID, pourResult.Created-1) // -1 for root - - // Step 2: Set status to in_progress (claim work) - updateCmd := exec.Command("bd", "--no-daemon", "update", rootID, "--status=in_progress") - updateCmd.Dir = beadsPath - if err := updateCmd.Run(); err != nil { - return fmt.Errorf("setting molecule status: %w", err) - } - - // Step 3: Pin the root for session recovery - pinCmd := exec.Command("bd", "--no-daemon", "pin", rootID) - pinCmd.Dir = beadsPath - if err := pinCmd.Run(); err != nil { - return fmt.Errorf("pinning molecule: %w", err) - } - - // Build molecule context for work assignment - moleculeCtx = &MoleculeContext{ - MoleculeID: spawnMolecule, - RootIssueID: rootID, - TotalSteps: pourResult.Created - 1, // -1 for root - StepNumber: 1, // Starting on first step - } - - // Update spawnIssue to be the molecule root (for assignment tracking) - spawnIssue = rootID - } - - // Get or create issue - var issue *BeadsIssue - var assignmentID string - if spawnIssue != "" { - // Use existing issue - issue, err = fetchBeadsIssue(beadsPath, spawnIssue) - if err != nil { - return fmt.Errorf("fetching issue %s: %w", spawnIssue, err) - } - assignmentID = spawnIssue - } else { - // Create a beads issue for free-form task - fmt.Printf("Creating beads issue for task...\n") - issue, err = createBeadsTask(beadsPath, spawnMessage) - if err != nil { - return fmt.Errorf("creating task issue: %w", err) - } - assignmentID = issue.ID - fmt.Printf("Created issue %s\n", assignmentID) - } - - // Assign issue to polecat (sets issue.assignee in beads) - if err := polecatMgr.AssignIssue(polecatName, assignmentID); err != nil { - return fmt.Errorf("assigning issue: %w", err) - } - - fmt.Printf("%s Assigned %s to %s/%s\n", - style.Bold.Render("✓"), - assignmentID, rigName, polecatName) - - // Pin the bead in the polecat's worktree for the propulsion protocol - pinCmd := exec.Command("bd", "update", assignmentID, "--status=pinned", "--assignee="+polecatAddress) - pinCmd.Dir = polecatObj.ClonePath - pinCmd.Stderr = os.Stderr - if err := pinCmd.Run(); err != nil { - fmt.Printf("%s pinning bead: %v\n", style.Dim.Render("Warning:"), err) - } else { - fmt.Printf("%s Bead pinned for polecat\n", style.Bold.Render("✓")) - } - - // Sync beads to push assignment changes - if err := syncBeads(beadsPath, false); err != nil { - // Non-fatal warning - fmt.Printf("%s beads push: %v\n", style.Dim.Render("Warning:"), err) - } - - // Stop here if --no-start - if spawnNoStart { - fmt.Printf("\n %s\n", style.Dim.Render("Use 'gt session start' to start the session")) - return nil - } - - // Send work assignment mail to polecat inbox (before starting session) - workMsg := buildWorkAssignmentMail(issue, spawnMessage, polecatAddress, moleculeCtx) - - fmt.Printf("Sending work assignment to %s inbox...\n", polecatAddress) - if err := router.Send(workMsg); err != nil { - return fmt.Errorf("sending work assignment: %w", err) - } - fmt.Printf("%s Work assignment sent\n", style.Bold.Render("✓")) - - // Stop here if --naked (no-tmux mode) - if spawnNaked { - fmt.Println() - fmt.Printf("%s\n", style.Bold.Render("🔧 NO-TMUX MODE (--naked)")) - fmt.Printf("Work assigned via mail. Agent must be started manually.\n\n") - fmt.Printf("To start the agent:\n") - fmt.Printf(" cd %s/%s/%s\n", townRoot, rigName, polecatName) - fmt.Printf(" claude # Or: claude-code\n\n") - fmt.Printf("Agent will discover work via gt prime / bd show on startup.\n") - return nil - } - - // Resolve account for Claude config - accountsPath := constants.MayorAccountsPath(townRoot) - claudeConfigDir, accountHandle, err := config.ResolveAccountConfigDir(accountsPath, spawnAccount) - if err != nil { - return fmt.Errorf("resolving account: %w", err) - } - if accountHandle != "" { - fmt.Printf("Using account: %s\n", accountHandle) - } - - // Start session - t := tmux.NewTmux() - sessMgr := session.NewManager(t, r) - - // Check if already running - running, _ := sessMgr.IsRunning(polecatName) - if running { - fmt.Printf("Session already running\n") - } else { - // Start new session - fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName) - startOpts := session.StartOptions{ - ClaudeConfigDir: claudeConfigDir, - } - if err := sessMgr.Start(polecatName, startOpts); err != nil { - return fmt.Errorf("starting session: %w", err) - } - } - - fmt.Printf("%s Session started. Attach with: %s\n", - style.Bold.Render("✓"), - style.Dim.Render(fmt.Sprintf("gt session at %s/%s", rigName, polecatName))) - - // Log spawn event - LogSpawn(townRoot, polecatAddress, assignmentID) - - // NOTE: We do NOT send a nudge here. Claude Code takes 10-20+ seconds to initialize, - // and sending keys before the prompt is ready causes them to be mangled. - // The Deacon will poll with WaitForClaudeReady and send a trigger when ready. - // The polecat's SessionStart hook runs gt prime, and work assignment is in its inbox. - - // Notify Witness and Deacon about the spawn for monitoring - // Use town-level beads for cross-agent mail (gt-c6b: mail coordination uses town-level) - townRouter := mail.NewRouter(townRoot) - sender := detectSender() - sessionName := sessMgr.SessionName(polecatName) - - // Notify Witness with POLECAT_STARTED message (ephemeral - lifecycle ping) - witnessAddr := fmt.Sprintf("%s/witness", rigName) - witnessNotification := &mail.Message{ - To: witnessAddr, - From: sender, - Subject: fmt.Sprintf("POLECAT_STARTED %s", polecatName), - Body: fmt.Sprintf("Issue: %s\nSession: %s", assignmentID, sessionName), - Wisp: true, - } - - if err := townRouter.Send(witnessNotification); err != nil { - fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("Warning: could not notify witness: %v", err))) - } else { - fmt.Printf(" %s\n", style.Dim.Render("Witness notified of polecat start")) - } - - // Notify Deacon with POLECAT_STARTED message (ephemeral - lifecycle ping) - deaconAddr := "deacon/" - deaconNotification := &mail.Message{ - To: deaconAddr, - From: sender, - Subject: fmt.Sprintf("POLECAT_STARTED %s/%s", rigName, polecatName), - Body: fmt.Sprintf("Issue: %s\nSession: %s", assignmentID, sessionName), - Wisp: true, - } - - if err := townRouter.Send(deaconNotification); err != nil { - fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("Warning: could not notify deacon: %v", err))) - } else { - fmt.Printf(" %s\n", style.Dim.Render("Deacon notified of polecat start")) - } - - // Auto-start infrastructure if not running (redundant system - Witness also self-checks) - // This ensures the merge queue and polecat monitor are alive to handle work - refineryMgr := refinery.NewManager(r) - if refStatus, err := refineryMgr.Status(); err == nil && refStatus.State != refinery.StateRunning { - fmt.Printf("Starting refinery for %s...\n", rigName) - if err := refineryMgr.Start(false); err != nil { - if err != refinery.ErrAlreadyRunning { - fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("Warning: could not start refinery: %v", err))) - } - } else { - fmt.Printf(" %s\n", style.Dim.Render("Refinery started")) - } - } - - witnessMgr := witness.NewManager(r) - if witStatus, err := witnessMgr.Status(); err == nil && witStatus.State != witness.StateRunning { - fmt.Printf("Starting witness for %s...\n", rigName) - if err := witnessMgr.Start(); err != nil { - if err != witness.ErrAlreadyRunning { - fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("Warning: could not start witness: %v", err))) - } - } else { - fmt.Printf(" %s\n", style.Dim.Render("Witness started")) - } - } - - return nil -} - -// parseSpawnAddress parses "rig/polecat" or "rig". -func parseSpawnAddress(addr string) (rigName, polecatName string, err error) { - if strings.Contains(addr, "/") { - parts := strings.SplitN(addr, "/", 2) - if parts[0] == "" { - return "", "", fmt.Errorf("invalid address: missing rig name") - } - return parts[0], parts[1], nil - } - return addr, "", nil -} - - -// selectIdlePolecat finds an idle polecat in the rig. -func selectIdlePolecat(mgr *polecat.Manager, r *rig.Rig) (string, error) { - polecats, err := mgr.List() - if err != nil { - return "", err - } - - // Prefer idle polecats - for _, pc := range polecats { - if pc.State == polecat.StateIdle { - return pc.Name, nil - } - } - - // Accept active polecats without current work - for _, pc := range polecats { - if pc.State == polecat.StateActive && pc.Issue == "" { - return pc.Name, nil - } - } - - // Check rig's polecat list for any we haven't loaded yet - for _, name := range r.Polecats { - found := false - for _, pc := range polecats { - if pc.Name == name { - found = true - break - } - } - if !found { - return name, nil - } - } - - return "", fmt.Errorf("no available polecats in rig '%s'", r.Name) -} - -// fetchBeadsIssue gets issue details from beads CLI. -func fetchBeadsIssue(rigPath, issueID string) (*BeadsIssue, error) { - cmd := exec.Command("bd", "show", issueID, "--json") - cmd.Dir = rigPath - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - errMsg := strings.TrimSpace(stderr.String()) - if errMsg != "" { - return nil, fmt.Errorf("%s", errMsg) - } - return nil, err - } - - // bd show --json returns an array, take the first element - var issues []BeadsIssue - if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil { - return nil, fmt.Errorf("parsing issue: %w", err) - } - if len(issues) == 0 { - return nil, fmt.Errorf("issue not found: %s", issueID) - } - - return &issues[0], nil -} - -// createBeadsTask creates a new beads task issue for a free-form task message. -func createBeadsTask(rigPath, message string) (*BeadsIssue, error) { - // Truncate message for title if too long - title := message - if len(title) > 60 { - title = title[:57] + "..." - } - - // Use bd create to make a new task issue - cmd := exec.Command("bd", "create", - "--title="+title, - "--type=task", - "--priority=2", - "--description="+message, - "--json") - cmd.Dir = rigPath - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - errMsg := strings.TrimSpace(stderr.String()) - if errMsg != "" { - return nil, fmt.Errorf("%s", errMsg) - } - return nil, err - } - - // bd create --json returns the created issue - var issue BeadsIssue - if err := json.Unmarshal(stdout.Bytes(), &issue); err != nil { - return nil, fmt.Errorf("parsing created issue: %w", err) - } - - return &issue, nil -} - -// syncBeads runs bd sync in the given directory. -// This ensures beads state is fresh before spawn operations. -func syncBeads(workDir string, fromMain bool) error { - args := []string{"sync"} - if fromMain { - args = append(args, "--from-main") - } - cmd := exec.Command("bd", args...) - cmd.Dir = workDir - return cmd.Run() -} - -// buildSpawnContext creates the initial context message for the polecat. -// Deprecated: Use buildWorkAssignmentMail instead for mail-based work assignment. -func buildSpawnContext(issue *BeadsIssue, message string) string { - var sb strings.Builder - - sb.WriteString("[SPAWN] You have been assigned work.\n\n") - - if issue != nil { - sb.WriteString(fmt.Sprintf("Issue: %s\n", issue.ID)) - sb.WriteString(fmt.Sprintf("Title: %s\n", issue.Title)) - sb.WriteString(fmt.Sprintf("Priority: P%d\n", issue.Priority)) - sb.WriteString(fmt.Sprintf("Type: %s\n", issue.Type)) - if issue.Description != "" { - sb.WriteString(fmt.Sprintf("\nDescription:\n%s\n", issue.Description)) - } - } else if message != "" { - sb.WriteString(fmt.Sprintf("Task: %s\n", message)) - } - - sb.WriteString("\n## Workflow\n") - sb.WriteString("1. Run `gt prime` to load polecat context\n") - sb.WriteString("2. Work on your task, commit changes regularly\n") - sb.WriteString("3. Run `bd close ` when done\n") - sb.WriteString("4. Run `bd sync` to push beads changes\n") - sb.WriteString("5. Run `gt done` to signal completion (branch stays local)\n") - - return sb.String() -} - -// MoleculeContext contains information about a molecule workflow assignment. -type MoleculeContext struct { - MoleculeID string // The molecule template ID (proto) - RootIssueID string // The created molecule root issue - TotalSteps int // Total number of steps in the molecule - StepNumber int // Which step this is (1-indexed) - IsWisp bool // True if this is a wisp (not durable mol) -} - -// buildWorkAssignmentMail creates a work assignment mail message for a polecat. -// This replaces tmux-based context injection with persistent mailbox delivery. -// If moleculeCtx is non-nil, includes molecule workflow instructions. -func buildWorkAssignmentMail(issue *BeadsIssue, message, polecatAddress string, moleculeCtx *MoleculeContext) *mail.Message { - var subject string - var body strings.Builder - - if issue != nil { - if moleculeCtx != nil { - subject = fmt.Sprintf("🧬 Molecule: %s (step %d/%d)", issue.Title, moleculeCtx.StepNumber, moleculeCtx.TotalSteps) - } else { - subject = fmt.Sprintf("📋 Work Assignment: %s", issue.Title) - } - - body.WriteString(fmt.Sprintf("Issue: %s\n", issue.ID)) - body.WriteString(fmt.Sprintf("Title: %s\n", issue.Title)) - body.WriteString(fmt.Sprintf("Priority: P%d\n", issue.Priority)) - body.WriteString(fmt.Sprintf("Type: %s\n", issue.Type)) - if issue.Description != "" { - body.WriteString(fmt.Sprintf("\nDescription:\n%s\n", issue.Description)) - } - } else if message != "" { - // Truncate for subject if too long - titleText := message - if len(titleText) > 50 { - titleText = titleText[:47] + "..." - } - subject = fmt.Sprintf("📋 Work Assignment: %s", titleText) - body.WriteString(fmt.Sprintf("Task: %s\n", message)) - } - - // Add molecule context if present - if moleculeCtx != nil { - body.WriteString("\n## Molecule Workflow\n") - body.WriteString(fmt.Sprintf("You are working on step %d of %d in molecule %s.\n", moleculeCtx.StepNumber, moleculeCtx.TotalSteps, moleculeCtx.MoleculeID)) - body.WriteString(fmt.Sprintf("Molecule root: %s\n\n", moleculeCtx.RootIssueID)) - body.WriteString("After completing this step:\n") - body.WriteString("1. Run `bd close `\n") - body.WriteString("2. Run `bd ready --parent " + moleculeCtx.RootIssueID + "` to find next ready steps\n") - body.WriteString("3. If more steps are ready, continue working on them\n") - body.WriteString("4. When all steps are done, run `gt done` to signal completion\n\n") - } - - body.WriteString("\n## Workflow\n") - body.WriteString("1. Run `gt prime` to load polecat context\n") - body.WriteString("2. Work on your task, commit changes regularly\n") - body.WriteString("3. Run `bd close ` when done\n") - if moleculeCtx != nil { - body.WriteString("4. Check `bd ready --parent " + moleculeCtx.RootIssueID + "` for more steps\n") - body.WriteString("5. Repeat steps 2-4 for each ready step\n") - body.WriteString("6. When all steps done: run `bd sync`, then `gt done`\n") - } else { - body.WriteString("4. Run `bd sync` to push beads changes\n") - body.WriteString("5. Run `gt done` to signal completion (branch stays local)\n") - } - body.WriteString("\n## Handoff Protocol\n") - body.WriteString("Before signaling done, ensure:\n") - body.WriteString("- Git status is clean (no uncommitted changes)\n") - body.WriteString("- Issue is closed with `bd close`\n") - body.WriteString("- Beads are synced with `bd sync`\n") - body.WriteString("\nThe `gt done` command verifies these and signals the Witness.\n") - - return &mail.Message{ - From: "mayor/", - To: polecatAddress, - Subject: subject, - Body: body.String(), - Priority: mail.PriorityHigh, - Type: mail.TypeTask, - } -} - -// runSpawnPending shows pending spawns with captured output for AI observation. -// This is the ZFC-compliant way to observe polecats waiting to be triggered. -func runSpawnPending(cmd *cobra.Command, args []string) error { - townRoot, err := workspace.FindFromCwdOrError() - if err != nil { - return fmt.Errorf("not in a Gas Town workspace: %w", err) - } - - // If session argument provided, clear it from pending - if len(args) == 1 { - return clearSpawnPending(townRoot, args[0]) - } - - // Step 1: Check inbox for new POLECAT_STARTED messages - pending, err := polecat.CheckInboxForSpawns(townRoot) - if err != nil { - return fmt.Errorf("checking inbox: %w", err) - } - - if len(pending) == 0 { - fmt.Printf("%s No pending spawns\n", style.Dim.Render("○")) - return nil - } - - t := tmux.NewTmux() - - fmt.Printf("%s Pending spawns (%d):\n\n", style.Bold.Render("●"), len(pending)) - - for i, ps := range pending { - // Check if session still exists - running, err := t.HasSession(ps.Session) - if err != nil { - fmt.Printf("Session: %s\n", ps.Session) - fmt.Printf(" Status: error checking session: %v\n\n", err) - continue - } - - if !running { - fmt.Printf("Session: %s\n", ps.Session) - fmt.Printf(" Status: session no longer exists\n\n") - continue - } - - // Capture terminal output for AI analysis - output, err := t.CapturePane(ps.Session, spawnPendingLines) - if err != nil { - fmt.Printf("Session: %s\n", ps.Session) - fmt.Printf(" Status: error capturing output: %v\n\n", err) - continue - } - - // Print session info - fmt.Printf("Session: %s\n", ps.Session) - fmt.Printf(" Rig: %s\n", ps.Rig) - fmt.Printf(" Polecat: %s\n", ps.Polecat) - if ps.Issue != "" { - fmt.Printf(" Issue: %s\n", ps.Issue) - } - fmt.Printf(" Spawned: %s ago\n", time.Since(ps.SpawnedAt).Round(time.Second)) - fmt.Printf(" Terminal output (last %d lines):\n", spawnPendingLines) - fmt.Println(strings.Repeat("─", 50)) - fmt.Println(output) - fmt.Println(strings.Repeat("─", 50)) - - if i < len(pending)-1 { - fmt.Println() - } - } - - fmt.Println() - fmt.Printf("%s To trigger a ready polecat:\n", style.Dim.Render("→")) - fmt.Printf(" gt nudge \"Begin.\"\n") - - return nil -} - -// clearSpawnPending removes a session from the pending list. -func clearSpawnPending(townRoot, session string) error { - pending, err := polecat.LoadPending(townRoot) - if err != nil { - return fmt.Errorf("loading pending: %w", err) - } - - var remaining []*polecat.PendingSpawn - found := false - for _, ps := range pending { - if ps.Session == session { - found = true - continue - } - remaining = append(remaining, ps) - } - - if !found { - return fmt.Errorf("session %s not found in pending list", session) - } - - if err := polecat.SavePending(townRoot, remaining); err != nil { - return fmt.Errorf("saving pending: %w", err) - } - - fmt.Printf("%s Cleared %s from pending list\n", style.Bold.Render("✓"), session) - return nil -} - -// SpawnedPolecatInfo contains info about a spawned polecat session. -type SpawnedPolecatInfo struct { - RigName string // Rig name (e.g., "gastown") - PolecatName string // Polecat name (e.g., "Toast") - ClonePath string // Path to polecat's git worktree - SessionName string // Tmux session name (e.g., "gt-gastown-p-Toast") - Pane string // Tmux pane ID -} - -// AgentID returns the agent identifier (e.g., "gastown/polecats/Toast") -func (s *SpawnedPolecatInfo) AgentID() string { - return fmt.Sprintf("%s/polecats/%s", s.RigName, s.PolecatName) -} - -// SlingSpawnOptions contains options for spawning a polecat via sling. -type SlingSpawnOptions struct { - Force bool // Force spawn even if polecat has uncommitted work - Naked bool // No-tmux mode: skip session creation - Account string // Claude Code account handle to use - Create bool // Create polecat if it doesn't exist (currently always true for sling) -} - -// SpawnPolecatForSling creates a fresh polecat and optionally starts its session. -// This is a lightweight spawn for sling - it doesn't assign issues or send mail. -// The caller (sling) handles hook attachment and nudging. -func SpawnPolecatForSling(rigName string, opts SlingSpawnOptions) (*SpawnedPolecatInfo, error) { - // Find workspace - townRoot, err := workspace.FindFromCwdOrError() - if err != nil { - return nil, fmt.Errorf("not in a Gas Town workspace: %w", err) - } - - // Load rig config - rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") - rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) - if err != nil { - rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} - } - - g := git.NewGit(townRoot) - rigMgr := rig.NewManager(townRoot, rigsConfig, g) - r, err := rigMgr.GetRig(rigName) - if err != nil { - return nil, fmt.Errorf("rig '%s' not found", rigName) - } - - // Get polecat manager - polecatGit := git.NewGit(r.Path) - polecatMgr := polecat.NewManager(r, polecatGit) - - // Allocate a new polecat name - polecatName, err := polecatMgr.AllocateName() - if err != nil { - return nil, fmt.Errorf("allocating polecat name: %w", err) - } - fmt.Printf("Allocated polecat: %s\n", polecatName) - - // Check if polecat already exists (shouldn't, since we allocated fresh) - existingPolecat, err := polecatMgr.Get(polecatName) - if err == nil { - // Exists - recreate with fresh worktree - // Check for uncommitted work first - if !opts.Force { - pGit := git.NewGit(existingPolecat.ClonePath) - workStatus, checkErr := pGit.CheckUncommittedWork() - if checkErr == nil && !workStatus.Clean() { - return nil, fmt.Errorf("polecat '%s' has uncommitted work: %s\nUse --force to proceed anyway", - polecatName, workStatus.String()) - } - } - fmt.Printf("Recreating polecat %s with fresh worktree...\n", polecatName) - if _, err = polecatMgr.Recreate(polecatName, opts.Force); err != nil { - return nil, fmt.Errorf("recreating polecat: %w", err) - } - } else if err == polecat.ErrPolecatNotFound { - // Create new polecat - fmt.Printf("Creating polecat %s...\n", polecatName) - if _, err = polecatMgr.Add(polecatName); err != nil { - return nil, fmt.Errorf("creating polecat: %w", err) - } - } else { - return nil, fmt.Errorf("getting polecat: %w", err) - } - - // Get polecat object for path info - polecatObj, err := polecatMgr.Get(polecatName) - if err != nil { - return nil, fmt.Errorf("getting polecat after creation: %w", err) - } - - // Handle naked mode (no-tmux) - if opts.Naked { - fmt.Println() - fmt.Printf("%s\n", style.Bold.Render("🔧 NO-TMUX MODE (--naked)")) - fmt.Printf("Polecat created. Agent must be started manually.\n\n") - fmt.Printf("To start the agent:\n") - fmt.Printf(" cd %s\n", polecatObj.ClonePath) - fmt.Printf(" claude # Or: claude-code\n\n") - fmt.Printf("Agent will discover work via gt prime on startup.\n") - - return &SpawnedPolecatInfo{ - RigName: rigName, - PolecatName: polecatName, - ClonePath: polecatObj.ClonePath, - SessionName: "", // No session in naked mode - Pane: "", // No pane in naked mode - }, nil - } - - // Resolve account for Claude config - accountsPath := constants.MayorAccountsPath(townRoot) - claudeConfigDir, accountHandle, err := config.ResolveAccountConfigDir(accountsPath, opts.Account) - if err != nil { - return nil, fmt.Errorf("resolving account: %w", err) - } - if accountHandle != "" { - fmt.Printf("Using account: %s\n", accountHandle) - } - - // Start session - t := tmux.NewTmux() - sessMgr := session.NewManager(t, r) - - // Check if already running - running, _ := sessMgr.IsRunning(polecatName) - if !running { - fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName) - startOpts := session.StartOptions{ - ClaudeConfigDir: claudeConfigDir, - } - if err := sessMgr.Start(polecatName, startOpts); err != nil { - return nil, fmt.Errorf("starting session: %w", err) - } - } - - // Get session name and pane - sessionName := sessMgr.SessionName(polecatName) - pane, err := getSessionPane(sessionName) - if err != nil { - return nil, fmt.Errorf("getting pane for %s: %w", sessionName, err) - } - - fmt.Printf("%s Polecat %s spawned\n", style.Bold.Render("✓"), polecatName) - - return &SpawnedPolecatInfo{ - RigName: rigName, - PolecatName: polecatName, - ClonePath: polecatObj.ClonePath, - SessionName: sessionName, - Pane: pane, - }, nil -} - -// IsRigName checks if a target string is a rig name (not a role or path). -// Returns the rig name and true if it's a valid rig. -func IsRigName(target string) (string, bool) { - // If it contains a slash, it's a path format (rig/role or rig/crew/name) - if strings.Contains(target, "/") { - return "", false - } - - // Check known non-rig role names - switch strings.ToLower(target) { - case "mayor", "may", "deacon", "dea", "crew", "witness", "wit", "refinery", "ref": - return "", false - } - - // Try to load as a rig - townRoot, err := workspace.FindFromCwdOrError() - if err != nil { - return "", false - } - - rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") - rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) - if err != nil { - return "", false - } - - g := git.NewGit(townRoot) - rigMgr := rig.NewManager(townRoot, rigsConfig, g) - _, err = rigMgr.GetRig(target) - if err != nil { - return "", false - } - - return target, true -} -