feat: Unified beads redirect for tracked and local beads (#222)
* feat: Beads redirect architecture for tracked and local beads This change implements proper redirect handling so that all rig agents (Witness, Refinery, Crew, Polecats) can work with both: - Tracked beads: .beads/ checked into git at mayor/rig/.beads - Local beads: .beads/ created at rig root during gt rig add Key changes: 1. SetupRedirect now handles tracked beads by skipping redirect chains. The bd CLI doesn't support chains (A→B→C), so worktrees redirect directly to the final destination (mayor/rig/.beads for tracked). 2. ResolveBeadsDir is now used consistently in polecat and refinery managers instead of hardcoded mayor/rig paths. 3. Rig-level agents (witness, refinery) now use rig beads with rig prefix instead of town beads. This follows the architecture where town beads are only for Mayor/Deacon. 4. prime.go simplified to always use ../../.beads for crew redirects, letting rig-level redirect handle tracked vs local routing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(doctor): Add beads-redirect check for tracked beads When a repo has .beads/ tracked in git (at mayor/rig/.beads), the rig root needs a redirect file pointing to that location. This check: - Detects missing rig-level redirect for tracked beads - Verifies redirect points to correct location (mayor/rig/.beads) - Auto-fixes with 'gt doctor --fix' 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Handle fileLock.Unlock error in daemon Wrap fileLock.Unlock() return value to satisfy errcheck linter. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -159,9 +159,9 @@ func TestDoneBeadsInitBothCodePaths(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestDoneRedirectChain verifies behavior with chained redirects.
|
||||
// ResolveBeadsDir follows exactly one level of redirect by design - it does NOT
|
||||
// follow chains transitively. This is intentional: chains typically indicate
|
||||
// misconfiguration (e.g., a redirect file that shouldn't exist).
|
||||
// ResolveBeadsDir follows chains up to depth 3 as a safety net for legacy configs.
|
||||
// SetupRedirect avoids creating chains (bd CLI doesn't support them), but if
|
||||
// chains exist we follow them to the final destination.
|
||||
func TestDoneRedirectChain(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -189,14 +189,15 @@ func TestDoneRedirectChain(t *testing.T) {
|
||||
t.Fatalf("write worktree redirect: %v", err)
|
||||
}
|
||||
|
||||
// ResolveBeadsDir follows exactly one level - stops at intermediate
|
||||
// (A warning is printed about the chain, but intermediate is returned)
|
||||
// ResolveBeadsDir follows chains up to depth 3 as a safety net.
|
||||
// Note: SetupRedirect avoids creating chains (bd CLI doesn't support them),
|
||||
// but if chains exist from legacy configs, we follow them to the final destination.
|
||||
resolved := beads.ResolveBeadsDir(worktreeDir)
|
||||
|
||||
// Should resolve to intermediate (one level), NOT canonical (two levels)
|
||||
if resolved != intermediateBeadsDir {
|
||||
t.Errorf("ResolveBeadsDir should follow one level only: got %s, want %s",
|
||||
resolved, intermediateBeadsDir)
|
||||
// Should resolve to canonical (follows the full chain)
|
||||
if resolved != canonicalBeadsDir {
|
||||
t.Errorf("ResolveBeadsDir should follow chain to final destination: got %s, want %s",
|
||||
resolved, canonicalBeadsDir)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1309,103 +1309,22 @@ func getAgentBeadID(ctx RoleContext) string {
|
||||
|
||||
// ensureBeadsRedirect ensures the .beads/redirect file exists for worktree-based roles.
|
||||
// This handles cases where git clean or other operations delete the redirect file.
|
||||
//
|
||||
// IMPORTANT: This function includes safety checks to prevent creating redirects in
|
||||
// the canonical beads location (mayor/rig/.beads), which would cause circular redirects.
|
||||
// Uses the shared SetupRedirect helper which handles both tracked and local beads.
|
||||
func ensureBeadsRedirect(ctx RoleContext) {
|
||||
// Only applies to crew and polecat roles (they use shared beads)
|
||||
if ctx.Role != RoleCrew && ctx.Role != RolePolecat {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the rig root (parent of crew/ or polecats/)
|
||||
relPath, err := filepath.Rel(ctx.TownRoot, ctx.WorkDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(relPath), "/")
|
||||
if len(parts) < 1 {
|
||||
return
|
||||
}
|
||||
rigRoot := filepath.Join(ctx.TownRoot, parts[0])
|
||||
|
||||
// SAFETY CHECK: Prevent creating redirect in canonical beads location
|
||||
// If workDir is inside mayor/rig/, we should NOT create a redirect there
|
||||
// This prevents circular redirects like mayor/rig/.beads/redirect -> ../../mayor/rig/.beads
|
||||
mayorRigPath := filepath.Join(rigRoot, "mayor", "rig")
|
||||
workDirAbs, _ := filepath.Abs(ctx.WorkDir)
|
||||
mayorRigPathAbs, _ := filepath.Abs(mayorRigPath)
|
||||
if strings.HasPrefix(workDirAbs, mayorRigPathAbs) {
|
||||
// We're inside mayor/rig/ - this is not a polecat/crew worker location
|
||||
// Role detection may be wrong (e.g., GT_ROLE env var mismatch)
|
||||
// Do NOT create a redirect here
|
||||
// Only applies to worktree-based roles that use shared beads
|
||||
if ctx.Role != RoleCrew && ctx.Role != RolePolecat && ctx.Role != RoleRefinery {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if redirect already exists
|
||||
beadsDir := filepath.Join(ctx.WorkDir, ".beads")
|
||||
redirectPath := filepath.Join(beadsDir, "redirect")
|
||||
|
||||
redirectPath := filepath.Join(ctx.WorkDir, ".beads", "redirect")
|
||||
if _, err := os.Stat(redirectPath); err == nil {
|
||||
// Redirect exists, nothing to do
|
||||
return
|
||||
}
|
||||
|
||||
// Determine the correct redirect path based on role and rig structure
|
||||
var redirectContent string
|
||||
|
||||
// Check for shared beads locations in order of preference:
|
||||
// 1. rig/mayor/rig/.beads/ (if mayor rig clone exists)
|
||||
// 2. rig/.beads/ (rig root beads)
|
||||
mayorRigBeads := filepath.Join(rigRoot, "mayor", "rig", ".beads")
|
||||
rigRootBeads := filepath.Join(rigRoot, ".beads")
|
||||
|
||||
if _, err := os.Stat(mayorRigBeads); err == nil {
|
||||
// Use mayor/rig/.beads
|
||||
if ctx.Role == RoleCrew {
|
||||
// crew/<name>/.beads -> ../../mayor/rig/.beads
|
||||
redirectContent = "../../mayor/rig/.beads"
|
||||
} else {
|
||||
// polecats/<name>/.beads -> ../../mayor/rig/.beads
|
||||
redirectContent = "../../mayor/rig/.beads"
|
||||
}
|
||||
} else if _, err := os.Stat(rigRootBeads); err == nil {
|
||||
// Use rig root .beads
|
||||
if ctx.Role == RoleCrew {
|
||||
// crew/<name>/.beads -> ../../.beads
|
||||
redirectContent = "../../.beads"
|
||||
} else {
|
||||
// polecats/<name>/.beads -> ../../.beads
|
||||
redirectContent = "../../.beads"
|
||||
}
|
||||
} else {
|
||||
// No shared beads found, nothing to redirect to
|
||||
return
|
||||
}
|
||||
|
||||
// SAFETY CHECK: Verify the redirect won't be circular
|
||||
// Resolve the redirect target and check it's not the same as our beads dir
|
||||
resolvedTarget := filepath.Join(ctx.WorkDir, redirectContent)
|
||||
resolvedTarget = filepath.Clean(resolvedTarget)
|
||||
if resolvedTarget == beadsDir {
|
||||
// Would create circular redirect - don't do it
|
||||
return
|
||||
}
|
||||
|
||||
// Create .beads directory if needed
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
// Silently fail - not critical
|
||||
return
|
||||
}
|
||||
|
||||
// Write redirect file
|
||||
if err := os.WriteFile(redirectPath, []byte(redirectContent+"\n"), 0644); err != nil {
|
||||
// Silently fail - not critical
|
||||
return
|
||||
}
|
||||
|
||||
// Note: We don't print a message here to avoid cluttering prime output
|
||||
// The redirect is silently restored
|
||||
// Use shared helper - silently ignore errors during prime
|
||||
_ = beads.SetupRedirect(ctx.TownRoot, ctx.WorkDir)
|
||||
}
|
||||
|
||||
// checkPendingEscalations queries for open escalation beads and displays them prominently.
|
||||
|
||||
Reference in New Issue
Block a user