From 9d7dcde1e2eceadc30b732358e6e726db049e4d0 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Tue, 6 Jan 2026 12:59:37 -0800 Subject: [PATCH] feat: Unified beads redirect for tracked and local beads (#222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 4.5 --- internal/beads/beads.go | 122 +++++++- internal/beads/beads_test.go | 234 +++++++++++++++ internal/cmd/done_test.go | 19 +- internal/cmd/prime.go | 93 +----- internal/crew/manager.go | 48 +--- internal/daemon/daemon.go | 2 +- internal/doctor/rig_check.go | 211 ++++++++++++++ internal/doctor/rig_check_test.go | 455 ++++++++++++++++++++++++++++++ internal/polecat/manager.go | 80 +----- internal/refinery/manager.go | 3 +- internal/rig/manager.go | 39 ++- 11 files changed, 1075 insertions(+), 231 deletions(-) mode change 100644 => 100755 internal/daemon/daemon.go create mode 100644 internal/doctor/rig_check_test.go diff --git a/internal/beads/beads.go b/internal/beads/beads.go index e5375a0b..45161082 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -71,15 +71,123 @@ func ResolveBeadsDir(workDir string) string { return beadsDir } - // Detect redirect chains: check if resolved path also has a redirect - resolvedRedirect := filepath.Join(resolved, "redirect") - if _, err := os.Stat(resolvedRedirect); err == nil { - fmt.Fprintf(os.Stderr, "Warning: redirect chain detected: %s -> %s (which also has a redirect)\n", beadsDir, resolved) - // Don't follow chains - just return the first resolved path - // The target's redirect is likely errant and should be removed + // Follow redirect chains (e.g., crew/.beads -> rig/.beads -> mayor/rig/.beads) + // This is intentional for the rig-level redirect architecture. + // Limit depth to prevent infinite loops from misconfigured redirects. + return resolveBeadsDirWithDepth(resolved, 3) +} + +// resolveBeadsDirWithDepth follows redirect chains with a depth limit. +func resolveBeadsDirWithDepth(beadsDir string, maxDepth int) string { + if maxDepth <= 0 { + fmt.Fprintf(os.Stderr, "Warning: redirect chain too deep at %s, stopping\n", beadsDir) + return beadsDir } - return resolved + redirectPath := filepath.Join(beadsDir, "redirect") + data, err := os.ReadFile(redirectPath) //nolint:gosec // G304: path is constructed internally + if err != nil { + // No redirect, this is the final destination + return beadsDir + } + + redirectTarget := strings.TrimSpace(string(data)) + if redirectTarget == "" { + return beadsDir + } + + // Resolve relative to parent of beadsDir (the workDir) + workDir := filepath.Dir(beadsDir) + resolved := filepath.Clean(filepath.Join(workDir, redirectTarget)) + + // Detect circular redirect + if resolved == beadsDir { + fmt.Fprintf(os.Stderr, "Warning: circular redirect detected in %s, stopping\n", redirectPath) + return beadsDir + } + + // Recursively follow + return resolveBeadsDirWithDepth(resolved, maxDepth-1) +} + +// SetupRedirect creates a .beads/redirect file for a worktree to point to the rig's shared beads. +// This is used by crew, polecats, and refinery worktrees to share the rig's beads database. +// +// Parameters: +// - townRoot: the town root directory (e.g., ~/gt) +// - worktreePath: the worktree directory (e.g., /crew/ or /refinery/rig) +// +// The function: +// 1. Computes the relative path from worktree to rig-level .beads +// 2. Cleans up any existing .beads/ contents (from tracked branches) +// 3. Creates the redirect file +// +// Safety: This function refuses to create redirects in the canonical beads location +// (mayor/rig) to prevent circular redirect chains. +func SetupRedirect(townRoot, worktreePath string) error { + // Get rig root from worktree path + // worktreePath = //crew/ or //refinery/rig etc. + relPath, err := filepath.Rel(townRoot, worktreePath) + if err != nil { + return fmt.Errorf("computing relative path: %w", err) + } + parts := strings.Split(filepath.ToSlash(relPath), "/") + if len(parts) < 2 { + return fmt.Errorf("invalid worktree path: must be at least 2 levels deep from town root") + } + + // Safety check: prevent creating redirect in canonical beads location (mayor/rig) + // This would create a circular redirect chain since rig/.beads redirects to mayor/rig/.beads + if len(parts) >= 2 && parts[1] == "mayor" { + return fmt.Errorf("cannot create redirect in canonical beads location (mayor/rig)") + } + + rigRoot := filepath.Join(townRoot, parts[0]) + rigBeadsPath := filepath.Join(rigRoot, ".beads") + + if _, err := os.Stat(rigBeadsPath); os.IsNotExist(err) { + return fmt.Errorf("no rig .beads found at %s", rigBeadsPath) + } + + // Clean up any existing .beads/ contents from the branch + worktreeBeadsDir := filepath.Join(worktreePath, ".beads") + if _, err := os.Stat(worktreeBeadsDir); err == nil { + if err := os.RemoveAll(worktreeBeadsDir); err != nil { + return fmt.Errorf("cleaning existing .beads dir: %w", err) + } + } + + // Create .beads directory + if err := os.MkdirAll(worktreeBeadsDir, 0755); err != nil { + return fmt.Errorf("creating .beads dir: %w", err) + } + + // Compute relative path from worktree to rig root + // e.g., crew/ (depth 2) -> ../../.beads + // refinery/rig (depth 2) -> ../../.beads + depth := len(parts) - 1 // subtract 1 for rig name itself + redirectPath := strings.Repeat("../", depth) + ".beads" + + // Check if rig-level beads has a redirect (tracked beads case). + // If so, redirect directly to the final destination to avoid chains. + // The bd CLI doesn't support redirect chains, so we must skip intermediate hops. + rigRedirectPath := filepath.Join(rigBeadsPath, "redirect") + if data, err := os.ReadFile(rigRedirectPath); err == nil { + rigRedirectTarget := strings.TrimSpace(string(data)) + if rigRedirectTarget != "" { + // Rig has redirect (e.g., "mayor/rig/.beads" for tracked beads). + // Redirect worktree directly to the final destination. + redirectPath = strings.Repeat("../", depth) + rigRedirectTarget + } + } + + // Create redirect file + redirectFile := filepath.Join(worktreeBeadsDir, "redirect") + if err := os.WriteFile(redirectFile, []byte(redirectPath+"\n"), 0644); err != nil { + return fmt.Errorf("creating redirect file: %w", err) + } + + return nil } // Issue represents a beads issue. diff --git a/internal/beads/beads_test.go b/internal/beads/beads_test.go index 58a42b8a..154ae302 100644 --- a/internal/beads/beads_test.go +++ b/internal/beads/beads_test.go @@ -1502,3 +1502,237 @@ func TestDelegationTerms(t *testing.T) { t.Errorf("parsed.CreditShare = %d, want %d", parsed.CreditShare, terms.CreditShare) } } + +// TestSetupRedirect tests the beads redirect setup for worktrees. +func TestSetupRedirect(t *testing.T) { + t.Run("crew worktree with local beads", func(t *testing.T) { + // Setup: town/rig/.beads (local, no redirect) + townRoot := t.TempDir() + rigRoot := filepath.Join(townRoot, "testrig") + rigBeads := filepath.Join(rigRoot, ".beads") + crewPath := filepath.Join(rigRoot, "crew", "max") + + // Create rig structure + if err := os.MkdirAll(rigBeads, 0755); err != nil { + t.Fatalf("mkdir rig beads: %v", err) + } + if err := os.MkdirAll(crewPath, 0755); err != nil { + t.Fatalf("mkdir crew: %v", err) + } + + // Run SetupRedirect + if err := SetupRedirect(townRoot, crewPath); err != nil { + t.Fatalf("SetupRedirect failed: %v", err) + } + + // Verify redirect was created + redirectPath := filepath.Join(crewPath, ".beads", "redirect") + content, err := os.ReadFile(redirectPath) + if err != nil { + t.Fatalf("read redirect: %v", err) + } + + want := "../../.beads\n" + if string(content) != want { + t.Errorf("redirect content = %q, want %q", string(content), want) + } + }) + + t.Run("crew worktree with tracked beads", func(t *testing.T) { + // Setup: town/rig/.beads/redirect -> mayor/rig/.beads (tracked) + townRoot := t.TempDir() + rigRoot := filepath.Join(townRoot, "testrig") + rigBeads := filepath.Join(rigRoot, ".beads") + mayorRigBeads := filepath.Join(rigRoot, "mayor", "rig", ".beads") + crewPath := filepath.Join(rigRoot, "crew", "max") + + // Create rig structure with tracked beads + if err := os.MkdirAll(mayorRigBeads, 0755); err != nil { + t.Fatalf("mkdir mayor/rig beads: %v", err) + } + if err := os.MkdirAll(rigBeads, 0755); err != nil { + t.Fatalf("mkdir rig beads: %v", err) + } + // Create rig-level redirect to mayor/rig/.beads + if err := os.WriteFile(filepath.Join(rigBeads, "redirect"), []byte("mayor/rig/.beads\n"), 0644); err != nil { + t.Fatalf("write rig redirect: %v", err) + } + if err := os.MkdirAll(crewPath, 0755); err != nil { + t.Fatalf("mkdir crew: %v", err) + } + + // Run SetupRedirect + if err := SetupRedirect(townRoot, crewPath); err != nil { + t.Fatalf("SetupRedirect failed: %v", err) + } + + // Verify redirect goes directly to mayor/rig/.beads (no chain - bd CLI doesn't support chains) + redirectPath := filepath.Join(crewPath, ".beads", "redirect") + content, err := os.ReadFile(redirectPath) + if err != nil { + t.Fatalf("read redirect: %v", err) + } + + want := "../../mayor/rig/.beads\n" + if string(content) != want { + t.Errorf("redirect content = %q, want %q", string(content), want) + } + + // Verify redirect resolves correctly + resolved := ResolveBeadsDir(crewPath) + // crew/max -> ../../mayor/rig/.beads (direct, no chain) + if resolved != mayorRigBeads { + t.Errorf("resolved = %q, want %q", resolved, mayorRigBeads) + } + }) + + t.Run("polecat worktree", func(t *testing.T) { + townRoot := t.TempDir() + rigRoot := filepath.Join(townRoot, "testrig") + rigBeads := filepath.Join(rigRoot, ".beads") + polecatPath := filepath.Join(rigRoot, "polecats", "worker1") + + if err := os.MkdirAll(rigBeads, 0755); err != nil { + t.Fatalf("mkdir rig beads: %v", err) + } + if err := os.MkdirAll(polecatPath, 0755); err != nil { + t.Fatalf("mkdir polecat: %v", err) + } + + if err := SetupRedirect(townRoot, polecatPath); err != nil { + t.Fatalf("SetupRedirect failed: %v", err) + } + + redirectPath := filepath.Join(polecatPath, ".beads", "redirect") + content, err := os.ReadFile(redirectPath) + if err != nil { + t.Fatalf("read redirect: %v", err) + } + + want := "../../.beads\n" + if string(content) != want { + t.Errorf("redirect content = %q, want %q", string(content), want) + } + }) + + t.Run("refinery worktree", func(t *testing.T) { + townRoot := t.TempDir() + rigRoot := filepath.Join(townRoot, "testrig") + rigBeads := filepath.Join(rigRoot, ".beads") + refineryPath := filepath.Join(rigRoot, "refinery", "rig") + + if err := os.MkdirAll(rigBeads, 0755); err != nil { + t.Fatalf("mkdir rig beads: %v", err) + } + if err := os.MkdirAll(refineryPath, 0755); err != nil { + t.Fatalf("mkdir refinery: %v", err) + } + + if err := SetupRedirect(townRoot, refineryPath); err != nil { + t.Fatalf("SetupRedirect failed: %v", err) + } + + redirectPath := filepath.Join(refineryPath, ".beads", "redirect") + content, err := os.ReadFile(redirectPath) + if err != nil { + t.Fatalf("read redirect: %v", err) + } + + want := "../../.beads\n" + if string(content) != want { + t.Errorf("redirect content = %q, want %q", string(content), want) + } + }) + + t.Run("cleans existing tracked beads from worktree", func(t *testing.T) { + townRoot := t.TempDir() + rigRoot := filepath.Join(townRoot, "testrig") + rigBeads := filepath.Join(rigRoot, ".beads") + crewPath := filepath.Join(rigRoot, "crew", "max") + crewBeads := filepath.Join(crewPath, ".beads") + + if err := os.MkdirAll(rigBeads, 0755); err != nil { + t.Fatalf("mkdir rig beads: %v", err) + } + // Simulate worktree with tracked .beads (has database files) + if err := os.MkdirAll(crewBeads, 0755); err != nil { + t.Fatalf("mkdir crew beads: %v", err) + } + if err := os.WriteFile(filepath.Join(crewBeads, "beads.db"), []byte("fake db"), 0644); err != nil { + t.Fatalf("write fake db: %v", err) + } + if err := os.WriteFile(filepath.Join(crewBeads, "config.yaml"), []byte("prefix: test"), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + + if err := SetupRedirect(townRoot, crewPath); err != nil { + t.Fatalf("SetupRedirect failed: %v", err) + } + + // Verify old files were cleaned up + if _, err := os.Stat(filepath.Join(crewBeads, "beads.db")); !os.IsNotExist(err) { + t.Error("beads.db should have been removed") + } + if _, err := os.Stat(filepath.Join(crewBeads, "config.yaml")); !os.IsNotExist(err) { + t.Error("config.yaml should have been removed") + } + + // Verify redirect was created + redirectPath := filepath.Join(crewBeads, "redirect") + if _, err := os.Stat(redirectPath); err != nil { + t.Errorf("redirect file should exist: %v", err) + } + }) + + t.Run("rejects mayor/rig canonical location", func(t *testing.T) { + townRoot := t.TempDir() + rigRoot := filepath.Join(townRoot, "testrig") + rigBeads := filepath.Join(rigRoot, ".beads") + mayorRigPath := filepath.Join(rigRoot, "mayor", "rig") + + if err := os.MkdirAll(rigBeads, 0755); err != nil { + t.Fatalf("mkdir rig beads: %v", err) + } + if err := os.MkdirAll(mayorRigPath, 0755); err != nil { + t.Fatalf("mkdir mayor/rig: %v", err) + } + + err := SetupRedirect(townRoot, mayorRigPath) + if err == nil { + t.Error("SetupRedirect should reject mayor/rig location") + } + if err != nil && !strings.Contains(err.Error(), "canonical") { + t.Errorf("error should mention canonical location, got: %v", err) + } + }) + + t.Run("rejects path too shallow", func(t *testing.T) { + townRoot := t.TempDir() + rigRoot := filepath.Join(townRoot, "testrig") + + if err := os.MkdirAll(rigRoot, 0755); err != nil { + t.Fatalf("mkdir rig: %v", err) + } + + err := SetupRedirect(townRoot, rigRoot) + if err == nil { + t.Error("SetupRedirect should reject rig root (too shallow)") + } + }) + + t.Run("fails if rig beads missing", func(t *testing.T) { + townRoot := t.TempDir() + rigRoot := filepath.Join(townRoot, "testrig") + crewPath := filepath.Join(rigRoot, "crew", "max") + + // No rig/.beads created + if err := os.MkdirAll(crewPath, 0755); err != nil { + t.Fatalf("mkdir crew: %v", err) + } + + err := SetupRedirect(townRoot, crewPath) + if err == nil { + t.Error("SetupRedirect should fail if rig .beads missing") + } + }) +} diff --git a/internal/cmd/done_test.go b/internal/cmd/done_test.go index 45121b1c..5dd9a9ed 100644 --- a/internal/cmd/done_test.go +++ b/internal/cmd/done_test.go @@ -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) } } diff --git a/internal/cmd/prime.go b/internal/cmd/prime.go index 650e4901..fcbe2130 100644 --- a/internal/cmd/prime.go +++ b/internal/cmd/prime.go @@ -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//.beads -> ../../mayor/rig/.beads - redirectContent = "../../mayor/rig/.beads" - } else { - // polecats//.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//.beads -> ../../.beads - redirectContent = "../../.beads" - } else { - // polecats//.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. diff --git a/internal/crew/manager.go b/internal/crew/manager.go index 69a53785..b900ddad 100644 --- a/internal/crew/manager.go +++ b/internal/crew/manager.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/util" @@ -381,50 +382,7 @@ type PristineResult struct { // setupSharedBeads creates a redirect file so the crew worker uses the rig's shared .beads database. // This eliminates the need for git sync between crew clones - all crew members share one database. -// -// Structure: -// -// rig/ -// mayor/rig/.beads/ <- Shared database (the canonical location) -// crew/ -// / -// .beads/ -// redirect <- Contains "../../mayor/rig/.beads" func (m *Manager) setupSharedBeads(crewPath string) error { - // The shared beads database is at rig/mayor/rig/.beads/ - // Crew clones are at rig/crew// - // So the relative path is ../../mayor/rig/.beads - sharedBeadsPath := filepath.Join(m.rig.Path, "mayor", "rig", ".beads") - - // Verify the shared beads exists - if _, err := os.Stat(sharedBeadsPath); os.IsNotExist(err) { - // Fall back to rig root .beads if mayor/rig doesn't exist - sharedBeadsPath = filepath.Join(m.rig.Path, ".beads") - if _, err := os.Stat(sharedBeadsPath); os.IsNotExist(err) { - return fmt.Errorf("no shared beads database found") - } - } - - // Create crew's .beads directory - crewBeadsDir := filepath.Join(crewPath, ".beads") - if err := os.MkdirAll(crewBeadsDir, 0755); err != nil { - return fmt.Errorf("creating crew .beads dir: %w", err) - } - - // Calculate relative path from crew/.beads/ to shared beads - // crew//.beads/ -> ../../mayor/rig/.beads or ../../.beads - var redirectContent string - if _, err := os.Stat(filepath.Join(m.rig.Path, "mayor", "rig", ".beads")); err == nil { - redirectContent = "../../mayor/rig/.beads\n" - } else { - redirectContent = "../../.beads\n" - } - - // Create redirect file - redirectPath := filepath.Join(crewBeadsDir, "redirect") - if err := os.WriteFile(redirectPath, []byte(redirectContent), 0644); err != nil { - return fmt.Errorf("creating redirect file: %w", err) - } - - return nil + townRoot := filepath.Dir(m.rig.Path) + return beads.SetupRedirect(townRoot, crewPath) } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go old mode 100644 new mode 100755 index fae692c4..85e02ac5 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -83,7 +83,7 @@ func (d *Daemon) Run() error { if !locked { return fmt.Errorf("daemon already running (lock held by another process)") } - defer fileLock.Unlock() + defer func() { _ = fileLock.Unlock() }() // Write PID file if err := os.WriteFile(d.config.PidFile, []byte(strconv.Itoa(os.Getpid())), 0644); err != nil { diff --git a/internal/doctor/rig_check.go b/internal/doctor/rig_check.go index 021267f2..ade82bd1 100644 --- a/internal/doctor/rig_check.go +++ b/internal/doctor/rig_check.go @@ -7,6 +7,8 @@ import ( "os/exec" "path/filepath" "strings" + + "github.com/steveyegge/gastown/internal/config" ) // RigIsGitRepoCheck verifies the rig has a valid mayor/rig git clone. @@ -865,6 +867,214 @@ func (c *BeadsConfigValidCheck) Fix(ctx *CheckContext) error { return nil } +// BeadsRedirectCheck verifies that rig-level beads redirect exists 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. +type BeadsRedirectCheck struct { + FixableCheck +} + +// NewBeadsRedirectCheck creates a new beads redirect check. +func NewBeadsRedirectCheck() *BeadsRedirectCheck { + return &BeadsRedirectCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "beads-redirect", + CheckDescription: "Verify rig-level beads redirect for tracked beads", + }, + }, + } +} + +// Run checks if the rig-level beads redirect exists when needed. +func (c *BeadsRedirectCheck) Run(ctx *CheckContext) *CheckResult { + // Only applies when checking a specific rig + if ctx.RigName == "" { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No rig specified (skipping redirect check)", + } + } + + rigPath := ctx.RigPath() + mayorRigBeads := filepath.Join(rigPath, "mayor", "rig", ".beads") + rigBeadsDir := filepath.Join(rigPath, ".beads") + redirectPath := filepath.Join(rigBeadsDir, "redirect") + + // Check if this rig has tracked beads (mayor/rig/.beads exists) + if _, err := os.Stat(mayorRigBeads); os.IsNotExist(err) { + // No tracked beads - check if rig/.beads exists (local beads) + if _, err := os.Stat(rigBeadsDir); os.IsNotExist(err) { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "No .beads directory found at rig root", + Details: []string{ + "Beads database not initialized for this rig", + "This prevents issue tracking for this rig", + }, + FixHint: "Run 'gt doctor --fix --rig " + ctx.RigName + "' to initialize beads", + } + } + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "Rig uses local beads (no redirect needed)", + } + } + + // Tracked beads exist - check for conflicting local beads + hasLocalData := hasBeadsData(rigBeadsDir) + redirectExists := false + if _, err := os.Stat(redirectPath); err == nil { + redirectExists = true + } + + // Case: Local beads directory has actual data (not just redirect) + if hasLocalData && !redirectExists { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "Conflicting local beads found with tracked beads", + Details: []string{ + "Tracked beads exist at: mayor/rig/.beads", + "Local beads with data exist at: .beads/", + "Fix will remove local beads and create redirect to tracked beads", + }, + FixHint: "Run 'gt doctor --fix --rig " + ctx.RigName + "' to fix", + } + } + + // Case: No redirect file (but no conflicting data) + if !redirectExists { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "Missing rig-level beads redirect for tracked beads", + Details: []string{ + "Tracked beads exist at: mayor/rig/.beads", + "Missing redirect at: .beads/redirect", + "Without this redirect, bd commands from rig root won't find beads", + }, + FixHint: "Run 'gt doctor --fix' to create the redirect", + } + } + + // Verify redirect points to correct location + content, err := os.ReadFile(redirectPath) + if err != nil { + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: fmt.Sprintf("Could not read redirect file: %v", err), + } + } + + target := strings.TrimSpace(string(content)) + if target != "mayor/rig/.beads" { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: fmt.Sprintf("Redirect points to %q, expected mayor/rig/.beads", target), + FixHint: "Run 'gt doctor --fix --rig " + ctx.RigName + "' to correct the redirect", + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "Rig-level beads redirect is correctly configured", + } +} + +// Fix creates or corrects the rig-level beads redirect, or initializes beads if missing. +func (c *BeadsRedirectCheck) Fix(ctx *CheckContext) error { + if ctx.RigName == "" { + return nil + } + + rigPath := ctx.RigPath() + mayorRigBeads := filepath.Join(rigPath, "mayor", "rig", ".beads") + rigBeadsDir := filepath.Join(rigPath, ".beads") + redirectPath := filepath.Join(rigBeadsDir, "redirect") + + // Check if tracked beads exist + hasTrackedBeads := true + if _, err := os.Stat(mayorRigBeads); os.IsNotExist(err) { + hasTrackedBeads = false + } + + // Check if local beads exist + hasLocalBeads := true + if _, err := os.Stat(rigBeadsDir); os.IsNotExist(err) { + hasLocalBeads = false + } + + // Case 1: No beads at all - initialize with bd init + if !hasTrackedBeads && !hasLocalBeads { + // Get the rig's beads prefix from rigs.json (falls back to "gt" if not found) + prefix := config.GetRigPrefix(ctx.TownRoot, ctx.RigName) + + // Create .beads directory + if err := os.MkdirAll(rigBeadsDir, 0755); err != nil { + return fmt.Errorf("creating .beads directory: %w", err) + } + + // Run bd init with the configured prefix + cmd := exec.Command("bd", "init", "--prefix", prefix) + cmd.Dir = rigPath + if output, err := cmd.CombinedOutput(); err != nil { + // bd might not be installed - create minimal config.yaml + configPath := filepath.Join(rigBeadsDir, "config.yaml") + configContent := fmt.Sprintf("prefix: %s\n", prefix) + if writeErr := os.WriteFile(configPath, []byte(configContent), 0644); writeErr != nil { + return fmt.Errorf("bd init failed (%v) and fallback config creation failed: %w", err, writeErr) + } + // Continue - minimal config created + } else { + _ = output // bd init succeeded + } + return nil + } + + // Case 2: Tracked beads exist - create redirect (may need to remove conflicting local beads) + if hasTrackedBeads { + // Check if local beads have conflicting data + if hasLocalBeads && hasBeadsData(rigBeadsDir) { + // Remove conflicting local beads directory + if err := os.RemoveAll(rigBeadsDir); err != nil { + return fmt.Errorf("removing conflicting local beads: %w", err) + } + } + + // Create .beads directory if needed + if err := os.MkdirAll(rigBeadsDir, 0755); err != nil { + return fmt.Errorf("creating .beads directory: %w", err) + } + + // Write redirect file + if err := os.WriteFile(redirectPath, []byte("mayor/rig/.beads\n"), 0644); err != nil { + return fmt.Errorf("writing redirect file: %w", err) + } + } + + return nil +} + +// hasBeadsData checks if a beads directory has actual data (issues.jsonl, issues.db, config.yaml) +// as opposed to just being a redirect-only directory. +func hasBeadsData(beadsDir string) bool { + // Check for actual beads data files + dataFiles := []string{"issues.jsonl", "issues.db", "config.yaml"} + for _, f := range dataFiles { + if _, err := os.Stat(filepath.Join(beadsDir, f)); err == nil { + return true + } + } + return false +} + // RigChecks returns all rig-level health checks. func RigChecks() []Check { return []Check{ @@ -876,5 +1086,6 @@ func RigChecks() []Check { NewMayorCloneExistsCheck(), NewPolecatClonesValidCheck(), NewBeadsConfigValidCheck(), + NewBeadsRedirectCheck(), } } diff --git a/internal/doctor/rig_check_test.go b/internal/doctor/rig_check_test.go new file mode 100644 index 00000000..a2f7f09e --- /dev/null +++ b/internal/doctor/rig_check_test.go @@ -0,0 +1,455 @@ +package doctor + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNewBeadsRedirectCheck(t *testing.T) { + check := NewBeadsRedirectCheck() + + if check.Name() != "beads-redirect" { + t.Errorf("expected name 'beads-redirect', got %q", check.Name()) + } + + if !check.CanFix() { + t.Error("expected CanFix to return true") + } +} + +func TestBeadsRedirectCheck_NoRigSpecified(t *testing.T) { + tmpDir := t.TempDir() + + check := NewBeadsRedirectCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: ""} + + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK when no rig specified, got %v", result.Status) + } + if !strings.Contains(result.Message, "skipping") { + t.Errorf("expected message about skipping, got %q", result.Message) + } +} + +func TestBeadsRedirectCheck_NoBeadsAtAll(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + if err := os.MkdirAll(rigDir, 0755); err != nil { + t.Fatal(err) + } + + check := NewBeadsRedirectCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + result := check.Run(ctx) + + if result.Status != StatusError { + t.Errorf("expected StatusError when no beads exist (fixable), got %v", result.Status) + } +} + +func TestBeadsRedirectCheck_LocalBeadsOnly(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create local beads at rig root (no mayor/rig/.beads) + localBeads := filepath.Join(rigDir, ".beads") + if err := os.MkdirAll(localBeads, 0755); err != nil { + t.Fatal(err) + } + + check := NewBeadsRedirectCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK for local beads (no redirect needed), got %v", result.Status) + } + if !strings.Contains(result.Message, "local beads") { + t.Errorf("expected message about local beads, got %q", result.Message) + } +} + +func TestBeadsRedirectCheck_TrackedBeadsMissingRedirect(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create tracked beads at mayor/rig/.beads + trackedBeads := filepath.Join(rigDir, "mayor", "rig", ".beads") + if err := os.MkdirAll(trackedBeads, 0755); err != nil { + t.Fatal(err) + } + + check := NewBeadsRedirectCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + result := check.Run(ctx) + + if result.Status != StatusError { + t.Errorf("expected StatusError for missing redirect, got %v", result.Status) + } + if !strings.Contains(result.Message, "Missing") { + t.Errorf("expected message about missing redirect, got %q", result.Message) + } +} + +func TestBeadsRedirectCheck_TrackedBeadsCorrectRedirect(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create tracked beads at mayor/rig/.beads + trackedBeads := filepath.Join(rigDir, "mayor", "rig", ".beads") + if err := os.MkdirAll(trackedBeads, 0755); err != nil { + t.Fatal(err) + } + + // Create rig-level .beads with correct redirect + rigBeads := filepath.Join(rigDir, ".beads") + if err := os.MkdirAll(rigBeads, 0755); err != nil { + t.Fatal(err) + } + redirectPath := filepath.Join(rigBeads, "redirect") + if err := os.WriteFile(redirectPath, []byte("mayor/rig/.beads\n"), 0644); err != nil { + t.Fatal(err) + } + + check := NewBeadsRedirectCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK for correct redirect, got %v", result.Status) + } + if !strings.Contains(result.Message, "correctly configured") { + t.Errorf("expected message about correct config, got %q", result.Message) + } +} + +func TestBeadsRedirectCheck_TrackedBeadsWrongRedirect(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create tracked beads at mayor/rig/.beads + trackedBeads := filepath.Join(rigDir, "mayor", "rig", ".beads") + if err := os.MkdirAll(trackedBeads, 0755); err != nil { + t.Fatal(err) + } + + // Create rig-level .beads with wrong redirect + rigBeads := filepath.Join(rigDir, ".beads") + if err := os.MkdirAll(rigBeads, 0755); err != nil { + t.Fatal(err) + } + redirectPath := filepath.Join(rigBeads, "redirect") + if err := os.WriteFile(redirectPath, []byte("wrong/path\n"), 0644); err != nil { + t.Fatal(err) + } + + check := NewBeadsRedirectCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + result := check.Run(ctx) + + if result.Status != StatusError { + t.Errorf("expected StatusError for wrong redirect (fixable), got %v", result.Status) + } + if !strings.Contains(result.Message, "wrong/path") { + t.Errorf("expected message to contain wrong path, got %q", result.Message) + } +} + +func TestBeadsRedirectCheck_FixWrongRedirect(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create tracked beads at mayor/rig/.beads + trackedBeads := filepath.Join(rigDir, "mayor", "rig", ".beads") + if err := os.MkdirAll(trackedBeads, 0755); err != nil { + t.Fatal(err) + } + + // Create rig-level .beads with wrong redirect + rigBeads := filepath.Join(rigDir, ".beads") + if err := os.MkdirAll(rigBeads, 0755); err != nil { + t.Fatal(err) + } + redirectPath := filepath.Join(rigBeads, "redirect") + if err := os.WriteFile(redirectPath, []byte("wrong/path\n"), 0644); err != nil { + t.Fatal(err) + } + + check := NewBeadsRedirectCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + // Verify fix is needed + result := check.Run(ctx) + if result.Status != StatusError { + t.Fatalf("expected StatusError before fix, got %v", result.Status) + } + + // Apply fix + if err := check.Fix(ctx); err != nil { + t.Fatalf("Fix failed: %v", err) + } + + // Verify redirect was corrected + content, err := os.ReadFile(redirectPath) + if err != nil { + t.Fatalf("redirect file not found: %v", err) + } + if string(content) != "mayor/rig/.beads\n" { + t.Errorf("redirect content = %q, want 'mayor/rig/.beads\\n'", string(content)) + } + + // Verify check now passes + result = check.Run(ctx) + if result.Status != StatusOK { + t.Errorf("expected StatusOK after fix, got %v", result.Status) + } +} + +func TestBeadsRedirectCheck_Fix(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create tracked beads at mayor/rig/.beads + trackedBeads := filepath.Join(rigDir, "mayor", "rig", ".beads") + if err := os.MkdirAll(trackedBeads, 0755); err != nil { + t.Fatal(err) + } + + check := NewBeadsRedirectCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + // Verify fix is needed + result := check.Run(ctx) + if result.Status != StatusError { + t.Fatalf("expected StatusError before fix, got %v", result.Status) + } + + // Apply fix + if err := check.Fix(ctx); err != nil { + t.Fatalf("Fix failed: %v", err) + } + + // Verify redirect file was created + redirectPath := filepath.Join(rigDir, ".beads", "redirect") + content, err := os.ReadFile(redirectPath) + if err != nil { + t.Fatalf("redirect file not created: %v", err) + } + + expected := "mayor/rig/.beads\n" + if string(content) != expected { + t.Errorf("redirect content = %q, want %q", string(content), expected) + } + + // Verify check now passes + result = check.Run(ctx) + if result.Status != StatusOK { + t.Errorf("expected StatusOK after fix, got %v", result.Status) + } +} + +func TestBeadsRedirectCheck_FixNoOp_LocalBeads(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create only local beads (no tracked beads) + localBeads := filepath.Join(rigDir, ".beads") + if err := os.MkdirAll(localBeads, 0755); err != nil { + t.Fatal(err) + } + + check := NewBeadsRedirectCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + // Fix should be a no-op + if err := check.Fix(ctx); err != nil { + t.Fatalf("Fix failed: %v", err) + } + + // Verify no redirect was created + redirectPath := filepath.Join(rigDir, ".beads", "redirect") + if _, err := os.Stat(redirectPath); !os.IsNotExist(err) { + t.Error("redirect file should not be created for local beads") + } +} + +func TestBeadsRedirectCheck_FixInitBeads(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create rig directory (no beads at all) + if err := os.MkdirAll(rigDir, 0755); err != nil { + t.Fatal(err) + } + + // Create mayor/rigs.json with prefix for the rig + mayorDir := filepath.Join(tmpDir, "mayor") + if err := os.MkdirAll(mayorDir, 0755); err != nil { + t.Fatal(err) + } + rigsJSON := `{ + "version": 1, + "rigs": { + "testrig": { + "git_url": "https://example.com/test.git", + "beads": { + "prefix": "tr" + } + } + } + }` + if err := os.WriteFile(filepath.Join(mayorDir, "rigs.json"), []byte(rigsJSON), 0644); err != nil { + t.Fatal(err) + } + + check := NewBeadsRedirectCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + // Verify fix is needed + result := check.Run(ctx) + if result.Status != StatusError { + t.Fatalf("expected StatusError before fix, got %v", result.Status) + } + + // Apply fix - this will run 'bd init' if available, otherwise create config.yaml + if err := check.Fix(ctx); err != nil { + t.Fatalf("Fix failed: %v", err) + } + + // Verify .beads directory was created + beadsDir := filepath.Join(rigDir, ".beads") + if _, err := os.Stat(beadsDir); os.IsNotExist(err) { + t.Fatal(".beads directory not created") + } + + // Verify beads was initialized (either by bd init or fallback) + // bd init creates config.yaml, fallback creates config.yaml with prefix + configPath := filepath.Join(beadsDir, "config.yaml") + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Fatal("config.yaml not created") + } + + // Verify check now passes (local beads exist) + result = check.Run(ctx) + if result.Status != StatusOK { + t.Errorf("expected StatusOK after fix, got %v", result.Status) + } +} + +func TestBeadsRedirectCheck_ConflictingLocalBeads(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create tracked beads at mayor/rig/.beads + trackedBeads := filepath.Join(rigDir, "mayor", "rig", ".beads") + if err := os.MkdirAll(trackedBeads, 0755); err != nil { + t.Fatal(err) + } + // Add some content to tracked beads + if err := os.WriteFile(filepath.Join(trackedBeads, "issues.jsonl"), []byte(`{"id":"tr-1"}`), 0644); err != nil { + t.Fatal(err) + } + + // Create conflicting local beads with actual data + localBeads := filepath.Join(rigDir, ".beads") + if err := os.MkdirAll(localBeads, 0755); err != nil { + t.Fatal(err) + } + // Add data to local beads (this is the conflict) + if err := os.WriteFile(filepath.Join(localBeads, "issues.jsonl"), []byte(`{"id":"local-1"}`), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(localBeads, "config.yaml"), []byte("prefix: local\n"), 0644); err != nil { + t.Fatal(err) + } + + check := NewBeadsRedirectCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + // Check should detect conflicting beads + result := check.Run(ctx) + if result.Status != StatusError { + t.Errorf("expected StatusError for conflicting beads, got %v", result.Status) + } + if !strings.Contains(result.Message, "Conflicting") { + t.Errorf("expected message about conflicting beads, got %q", result.Message) + } +} + +func TestBeadsRedirectCheck_FixConflictingLocalBeads(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + rigDir := filepath.Join(tmpDir, rigName) + + // Create tracked beads at mayor/rig/.beads + trackedBeads := filepath.Join(rigDir, "mayor", "rig", ".beads") + if err := os.MkdirAll(trackedBeads, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(trackedBeads, "issues.jsonl"), []byte(`{"id":"tr-1"}`), 0644); err != nil { + t.Fatal(err) + } + + // Create conflicting local beads with actual data + localBeads := filepath.Join(rigDir, ".beads") + if err := os.MkdirAll(localBeads, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(localBeads, "issues.jsonl"), []byte(`{"id":"local-1"}`), 0644); err != nil { + t.Fatal(err) + } + + check := NewBeadsRedirectCheck() + ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName} + + // Verify fix is needed + result := check.Run(ctx) + if result.Status != StatusError { + t.Fatalf("expected StatusError before fix, got %v", result.Status) + } + + // Apply fix - should remove conflicting local beads and create redirect + if err := check.Fix(ctx); err != nil { + t.Fatalf("Fix failed: %v", err) + } + + // Verify local issues.jsonl was removed + if _, err := os.Stat(filepath.Join(localBeads, "issues.jsonl")); !os.IsNotExist(err) { + t.Error("local issues.jsonl should have been removed") + } + + // Verify redirect was created + redirectPath := filepath.Join(localBeads, "redirect") + content, err := os.ReadFile(redirectPath) + if err != nil { + t.Fatalf("redirect file not created: %v", err) + } + if string(content) != "mayor/rig/.beads\n" { + t.Errorf("redirect content = %q, want 'mayor/rig/.beads\\n'", string(content)) + } + + // Verify check now passes + result = check.Run(ctx) + if result.Status != StatusOK { + t.Errorf("expected StatusOK after fix, got %v", result.Status) + } +} diff --git a/internal/polecat/manager.go b/internal/polecat/manager.go index 5cf1908f..cb09494f 100644 --- a/internal/polecat/manager.go +++ b/internal/polecat/manager.go @@ -48,12 +48,11 @@ type Manager struct { // NewManager creates a new polecat manager. func NewManager(r *rig.Rig, g *git.Git) *Manager { - // Always use mayor/rig as the beads path. - // This matches routes.jsonl which maps prefixes to /mayor/rig. - // The rig root .beads/ only contains config.yaml (no database), - // so running bd from there causes it to walk up and find town beads - // with the wrong prefix (e.g., 'gm' instead of the rig's prefix). - beadsPath := filepath.Join(r.Path, "mayor", "rig") + // Use the resolved beads directory to find where bd commands should run. + // For tracked beads: rig/.beads/redirect -> mayor/rig/.beads, so use mayor/rig + // For local beads: rig/.beads is the database, so use rig root + resolvedBeads := beads.ResolveBeadsDir(r.Path) + beadsPath := filepath.Dir(resolvedBeads) // Get the directory containing .beads // Try to load rig settings for namepool config settingsPath := filepath.Join(r.Path, "settings", "config.json") @@ -721,74 +720,9 @@ func (m *Manager) loadFromBeads(name string) (*Polecat, error) { // setupSharedBeads creates a redirect file so the polecat uses the rig's shared .beads database. // This eliminates the need for git sync between polecat clones - all polecats share one database. -// -// Structure: -// -// rig/ -// .beads/ <- Shared database (ensured to exist) -// polecats/ -// / -// .beads/ -// redirect <- Contains "../../.beads" or "../../mayor/rig/.beads" -// -// IMPORTANT: If the polecat was created from a branch that had .beads/ tracked in git, -// those files will be present. We must clean them out and replace with just the redirect. -// -// The redirect target is conditional: repos with .beads/ tracked in git have their canonical -// database at mayor/rig/.beads, while fresh rigs use the database at rig root .beads/. func (m *Manager) setupSharedBeads(polecatPath string) error { - // Determine the shared beads location: - // - If mayor/rig/.beads exists (source repo has beads tracked in git), use that - // - Otherwise fall back to rig/.beads (created by initBeads during gt rig add) - // This matches the crew manager's logic for consistency. - mayorRigBeads := filepath.Join(m.rig.Path, "mayor", "rig", ".beads") - rigRootBeads := filepath.Join(m.rig.Path, ".beads") - - var sharedBeadsPath string - var redirectContent string - - if _, err := os.Stat(mayorRigBeads); err == nil { - // Source repo has .beads/ tracked - use mayor/rig/.beads - sharedBeadsPath = mayorRigBeads - redirectContent = "../../mayor/rig/.beads\n" - } else { - // No beads in source repo - use rig root .beads (from initBeads) - sharedBeadsPath = rigRootBeads - redirectContent = "../../.beads\n" - // Ensure rig root has .beads/ directory - if err := os.MkdirAll(rigRootBeads, 0755); err != nil { - return fmt.Errorf("creating rig .beads dir: %w", err) - } - } - - // Verify shared beads exists - if _, err := os.Stat(sharedBeadsPath); os.IsNotExist(err) { - return fmt.Errorf("no shared beads database found at %s", sharedBeadsPath) - } - - // Clean up any existing .beads/ contents from the branch - // This handles the case where the polecat was created from a branch that - // had .beads/ tracked (e.g., from previous bd sync operations) - polecatBeadsDir := filepath.Join(polecatPath, ".beads") - if _, err := os.Stat(polecatBeadsDir); err == nil { - // Directory exists - remove it entirely and recreate fresh - if err := os.RemoveAll(polecatBeadsDir); err != nil { - return fmt.Errorf("cleaning existing .beads dir: %w", err) - } - } - - // Create fresh .beads directory - if err := os.MkdirAll(polecatBeadsDir, 0755); err != nil { - return fmt.Errorf("creating polecat .beads dir: %w", err) - } - - // Create redirect file pointing to the shared beads location - redirectPath := filepath.Join(polecatBeadsDir, "redirect") - if err := os.WriteFile(redirectPath, []byte(redirectContent), 0644); err != nil { - return fmt.Errorf("creating redirect file: %w", err) - } - - return nil + townRoot := filepath.Dir(m.rig.Path) + return beads.SetupRedirect(townRoot, polecatPath) } // CleanupStaleBranches removes orphaned polecat branches that are no longer in use. diff --git a/internal/refinery/manager.go b/internal/refinery/manager.go index 8de5aa62..772e4680 100644 --- a/internal/refinery/manager.go +++ b/internal/refinery/manager.go @@ -179,7 +179,8 @@ func (m *Manager) Start(foreground bool) error { _ = t.SetEnvironment(sessionID, "BD_ACTOR", bdActor) // Set beads environment - refinery uses rig-level beads (non-fatal) - beadsDir := filepath.Join(m.rig.Path, "mayor", "rig", ".beads") + // Use ResolveBeadsDir to handle both tracked (mayor/rig) and local beads + beadsDir := beads.ResolveBeadsDir(m.rig.Path) _ = t.SetEnvironment(sessionID, "BEADS_DIR", beadsDir) _ = t.SetEnvironment(sessionID, "BEADS_NO_DAEMON", "1") _ = t.SetEnvironment(sessionID, "BEADS_AGENT_NAME", fmt.Sprintf("%s/refinery", m.rig.Name)) diff --git a/internal/rig/manager.go b/internal/rig/manager.go index f1c9526f..cd7d6cf7 100644 --- a/internal/rig/manager.go +++ b/internal/rig/manager.go @@ -367,6 +367,14 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) { return nil, fmt.Errorf("creating mayor CLAUDE.md: %w", err) } + // Initialize beads at rig level BEFORE creating worktrees. + // This ensures rig/.beads exists so worktree redirects can point to it. + fmt.Printf(" Initializing beads database...\n") + if err := m.initBeads(rigPath, opts.BeadsPrefix); err != nil { + return nil, fmt.Errorf("initializing beads: %w", err) + } + fmt.Printf(" ✓ Initialized beads (prefix: %s)\n", opts.BeadsPrefix) + // Create refinery as worktree from bare repo on default branch. // Refinery needs to see polecat branches (shared .repo.git) and merges them. // Being on the default branch allows direct merge workflow. @@ -379,6 +387,10 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) { return nil, fmt.Errorf("creating refinery worktree: %w", err) } fmt.Printf(" ✓ Created refinery worktree\n") + // Set up beads redirect for refinery (points to rig-level .beads) + if err := beads.SetupRedirect(m.townRoot, refineryRigPath); err != nil { + fmt.Printf(" Warning: Could not set up refinery beads redirect: %v\n", err) + } // Create refinery CLAUDE.md (overrides any from cloned repo) if err := m.createRoleCLAUDEmd(refineryRigPath, "refinery", opts.Name, ""); err != nil { return nil, fmt.Errorf("creating refinery CLAUDE.md: %w", err) @@ -433,13 +445,6 @@ Use crew for your own workspace. Polecats are for batch work dispatch. return nil, fmt.Errorf("creating polecats dir: %w", err) } - // Initialize beads at rig level - fmt.Printf(" Initializing beads database...\n") - if err := m.initBeads(rigPath, opts.BeadsPrefix); err != nil { - return nil, fmt.Errorf("initializing beads: %w", err) - } - fmt.Printf(" ✓ Initialized beads (prefix: %s)\n", opts.BeadsPrefix) - // Create rig-level agent beads (witness, refinery) in rig beads. // Town-level agents (mayor, deacon) are created by gt install in town beads. if err := m.initAgentBeads(rigPath, opts.Name, opts.BeadsPrefix); err != nil { @@ -508,6 +513,23 @@ func (m *Manager) initBeads(rigPath, prefix string) error { } beadsDir := filepath.Join(rigPath, ".beads") + mayorRigBeads := filepath.Join(rigPath, "mayor", "rig", ".beads") + + // Check if source repo has tracked .beads/ (cloned into mayor/rig). + // If so, create a redirect file instead of a new database. + if _, err := os.Stat(mayorRigBeads); err == nil { + // Tracked beads exist - create redirect to mayor/rig/.beads + if err := os.MkdirAll(beadsDir, 0755); err != nil { + return err + } + redirectPath := filepath.Join(beadsDir, "redirect") + if err := os.WriteFile(redirectPath, []byte("mayor/rig/.beads\n"), 0644); err != nil { + return fmt.Errorf("creating redirect file: %w", err) + } + return nil + } + + // No tracked beads - create local database if err := os.MkdirAll(beadsDir, 0755); err != nil { return err } @@ -572,7 +594,8 @@ func (m *Manager) initBeads(rigPath, prefix string) error { func (m *Manager) initAgentBeads(rigPath, rigName, prefix string) error { // Rig-level agents go in rig beads with rig prefix (per docs/architecture.md). // Town-level agents (Mayor, Deacon) are created by gt install in town beads. - rigBeadsDir := filepath.Join(rigPath, ".beads") + // Use ResolveBeadsDir to follow redirect files for tracked beads. + rigBeadsDir := beads.ResolveBeadsDir(rigPath) bd := beads.NewWithBeadsDir(rigPath, rigBeadsDir) // Define rig-level agents to create