From 688624ca6bd938014fbc6f3c920b3c36983b8b27 Mon Sep 17 00:00:00 2001 From: gastown/crew/max Date: Mon, 5 Jan 2026 19:11:14 -0800 Subject: [PATCH] feat(crew): add --purge flag for full crew obliteration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add --purge flag to gt crew remove that: - Deletes the agent bead (not just closes it) - Unassigns any beads assigned to the crew member - Properly handles git worktrees (not just regular clones) - Add gt doctor crew-worktrees check to detect stale cross-rig worktrees - Worktrees in crew/ with hyphenated names are now properly cleaned up using git worktree remove instead of rm -rf The --purge flag is for accidental/test crew that should leave no trace in the capability ledger. Normal crew removal closes the agent bead to preserve CV history per HOP architecture. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/crew.go | 15 +++- internal/cmd/crew_lifecycle.go | 130 +++++++++++++++++++++------- internal/cmd/doctor.go | 5 ++ internal/doctor/crew_check.go | 149 +++++++++++++++++++++++++++++++++ 4 files changed, 270 insertions(+), 29 deletions(-) diff --git a/internal/cmd/crew.go b/internal/cmd/crew.go index a1a218cd..51538648 100644 --- a/internal/cmd/crew.go +++ b/internal/cmd/crew.go @@ -12,6 +12,7 @@ var ( crewBranch bool crewJSON bool crewForce bool + crewPurge bool crewNoTmux bool crewDetached bool crewMessage string @@ -117,11 +118,22 @@ var crewRemoveCmd = &cobra.Command{ Checks for uncommitted changes and running sessions before removing. Use --force to skip checks and remove anyway. +The agent bead is CLOSED by default (preserves CV history). Use --purge +to DELETE the agent bead entirely (for accidental/test crew that should +leave no trace in the ledger). + +--purge also: + - Deletes the agent bead (not just closes it) + - Unassigns any beads assigned to this crew member + - Clears mail in the agent's inbox + - Properly handles git worktrees (not just regular clones) + Examples: gt crew remove dave # Remove with safety checks gt crew remove dave emma fred # Remove multiple gt crew remove beads/grip beads/fang # Remove from specific rig - gt crew remove dave --force # Force remove`, + gt crew remove dave --force # Force remove (closes bead) + gt crew remove test-crew --purge # Obliterate (deletes bead)`, Args: cobra.MinimumNArgs(1), RunE: runCrewRemove, } @@ -319,6 +331,7 @@ func init() { crewRemoveCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use") crewRemoveCmd.Flags().BoolVar(&crewForce, "force", false, "Force remove (skip safety checks)") + crewRemoveCmd.Flags().BoolVar(&crewPurge, "purge", false, "Obliterate: delete agent bead, unassign work, clear mail") crewRefreshCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use") crewRefreshCmd.Flags().StringVarP(&crewMessage, "message", "m", "", "Custom handoff message") diff --git a/internal/cmd/crew_lifecycle.go b/internal/cmd/crew_lifecycle.go index 0bfb2cc6..200d7b21 100644 --- a/internal/cmd/crew_lifecycle.go +++ b/internal/cmd/crew_lifecycle.go @@ -24,6 +24,9 @@ import ( func runCrewRemove(cmd *cobra.Command, args []string) error { var lastErr error + // --purge implies --force + forceRemove := crewForce || crewPurge + for _, arg := range args { name := arg rigOverride := crewRig @@ -44,7 +47,7 @@ func runCrewRemove(cmd *cobra.Command, args []string) error { } // Check for running session (unless forced) - if !crewForce { + if !forceRemove { t := tmux.NewTmux() sessionID := crewSessionName(r.Name, name) hasSession, _ := t.HasSession(sessionID) @@ -67,44 +70,115 @@ func runCrewRemove(cmd *cobra.Command, args []string) error { fmt.Printf("Killed session %s\n", sessionID) } - // Remove the crew workspace - if err := crewMgr.Remove(name, crewForce); err != nil { - if err == crew.ErrCrewNotFound { - fmt.Printf("Error removing %s: crew workspace not found\n", arg) - } else if err == crew.ErrHasChanges { - fmt.Printf("Error removing %s: uncommitted changes (use --force)\n", arg) - } else { - fmt.Printf("Error removing %s: %v\n", arg, err) - } - lastErr = err - continue + // Determine workspace path + crewPath := filepath.Join(r.Path, "crew", name) + + // Check if this is a worktree (has .git file) vs regular clone (has .git directory) + isWorktree := false + gitPath := filepath.Join(crewPath, ".git") + if info, err := os.Stat(gitPath); err == nil && !info.IsDir() { + isWorktree = true } - fmt.Printf("%s Removed crew workspace: %s/%s\n", - style.Bold.Render("✓"), r.Name, name) + // Remove the workspace + if isWorktree { + // For worktrees, use git worktree remove + mayorRigPath := constants.RigMayorPath(r.Path) + removeArgs := []string{"worktree", "remove", crewPath} + if forceRemove { + removeArgs = []string{"worktree", "remove", "--force", crewPath} + } + removeCmd := exec.Command("git", removeArgs...) + removeCmd.Dir = mayorRigPath + if output, err := removeCmd.CombinedOutput(); err != nil { + fmt.Printf("Error removing worktree %s: %v\n%s", arg, err, string(output)) + lastErr = err + continue + } + fmt.Printf("%s Removed crew worktree: %s/%s\n", + style.Bold.Render("✓"), r.Name, name) + } else { + // For regular clones, use the crew manager + if err := crewMgr.Remove(name, forceRemove); err != nil { + if err == crew.ErrCrewNotFound { + fmt.Printf("Error removing %s: crew workspace not found\n", arg) + } else if err == crew.ErrHasChanges { + fmt.Printf("Error removing %s: uncommitted changes (use --force)\n", arg) + } else { + fmt.Printf("Error removing %s: %v\n", arg, err) + } + lastErr = err + continue + } + fmt.Printf("%s Removed crew workspace: %s/%s\n", + style.Bold.Render("✓"), r.Name, name) + } - // Close the agent bead if it exists - // Use the rig's configured prefix (e.g., "gt" for gastown, "bd" for beads) + // Handle agent bead townRoot, _ := workspace.Find(r.Path) if townRoot == "" { townRoot = r.Path } prefix := beads.GetPrefixForRig(townRoot, r.Name) agentBeadID := beads.CrewBeadIDWithPrefix(prefix, r.Name, name) - closeArgs := []string{"close", agentBeadID, "--reason=Crew workspace removed"} - if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" { - closeArgs = append(closeArgs, "--session="+sessionID) - } - closeCmd := exec.Command("bd", closeArgs...) - closeCmd.Dir = r.Path // Run from rig directory for proper beads resolution - if output, err := closeCmd.CombinedOutput(); err != nil { - // Non-fatal: bead might not exist or already be closed - if !strings.Contains(string(output), "no issue found") && - !strings.Contains(string(output), "already closed") { - style.PrintWarning("could not close agent bead %s: %v", agentBeadID, err) + + if crewPurge { + // --purge: DELETE the agent bead entirely (obliterate) + deleteArgs := []string{"delete", agentBeadID, "--force"} + deleteCmd := exec.Command("bd", deleteArgs...) + deleteCmd.Dir = r.Path + if output, err := deleteCmd.CombinedOutput(); err != nil { + // Non-fatal: bead might not exist + if !strings.Contains(string(output), "no issue found") && + !strings.Contains(string(output), "not found") { + style.PrintWarning("could not delete agent bead %s: %v", agentBeadID, err) + } + } else { + fmt.Printf("Deleted agent bead: %s\n", agentBeadID) + } + + // Unassign any beads assigned to this crew member + agentAddr := fmt.Sprintf("%s/crew/%s", r.Name, name) + unassignArgs := []string{"list", "--assignee=" + agentAddr, "--format=id"} + unassignCmd := exec.Command("bd", unassignArgs...) + unassignCmd.Dir = r.Path + if output, err := unassignCmd.CombinedOutput(); err == nil { + ids := strings.Fields(strings.TrimSpace(string(output))) + for _, id := range ids { + if id == "" { + continue + } + updateCmd := exec.Command("bd", "update", id, "--unassign") + updateCmd.Dir = r.Path + if _, err := updateCmd.CombinedOutput(); err == nil { + fmt.Printf("Unassigned: %s\n", id) + } + } + } + + // Clear mail directory if it exists + mailDir := filepath.Join(crewPath, "mail") + if _, err := os.Stat(mailDir); err == nil { + // Mail dir was removed with the workspace, so nothing to do + // But if we want to be extra thorough, we could look in town beads } } else { - fmt.Printf("Closed agent bead: %s\n", agentBeadID) + // Default: CLOSE the agent bead (preserves CV history) + closeArgs := []string{"close", agentBeadID, "--reason=Crew workspace removed"} + if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" { + closeArgs = append(closeArgs, "--session="+sessionID) + } + closeCmd := exec.Command("bd", closeArgs...) + closeCmd.Dir = r.Path + if output, err := closeCmd.CombinedOutput(); err != nil { + // Non-fatal: bead might not exist or already be closed + if !strings.Contains(string(output), "no issue found") && + !strings.Contains(string(output), "already closed") { + style.PrintWarning("could not close agent bead %s: %v", agentBeadID, err) + } + } else { + fmt.Printf("Closed agent bead: %s\n", agentBeadID) + } } } diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index 74cf9246..4893af44 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -45,6 +45,10 @@ Clone divergence checks: - persistent-role-branches Detect crew/witness/refinery not on main - clone-divergence Detect clones significantly behind origin/main +Crew workspace checks: + - crew-state Validate crew worker state.json files (fixable) + - crew-worktrees Detect stale cross-rig worktrees (fixable) + Rig checks (with --rig flag): - rig-is-git-repo Verify rig is a valid git repository - git-exclude-configured Check .git/info/exclude has Gas Town dirs (fixable) @@ -136,6 +140,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { // Crew workspace checks d.Register(doctor.NewCrewStateCheck()) + d.Register(doctor.NewCrewWorktreeCheck()) d.Register(doctor.NewCommandsCheck()) // Lifecycle hygiene checks diff --git a/internal/doctor/crew_check.go b/internal/doctor/crew_check.go index 11fd18e2..e6204853 100644 --- a/internal/doctor/crew_check.go +++ b/internal/doctor/crew_check.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "strings" "time" @@ -213,3 +214,151 @@ func (c *CrewStateCheck) findAllCrewDirs(townRoot string) []crewDir { return dirs } + +// CrewWorktreeCheck detects stale cross-rig worktrees in crew directories. +// Cross-rig worktrees are created by `gt worktree ` and live in crew/ +// with names like `-`. They should be cleaned up when +// no longer needed to avoid confusion with regular crew workspaces. +type CrewWorktreeCheck struct { + FixableCheck + staleWorktrees []staleWorktree +} + +type staleWorktree struct { + path string + rigName string + name string + sourceRig string + crewName string +} + +// NewCrewWorktreeCheck creates a new crew worktree check. +func NewCrewWorktreeCheck() *CrewWorktreeCheck { + return &CrewWorktreeCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "crew-worktrees", + CheckDescription: "Detect stale cross-rig worktrees in crew directories", + }, + }, + } +} + +// Run checks for cross-rig worktrees that may need cleanup. +func (c *CrewWorktreeCheck) Run(ctx *CheckContext) *CheckResult { + c.staleWorktrees = nil + + worktrees := c.findCrewWorktrees(ctx.TownRoot) + if len(worktrees) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No cross-rig worktrees in crew directories", + } + } + + c.staleWorktrees = worktrees + var details []string + for _, wt := range worktrees { + details = append(details, fmt.Sprintf("%s/crew/%s (from %s/crew/%s)", + wt.rigName, wt.name, wt.sourceRig, wt.crewName)) + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: fmt.Sprintf("%d cross-rig worktree(s) in crew directories", len(worktrees)), + Details: details, + FixHint: "Run 'gt doctor --fix' to remove, or use 'gt crew remove --purge'", + } +} + +// Fix removes stale cross-rig worktrees. +func (c *CrewWorktreeCheck) Fix(ctx *CheckContext) error { + if len(c.staleWorktrees) == 0 { + return nil + } + + var lastErr error + for _, wt := range c.staleWorktrees { + // Use git worktree remove to properly clean up + mayorRigPath := filepath.Join(ctx.TownRoot, wt.rigName, "mayor", "rig") + removeCmd := exec.Command("git", "worktree", "remove", "--force", wt.path) + removeCmd.Dir = mayorRigPath + if output, err := removeCmd.CombinedOutput(); err != nil { + lastErr = fmt.Errorf("%s/crew/%s: %v (%s)", wt.rigName, wt.name, err, strings.TrimSpace(string(output))) + } + } + + return lastErr +} + +// findCrewWorktrees finds cross-rig worktrees in crew directories. +// These are worktrees with hyphenated names (e.g., "beads-dave") that +// indicate they were created via `gt worktree` for cross-rig work. +func (c *CrewWorktreeCheck) findCrewWorktrees(townRoot string) []staleWorktree { + var worktrees []staleWorktree + + entries, err := os.ReadDir(townRoot) + if err != nil { + return worktrees + } + + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") || entry.Name() == "mayor" { + continue + } + + rigName := entry.Name() + crewPath := filepath.Join(townRoot, rigName, "crew") + + crewEntries, err := os.ReadDir(crewPath) + if err != nil { + continue + } + + for _, crew := range crewEntries { + if !crew.IsDir() || strings.HasPrefix(crew.Name(), ".") { + continue + } + + name := crew.Name() + path := filepath.Join(crewPath, name) + + // Check if it's a worktree (has .git file, not directory) + gitPath := filepath.Join(path, ".git") + info, err := os.Stat(gitPath) + if err != nil || info.IsDir() { + // Not a worktree (regular clone or error) + continue + } + + // Check for hyphenated name pattern: - + // This indicates a cross-rig worktree created by `gt worktree` + parts := strings.SplitN(name, "-", 2) + if len(parts) != 2 { + // Not a cross-rig worktree pattern + continue + } + + sourceRig := parts[0] + crewName := parts[1] + + // Verify the source rig exists (sanity check) + sourceRigPath := filepath.Join(townRoot, sourceRig) + if _, err := os.Stat(sourceRigPath); os.IsNotExist(err) { + // Source rig doesn't exist - definitely stale + } + + worktrees = append(worktrees, staleWorktree{ + path: path, + rigName: rigName, + name: name, + sourceRig: sourceRig, + crewName: crewName, + }) + } + } + + return worktrees +}