diff --git a/internal/beads/beads.go b/internal/beads/beads.go index 188cfd75..6c99809d 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -424,6 +424,9 @@ func (b *Beads) Create(opts CreateOptions) (*Issue, error) { // deterministic IDs rather than auto-generated ones. func (b *Beads) CreateWithID(id string, opts CreateOptions) (*Issue, error) { args := []string{"create", "--json", "--id=" + id} + if NeedsForceForID(id) { + args = append(args, "--force") + } if opts.Title != "" { args = append(args, "--title="+opts.Title) diff --git a/internal/beads/beads_agent.go b/internal/beads/beads_agent.go index 4506f3a7..1f30dac8 100644 --- a/internal/beads/beads_agent.go +++ b/internal/beads/beads_agent.go @@ -139,6 +139,9 @@ func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue, "--type=agent", "--labels=gt:agent", } + if NeedsForceForID(id) { + args = append(args, "--force") + } // Default actor from BD_ACTOR env var for provenance tracking if actor := os.Getenv("BD_ACTOR"); actor != "" { diff --git a/internal/beads/beads_rig.go b/internal/beads/beads_rig.go index 8994ad1d..b645c26f 100644 --- a/internal/beads/beads_rig.go +++ b/internal/beads/beads_rig.go @@ -85,6 +85,9 @@ func (b *Beads) CreateRigBead(id, title string, fields *RigFields) (*Issue, erro "--description=" + description, "--labels=gt:rig", } + if NeedsForceForID(id) { + args = append(args, "--force") + } // Default actor from BD_ACTOR env var for provenance tracking if actor := os.Getenv("BD_ACTOR"); actor != "" { diff --git a/internal/beads/force.go b/internal/beads/force.go new file mode 100644 index 00000000..b6d0ba41 --- /dev/null +++ b/internal/beads/force.go @@ -0,0 +1,11 @@ +package beads + +import "strings" + +// NeedsForceForID returns true when a bead ID uses multiple hyphens. +// Recent bd versions infer the prefix from the last hyphen, which can cause +// prefix-mismatch errors for valid system IDs like "st-stockdrop-polecat-nux" +// and "hq-cv-abc". We pass --force to honor the explicit ID in those cases. +func NeedsForceForID(id string) bool { + return strings.Count(id, "-") > 1 +} diff --git a/internal/beads/force_test.go b/internal/beads/force_test.go new file mode 100644 index 00000000..a72cbabb --- /dev/null +++ b/internal/beads/force_test.go @@ -0,0 +1,23 @@ +package beads + +import "testing" + +func TestNeedsForceForID(t *testing.T) { + tests := []struct { + id string + want bool + }{ + {id: "", want: false}, + {id: "hq-mayor", want: false}, + {id: "gt-abc123", want: false}, + {id: "hq-mayor-role", want: true}, + {id: "st-stockdrop-polecat-nux", want: true}, + {id: "hq-cv-abc", want: true}, + } + + for _, tc := range tests { + if got := NeedsForceForID(tc.id); got != tc.want { + t.Fatalf("NeedsForceForID(%q) = %v, want %v", tc.id, got, tc.want) + } + } +} diff --git a/internal/beads/routes.go b/internal/beads/routes.go index 1afb51a2..cf67d7f6 100644 --- a/internal/beads/routes.go +++ b/internal/beads/routes.go @@ -8,6 +8,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/steveyegge/gastown/internal/config" ) // Route represents a prefix-to-path routing rule. @@ -150,7 +152,7 @@ func GetPrefixForRig(townRoot, rigName string) string { beadsDir := filepath.Join(townRoot, ".beads") routes, err := LoadRoutes(beadsDir) if err != nil || routes == nil { - return "gt" // Default prefix + return config.GetRigPrefix(townRoot, rigName) } // Look for a route where the path starts with the rig name @@ -163,7 +165,7 @@ func GetPrefixForRig(townRoot, rigName string) string { } } - return "gt" // Default prefix + return config.GetRigPrefix(townRoot, rigName) } // FindConflictingPrefixes checks for duplicate prefixes in routes. diff --git a/internal/beads/routes_test.go b/internal/beads/routes_test.go index 94f7a5f9..811045c5 100644 --- a/internal/beads/routes_test.go +++ b/internal/beads/routes_test.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/steveyegge/gastown/internal/config" ) func TestGetPrefixForRig(t *testing.T) { @@ -52,6 +54,33 @@ func TestGetPrefixForRig_NoRoutesFile(t *testing.T) { } } +func TestGetPrefixForRig_RigsConfigFallback(t *testing.T) { + tmpDir := t.TempDir() + + // Write rigs.json with a non-gt prefix + rigsPath := filepath.Join(tmpDir, "mayor", "rigs.json") + if err := os.MkdirAll(filepath.Dir(rigsPath), 0755); err != nil { + t.Fatal(err) + } + + cfg := &config.RigsConfig{ + Version: config.CurrentRigsVersion, + Rigs: map[string]config.RigEntry{ + "project_ideas": { + BeadsConfig: &config.BeadsConfig{Prefix: "pi"}, + }, + }, + } + if err := config.SaveRigsConfig(rigsPath, cfg); err != nil { + t.Fatalf("SaveRigsConfig: %v", err) + } + + result := GetPrefixForRig(tmpDir, "project_ideas") + if result != "pi" { + t.Errorf("Expected prefix from rigs config, got %q", result) + } +} + func TestExtractPrefix(t *testing.T) { tests := []struct { beadID string @@ -100,7 +129,7 @@ func TestGetRigPathForPrefix(t *testing.T) { }{ {"ap-", filepath.Join(tmpDir, "ai_platform/mayor/rig")}, {"gt-", filepath.Join(tmpDir, "gastown/mayor/rig")}, - {"hq-", tmpDir}, // Town-level beads return townRoot + {"hq-", tmpDir}, // Town-level beads return townRoot {"unknown-", ""}, // Unknown prefix returns empty {"", ""}, // Empty prefix returns empty } diff --git a/internal/checkpoint/checkpoint.go b/internal/checkpoint/checkpoint.go index 472333e0..0424ff7f 100644 --- a/internal/checkpoint/checkpoint.go +++ b/internal/checkpoint/checkpoint.go @@ -181,9 +181,9 @@ func (cp *Checkpoint) Age() time.Duration { return time.Since(cp.Timestamp) } -// IsStale returns true if the checkpoint is older than the threshold. +// IsStale returns true if the checkpoint is at or older than the threshold. func (cp *Checkpoint) IsStale(threshold time.Duration) bool { - return cp.Age() > threshold + return cp.Age() >= threshold } // Summary returns a concise summary of the checkpoint. diff --git a/internal/cmd/convoy.go b/internal/cmd/convoy.go index dbd45907..7a8fe8c7 100644 --- a/internal/cmd/convoy.go +++ b/internal/cmd/convoy.go @@ -16,6 +16,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tui/convoy" "github.com/steveyegge/gastown/internal/workspace" @@ -319,6 +320,9 @@ func runConvoyCreate(cmd *cobra.Command, args []string) error { "--description=" + description, "--json", } + if beads.NeedsForceForID(convoyID) { + createArgs = append(createArgs, "--force") + } createCmd := exec.Command("bd", createArgs...) createCmd.Dir = townBeads diff --git a/internal/cmd/costs_workdir_test.go b/internal/cmd/costs_workdir_test.go index 415d7972..3954d69d 100644 --- a/internal/cmd/costs_workdir_test.go +++ b/internal/cmd/costs_workdir_test.go @@ -211,7 +211,14 @@ func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) { if wsErr != nil { t.Fatalf("workspace.FindFromCwdOrError failed: %v", wsErr) } - if foundTownRoot != townRoot { + normalizePath := func(path string) string { + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return filepath.Clean(path) + } + return resolved + } + if normalizePath(foundTownRoot) != normalizePath(townRoot) { t.Errorf("workspace.FindFromCwdOrError returned %s, expected %s", foundTownRoot, townRoot) } diff --git a/internal/cmd/formula.go b/internal/cmd/formula.go index ce417046..eaf6bf51 100644 --- a/internal/cmd/formula.go +++ b/internal/cmd/formula.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/workspace" @@ -335,6 +336,9 @@ func executeConvoyFormula(f *formulaData, formulaName, targetRig string) error { "--title=" + convoyTitle, "--description=" + description, } + if beads.NeedsForceForID(convoyID) { + createArgs = append(createArgs, "--force") + } createCmd := exec.Command("bd", createArgs...) createCmd.Dir = townBeads @@ -365,6 +369,9 @@ func executeConvoyFormula(f *formulaData, formulaName, targetRig string) error { "--title=" + leg.Title, "--description=" + legDesc, } + if beads.NeedsForceForID(legBeadID) { + legArgs = append(legArgs, "--force") + } legCmd := exec.Command("bd", legArgs...) legCmd.Dir = townBeads @@ -405,6 +412,9 @@ func executeConvoyFormula(f *formulaData, formulaName, targetRig string) error { "--title=" + f.Synthesis.Title, "--description=" + synDesc, } + if beads.NeedsForceForID(synthesisBeadID) { + synArgs = append(synArgs, "--force") + } synCmd := exec.Command("bd", synArgs...) synCmd.Dir = townBeads diff --git a/internal/cmd/polecat.go b/internal/cmd/polecat.go index 74d49a13..54df8d1d 100644 --- a/internal/cmd/polecat.go +++ b/internal/cmd/polecat.go @@ -957,7 +957,7 @@ func runPolecatCheckRecovery(cmd *cobra.Command, args []string) error { // We need to read it directly from beads since manager doesn't expose it rigPath := r.Path bd := beads.New(rigPath) - agentBeadID := beads.PolecatBeadID(rigName, polecatName) + agentBeadID := polecatBeadIDForRig(r, rigName, polecatName) _, fields, err := bd.GetAgentBead(agentBeadID) status := RecoveryStatus{ @@ -1158,7 +1158,7 @@ func runPolecatNuke(cmd *cobra.Command, args []string) error { fmt.Printf(" - Kill session: gt-%s-%s\n", p.rigName, p.polecatName) fmt.Printf(" - Delete worktree: %s/polecats/%s\n", p.r.Path, p.polecatName) fmt.Printf(" - Delete branch (if exists)\n") - fmt.Printf(" - Close agent bead: %s\n", beads.PolecatBeadID(p.rigName, p.polecatName)) + fmt.Printf(" - Close agent bead: %s\n", polecatBeadIDForRig(p.r, p.rigName, p.polecatName)) displayDryRunSafetyCheck(p) fmt.Println() @@ -1214,7 +1214,7 @@ func runPolecatNuke(cmd *cobra.Command, args []string) error { } // Step 5: Close agent bead (if exists) - agentBeadID := beads.PolecatBeadID(p.rigName, p.polecatName) + agentBeadID := polecatBeadIDForRig(p.r, p.rigName, p.polecatName) closeArgs := []string{"close", agentBeadID, "--reason=nuked"} if sessionID := runtime.SessionIDFromEnv(); sessionID != "" { closeArgs = append(closeArgs, "--session="+sessionID) diff --git a/internal/cmd/polecat_helpers.go b/internal/cmd/polecat_helpers.go index 2a028223..fcf1ab8b 100644 --- a/internal/cmd/polecat_helpers.go +++ b/internal/cmd/polecat_helpers.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "path/filepath" "strings" "github.com/steveyegge/gastown/internal/beads" @@ -104,7 +105,7 @@ func checkPolecatSafety(target polecatTarget) *SafetyCheckResult { // Check 1: Unpushed commits via cleanup_status or git state bd := beads.New(target.r.Path) - agentBeadID := beads.PolecatBeadID(target.rigName, target.polecatName) + agentBeadID := polecatBeadIDForRig(target.r, target.rigName, target.polecatName) agentIssue, fields, err := bd.GetAgentBead(agentBeadID) if err != nil || fields == nil { @@ -176,6 +177,15 @@ func checkPolecatSafety(target polecatTarget) *SafetyCheckResult { return result } +func rigPrefix(r *rig.Rig) string { + townRoot := filepath.Dir(r.Path) + return beads.GetPrefixForRig(townRoot, r.Name) +} + +func polecatBeadIDForRig(r *rig.Rig, rigName, polecatName string) string { + return beads.PolecatBeadIDWithPrefix(rigPrefix(r), rigName, polecatName) +} + // displaySafetyCheckBlocked prints blocked polecats and guidance. func displaySafetyCheckBlocked(blocked []*SafetyCheckResult) { fmt.Printf("%s Cannot nuke the following polecats:\n\n", style.Error.Render("Error:")) @@ -202,7 +212,7 @@ func displayDryRunSafetyCheck(target polecatTarget) { fmt.Printf("\n Safety checks:\n") polecatInfo, infoErr := target.mgr.Get(target.polecatName) bd := beads.New(target.r.Path) - agentBeadID := beads.PolecatBeadID(target.rigName, target.polecatName) + agentBeadID := polecatBeadIDForRig(target.r, target.rigName, target.polecatName) agentIssue, fields, err := bd.GetAgentBead(agentBeadID) // Check 1: Git state diff --git a/internal/cmd/polecat_identity.go b/internal/cmd/polecat_identity.go index eaf342c8..f0e98900 100644 --- a/internal/cmd/polecat_identity.go +++ b/internal/cmd/polecat_identity.go @@ -232,7 +232,7 @@ func runPolecatIdentityAdd(cmd *cobra.Command, args []string) error { // Check if identity already exists bd := beads.New(r.Path) - beadID := beads.PolecatBeadID(rigName, polecatName) + beadID := polecatBeadIDForRig(r, rigName, polecatName) existingIssue, _, _ := bd.GetAgentBead(beadID) if existingIssue != nil && existingIssue.Status != "closed" { return fmt.Errorf("identity bead %s already exists", beadID) @@ -385,7 +385,7 @@ func runPolecatIdentityShow(cmd *cobra.Command, args []string) error { // Get identity bead bd := beads.New(r.Path) - beadID := beads.PolecatBeadID(rigName, polecatName) + beadID := polecatBeadIDForRig(r, rigName, polecatName) issue, fields, err := bd.GetAgentBead(beadID) if err != nil { return fmt.Errorf("getting identity bead: %w", err) @@ -414,10 +414,10 @@ func runPolecatIdentityShow(cmd *cobra.Command, args []string) error { if polecatIdentityShowJSON { output := struct { IdentityInfo - Title string `json:"title"` - CreatedAt string `json:"created_at,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` - CV *CVSummary `json:"cv,omitempty"` + Title string `json:"title"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + CV *CVSummary `json:"cv,omitempty"` }{ IdentityInfo: IdentityInfo{ Rig: rigName, @@ -563,8 +563,8 @@ func runPolecatIdentityRename(cmd *cobra.Command, args []string) error { } bd := beads.New(r.Path) - oldBeadID := beads.PolecatBeadID(rigName, oldName) - newBeadID := beads.PolecatBeadID(rigName, newName) + oldBeadID := polecatBeadIDForRig(r, rigName, oldName) + newBeadID := polecatBeadIDForRig(r, rigName, newName) // Check old identity exists oldIssue, oldFields, err := bd.GetAgentBead(oldBeadID) @@ -631,7 +631,7 @@ func runPolecatIdentityRemove(cmd *cobra.Command, args []string) error { } bd := beads.New(r.Path) - beadID := beads.PolecatBeadID(rigName, polecatName) + beadID := polecatBeadIDForRig(r, rigName, polecatName) // Check identity exists issue, fields, err := bd.GetAgentBead(beadID) diff --git a/internal/cmd/sling_convoy.go b/internal/cmd/sling_convoy.go index 06355ade..8828aba8 100644 --- a/internal/cmd/sling_convoy.go +++ b/internal/cmd/sling_convoy.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" + "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/workspace" ) @@ -80,6 +81,9 @@ func createAutoConvoy(beadID, beadTitle string) (string, error) { "--title=" + convoyTitle, "--description=" + description, } + if beads.NeedsForceForID(convoyID) { + createArgs = append(createArgs, "--force") + } createCmd := exec.Command("bd", append([]string{"--no-daemon"}, createArgs...)...) createCmd.Dir = townBeads diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 6fb50066..2880531f 100755 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -751,7 +751,8 @@ func (d *Daemon) checkPolecatHealth(rigName, polecatName string) { } // Session is dead. Check if the polecat has work-on-hook. - agentBeadID := beads.PolecatBeadID(rigName, polecatName) + prefix := beads.GetPrefixForRig(d.config.TownRoot, rigName) + agentBeadID := beads.PolecatBeadIDWithPrefix(prefix, rigName, polecatName) info, err := d.getAgentBeadInfo(agentBeadID) if err != nil { // Agent bead doesn't exist or error - polecat might not be registered diff --git a/internal/doctor/role_beads_check.go b/internal/doctor/role_beads_check.go index af06b545..aa9c9c77 100644 --- a/internal/doctor/role_beads_check.go +++ b/internal/doctor/role_beads_check.go @@ -100,12 +100,17 @@ func (c *RoleBeadsCheck) Fix(ctx *CheckContext) error { } // Create role bead using bd create --type=role - cmd := exec.Command("bd", "create", + args := []string{ + "create", "--type=role", - "--id="+role.ID, - "--title="+role.Title, - "--description="+role.Desc, - ) + "--id=" + role.ID, + "--title=" + role.Title, + "--description=" + role.Desc, + } + if beads.NeedsForceForID(role.ID) { + args = append(args, "--force") + } + cmd := exec.Command("bd", args...) cmd.Dir = ctx.TownRoot if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("creating %s: %s", role.ID, strings.TrimSpace(string(output)))