fix: gt commands follow .beads/redirect for shared beads (gt-ln5af)

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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-27 21:34:22 -08:00
parent f443cd6637
commit 879018f35d
7 changed files with 153 additions and 13 deletions

View File

@@ -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"`

View File

@@ -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)
}
})
}

View File

@@ -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)
}

View File

@@ -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")

View File

@@ -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...)
}

View File

@@ -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

View File

@@ -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,