From 879018f35d243561c90b5dd0a8ade50e3fbe0ff8 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sat, 27 Dec 2025 21:34:22 -0800 Subject: [PATCH] fix: gt commands follow .beads/redirect for shared beads (gt-ln5af) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added ResolveBeadsDir() helper that follows .beads/redirect files, enabling crew workers and polecats to properly access shared beads. Updated callers: - mailbox.go: NewMailboxFromAddress follows redirect - catalog.go: LoadCatalog follows redirect at all levels - doctor checks: beads_check, patrol_check, wisp_check follow redirect Also added comprehensive tests for the redirect resolution logic. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/beads/beads.go | 39 +++++++++++++++ internal/beads/beads_test.go | 86 +++++++++++++++++++++++++++++++++ internal/beads/catalog.go | 16 +++--- internal/doctor/beads_check.go | 7 +-- internal/doctor/patrol_check.go | 6 ++- internal/doctor/wisp_check.go | 7 ++- internal/mail/mailbox.go | 5 +- 7 files changed, 153 insertions(+), 13 deletions(-) diff --git a/internal/beads/beads.go b/internal/beads/beads.go index cc49f6b1..30becb95 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -21,6 +21,45 @@ var ( ErrNotFound = errors.New("issue not found") ) +// ResolveBeadsDir returns the actual beads directory, following any redirect. +// If workDir/.beads/redirect exists, it reads the redirect path and resolves it +// relative to workDir (not the .beads directory). Otherwise, returns workDir/.beads. +// +// This is essential for crew workers and polecats that use shared beads via redirect. +// The redirect file contains a relative path like "../../mayor/rig/.beads". +// +// Example: if we're at crew/max/ and .beads/redirect contains "../../mayor/rig/.beads", +// the redirect is resolved from crew/max/ (not crew/max/.beads/), giving us +// mayor/rig/.beads at the rig root level. +func ResolveBeadsDir(workDir string) string { + beadsDir := filepath.Join(workDir, ".beads") + redirectPath := filepath.Join(beadsDir, "redirect") + + // Check for redirect file + data, err := os.ReadFile(redirectPath) + if err != nil { + // No redirect, use local .beads + return beadsDir + } + + // Read and clean the redirect path + redirectTarget := strings.TrimSpace(string(data)) + if redirectTarget == "" { + return beadsDir + } + + // Resolve relative to workDir (the redirect is written from the perspective + // of being inside workDir, not inside workDir/.beads) + // e.g., redirect contains "../../mayor/rig/.beads" + // from crew/max/, this resolves to mayor/rig/.beads + resolved := filepath.Join(workDir, redirectTarget) + + // Clean the path to resolve .. components + resolved = filepath.Clean(resolved) + + return resolved +} + // Issue represents a beads issue. type Issue struct { ID string `json:"id"` diff --git a/internal/beads/beads_test.go b/internal/beads/beads_test.go index 95f6f335..9ec8c7ca 100644 --- a/internal/beads/beads_test.go +++ b/internal/beads/beads_test.go @@ -881,3 +881,89 @@ func TestAttachmentFieldsRoundTrip(t *testing.T) { t.Errorf("round-trip mismatch:\ngot %+v\nwant %+v", parsed, original) } } + +// TestResolveBeadsDir tests the redirect following logic. +func TestResolveBeadsDir(t *testing.T) { + // Create temp directory structure + tmpDir, err := os.MkdirTemp("", "beads-redirect-test-*") + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + t.Run("no redirect", func(t *testing.T) { + // Create a simple .beads directory without redirect + workDir := filepath.Join(tmpDir, "no-redirect") + beadsDir := filepath.Join(workDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + got := ResolveBeadsDir(workDir) + want := beadsDir + if got != want { + t.Errorf("ResolveBeadsDir() = %q, want %q", got, want) + } + }) + + t.Run("with redirect", func(t *testing.T) { + // Create structure like: crew/max/.beads/redirect -> ../../mayor/rig/.beads + workDir := filepath.Join(tmpDir, "crew", "max") + localBeadsDir := filepath.Join(workDir, ".beads") + targetBeadsDir := filepath.Join(tmpDir, "mayor", "rig", ".beads") + + // Create both directories + if err := os.MkdirAll(localBeadsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(targetBeadsDir, 0755); err != nil { + t.Fatal(err) + } + + // Create redirect file + redirectPath := filepath.Join(localBeadsDir, "redirect") + if err := os.WriteFile(redirectPath, []byte("../../mayor/rig/.beads\n"), 0644); err != nil { + t.Fatal(err) + } + + got := ResolveBeadsDir(workDir) + want := targetBeadsDir + if got != want { + t.Errorf("ResolveBeadsDir() = %q, want %q", got, want) + } + }) + + t.Run("no beads directory", func(t *testing.T) { + // Directory with no .beads at all + workDir := filepath.Join(tmpDir, "empty") + if err := os.MkdirAll(workDir, 0755); err != nil { + t.Fatal(err) + } + + got := ResolveBeadsDir(workDir) + want := filepath.Join(workDir, ".beads") + if got != want { + t.Errorf("ResolveBeadsDir() = %q, want %q", got, want) + } + }) + + t.Run("empty redirect file", func(t *testing.T) { + // Redirect file exists but is empty - should fall back to local + workDir := filepath.Join(tmpDir, "empty-redirect") + beadsDir := filepath.Join(workDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + redirectPath := filepath.Join(beadsDir, "redirect") + if err := os.WriteFile(redirectPath, []byte(" \n"), 0644); err != nil { + t.Fatal(err) + } + + got := ResolveBeadsDir(workDir) + want := beadsDir + if got != want { + t.Errorf("ResolveBeadsDir() = %q, want %q", got, want) + } + }) +} diff --git a/internal/beads/catalog.go b/internal/beads/catalog.go index 4913903a..29352dff 100644 --- a/internal/beads/catalog.go +++ b/internal/beads/catalog.go @@ -46,28 +46,32 @@ func NewMoleculeCatalog() *MoleculeCatalog { // - projectPath: Path to the project directory. Empty to skip project-level. // // Molecules are loaded from town, rig, and project levels (no builtin molecules). +// Each level follows .beads/redirect if present (for shared beads support). func LoadCatalog(townRoot, rigPath, projectPath string) (*MoleculeCatalog, error) { catalog := NewMoleculeCatalog() - // 1. Load town-level molecules + // 1. Load town-level molecules (follows redirect if present) if townRoot != "" { - townMolsPath := filepath.Join(townRoot, ".beads", "molecules.jsonl") + townBeadsDir := ResolveBeadsDir(townRoot) + townMolsPath := filepath.Join(townBeadsDir, "molecules.jsonl") if err := catalog.LoadFromFile(townMolsPath, "town"); err != nil && !os.IsNotExist(err) { return nil, fmt.Errorf("loading town molecules: %w", err) } } - // 2. Load rig-level molecules + // 2. Load rig-level molecules (follows redirect if present) if rigPath != "" { - rigMolsPath := filepath.Join(rigPath, ".beads", "molecules.jsonl") + rigBeadsDir := ResolveBeadsDir(rigPath) + rigMolsPath := filepath.Join(rigBeadsDir, "molecules.jsonl") if err := catalog.LoadFromFile(rigMolsPath, "rig"); err != nil && !os.IsNotExist(err) { return nil, fmt.Errorf("loading rig molecules: %w", err) } } - // 3. Load project-level molecules + // 3. Load project-level molecules (follows redirect if present) if projectPath != "" { - projectMolsPath := filepath.Join(projectPath, ".beads", "molecules.jsonl") + projectBeadsDir := ResolveBeadsDir(projectPath) + projectMolsPath := filepath.Join(projectBeadsDir, "molecules.jsonl") if err := catalog.LoadFromFile(projectMolsPath, "project"); err != nil && !os.IsNotExist(err) { return nil, fmt.Errorf("loading project molecules: %w", err) } diff --git a/internal/doctor/beads_check.go b/internal/doctor/beads_check.go index 60ab4913..fc09003b 100644 --- a/internal/doctor/beads_check.go +++ b/internal/doctor/beads_check.go @@ -76,8 +76,9 @@ func (c *BeadsDatabaseCheck) Run(ctx *CheckContext) *CheckResult { } // Also check rig-level beads if a rig is specified + // Follows redirect if present (rig root may redirect to mayor/rig/.beads) if ctx.RigName != "" { - rigBeadsDir := filepath.Join(ctx.RigPath(), ".beads") + rigBeadsDir := beads.ResolveBeadsDir(ctx.RigPath()) if _, err := os.Stat(rigBeadsDir); err == nil { rigDB := filepath.Join(rigBeadsDir, "issues.db") rigJSONL := filepath.Join(rigBeadsDir, "issues.jsonl") @@ -135,9 +136,9 @@ func (c *BeadsDatabaseCheck) Fix(ctx *CheckContext) error { } } - // Also fix rig-level if specified + // Also fix rig-level if specified (follows redirect if present) if ctx.RigName != "" { - rigBeadsDir := filepath.Join(ctx.RigPath(), ".beads") + rigBeadsDir := beads.ResolveBeadsDir(ctx.RigPath()) rigDB := filepath.Join(rigBeadsDir, "issues.db") rigJSONL := filepath.Join(rigBeadsDir, "issues.jsonl") diff --git a/internal/doctor/patrol_check.go b/internal/doctor/patrol_check.go index d3191b75..a64f5aea 100644 --- a/internal/doctor/patrol_check.go +++ b/internal/doctor/patrol_check.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/config" ) @@ -262,7 +263,10 @@ func (c *PatrolNotStuckCheck) Run(ctx *CheckContext) *CheckResult { var stuckWisps []string for _, rigName := range rigs { // Check main beads database for wisps (issues with Wisp=true) - beadsPath := filepath.Join(ctx.TownRoot, rigName, ".beads", "issues.jsonl") + // Follows redirect if present (rig root may redirect to mayor/rig/.beads) + rigPath := filepath.Join(ctx.TownRoot, rigName) + beadsDir := beads.ResolveBeadsDir(rigPath) + beadsPath := filepath.Join(beadsDir, "issues.jsonl") stuck := c.checkStuckWisps(beadsPath, rigName) stuckWisps = append(stuckWisps, stuck...) } diff --git a/internal/doctor/wisp_check.go b/internal/doctor/wisp_check.go index b96445e7..7a1f44bc 100644 --- a/internal/doctor/wisp_check.go +++ b/internal/doctor/wisp_check.go @@ -8,6 +8,8 @@ import ( "os/exec" "path/filepath" "time" + + "github.com/steveyegge/gastown/internal/beads" ) // WispGCCheck detects and cleans orphaned wisps that are older than a threshold. @@ -87,8 +89,9 @@ func (c *WispGCCheck) Run(ctx *CheckContext) *CheckResult { // countAbandonedWisps counts wisps older than the threshold in a rig. func (c *WispGCCheck) countAbandonedWisps(rigPath string) int { - // Check the beads database for wisps - issuesPath := filepath.Join(rigPath, ".beads", "issues.jsonl") + // Check the beads database for wisps (follows redirect if present) + beadsDir := beads.ResolveBeadsDir(rigPath) + issuesPath := filepath.Join(beadsDir, "issues.jsonl") file, err := os.Open(issuesPath) if err != nil { return 0 // No issues file diff --git a/internal/mail/mailbox.go b/internal/mail/mailbox.go index 1ee0e9e3..0cb5c282 100644 --- a/internal/mail/mailbox.go +++ b/internal/mail/mailbox.go @@ -10,6 +10,8 @@ import ( "path/filepath" "sort" "strings" + + "github.com/steveyegge/gastown/internal/beads" ) // Common errors @@ -46,8 +48,9 @@ func NewMailboxBeads(identity, workDir string) *Mailbox { } // NewMailboxFromAddress creates a beads-backed mailbox from a GGT address. +// Follows .beads/redirect for crew workers and polecats using shared beads. func NewMailboxFromAddress(address, workDir string) *Mailbox { - beadsDir := filepath.Join(workDir, ".beads") + beadsDir := beads.ResolveBeadsDir(workDir) return &Mailbox{ identity: addressToIdentity(address), workDir: workDir,