feat(polecat): add repo path to worktrees for LLM ergonomics (GH#283)

Changes polecat worktree structure from:
  polecats/<name>/
to:
  polecats/<name>/<rigname>/

This gives Claude Code agents a recognizable directory name (e.g., tidepool/)
in their cwd instead of just the polecat name, preventing confusion about
which repo they are working in.

Key changes:
- Add clonePath() method to manager.go and session_manager.go for the actual
  git worktree path, keeping polecatDir() for existence checks
- Update Add(), RepairWorktree(), Remove() to use new structure
- Update daemon lifecycle and restart code for new paths
- Update witness handlers to detect both structures
- Update doctor checks (rig_check, branch_check, config_check,
  claude_settings_check) for backward compatibility
- All code includes fallback to old structure for existing polecats

Fixes #283

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gastown/crew/max
2026-01-08 23:15:51 -08:00
committed by Steve Yegge
parent c8c97fdf64
commit 9b2f4a7652
10 changed files with 189 additions and 52 deletions
+79 -28
View File
@@ -178,11 +178,36 @@ func (m *Manager) repoBase() (*git.Git, error) {
return git.NewGit(mayorPath), nil
}
// polecatDir returns the directory for a polecat.
// polecatDir returns the parent directory for a polecat.
// This is polecats/<name>/ - the polecat's home directory.
func (m *Manager) polecatDir(name string) string {
return filepath.Join(m.rig.Path, "polecats", name)
}
// clonePath returns the path where the git worktree lives.
// New structure: polecats/<name>/<rigname>/ - gives LLMs recognizable repo context.
// Falls back to old structure: polecats/<name>/ for backward compatibility.
func (m *Manager) clonePath(name string) string {
// New structure: polecats/<name>/<rigname>/
newPath := filepath.Join(m.rig.Path, "polecats", name, m.rig.Name)
if info, err := os.Stat(newPath); err == nil && info.IsDir() {
return newPath
}
// Old structure: polecats/<name>/ (backward compat)
oldPath := filepath.Join(m.rig.Path, "polecats", name)
if info, err := os.Stat(oldPath); err == nil && info.IsDir() {
// Check if this is actually a git worktree (has .git file or dir)
gitPath := filepath.Join(oldPath, ".git")
if _, err := os.Stat(gitPath); err == nil {
return oldPath
}
}
// Default to new structure for new polecats
return newPath
}
// exists checks if a polecat exists.
func (m *Manager) exists(name string) bool {
_, err := os.Stat(m.polecatDir(name))
@@ -214,15 +239,18 @@ func (m *Manager) AddWithOptions(name string, opts AddOptions) (*Polecat, error)
return nil, ErrPolecatExists
}
polecatPath := m.polecatDir(name)
// New structure: polecats/<name>/<rigname>/ for LLM ergonomics
// The polecat's home dir is polecats/<name>/, worktree is polecats/<name>/<rigname>/
polecatDir := m.polecatDir(name)
clonePath := filepath.Join(polecatDir, m.rig.Name)
// Unique branch per run - prevents drift from stale branches
// Use base36 encoding for shorter branch names (8 chars vs 13 digits)
branchName := fmt.Sprintf("polecat/%s-%s", name, strconv.FormatInt(time.Now().UnixMilli(), 36))
// Create polecats directory if needed
polecatsDir := filepath.Join(m.rig.Path, "polecats")
if err := os.MkdirAll(polecatsDir, 0755); err != nil {
return nil, fmt.Errorf("creating polecats dir: %w", err)
// Create polecat directory (polecats/<name>/)
if err := os.MkdirAll(polecatDir, 0755); err != nil {
return nil, fmt.Errorf("creating polecat dir: %w", err)
}
// Get the repo base (bare repo or mayor/rig)
@@ -233,7 +261,8 @@ func (m *Manager) AddWithOptions(name string, opts AddOptions) (*Polecat, error)
// Always create fresh branch - unique name guarantees no collision
// git worktree add -b polecat/<name>-<timestamp> <path>
if err := repoGit.WorktreeAdd(polecatPath, branchName); err != nil {
// Worktree goes in polecats/<name>/<rigname>/ for LLM ergonomics
if err := repoGit.WorktreeAdd(clonePath, branchName); err != nil {
return nil, fmt.Errorf("creating worktree: %w", err)
}
@@ -244,7 +273,7 @@ func (m *Manager) AddWithOptions(name string, opts AddOptions) (*Polecat, error)
// Set up shared beads: polecat uses rig's .beads via redirect file.
// This eliminates git sync overhead - all polecats share one database.
if err := m.setupSharedBeads(polecatPath); err != nil {
if err := m.setupSharedBeads(clonePath); err != nil {
// Non-fatal - polecat can still work with local beads
// Log warning but don't fail the spawn
fmt.Printf("Warning: could not set up shared beads: %v\n", err)
@@ -283,7 +312,7 @@ func (m *Manager) AddWithOptions(name string, opts AddOptions) (*Polecat, error)
Name: name,
Rig: m.rig.Name,
State: StateWorking, // Transient model: polecat spawns with work
ClonePath: polecatPath,
ClonePath: clonePath,
Branch: branchName,
CreatedAt: now,
UpdatedAt: now,
@@ -310,7 +339,10 @@ func (m *Manager) RemoveWithOptions(name string, force, nuclear bool) error {
return ErrPolecatNotFound
}
polecatPath := m.polecatDir(name)
// Clone path is where the git worktree lives (new or old structure)
clonePath := m.clonePath(name)
// Polecat dir is the parent directory (polecats/<name>/)
polecatDir := m.polecatDir(name)
// Check for uncommitted work unless bypassed
if !nuclear {
@@ -325,7 +357,7 @@ func (m *Manager) RemoveWithOptions(name string, force, nuclear bool) error {
}
} else {
// Fallback path: Check git directly (for polecats that haven't reported yet)
polecatGit := git.NewGit(polecatPath)
polecatGit := git.NewGit(clonePath)
status, err := polecatGit.CheckUncommittedWork()
if err == nil && !status.Clean() {
// For backward compatibility: force only bypasses uncommitted changes, not stashes/unpushed
@@ -345,18 +377,24 @@ func (m *Manager) RemoveWithOptions(name string, force, nuclear bool) error {
repoGit, err := m.repoBase()
if err != nil {
// Fall back to direct removal if repo base not found
return os.RemoveAll(polecatPath)
return os.RemoveAll(polecatDir)
}
// Try to remove as a worktree first (use force flag for worktree removal too)
if err := repoGit.WorktreeRemove(polecatPath, force); err != nil {
if err := repoGit.WorktreeRemove(clonePath, force); err != nil {
// Fall back to direct removal if worktree removal fails
// (e.g., if this is an old-style clone, not a worktree)
if removeErr := os.RemoveAll(polecatPath); removeErr != nil {
return fmt.Errorf("removing polecat dir: %w", removeErr)
if removeErr := os.RemoveAll(clonePath); removeErr != nil {
return fmt.Errorf("removing clone path: %w", removeErr)
}
}
// Also remove the parent polecat directory if it's now empty
// (for new structure: polecats/<name>/ contains only polecats/<name>/<rigname>/)
if polecatDir != clonePath {
_ = os.Remove(polecatDir) // Non-fatal: only removes if empty
}
// Prune any stale worktree entries (non-fatal: cleanup only)
_ = repoGit.WorktreePrune()
@@ -419,13 +457,19 @@ func (m *Manager) RepairWorktree(name string, force bool) (*Polecat, error) {
// RepairWorktreeWithOptions repairs a stale polecat and creates a fresh worktree with options.
// This is NOT for normal operation - see RepairWorktree for context.
// Allows setting hook_bead atomically at repair time.
// After repair, uses new structure: polecats/<name>/<rigname>/
func (m *Manager) RepairWorktreeWithOptions(name string, force bool, opts AddOptions) (*Polecat, error) {
if !m.exists(name) {
return nil, ErrPolecatNotFound
}
polecatPath := m.polecatDir(name)
polecatGit := git.NewGit(polecatPath)
// Get the old clone path (may be old or new structure)
oldClonePath := m.clonePath(name)
polecatGit := git.NewGit(oldClonePath)
// New clone path uses new structure
polecatDir := m.polecatDir(name)
newClonePath := filepath.Join(polecatDir, m.rig.Name)
// Get the repo base (bare repo or mayor/rig)
repoGit, err := m.repoBase()
@@ -449,11 +493,11 @@ func (m *Manager) RepairWorktreeWithOptions(name string, force bool, opts AddOpt
}
}
// Remove the worktree (use force for git worktree removal)
if err := repoGit.WorktreeRemove(polecatPath, true); err != nil {
// Remove the old worktree (use force for git worktree removal)
if err := repoGit.WorktreeRemove(oldClonePath, true); err != nil {
// Fall back to direct removal
if removeErr := os.RemoveAll(polecatPath); removeErr != nil {
return nil, fmt.Errorf("removing polecat dir: %w", removeErr)
if removeErr := os.RemoveAll(oldClonePath); removeErr != nil {
return nil, fmt.Errorf("removing old clone path: %w", removeErr)
}
}
@@ -463,6 +507,11 @@ func (m *Manager) RepairWorktreeWithOptions(name string, force bool, opts AddOpt
// Fetch latest from origin to ensure we have fresh commits (non-fatal: may be offline)
_ = repoGit.Fetch("origin")
// Ensure polecat directory exists for new structure
if err := os.MkdirAll(polecatDir, 0755); err != nil {
return nil, fmt.Errorf("creating polecat dir: %w", err)
}
// Determine the start point for the new worktree
// Use origin/<default-branch> to ensure we start from latest fetched commits
defaultBranch := "main"
@@ -476,7 +525,7 @@ func (m *Manager) RepairWorktreeWithOptions(name string, force bool, opts AddOpt
// and will be cleaned up by garbage collection
// Use base36 encoding for shorter branch names (8 chars vs 13 digits)
branchName := fmt.Sprintf("polecat/%s-%s", name, strconv.FormatInt(time.Now().UnixMilli(), 36))
if err := repoGit.WorktreeAddFromRef(polecatPath, branchName, startPoint); err != nil {
if err := repoGit.WorktreeAddFromRef(newClonePath, branchName, startPoint); err != nil {
return nil, fmt.Errorf("creating fresh worktree from %s: %w", startPoint, err)
}
@@ -484,7 +533,7 @@ func (m *Manager) RepairWorktreeWithOptions(name string, force bool, opts AddOpt
// Gas Town context is injected ephemerally via SessionStart hook (gt prime).
// Set up shared beads
if err := m.setupSharedBeads(polecatPath); err != nil {
if err := m.setupSharedBeads(newClonePath); err != nil {
fmt.Printf("Warning: could not set up shared beads: %v\n", err)
}
@@ -514,7 +563,7 @@ func (m *Manager) RepairWorktreeWithOptions(name string, force bool, opts AddOpt
Name: name,
Rig: m.rig.Name,
State: StateWorking,
ClonePath: polecatPath,
ClonePath: newClonePath,
Branch: branchName,
CreatedAt: now,
UpdatedAt: now,
@@ -690,10 +739,12 @@ func (m *Manager) ClearIssue(name string) error {
// Transient polecats should always have work; no work means ready for Witness cleanup.
// We don't interpret issue status (ZFC: Go is transport, not decision-maker).
func (m *Manager) loadFromBeads(name string) (*Polecat, error) {
polecatPath := m.polecatDir(name)
// Use clonePath which handles both new (polecats/<name>/<rigname>/)
// and old (polecats/<name>/) structures
clonePath := m.clonePath(name)
// Get actual branch from worktree (branches are now timestamped)
polecatGit := git.NewGit(polecatPath)
polecatGit := git.NewGit(clonePath)
branchName, err := polecatGit.CurrentBranch()
if err != nil {
// Fall back to old format if we can't read the branch
@@ -710,7 +761,7 @@ func (m *Manager) loadFromBeads(name string) (*Polecat, error) {
Name: name,
Rig: m.rig.Name,
State: StateWorking,
ClonePath: polecatPath,
ClonePath: clonePath,
Branch: branchName,
}, nil
}
@@ -728,7 +779,7 @@ func (m *Manager) loadFromBeads(name string) (*Polecat, error) {
Name: name,
Rig: m.rig.Name,
State: state,
ClonePath: polecatPath,
ClonePath: clonePath,
Branch: branchName,
Issue: issueID,
}, nil
+27 -2
View File
@@ -96,11 +96,36 @@ func (m *SessionManager) SessionName(polecat string) string {
return fmt.Sprintf("gt-%s-%s", m.rig.Name, polecat)
}
// polecatDir returns the working directory for a polecat.
// polecatDir returns the parent directory for a polecat.
// This is polecats/<name>/ - the polecat's home directory.
func (m *SessionManager) polecatDir(polecat string) string {
return filepath.Join(m.rig.Path, "polecats", polecat)
}
// clonePath returns the path where the git worktree lives.
// New structure: polecats/<name>/<rigname>/ - gives LLMs recognizable repo context.
// Falls back to old structure: polecats/<name>/ for backward compatibility.
func (m *SessionManager) clonePath(polecat string) string {
// New structure: polecats/<name>/<rigname>/
newPath := filepath.Join(m.rig.Path, "polecats", polecat, m.rig.Name)
if info, err := os.Stat(newPath); err == nil && info.IsDir() {
return newPath
}
// Old structure: polecats/<name>/ (backward compat)
oldPath := filepath.Join(m.rig.Path, "polecats", polecat)
if info, err := os.Stat(oldPath); err == nil && info.IsDir() {
// Check if this is actually a git worktree (has .git file or dir)
gitPath := filepath.Join(oldPath, ".git")
if _, err := os.Stat(gitPath); err == nil {
return oldPath
}
}
// Default to new structure for new polecats
return newPath
}
// hasPolecat checks if the polecat exists in this rig.
func (m *SessionManager) hasPolecat(polecat string) bool {
polecatPath := m.polecatDir(polecat)
@@ -131,7 +156,7 @@ func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error {
// Determine working directory
workDir := opts.WorkDir
if workDir == "" {
workDir = m.polecatDir(polecat)
workDir = m.clonePath(polecat)
}
runtimeConfig := config.LoadRuntimeConfig(m.rig.Path)