feat(crew): add --purge flag for full crew obliteration

- 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 <noreply@anthropic.com>
This commit is contained in:
gastown/crew/max
2026-01-05 19:11:14 -08:00
committed by Steve Yegge
parent c529d09e77
commit 688624ca6b
4 changed files with 270 additions and 29 deletions

View File

@@ -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 <rig>` and live in crew/
// with names like `<source-rig>-<crewname>`. 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 <name> --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: <source-rig>-<crewname>
// 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
}