package cmd import ( "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "time" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/polecat" "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/runtime" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" ) // Polecat command flags var ( polecatListJSON bool polecatListAll bool polecatForce bool polecatRemoveAll bool ) var polecatCmd = &cobra.Command{ Use: "polecat", Aliases: []string{"cat", "polecats"}, GroupID: GroupAgents, Short: "Manage polecats in rigs", RunE: requireSubcommand, Long: `Manage polecat lifecycle in rigs. Polecats are worker agents that operate in their own git worktrees. Use the subcommands to add, remove, list, wake, and sleep polecats.`, } var polecatListCmd = &cobra.Command{ Use: "list [rig]", Short: "List polecats in a rig", Long: `List polecats in a rig or all rigs. In the transient model, polecats exist only while working. The list shows all currently active polecats with their states: - working: Actively working on an issue - done: Completed work, waiting for cleanup - stuck: Needs assistance Examples: gt polecat list greenplace gt polecat list --all gt polecat list greenplace --json`, RunE: runPolecatList, } var polecatAddCmd = &cobra.Command{ Use: "add ", Short: "Add a new polecat to a rig", Long: `Add a new polecat to a rig. Creates a polecat directory, clones the rig repo, creates a work branch, and initializes state. Example: gt polecat add greenplace Toast`, Args: cobra.ExactArgs(2), RunE: runPolecatAdd, } var polecatRemoveCmd = &cobra.Command{ Use: "remove /... | --all", Short: "Remove polecats from a rig", Long: `Remove one or more polecats from a rig. Fails if session is running (stop first). Warns if uncommitted changes exist. Use --force to bypass checks. Examples: gt polecat remove greenplace/Toast gt polecat remove greenplace/Toast greenplace/Furiosa gt polecat remove greenplace --all gt polecat remove greenplace --all --force`, Args: cobra.MinimumNArgs(1), RunE: runPolecatRemove, } var polecatSyncCmd = &cobra.Command{ Use: "sync /", Short: "Sync beads for a polecat", Long: `Sync beads for a polecat's worktree. Runs 'bd sync' in the polecat's worktree to push local beads changes to the shared sync branch and pull remote changes. Use --all to sync all polecats in a rig. Use --from-main to only pull (no push). Examples: gt polecat sync greenplace/Toast gt polecat sync greenplace --all gt polecat sync greenplace/Toast --from-main`, Args: cobra.MaximumNArgs(1), RunE: runPolecatSync, } var polecatStatusCmd = &cobra.Command{ Use: "status /", Short: "Show detailed status for a polecat", Long: `Show detailed status for a polecat. Displays comprehensive information including: - Current lifecycle state (working, done, stuck, idle) - Assigned issue (if any) - Session status (running/stopped, attached/detached) - Session creation time - Last activity time Examples: gt polecat status greenplace/Toast gt polecat status greenplace/Toast --json`, Args: cobra.ExactArgs(1), RunE: runPolecatStatus, } var ( polecatSyncAll bool polecatSyncFromMain bool polecatStatusJSON bool polecatGitStateJSON bool polecatGCDryRun bool polecatNukeAll bool polecatNukeDryRun bool polecatNukeForce bool polecatCheckRecoveryJSON bool ) var polecatGCCmd = &cobra.Command{ Use: "gc ", Short: "Garbage collect stale polecat branches", Long: `Garbage collect stale polecat branches in a rig. Polecats use unique timestamped branches (polecat/-) to prevent drift issues. Over time, these branches accumulate when stale polecats are repaired. This command removes orphaned branches: - Branches for polecats that no longer exist - Old timestamped branches (keeps only the current one per polecat) Examples: gt polecat gc greenplace gt polecat gc greenplace --dry-run`, Args: cobra.ExactArgs(1), RunE: runPolecatGC, } var polecatNukeCmd = &cobra.Command{ Use: "nuke /... | --all", Short: "Completely destroy a polecat (session, worktree, branch, agent bead)", Long: `Completely destroy a polecat and all its artifacts. This is the nuclear option for post-merge cleanup. It: 1. Kills the Claude session (if running) 2. Deletes the git worktree (bypassing all safety checks) 3. Deletes the polecat branch 4. Closes the agent bead (if exists) SAFETY CHECKS: The command refuses to nuke a polecat if: - Worktree has unpushed/uncommitted changes - Polecat has an open merge request (MR bead) - Polecat has work on its hook Use --force to bypass safety checks (LOSES WORK). Use --dry-run to see what would happen and safety check status. Examples: gt polecat nuke greenplace/Toast gt polecat nuke greenplace/Toast greenplace/Furiosa gt polecat nuke greenplace --all gt polecat nuke greenplace --all --dry-run gt polecat nuke greenplace/Toast --force # bypass safety checks`, Args: cobra.MinimumNArgs(1), RunE: runPolecatNuke, } var polecatGitStateCmd = &cobra.Command{ Use: "git-state /", Short: "Show git state for pre-kill verification", Long: `Show git state for a polecat's worktree. Used by the Witness for pre-kill verification to ensure no work is lost. Returns whether the worktree is clean (safe to kill) or dirty (needs cleanup). Checks: - Working tree: uncommitted changes - Unpushed commits: commits ahead of origin/main - Stashes: stashed changes Examples: gt polecat git-state greenplace/Toast gt polecat git-state greenplace/Toast --json`, Args: cobra.ExactArgs(1), RunE: runPolecatGitState, } var polecatCheckRecoveryCmd = &cobra.Command{ Use: "check-recovery /", Short: "Check if polecat needs recovery vs safe to nuke", Long: `Check recovery status of a polecat based on cleanup_status in agent bead. Used by the Witness to determine appropriate cleanup action: - SAFE_TO_NUKE: cleanup_status is 'clean' - no work at risk - NEEDS_RECOVERY: cleanup_status indicates unpushed/uncommitted work This prevents accidental data loss when cleaning up dormant polecats. The Witness should escalate NEEDS_RECOVERY cases to the Mayor. Examples: gt polecat check-recovery greenplace/Toast gt polecat check-recovery greenplace/Toast --json`, Args: cobra.ExactArgs(1), RunE: runPolecatCheckRecovery, } var ( polecatStaleJSON bool polecatStaleThreshold int polecatStaleCleanup bool ) var polecatStaleCmd = &cobra.Command{ Use: "stale ", Short: "Detect stale polecats that may need cleanup", Long: `Detect stale polecats in a rig that are candidates for cleanup. A polecat is considered stale if: - No active tmux session - Way behind main (>threshold commits) OR no agent bead - Has no uncommitted work that could be lost The default threshold is 20 commits behind main. Use --cleanup to automatically nuke stale polecats that are safe to remove. Use --dry-run with --cleanup to see what would be cleaned. Examples: gt polecat stale greenplace gt polecat stale greenplace --threshold 50 gt polecat stale greenplace --json gt polecat stale greenplace --cleanup gt polecat stale greenplace --cleanup --dry-run`, Args: cobra.ExactArgs(1), RunE: runPolecatStale, } func init() { // List flags polecatListCmd.Flags().BoolVar(&polecatListJSON, "json", false, "Output as JSON") polecatListCmd.Flags().BoolVar(&polecatListAll, "all", false, "List polecats in all rigs") // Remove flags polecatRemoveCmd.Flags().BoolVarP(&polecatForce, "force", "f", false, "Force removal, bypassing checks") polecatRemoveCmd.Flags().BoolVar(&polecatRemoveAll, "all", false, "Remove all polecats in the rig") // Sync flags polecatSyncCmd.Flags().BoolVar(&polecatSyncAll, "all", false, "Sync all polecats in the rig") polecatSyncCmd.Flags().BoolVar(&polecatSyncFromMain, "from-main", false, "Pull only, no push") // Status flags polecatStatusCmd.Flags().BoolVar(&polecatStatusJSON, "json", false, "Output as JSON") // Git-state flags polecatGitStateCmd.Flags().BoolVar(&polecatGitStateJSON, "json", false, "Output as JSON") // GC flags polecatGCCmd.Flags().BoolVar(&polecatGCDryRun, "dry-run", false, "Show what would be deleted without deleting") // Nuke flags polecatNukeCmd.Flags().BoolVar(&polecatNukeAll, "all", false, "Nuke all polecats in the rig") polecatNukeCmd.Flags().BoolVar(&polecatNukeDryRun, "dry-run", false, "Show what would be nuked without doing it") polecatNukeCmd.Flags().BoolVarP(&polecatNukeForce, "force", "f", false, "Force nuke, bypassing all safety checks (LOSES WORK)") // Check-recovery flags polecatCheckRecoveryCmd.Flags().BoolVar(&polecatCheckRecoveryJSON, "json", false, "Output as JSON") // Stale flags polecatStaleCmd.Flags().BoolVar(&polecatStaleJSON, "json", false, "Output as JSON") polecatStaleCmd.Flags().IntVar(&polecatStaleThreshold, "threshold", 20, "Commits behind main to consider stale") polecatStaleCmd.Flags().BoolVar(&polecatStaleCleanup, "cleanup", false, "Automatically nuke stale polecats") // Add subcommands polecatCmd.AddCommand(polecatListCmd) polecatCmd.AddCommand(polecatAddCmd) polecatCmd.AddCommand(polecatRemoveCmd) polecatCmd.AddCommand(polecatSyncCmd) polecatCmd.AddCommand(polecatStatusCmd) polecatCmd.AddCommand(polecatGitStateCmd) polecatCmd.AddCommand(polecatCheckRecoveryCmd) polecatCmd.AddCommand(polecatGCCmd) polecatCmd.AddCommand(polecatNukeCmd) polecatCmd.AddCommand(polecatStaleCmd) rootCmd.AddCommand(polecatCmd) } // PolecatListItem represents a polecat in list output. type PolecatListItem struct { Rig string `json:"rig"` Name string `json:"name"` State polecat.State `json:"state"` Issue string `json:"issue,omitempty"` SessionRunning bool `json:"session_running"` } // getPolecatManager creates a polecat manager for the given rig. func getPolecatManager(rigName string) (*polecat.Manager, *rig.Rig, error) { _, r, err := getRig(rigName) if err != nil { return nil, nil, err } polecatGit := git.NewGit(r.Path) mgr := polecat.NewManager(r, polecatGit) return mgr, r, nil } func runPolecatList(cmd *cobra.Command, args []string) error { var rigs []*rig.Rig if polecatListAll { // List all rigs allRigs, _, err := getAllRigs() if err != nil { return err } rigs = allRigs } else { // Need a rig name if len(args) < 1 { return fmt.Errorf("rig name required (or use --all)") } _, r, err := getPolecatManager(args[0]) if err != nil { return err } rigs = []*rig.Rig{r} } // Collect polecats from all rigs t := tmux.NewTmux() var allPolecats []PolecatListItem for _, r := range rigs { polecatGit := git.NewGit(r.Path) mgr := polecat.NewManager(r, polecatGit) polecatMgr := polecat.NewSessionManager(t, r) polecats, err := mgr.List() if err != nil { fmt.Fprintf(os.Stderr, "warning: failed to list polecats in %s: %v\n", r.Name, err) continue } for _, p := range polecats { running, _ := polecatMgr.IsRunning(p.Name) allPolecats = append(allPolecats, PolecatListItem{ Rig: r.Name, Name: p.Name, State: p.State, Issue: p.Issue, SessionRunning: running, }) } } // Output if polecatListJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(allPolecats) } if len(allPolecats) == 0 { fmt.Println("No active polecats found.") return nil } fmt.Printf("%s\n\n", style.Bold.Render("Active Polecats")) for _, p := range allPolecats { // Session indicator sessionStatus := style.Dim.Render("○") if p.SessionRunning { sessionStatus = style.Success.Render("●") } // Display actual state (no normalization - idle means idle) displayState := p.State // State color stateStr := string(displayState) switch displayState { case polecat.StateWorking: stateStr = style.Info.Render(stateStr) case polecat.StateStuck: stateStr = style.Warning.Render(stateStr) case polecat.StateDone: stateStr = style.Success.Render(stateStr) default: stateStr = style.Dim.Render(stateStr) } fmt.Printf(" %s %s/%s %s\n", sessionStatus, p.Rig, p.Name, stateStr) if p.Issue != "" { fmt.Printf(" %s\n", style.Dim.Render(p.Issue)) } } return nil } func runPolecatAdd(cmd *cobra.Command, args []string) error { rigName := args[0] polecatName := args[1] mgr, _, err := getPolecatManager(rigName) if err != nil { return err } fmt.Printf("Adding polecat %s to rig %s...\n", polecatName, rigName) p, err := mgr.Add(polecatName) if err != nil { return fmt.Errorf("adding polecat: %w", err) } fmt.Printf("%s Polecat %s added.\n", style.SuccessPrefix, p.Name) fmt.Printf(" %s\n", style.Dim.Render(p.ClonePath)) fmt.Printf(" Branch: %s\n", style.Dim.Render(p.Branch)) return nil } func runPolecatRemove(cmd *cobra.Command, args []string) error { // Build list of polecats to remove type polecatToRemove struct { rigName string polecatName string mgr *polecat.Manager r *rig.Rig } var toRemove []polecatToRemove if polecatRemoveAll { // --all flag: first arg is just the rig name rigName := args[0] // Check if it looks like rig/polecat format if _, _, err := parseAddress(rigName); err == nil { return fmt.Errorf("with --all, provide just the rig name (e.g., 'gt polecat remove greenplace --all')") } mgr, r, err := getPolecatManager(rigName) if err != nil { return err } polecats, err := mgr.List() if err != nil { return fmt.Errorf("listing polecats: %w", err) } if len(polecats) == 0 { fmt.Println("No polecats to remove.") return nil } for _, p := range polecats { toRemove = append(toRemove, polecatToRemove{ rigName: rigName, polecatName: p.Name, mgr: mgr, r: r, }) } } else { // Multiple rig/polecat arguments - require explicit rig/polecat format for _, arg := range args { // Validate format: must contain "/" to avoid misinterpreting rig names as polecat names if !strings.Contains(arg, "/") { return fmt.Errorf("invalid address '%s': must be in 'rig/polecat' format (e.g., 'gastown/Toast')", arg) } rigName, polecatName, err := parseAddress(arg) if err != nil { return fmt.Errorf("invalid address '%s': %w", arg, err) } mgr, r, err := getPolecatManager(rigName) if err != nil { return err } toRemove = append(toRemove, polecatToRemove{ rigName: rigName, polecatName: polecatName, mgr: mgr, r: r, }) } } // Remove each polecat t := tmux.NewTmux() var removeErrors []string removed := 0 for _, p := range toRemove { // Check if session is running if !polecatForce { polecatMgr := polecat.NewSessionManager(t, p.r) running, _ := polecatMgr.IsRunning(p.polecatName) if running { removeErrors = append(removeErrors, fmt.Sprintf("%s/%s: session is running (stop first or use --force)", p.rigName, p.polecatName)) continue } } fmt.Printf("Removing polecat %s/%s...\n", p.rigName, p.polecatName) if err := p.mgr.Remove(p.polecatName, polecatForce); err != nil { if errors.Is(err, polecat.ErrHasChanges) { removeErrors = append(removeErrors, fmt.Sprintf("%s/%s: has uncommitted changes (use --force)", p.rigName, p.polecatName)) } else { removeErrors = append(removeErrors, fmt.Sprintf("%s/%s: %v", p.rigName, p.polecatName, err)) } continue } fmt.Printf(" %s removed\n", style.Success.Render("✓")) removed++ } // Report results if len(removeErrors) > 0 { fmt.Printf("\n%s Some removals failed:\n", style.Warning.Render("Warning:")) for _, e := range removeErrors { fmt.Printf(" - %s\n", e) } } if removed > 0 { fmt.Printf("\n%s Removed %d polecat(s).\n", style.SuccessPrefix, removed) } if len(removeErrors) > 0 { return fmt.Errorf("%d removal(s) failed", len(removeErrors)) } return nil } func runPolecatSync(cmd *cobra.Command, args []string) error { if len(args) < 1 { return fmt.Errorf("rig or rig/polecat address required") } // Parse address - could be "rig" or "rig/polecat" rigName, polecatName, err := parseAddress(args[0]) if err != nil { // Might just be a rig name rigName = args[0] polecatName = "" } mgr, _, err := getPolecatManager(rigName) if err != nil { return err } // Get list of polecats to sync var polecatsToSync []string if polecatSyncAll || polecatName == "" { polecats, err := mgr.List() if err != nil { return fmt.Errorf("listing polecats: %w", err) } for _, p := range polecats { polecatsToSync = append(polecatsToSync, p.Name) } } else { polecatsToSync = []string{polecatName} } if len(polecatsToSync) == 0 { fmt.Println("No polecats to sync.") return nil } // Sync each polecat var syncErrors []string for _, name := range polecatsToSync { // Get polecat to get correct clone path (handles old vs new structure) p, err := mgr.Get(name) if err != nil { syncErrors = append(syncErrors, fmt.Sprintf("%s: %v", name, err)) continue } // Check directory exists if _, err := os.Stat(p.ClonePath); os.IsNotExist(err) { syncErrors = append(syncErrors, fmt.Sprintf("%s: directory not found", name)) continue } // Build sync command syncArgs := []string{"sync"} if polecatSyncFromMain { syncArgs = append(syncArgs, "--from-main") } fmt.Printf("Syncing %s/%s...\n", rigName, name) syncCmd := exec.Command("bd", syncArgs...) syncCmd.Dir = p.ClonePath output, err := syncCmd.CombinedOutput() if err != nil { syncErrors = append(syncErrors, fmt.Sprintf("%s: %v", name, err)) if len(output) > 0 { fmt.Printf(" %s\n", style.Dim.Render(string(output))) } } else { fmt.Printf(" %s\n", style.Success.Render("✓ synced")) } } if len(syncErrors) > 0 { fmt.Printf("\n%s Some syncs failed:\n", style.Warning.Render("Warning:")) for _, e := range syncErrors { fmt.Printf(" - %s\n", e) } return fmt.Errorf("%d sync(s) failed", len(syncErrors)) } return nil } // PolecatStatus represents detailed polecat status for JSON output. type PolecatStatus struct { Rig string `json:"rig"` Name string `json:"name"` State polecat.State `json:"state"` Issue string `json:"issue,omitempty"` ClonePath string `json:"clone_path"` Branch string `json:"branch"` SessionRunning bool `json:"session_running"` SessionID string `json:"session_id,omitempty"` Attached bool `json:"attached,omitempty"` Windows int `json:"windows,omitempty"` CreatedAt string `json:"created_at,omitempty"` LastActivity string `json:"last_activity,omitempty"` } func runPolecatStatus(cmd *cobra.Command, args []string) error { rigName, polecatName, err := parseAddress(args[0]) if err != nil { return err } mgr, r, err := getPolecatManager(rigName) if err != nil { return err } // Get polecat info p, err := mgr.Get(polecatName) if err != nil { return fmt.Errorf("polecat '%s' not found in rig '%s'", polecatName, rigName) } // Get session info t := tmux.NewTmux() polecatMgr := polecat.NewSessionManager(t, r) sessInfo, err := polecatMgr.Status(polecatName) if err != nil { // Non-fatal - continue without session info sessInfo = &polecat.SessionInfo{ Polecat: polecatName, Running: false, } } // JSON output if polecatStatusJSON { status := PolecatStatus{ Rig: rigName, Name: polecatName, State: p.State, Issue: p.Issue, ClonePath: p.ClonePath, Branch: p.Branch, SessionRunning: sessInfo.Running, SessionID: sessInfo.SessionID, Attached: sessInfo.Attached, Windows: sessInfo.Windows, } if !sessInfo.Created.IsZero() { status.CreatedAt = sessInfo.Created.Format("2006-01-02 15:04:05") } if !sessInfo.LastActivity.IsZero() { status.LastActivity = sessInfo.LastActivity.Format("2006-01-02 15:04:05") } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(status) } // Human-readable output fmt.Printf("%s\n\n", style.Bold.Render(fmt.Sprintf("Polecat: %s/%s", rigName, polecatName))) // State with color stateStr := string(p.State) switch p.State { case polecat.StateWorking: stateStr = style.Info.Render(stateStr) case polecat.StateStuck: stateStr = style.Warning.Render(stateStr) case polecat.StateDone: stateStr = style.Success.Render(stateStr) default: stateStr = style.Dim.Render(stateStr) } fmt.Printf(" State: %s\n", stateStr) // Issue if p.Issue != "" { fmt.Printf(" Issue: %s\n", p.Issue) } else { fmt.Printf(" Issue: %s\n", style.Dim.Render("(none)")) } // Clone path and branch fmt.Printf(" Clone: %s\n", style.Dim.Render(p.ClonePath)) fmt.Printf(" Branch: %s\n", style.Dim.Render(p.Branch)) // Session info fmt.Println() fmt.Printf("%s\n", style.Bold.Render("Session")) if sessInfo.Running { fmt.Printf(" Status: %s\n", style.Success.Render("running")) fmt.Printf(" Session ID: %s\n", style.Dim.Render(sessInfo.SessionID)) if sessInfo.Attached { fmt.Printf(" Attached: %s\n", style.Info.Render("yes")) } else { fmt.Printf(" Attached: %s\n", style.Dim.Render("no")) } if sessInfo.Windows > 0 { fmt.Printf(" Windows: %d\n", sessInfo.Windows) } if !sessInfo.Created.IsZero() { fmt.Printf(" Created: %s\n", sessInfo.Created.Format("2006-01-02 15:04:05")) } if !sessInfo.LastActivity.IsZero() { // Show relative time for activity ago := formatActivityTime(sessInfo.LastActivity) fmt.Printf(" Last Activity: %s (%s)\n", sessInfo.LastActivity.Format("15:04:05"), style.Dim.Render(ago)) } } else { fmt.Printf(" Status: %s\n", style.Dim.Render("not running")) } return nil } // formatActivityTime returns a human-readable relative time string. func formatActivityTime(t time.Time) string { d := time.Since(t) switch { case d < time.Minute: return fmt.Sprintf("%d seconds ago", int(d.Seconds())) case d < time.Hour: return fmt.Sprintf("%d minutes ago", int(d.Minutes())) case d < 24*time.Hour: return fmt.Sprintf("%d hours ago", int(d.Hours())) default: return fmt.Sprintf("%d days ago", int(d.Hours()/24)) } } // GitState represents the git state of a polecat's worktree. type GitState struct { Clean bool `json:"clean"` UncommittedFiles []string `json:"uncommitted_files"` UnpushedCommits int `json:"unpushed_commits"` StashCount int `json:"stash_count"` } func runPolecatGitState(cmd *cobra.Command, args []string) error { rigName, polecatName, err := parseAddress(args[0]) if err != nil { return err } mgr, r, err := getPolecatManager(rigName) if err != nil { return err } // Verify polecat exists p, err := mgr.Get(polecatName) if err != nil { return fmt.Errorf("polecat '%s' not found in rig '%s'", polecatName, rigName) } // Get git state from the polecat's worktree state, err := getGitState(p.ClonePath) if err != nil { return fmt.Errorf("getting git state: %w", err) } // JSON output if polecatGitStateJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(state) } // Human-readable output fmt.Printf("%s\n\n", style.Bold.Render(fmt.Sprintf("Git State: %s/%s", r.Name, polecatName))) // Working tree status if len(state.UncommittedFiles) == 0 { fmt.Printf(" Working Tree: %s\n", style.Success.Render("clean")) } else { fmt.Printf(" Working Tree: %s\n", style.Warning.Render("dirty")) fmt.Printf(" Uncommitted: %s\n", style.Warning.Render(fmt.Sprintf("%d files", len(state.UncommittedFiles)))) for _, f := range state.UncommittedFiles { fmt.Printf(" %s\n", style.Dim.Render(f)) } } // Unpushed commits if state.UnpushedCommits == 0 { fmt.Printf(" Unpushed: %s\n", style.Success.Render("0 commits")) } else { fmt.Printf(" Unpushed: %s\n", style.Warning.Render(fmt.Sprintf("%d commits ahead", state.UnpushedCommits))) } // Stashes if state.StashCount == 0 { fmt.Printf(" Stashes: %s\n", style.Dim.Render("0")) } else { fmt.Printf(" Stashes: %s\n", style.Warning.Render(fmt.Sprintf("%d", state.StashCount))) } // Verdict fmt.Println() if state.Clean { fmt.Printf(" Verdict: %s\n", style.Success.Render("CLEAN (safe to kill)")) } else { fmt.Printf(" Verdict: %s\n", style.Error.Render("DIRTY (needs cleanup)")) } return nil } // getGitState checks the git state of a worktree. func getGitState(worktreePath string) (*GitState, error) { state := &GitState{ Clean: true, UncommittedFiles: []string{}, } // Check for uncommitted changes (git status --porcelain) statusCmd := exec.Command("git", "status", "--porcelain") statusCmd.Dir = worktreePath output, err := statusCmd.Output() if err != nil { return nil, fmt.Errorf("git status: %w", err) } if len(output) > 0 { lines := splitLines(string(output)) for _, line := range lines { if line != "" { // Extract filename (skip the status prefix) if len(line) > 3 { state.UncommittedFiles = append(state.UncommittedFiles, line[3:]) } else { state.UncommittedFiles = append(state.UncommittedFiles, line) } } } state.Clean = false } // Check for unpushed commits (git log origin/main..HEAD) // We check commits first, then verify if content differs. // After squash merge, commits may differ but content may be identical. mainRef := "origin/main" logCmd := exec.Command("git", "log", mainRef+"..HEAD", "--oneline") logCmd.Dir = worktreePath output, err = logCmd.Output() if err != nil { // origin/main might not exist - try origin/master mainRef = "origin/master" logCmd = exec.Command("git", "log", mainRef+"..HEAD", "--oneline") logCmd.Dir = worktreePath output, _ = logCmd.Output() // non-fatal: might be a new repo without remote tracking } if len(output) > 0 { lines := splitLines(string(output)) count := 0 for _, line := range lines { if line != "" { count++ } } if count > 0 { // Commits exist that aren't on main. But after squash merge, // the content may actually be on main with different commit SHAs. // Check if there's any actual diff between HEAD and main. diffCmd := exec.Command("git", "diff", mainRef, "HEAD", "--quiet") diffCmd.Dir = worktreePath diffErr := diffCmd.Run() if diffErr == nil { // Exit code 0 means no diff - content IS on main (squash merged) // Don't count these as unpushed state.UnpushedCommits = 0 } else { // Exit code 1 means there's a diff - truly unpushed work state.UnpushedCommits = count state.Clean = false } } } // Check for stashes (git stash list) stashCmd := exec.Command("git", "stash", "list") stashCmd.Dir = worktreePath output, err = stashCmd.Output() if err != nil { // Ignore stash errors output = nil } if len(output) > 0 { lines := splitLines(string(output)) count := 0 for _, line := range lines { if line != "" { count++ } } state.StashCount = count if count > 0 { state.Clean = false } } return state, nil } // RecoveryStatus represents whether a polecat needs recovery or is safe to nuke. type RecoveryStatus struct { Rig string `json:"rig"` Polecat string `json:"polecat"` CleanupStatus polecat.CleanupStatus `json:"cleanup_status"` NeedsRecovery bool `json:"needs_recovery"` Verdict string `json:"verdict"` // SAFE_TO_NUKE or NEEDS_RECOVERY Branch string `json:"branch,omitempty"` Issue string `json:"issue,omitempty"` } func runPolecatCheckRecovery(cmd *cobra.Command, args []string) error { rigName, polecatName, err := parseAddress(args[0]) if err != nil { return err } mgr, r, err := getPolecatManager(rigName) if err != nil { return err } // Verify polecat exists and get info p, err := mgr.Get(polecatName) if err != nil { return fmt.Errorf("polecat '%s' not found in rig '%s'", polecatName, rigName) } // Get cleanup_status from agent bead // We need to read it directly from beads since manager doesn't expose it rigPath := r.Path bd := beads.New(rigPath) agentBeadID := beads.PolecatBeadID(rigName, polecatName) _, fields, err := bd.GetAgentBead(agentBeadID) status := RecoveryStatus{ Rig: rigName, Polecat: polecatName, Branch: p.Branch, Issue: p.Issue, } if err != nil || fields == nil { // No agent bead or no cleanup_status - fall back to git check // This handles polecats that haven't self-reported yet gitState, gitErr := getGitState(p.ClonePath) if gitErr != nil { status.CleanupStatus = polecat.CleanupUnknown status.NeedsRecovery = true status.Verdict = "NEEDS_RECOVERY" } else if gitState.Clean { status.CleanupStatus = polecat.CleanupClean status.NeedsRecovery = false status.Verdict = "SAFE_TO_NUKE" } else if gitState.UnpushedCommits > 0 { status.CleanupStatus = polecat.CleanupUnpushed status.NeedsRecovery = true status.Verdict = "NEEDS_RECOVERY" } else if gitState.StashCount > 0 { status.CleanupStatus = polecat.CleanupStash status.NeedsRecovery = true status.Verdict = "NEEDS_RECOVERY" } else { status.CleanupStatus = polecat.CleanupUncommitted status.NeedsRecovery = true status.Verdict = "NEEDS_RECOVERY" } } else { // Use cleanup_status from agent bead status.CleanupStatus = polecat.CleanupStatus(fields.CleanupStatus) if status.CleanupStatus.IsSafe() { status.NeedsRecovery = false status.Verdict = "SAFE_TO_NUKE" } else { // RequiresRecovery covers uncommitted, stash, unpushed // Unknown/empty also treated conservatively status.NeedsRecovery = true status.Verdict = "NEEDS_RECOVERY" } } // JSON output if polecatCheckRecoveryJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(status) } // Human-readable output fmt.Printf("%s\n\n", style.Bold.Render(fmt.Sprintf("Recovery Status: %s/%s", rigName, polecatName))) fmt.Printf(" Cleanup Status: %s\n", status.CleanupStatus) if status.Branch != "" { fmt.Printf(" Branch: %s\n", status.Branch) } if status.Issue != "" { fmt.Printf(" Issue: %s\n", status.Issue) } fmt.Println() if status.NeedsRecovery { fmt.Printf(" Verdict: %s\n", style.Error.Render("NEEDS_RECOVERY")) fmt.Println() fmt.Printf(" %s This polecat has unpushed/uncommitted work.\n", style.Warning.Render("⚠")) fmt.Println(" Escalate to Mayor for recovery before cleanup.") } else { fmt.Printf(" Verdict: %s\n", style.Success.Render("SAFE_TO_NUKE")) fmt.Println() fmt.Printf(" %s Safe to nuke - no work at risk.\n", style.Success.Render("✓")) } return nil } func runPolecatGC(cmd *cobra.Command, args []string) error { rigName := args[0] mgr, r, err := getPolecatManager(rigName) if err != nil { return err } fmt.Printf("Garbage collecting stale polecat branches in %s...\n\n", r.Name) if polecatGCDryRun { // Dry run - list branches that would be deleted repoGit := git.NewGit(r.Path) // List all polecat branches branches, err := repoGit.ListBranches("polecat/*") if err != nil { return fmt.Errorf("listing branches: %w", err) } if len(branches) == 0 { fmt.Println("No polecat branches found.") return nil } // Get current branches polecats, err := mgr.List() if err != nil { return fmt.Errorf("listing polecats: %w", err) } currentBranches := make(map[string]bool) for _, p := range polecats { currentBranches[p.Branch] = true } // Show what would be deleted toDelete := 0 for _, branch := range branches { if !currentBranches[branch] { fmt.Printf(" Would delete: %s\n", style.Dim.Render(branch)) toDelete++ } else { fmt.Printf(" Keep (in use): %s\n", style.Success.Render(branch)) } } fmt.Printf("\nWould delete %d branch(es), keep %d\n", toDelete, len(branches)-toDelete) return nil } // Actually clean up deleted, err := mgr.CleanupStaleBranches() if err != nil { return fmt.Errorf("cleanup failed: %w", err) } if deleted == 0 { fmt.Println("No stale branches to clean up.") } else { fmt.Printf("%s Deleted %d stale branch(es).\n", style.SuccessPrefix, deleted) } return nil } // splitLines splits a string into non-empty lines. func splitLines(s string) []string { var lines []string for _, line := range filepath.SplitList(s) { if line != "" { lines = append(lines, line) } } // filepath.SplitList doesn't work for newlines, use strings.Split instead lines = nil for _, line := range strings.Split(s, "\n") { lines = append(lines, line) } return lines } func runPolecatNuke(cmd *cobra.Command, args []string) error { // Build list of polecats to nuke type polecatToNuke struct { rigName string polecatName string mgr *polecat.Manager r *rig.Rig } var toNuke []polecatToNuke if polecatNukeAll { // --all flag: first arg is just the rig name rigName := args[0] // Check if it looks like rig/polecat format if _, _, err := parseAddress(rigName); err == nil { return fmt.Errorf("with --all, provide just the rig name (e.g., 'gt polecat nuke greenplace --all')") } mgr, r, err := getPolecatManager(rigName) if err != nil { return err } polecats, err := mgr.List() if err != nil { return fmt.Errorf("listing polecats: %w", err) } if len(polecats) == 0 { fmt.Println("No polecats to nuke.") return nil } for _, p := range polecats { toNuke = append(toNuke, polecatToNuke{ rigName: rigName, polecatName: p.Name, mgr: mgr, r: r, }) } } else { // Multiple rig/polecat arguments - require explicit rig/polecat format for _, arg := range args { // Validate format: must contain "/" to avoid misinterpreting rig names as polecat names if !strings.Contains(arg, "/") { return fmt.Errorf("invalid address '%s': must be in 'rig/polecat' format (e.g., 'gastown/Toast')", arg) } rigName, polecatName, err := parseAddress(arg) if err != nil { return fmt.Errorf("invalid address '%s': %w", arg, err) } mgr, r, err := getPolecatManager(rigName) if err != nil { return err } toNuke = append(toNuke, polecatToNuke{ rigName: rigName, polecatName: polecatName, mgr: mgr, r: r, }) } } // Safety checks: refuse to nuke polecats with active work unless --force is set // Checks: // 1. Unpushed commits - worktree has uncommitted/unpushed changes // 2. Open MR beads - polecat has open merge requests pending // 3. Work on hook - polecat has work assigned to its hook if !polecatNukeForce && !polecatNukeDryRun { type blockReason struct { polecat string reasons []string } var blocked []blockReason for _, p := range toNuke { var reasons []string // Get polecat info for branch name polecatInfo, infoErr := p.mgr.Get(p.polecatName) // Check 1: Unpushed commits via cleanup_status or git state bd := beads.New(p.r.Path) agentBeadID := beads.PolecatBeadID(p.rigName, p.polecatName) agentIssue, fields, err := bd.GetAgentBead(agentBeadID) if err != nil || fields == nil { // No agent bead - fall back to git check if infoErr == nil && polecatInfo != nil { gitState, gitErr := getGitState(polecatInfo.ClonePath) if gitErr != nil { reasons = append(reasons, "cannot check git state") } else if !gitState.Clean { if gitState.UnpushedCommits > 0 { reasons = append(reasons, fmt.Sprintf("has %d unpushed commit(s)", gitState.UnpushedCommits)) } else if len(gitState.UncommittedFiles) > 0 { reasons = append(reasons, fmt.Sprintf("has %d uncommitted file(s)", len(gitState.UncommittedFiles))) } else if gitState.StashCount > 0 { reasons = append(reasons, fmt.Sprintf("has %d stash(es)", gitState.StashCount)) } } } } else { // Check cleanup_status from agent bead cleanupStatus := polecat.CleanupStatus(fields.CleanupStatus) switch cleanupStatus { case polecat.CleanupClean: // OK case polecat.CleanupUnpushed: reasons = append(reasons, "has unpushed commits") case polecat.CleanupUncommitted: reasons = append(reasons, "has uncommitted changes") case polecat.CleanupStash: reasons = append(reasons, "has stashed changes") case polecat.CleanupUnknown, "": reasons = append(reasons, "cleanup status unknown") default: reasons = append(reasons, fmt.Sprintf("cleanup status: %s", cleanupStatus)) } // Check 3: Work on hook (check both Issue.HookBead from slot and fields.HookBead) // Only flag as blocking if the hooked bead is still in an active status. // If the hooked bead was closed externally (gt-jc7bq), don't block nuke. hookBead := agentIssue.HookBead if hookBead == "" { hookBead = fields.HookBead } if hookBead != "" { // Check if hooked bead is still active (not closed) hookedIssue, err := bd.Show(hookBead) if err == nil && hookedIssue != nil { // Only block if bead is still active (not closed) if hookedIssue.Status != "closed" { reasons = append(reasons, fmt.Sprintf("has work on hook (%s)", hookBead)) } // If closed, the hook is stale - don't block nuke } else { // Can't verify hooked bead - be conservative reasons = append(reasons, fmt.Sprintf("has work on hook (%s, unverified)", hookBead)) } } } // Check 2: Open MR beads for this branch if infoErr == nil && polecatInfo != nil && polecatInfo.Branch != "" { mr, mrErr := bd.FindMRForBranch(polecatInfo.Branch) if mrErr == nil && mr != nil { reasons = append(reasons, fmt.Sprintf("has open MR (%s)", mr.ID)) } } if len(reasons) > 0 { blocked = append(blocked, blockReason{ polecat: fmt.Sprintf("%s/%s", p.rigName, p.polecatName), reasons: reasons, }) } } if len(blocked) > 0 { fmt.Printf("%s Cannot nuke the following polecats:\n\n", style.Error.Render("Error:")) var polecatList []string for _, b := range blocked { fmt.Printf(" %s:\n", style.Bold.Render(b.polecat)) for _, r := range b.reasons { fmt.Printf(" - %s\n", r) } polecatList = append(polecatList, b.polecat) } fmt.Println() fmt.Println("Safety checks failed. Resolve issues before nuking, or use --force.") fmt.Println("Options:") fmt.Printf(" 1. Complete work: gt done (from polecat session)\n") fmt.Printf(" 2. Push changes: git push (from polecat worktree)\n") fmt.Printf(" 3. Escalate: gt mail send mayor/ -s \"RECOVERY_NEEDED\" -m \"...\"\n") fmt.Printf(" 4. Force nuke (LOSES WORK): gt polecat nuke --force %s\n", strings.Join(polecatList, " ")) fmt.Println() return fmt.Errorf("blocked: %d polecat(s) have active work", len(blocked)) } } // Nuke each polecat t := tmux.NewTmux() var nukeErrors []string nuked := 0 for _, p := range toNuke { if polecatNukeDryRun { fmt.Printf("Would nuke %s/%s:\n", p.rigName, p.polecatName) fmt.Printf(" - Kill session: gt-%s-%s\n", p.rigName, p.polecatName) fmt.Printf(" - Delete worktree: %s/polecats/%s\n", p.r.Path, p.polecatName) fmt.Printf(" - Delete branch (if exists)\n") fmt.Printf(" - Close agent bead: %s\n", beads.PolecatBeadID(p.rigName, p.polecatName)) // Show safety check status in dry-run fmt.Printf("\n Safety checks:\n") polecatInfo, infoErr := p.mgr.Get(p.polecatName) bd := beads.New(p.r.Path) agentBeadID := beads.PolecatBeadID(p.rigName, p.polecatName) agentIssue, fields, err := bd.GetAgentBead(agentBeadID) // Check 1: Git state if err != nil || fields == nil { if infoErr == nil && polecatInfo != nil { gitState, gitErr := getGitState(polecatInfo.ClonePath) if gitErr != nil { fmt.Printf(" - Git state: %s\n", style.Warning.Render("cannot check")) } else if gitState.Clean { fmt.Printf(" - Git state: %s\n", style.Success.Render("clean")) } else { fmt.Printf(" - Git state: %s\n", style.Error.Render("dirty")) } } else { fmt.Printf(" - Git state: %s\n", style.Dim.Render("unknown (no polecat info)")) } fmt.Printf(" - Hook: %s\n", style.Dim.Render("unknown (no agent bead)")) } else { cleanupStatus := polecat.CleanupStatus(fields.CleanupStatus) if cleanupStatus.IsSafe() { fmt.Printf(" - Git state: %s\n", style.Success.Render("clean")) } else if cleanupStatus.RequiresRecovery() { fmt.Printf(" - Git state: %s (%s)\n", style.Error.Render("dirty"), cleanupStatus) } else { fmt.Printf(" - Git state: %s\n", style.Warning.Render("unknown")) } hookBead := agentIssue.HookBead if hookBead == "" { hookBead = fields.HookBead } if hookBead != "" { // Check if hooked bead is still active hookedIssue, err := bd.Show(hookBead) if err == nil && hookedIssue != nil && hookedIssue.Status == "closed" { fmt.Printf(" - Hook: %s (%s, closed - stale)\n", style.Warning.Render("stale"), hookBead) } else { fmt.Printf(" - Hook: %s (%s)\n", style.Error.Render("has work"), hookBead) } } else { fmt.Printf(" - Hook: %s\n", style.Success.Render("empty")) } } // Check 2: Open MR if infoErr == nil && polecatInfo != nil && polecatInfo.Branch != "" { mr, mrErr := bd.FindMRForBranch(polecatInfo.Branch) if mrErr == nil && mr != nil { fmt.Printf(" - Open MR: %s (%s)\n", style.Error.Render("yes"), mr.ID) } else { fmt.Printf(" - Open MR: %s\n", style.Success.Render("none")) } } else { fmt.Printf(" - Open MR: %s\n", style.Dim.Render("unknown (no branch info)")) } fmt.Println() continue } if polecatNukeForce { fmt.Printf("%s Nuking %s/%s (--force)...\n", style.Warning.Render("⚠"), p.rigName, p.polecatName) } else { fmt.Printf("Nuking %s/%s...\n", p.rigName, p.polecatName) } // Step 1: Kill session (force mode - no graceful shutdown) polecatMgr := polecat.NewSessionManager(t, p.r) running, _ := polecatMgr.IsRunning(p.polecatName) if running { if err := polecatMgr.Stop(p.polecatName, true); err != nil { fmt.Printf(" %s session kill failed: %v\n", style.Warning.Render("⚠"), err) // Continue anyway - worktree removal will still work } else { fmt.Printf(" %s killed session\n", style.Success.Render("✓")) } } // Step 2: Get polecat info before deletion (for branch name) polecatInfo, err := p.mgr.Get(p.polecatName) var branchToDelete string if err == nil && polecatInfo != nil { branchToDelete = polecatInfo.Branch } // Step 3: Delete worktree (nuclear mode - bypass all safety checks) if err := p.mgr.RemoveWithOptions(p.polecatName, true, true); err != nil { if errors.Is(err, polecat.ErrPolecatNotFound) { fmt.Printf(" %s worktree already gone\n", style.Dim.Render("○")) } else { nukeErrors = append(nukeErrors, fmt.Sprintf("%s/%s: worktree removal failed: %v", p.rigName, p.polecatName, err)) continue } } else { fmt.Printf(" %s deleted worktree\n", style.Success.Render("✓")) } // Step 4: Delete branch (if we know it) if branchToDelete != "" { repoGit := git.NewGit(filepath.Join(p.r.Path, "mayor", "rig")) if err := repoGit.DeleteBranch(branchToDelete, true); err != nil { // Non-fatal - branch might already be gone fmt.Printf(" %s branch delete: %v\n", style.Dim.Render("○"), err) } else { fmt.Printf(" %s deleted branch %s\n", style.Success.Render("✓"), branchToDelete) } } // Step 5: Close agent bead (if exists) agentBeadID := beads.PolecatBeadID(p.rigName, p.polecatName) closeArgs := []string{"close", agentBeadID, "--reason=nuked"} if sessionID := runtime.SessionIDFromEnv(); sessionID != "" { closeArgs = append(closeArgs, "--session="+sessionID) } closeCmd := exec.Command("bd", closeArgs...) closeCmd.Dir = filepath.Join(p.r.Path, "mayor", "rig") if err := closeCmd.Run(); err != nil { // Non-fatal - agent bead might not exist fmt.Printf(" %s agent bead not found or already closed\n", style.Dim.Render("○")) } else { fmt.Printf(" %s closed agent bead %s\n", style.Success.Render("✓"), agentBeadID) } nuked++ } // Report results if polecatNukeDryRun { fmt.Printf("\n%s Would nuke %d polecat(s).\n", style.Info.Render("ℹ"), len(toNuke)) return nil } if len(nukeErrors) > 0 { fmt.Printf("\n%s Some nukes failed:\n", style.Warning.Render("Warning:")) for _, e := range nukeErrors { fmt.Printf(" - %s\n", e) } } if nuked > 0 { fmt.Printf("\n%s Nuked %d polecat(s).\n", style.SuccessPrefix, nuked) } if len(nukeErrors) > 0 { return fmt.Errorf("%d nuke(s) failed", len(nukeErrors)) } return nil } func runPolecatStale(cmd *cobra.Command, args []string) error { rigName := args[0] mgr, r, err := getPolecatManager(rigName) if err != nil { return err } fmt.Printf("Detecting stale polecats in %s (threshold: %d commits behind main)...\n\n", r.Name, polecatStaleThreshold) staleInfos, err := mgr.DetectStalePolecats(polecatStaleThreshold) if err != nil { return fmt.Errorf("detecting stale polecats: %w", err) } if len(staleInfos) == 0 { fmt.Println("No polecats found.") return nil } // JSON output if polecatStaleJSON { return json.NewEncoder(os.Stdout).Encode(staleInfos) } // Summary counts var staleCount, safeCount int for _, info := range staleInfos { if info.IsStale { staleCount++ } else { safeCount++ } } // Display results for _, info := range staleInfos { statusIcon := style.Success.Render("●") statusText := "active" if info.IsStale { statusIcon = style.Warning.Render("○") statusText = "stale" } fmt.Printf("%s %s (%s)\n", statusIcon, style.Bold.Render(info.Name), statusText) // Session status if info.HasActiveSession { fmt.Printf(" Session: %s\n", style.Success.Render("running")) } else { fmt.Printf(" Session: %s\n", style.Dim.Render("stopped")) } // Commits behind if info.CommitsBehind > 0 { behindStyle := style.Dim if info.CommitsBehind >= polecatStaleThreshold { behindStyle = style.Warning } fmt.Printf(" Behind main: %s\n", behindStyle.Render(fmt.Sprintf("%d commits", info.CommitsBehind))) } // Agent state if info.AgentState != "" { fmt.Printf(" Agent state: %s\n", info.AgentState) } else { fmt.Printf(" Agent state: %s\n", style.Dim.Render("no bead")) } // Uncommitted work if info.HasUncommittedWork { fmt.Printf(" Uncommitted: %s\n", style.Error.Render("yes")) } // Reason fmt.Printf(" Reason: %s\n", info.Reason) fmt.Println() } // Summary fmt.Printf("Summary: %d stale, %d active\n", staleCount, safeCount) // Cleanup if requested if polecatStaleCleanup && staleCount > 0 { fmt.Println() if polecatNukeDryRun { fmt.Printf("Would clean up %d stale polecat(s):\n", staleCount) for _, info := range staleInfos { if info.IsStale { fmt.Printf(" - %s: %s\n", info.Name, info.Reason) } } } else { fmt.Printf("Cleaning up %d stale polecat(s)...\n", staleCount) nuked := 0 for _, info := range staleInfos { if !info.IsStale { continue } fmt.Printf(" Nuking %s...", info.Name) if err := mgr.RemoveWithOptions(info.Name, true, false); err != nil { fmt.Printf(" %s (%v)\n", style.Error.Render("failed"), err) } else { fmt.Printf(" %s\n", style.Success.Render("done")) nuked++ } } fmt.Printf("\n%s Nuked %d stale polecat(s).\n", style.SuccessPrefix, nuked) } } return nil }