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:
committed by
Steve Yegge
parent
c529d09e77
commit
688624ca6b
@@ -12,6 +12,7 @@ var (
|
|||||||
crewBranch bool
|
crewBranch bool
|
||||||
crewJSON bool
|
crewJSON bool
|
||||||
crewForce bool
|
crewForce bool
|
||||||
|
crewPurge bool
|
||||||
crewNoTmux bool
|
crewNoTmux bool
|
||||||
crewDetached bool
|
crewDetached bool
|
||||||
crewMessage string
|
crewMessage string
|
||||||
@@ -117,11 +118,22 @@ var crewRemoveCmd = &cobra.Command{
|
|||||||
Checks for uncommitted changes and running sessions before removing.
|
Checks for uncommitted changes and running sessions before removing.
|
||||||
Use --force to skip checks and remove anyway.
|
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:
|
Examples:
|
||||||
gt crew remove dave # Remove with safety checks
|
gt crew remove dave # Remove with safety checks
|
||||||
gt crew remove dave emma fred # Remove multiple
|
gt crew remove dave emma fred # Remove multiple
|
||||||
gt crew remove beads/grip beads/fang # Remove from specific rig
|
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),
|
Args: cobra.MinimumNArgs(1),
|
||||||
RunE: runCrewRemove,
|
RunE: runCrewRemove,
|
||||||
}
|
}
|
||||||
@@ -319,6 +331,7 @@ func init() {
|
|||||||
|
|
||||||
crewRemoveCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use")
|
crewRemoveCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use")
|
||||||
crewRemoveCmd.Flags().BoolVar(&crewForce, "force", false, "Force remove (skip safety checks)")
|
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().StringVar(&crewRig, "rig", "", "Rig to use")
|
||||||
crewRefreshCmd.Flags().StringVarP(&crewMessage, "message", "m", "", "Custom handoff message")
|
crewRefreshCmd.Flags().StringVarP(&crewMessage, "message", "m", "", "Custom handoff message")
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ import (
|
|||||||
func runCrewRemove(cmd *cobra.Command, args []string) error {
|
func runCrewRemove(cmd *cobra.Command, args []string) error {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
|
||||||
|
// --purge implies --force
|
||||||
|
forceRemove := crewForce || crewPurge
|
||||||
|
|
||||||
for _, arg := range args {
|
for _, arg := range args {
|
||||||
name := arg
|
name := arg
|
||||||
rigOverride := crewRig
|
rigOverride := crewRig
|
||||||
@@ -44,7 +47,7 @@ func runCrewRemove(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for running session (unless forced)
|
// Check for running session (unless forced)
|
||||||
if !crewForce {
|
if !forceRemove {
|
||||||
t := tmux.NewTmux()
|
t := tmux.NewTmux()
|
||||||
sessionID := crewSessionName(r.Name, name)
|
sessionID := crewSessionName(r.Name, name)
|
||||||
hasSession, _ := t.HasSession(sessionID)
|
hasSession, _ := t.HasSession(sessionID)
|
||||||
@@ -67,44 +70,115 @@ func runCrewRemove(cmd *cobra.Command, args []string) error {
|
|||||||
fmt.Printf("Killed session %s\n", sessionID)
|
fmt.Printf("Killed session %s\n", sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the crew workspace
|
// Determine workspace path
|
||||||
if err := crewMgr.Remove(name, crewForce); err != nil {
|
crewPath := filepath.Join(r.Path, "crew", name)
|
||||||
if err == crew.ErrCrewNotFound {
|
|
||||||
fmt.Printf("Error removing %s: crew workspace not found\n", arg)
|
// Check if this is a worktree (has .git file) vs regular clone (has .git directory)
|
||||||
} else if err == crew.ErrHasChanges {
|
isWorktree := false
|
||||||
fmt.Printf("Error removing %s: uncommitted changes (use --force)\n", arg)
|
gitPath := filepath.Join(crewPath, ".git")
|
||||||
} else {
|
if info, err := os.Stat(gitPath); err == nil && !info.IsDir() {
|
||||||
fmt.Printf("Error removing %s: %v\n", arg, err)
|
isWorktree = true
|
||||||
}
|
|
||||||
lastErr = err
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("%s Removed crew workspace: %s/%s\n",
|
// Remove the workspace
|
||||||
style.Bold.Render("✓"), r.Name, name)
|
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
|
// Handle agent bead
|
||||||
// Use the rig's configured prefix (e.g., "gt" for gastown, "bd" for beads)
|
|
||||||
townRoot, _ := workspace.Find(r.Path)
|
townRoot, _ := workspace.Find(r.Path)
|
||||||
if townRoot == "" {
|
if townRoot == "" {
|
||||||
townRoot = r.Path
|
townRoot = r.Path
|
||||||
}
|
}
|
||||||
prefix := beads.GetPrefixForRig(townRoot, r.Name)
|
prefix := beads.GetPrefixForRig(townRoot, r.Name)
|
||||||
agentBeadID := beads.CrewBeadIDWithPrefix(prefix, r.Name, name)
|
agentBeadID := beads.CrewBeadIDWithPrefix(prefix, r.Name, name)
|
||||||
closeArgs := []string{"close", agentBeadID, "--reason=Crew workspace removed"}
|
|
||||||
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
|
if crewPurge {
|
||||||
closeArgs = append(closeArgs, "--session="+sessionID)
|
// --purge: DELETE the agent bead entirely (obliterate)
|
||||||
}
|
deleteArgs := []string{"delete", agentBeadID, "--force"}
|
||||||
closeCmd := exec.Command("bd", closeArgs...)
|
deleteCmd := exec.Command("bd", deleteArgs...)
|
||||||
closeCmd.Dir = r.Path // Run from rig directory for proper beads resolution
|
deleteCmd.Dir = r.Path
|
||||||
if output, err := closeCmd.CombinedOutput(); err != nil {
|
if output, err := deleteCmd.CombinedOutput(); err != nil {
|
||||||
// Non-fatal: bead might not exist or already be closed
|
// Non-fatal: bead might not exist
|
||||||
if !strings.Contains(string(output), "no issue found") &&
|
if !strings.Contains(string(output), "no issue found") &&
|
||||||
!strings.Contains(string(output), "already closed") {
|
!strings.Contains(string(output), "not found") {
|
||||||
style.PrintWarning("could not close agent bead %s: %v", agentBeadID, err)
|
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 {
|
} 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ Clone divergence checks:
|
|||||||
- persistent-role-branches Detect crew/witness/refinery not on main
|
- persistent-role-branches Detect crew/witness/refinery not on main
|
||||||
- clone-divergence Detect clones significantly behind origin/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 checks (with --rig flag):
|
||||||
- rig-is-git-repo Verify rig is a valid git repository
|
- rig-is-git-repo Verify rig is a valid git repository
|
||||||
- git-exclude-configured Check .git/info/exclude has Gas Town dirs (fixable)
|
- 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
|
// Crew workspace checks
|
||||||
d.Register(doctor.NewCrewStateCheck())
|
d.Register(doctor.NewCrewStateCheck())
|
||||||
|
d.Register(doctor.NewCrewWorktreeCheck())
|
||||||
d.Register(doctor.NewCommandsCheck())
|
d.Register(doctor.NewCommandsCheck())
|
||||||
|
|
||||||
// Lifecycle hygiene checks
|
// Lifecycle hygiene checks
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -213,3 +214,151 @@ func (c *CrewStateCheck) findAllCrewDirs(townRoot string) []crewDir {
|
|||||||
|
|
||||||
return dirs
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user