Files
gastown/internal/cmd/polecat.go
JJ b1a5241430 fix(beads): align agent bead prefixes and force multi-hyphen IDs (#482)
* fix(beads): align agent bead prefixes and force multi-hyphen IDs

* fix(checkpoint): treat threshold as stale at boundary
2026-01-16 12:33:51 -08:00

1370 lines
39 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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{"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 <rig> <name>",
Short: "Add a new polecat to a rig (DEPRECATED)",
Deprecated: "use 'gt polecat identity add' instead. This command will be removed in v1.0.",
Long: `Add a new polecat to a rig.
DEPRECATED: Use 'gt polecat identity add' instead. This command will be removed in v1.0.
Creates a polecat directory, clones the rig repo, creates a work branch,
and initializes state.
Example:
gt polecat identity add greenplace Toast # Preferred
gt polecat add greenplace Toast # Deprecated`,
Args: cobra.ExactArgs(2),
RunE: runPolecatAdd,
}
var polecatRemoveCmd = &cobra.Command{
Use: "remove <rig>/<polecat>... | <rig> --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 <rig>/<polecat>",
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 <rig>/<polecat>",
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 <rig>",
Short: "Garbage collect stale polecat branches",
Long: `Garbage collect stale polecat branches in a rig.
Polecats use unique timestamped branches (polecat/<name>-<timestamp>) 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 <rig>/<polecat>... | <rig> --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 <rig>/<polecat>",
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 <rig>/<polecat>",
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 <rig>",
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)
t := tmux.NewTmux()
mgr := polecat.NewManager(r, polecatGit, t)
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, t)
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 {
// Emit deprecation warning
fmt.Fprintf(os.Stderr, "%s 'gt polecat add' is deprecated. Use 'gt polecat identity add' instead.\n",
style.Warning.Render("Warning:"))
fmt.Fprintf(os.Stderr, " This command will be removed in v1.0.\n\n")
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 {
targets, err := resolvePolecatTargets(args, polecatRemoveAll)
if err != nil {
return err
}
if len(targets) == 0 {
fmt.Println("No polecats to remove.")
return nil
}
// Remove each polecat
t := tmux.NewTmux()
var removeErrors []string
removed := 0
for _, p := range targets {
// 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 := polecatBeadIDForRig(r, 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 {
targets, err := resolvePolecatTargets(args, polecatNukeAll)
if err != nil {
return err
}
if len(targets) == 0 {
fmt.Println("No polecats to nuke.")
return nil
}
// Safety checks: refuse to nuke polecats with active work unless --force is set
if !polecatNukeForce && !polecatNukeDryRun {
var blocked []*SafetyCheckResult
for _, p := range targets {
result := checkPolecatSafety(p)
if result.Blocked {
blocked = append(blocked, result)
}
}
if len(blocked) > 0 {
displaySafetyCheckBlocked(blocked)
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 targets {
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", polecatBeadIDForRig(p.r, p.rigName, p.polecatName))
displayDryRunSafetyCheck(p)
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 := polecatBeadIDForRig(p.r, 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(targets))
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
}