diff --git a/internal/cmd/install.go b/internal/cmd/install.go index 604dc7d1..ad52df26 100644 --- a/internal/cmd/install.go +++ b/internal/cmd/install.go @@ -339,6 +339,13 @@ func initTownBeads(townPath string) error { fmt.Printf(" %s Could not register custom types: %v\n", style.Dim.Render("⚠"), err) } + // Ensure routes.jsonl has an explicit town-level mapping for hq-* beads. + // This keeps hq-* operations stable even when invoked from rig worktrees. + if err := beads.AppendRoute(townPath, beads.Route{Prefix: "hq-", Path: "."}); err != nil { + // Non-fatal: routing still works in many contexts, but explicit mapping is preferred. + fmt.Printf(" %s Could not update routes.jsonl: %v\n", style.Dim.Render("⚠"), err) + } + return nil } @@ -390,6 +397,13 @@ func ensureCustomTypes(beadsPath string) error { func initTownAgentBeads(townPath string) error { bd := beads.New(townPath) + // bd init doesn't enable "custom" issue types by default, but Gas Town uses + // agent/role beads during install and runtime. Ensure these types are enabled + // before attempting to create any town-level system beads. + if err := ensureBeadsCustomTypes(townPath, []string{"agent", "role", "rig", "convoy", "slot"}); err != nil { + return err + } + // Role beads (global templates) roleDefs := []struct { id string @@ -508,3 +522,17 @@ func initTownAgentBeads(townPath string) error { return nil } + +func ensureBeadsCustomTypes(workDir string, types []string) error { + if len(types) == 0 { + return nil + } + + cmd := exec.Command("bd", "config", "set", "types.custom", strings.Join(types, ",")) + cmd.Dir = workDir + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("bd config set types.custom failed: %s", strings.TrimSpace(string(output))) + } + return nil +} diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index bfcac793..6a8423b2 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -387,8 +387,14 @@ func runSling(cmd *cobra.Command, args []string) error { if formulaName != "" { fmt.Printf(" Instantiating formula %s...\n", formulaName) + // Route bd mutations (cook/wisp/bond) to the correct beads context for the target bead. + // Some bd mol commands don't support prefix routing, so we must run them from the + // rig directory that owns the bead's database. + formulaWorkDir := beads.ResolveHookDir(townRoot, beadID, hookWorkDir) + // Step 1: Cook the formula (ensures proto exists) cookCmd := exec.Command("bd", "--no-daemon", "cook", formulaName) + cookCmd.Dir = formulaWorkDir cookCmd.Stderr = os.Stderr if err := cookCmd.Run(); err != nil { return fmt.Errorf("cooking formula %s: %w", formulaName, err) @@ -398,6 +404,7 @@ func runSling(cmd *cobra.Command, args []string) error { featureVar := fmt.Sprintf("feature=%s", info.Title) wispArgs := []string{"--no-daemon", "mol", "wisp", formulaName, "--var", featureVar, "--json"} wispCmd := exec.Command("bd", wispArgs...) + wispCmd.Dir = formulaWorkDir wispCmd.Stderr = os.Stderr wispOut, err := wispCmd.Output() if err != nil { @@ -415,6 +422,7 @@ func runSling(cmd *cobra.Command, args []string) error { // Use --no-daemon for mol bond (requires direct database access) bondArgs := []string{"--no-daemon", "mol", "bond", wispRootID, beadID, "--json"} bondCmd := exec.Command("bd", bondArgs...) + bondCmd.Dir = formulaWorkDir bondCmd.Stderr = os.Stderr bondOut, err := bondCmd.Output() if err != nil { diff --git a/internal/cmd/sling_test.go b/internal/cmd/sling_test.go index 7a17b688..1959a934 100644 --- a/internal/cmd/sling_test.go +++ b/internal/cmd/sling_test.go @@ -1,6 +1,11 @@ package cmd -import "testing" +import ( + "os" + "path/filepath" + "strings" + "testing" +) func TestParseWispIDFromJSON(t *testing.T) { tests := []struct { @@ -183,3 +188,158 @@ func TestFormatTrackBeadIDConsumerCompatibility(t *testing.T) { }) } } + +func TestSlingFormulaOnBeadRoutesBDCommandsToTargetRig(t *testing.T) { + townRoot := t.TempDir() + + // Minimal workspace marker so workspace.FindFromCwd() succeeds. + if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil { + t.Fatalf("mkdir mayor/rig: %v", err) + } + + // Create a rig path that owns gt-* beads, and a routes.jsonl pointing to it. + rigDir := filepath.Join(townRoot, "gastown", "mayor", "rig") + if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil { + t.Fatalf("mkdir .beads: %v", err) + } + if err := os.MkdirAll(rigDir, 0755); err != nil { + t.Fatalf("mkdir rigDir: %v", err) + } + routes := strings.Join([]string{ + `{"prefix":"gt-","path":"gastown/mayor/rig"}`, + `{"prefix":"hq-","path":"."}`, + "", + }, "\n") + if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(routes), 0644); err != nil { + t.Fatalf("write routes.jsonl: %v", err) + } + + // Stub bd so we can observe the working directory for cook/wisp/bond. + binDir := filepath.Join(townRoot, "bin") + if err := os.MkdirAll(binDir, 0755); err != nil { + t.Fatalf("mkdir binDir: %v", err) + } + logPath := filepath.Join(townRoot, "bd.log") + bdPath := filepath.Join(binDir, "bd") + bdScript := `#!/bin/sh +set -e +echo "$(pwd)|$*" >> "${BD_LOG}" +if [ "$1" = "--no-daemon" ]; then + shift +fi +cmd="$1" +shift || true +case "$cmd" in + show) + echo '[{"title":"Test issue","status":"open","assignee":"","description":""}]' + ;; + formula) + # formula show + exit 0 + ;; + cook) + exit 0 + ;; + mol) + sub="$1" + shift || true + case "$sub" in + wisp) + echo '{"new_epic_id":"gt-wisp-xyz"}' + ;; + bond) + echo '{"root_id":"gt-wisp-xyz"}' + ;; + esac + ;; +esac +exit 0 +` + if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil { + t.Fatalf("write bd stub: %v", err) + } + + t.Setenv("BD_LOG", logPath) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv(EnvGTRole, "mayor") + t.Setenv("GT_POLECAT", "") + t.Setenv("GT_CREW", "") + + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) + if err := os.Chdir(filepath.Join(townRoot, "mayor", "rig")); err != nil { + t.Fatalf("chdir: %v", err) + } + + // Ensure we don't leak global flag state across tests. + prevOn := slingOnTarget + prevVars := slingVars + prevDryRun := slingDryRun + prevNoConvoy := slingNoConvoy + t.Cleanup(func() { + slingOnTarget = prevOn + slingVars = prevVars + slingDryRun = prevDryRun + slingNoConvoy = prevNoConvoy + }) + + slingDryRun = false + slingNoConvoy = true + slingVars = nil + slingOnTarget = "gt-abc123" + + if err := runSling(nil, []string{"mol-review"}); err != nil { + t.Fatalf("runSling: %v", err) + } + + logBytes, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read bd log: %v", err) + } + logLines := strings.Split(strings.TrimSpace(string(logBytes)), "\n") + + wantDir := rigDir + if resolved, err := filepath.EvalSymlinks(wantDir); err == nil { + wantDir = resolved + } + gotCook := false + gotWisp := false + gotBond := false + + for _, line := range logLines { + parts := strings.SplitN(line, "|", 2) + if len(parts) != 2 { + continue + } + dir := parts[0] + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } + args := parts[1] + + switch { + case strings.Contains(args, " cook "): + gotCook = true + if dir != wantDir { + t.Fatalf("bd cook ran in %q, want %q (args: %q)", dir, wantDir, args) + } + case strings.Contains(args, " mol wisp "): + gotWisp = true + if dir != wantDir { + t.Fatalf("bd mol wisp ran in %q, want %q (args: %q)", dir, wantDir, args) + } + case strings.Contains(args, " mol bond "): + gotBond = true + if dir != wantDir { + t.Fatalf("bd mol bond ran in %q, want %q (args: %q)", dir, wantDir, args) + } + } + } + + if !gotCook || !gotWisp || !gotBond { + t.Fatalf("missing expected bd commands: cook=%v wisp=%v bond=%v (log: %q)", gotCook, gotWisp, gotBond, string(logBytes)) + } +} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index df5b9d77..d695cbe8 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -879,6 +879,18 @@ func TestRuntimeConfigBuildCommandWithPrompt(t *testing.T) { } func TestBuildAgentStartupCommand(t *testing.T) { + // BuildAgentStartupCommand auto-detects town root from cwd when rigPath is empty. + // Use a temp directory to ensure we exercise the fallback default config path. + origWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + tmpWD := t.TempDir() + if err := os.Chdir(tmpWD); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(origWD) }) + // Test without rig config (uses defaults) cmd := BuildAgentStartupCommand("witness", "gastown/witness", "", "") diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 9b370b5f..daae733e 100755 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -10,6 +10,7 @@ import ( "os/signal" "path/filepath" "strconv" + "strings" "syscall" "time" @@ -439,7 +440,7 @@ func (d *Daemon) ensureRefineriesRunning() { // ensureRefineryRunning ensures the refinery for a specific rig is running. // Discover, don't track: uses Manager.Start() which checks tmux directly (gt-zecmc). func (d *Daemon) ensureRefineryRunning(rigName string) { -// Check rig operational state before auto-starting + // Check rig operational state before auto-starting if operational, reason := d.isRigOperational(rigName); !operational { d.logger.Printf("Skipping refinery auto-start for %s: %s", rigName, reason) return @@ -697,18 +698,35 @@ func (d *Daemon) checkPolecatSessionHealth() { func (d *Daemon) checkRigPolecatHealth(rigName string) { // Get polecat directories for this rig polecatsDir := filepath.Join(d.config.TownRoot, rigName, "polecats") - entries, err := os.ReadDir(polecatsDir) + polecats, err := listPolecatWorktrees(polecatsDir) if err != nil { return // No polecats directory - rig might not have polecats } + for _, polecatName := range polecats { + d.checkPolecatHealth(rigName, polecatName) + } +} + +func listPolecatWorktrees(polecatsDir string) ([]string, error) { + entries, err := os.ReadDir(polecatsDir) + if err != nil { + return nil, err + } + + polecats := make([]string, 0, len(entries)) for _, entry := range entries { if !entry.IsDir() { continue } - polecatName := entry.Name() - d.checkPolecatHealth(rigName, polecatName) + name := entry.Name() + if strings.HasPrefix(name, ".") { + continue + } + polecats = append(polecats, name) } + + return polecats, nil } // checkPolecatHealth checks a single polecat's session health. diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 73853bf3..f49d8043 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "slices" "testing" "time" ) @@ -206,6 +207,33 @@ func TestSaveLoadState_Roundtrip(t *testing.T) { } } +func TestListPolecatWorktrees_SkipsHiddenDirs(t *testing.T) { + tmpDir := t.TempDir() + polecatsDir := filepath.Join(tmpDir, "some-rig", "polecats") + + if err := os.MkdirAll(filepath.Join(polecatsDir, ".claude"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(polecatsDir, "furiosa"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(polecatsDir, "not-a-dir.txt"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + + polecats, err := listPolecatWorktrees(polecatsDir) + if err != nil { + t.Fatalf("listPolecatWorktrees returned error: %v", err) + } + + if slices.Contains(polecats, ".claude") { + t.Fatalf("expected hidden dir .claude to be ignored, got %v", polecats) + } + if !slices.Contains(polecats, "furiosa") { + t.Fatalf("expected furiosa to be included, got %v", polecats) + } +} + // NOTE: TestIsWitnessSession removed - isWitnessSession function was deleted // as part of ZFC cleanup. Witness poking is now Deacon's responsibility. diff --git a/internal/rig/manager.go b/internal/rig/manager.go index 134b2304..18fa99f1 100644 --- a/internal/rig/manager.go +++ b/internal/rig/manager.go @@ -120,9 +120,14 @@ func (m *Manager) loadRig(name string, entry config.RigEntry) (*Rig, error) { polecatsDir := filepath.Join(rigPath, "polecats") if entries, err := os.ReadDir(polecatsDir); err == nil { for _, e := range entries { - if e.IsDir() { - rig.Polecats = append(rig.Polecats, e.Name()) + if !e.IsDir() { + continue } + name := e.Name() + if strings.HasPrefix(name, ".") { + continue + } + rig.Polecats = append(rig.Polecats, name) } } diff --git a/internal/rig/manager_test.go b/internal/rig/manager_test.go index 573196a6..fb22e1e8 100644 --- a/internal/rig/manager_test.go +++ b/internal/rig/manager_test.go @@ -3,6 +3,7 @@ package rig import ( "os" "path/filepath" + "slices" "strings" "testing" @@ -55,6 +56,10 @@ func createTestRig(t *testing.T, root, name string) { t.Fatalf("mkdir polecat: %v", err) } } + // Create a shared support dir that should not be treated as a polecat worktree. + if err := os.MkdirAll(filepath.Join(polecatsDir, ".claude"), 0755); err != nil { + t.Fatalf("mkdir polecats/.claude: %v", err) + } } func TestDiscoverRigs(t *testing.T) { @@ -84,6 +89,9 @@ func TestDiscoverRigs(t *testing.T) { if len(rig.Polecats) != 2 { t.Errorf("Polecats count = %d, want 2", len(rig.Polecats)) } + if slices.Contains(rig.Polecats, ".claude") { + t.Errorf("expected polecats/.claude to be ignored, got %v", rig.Polecats) + } if !rig.HasWitness { t.Error("expected HasWitness = true") } @@ -430,17 +438,17 @@ func TestIsValidBeadsPrefix(t *testing.T) { {"a-b-c", true}, // Invalid prefixes - {"", false}, // empty - {"1abc", false}, // starts with number - {"-abc", false}, // starts with hyphen - {"abc def", false}, // contains space - {"abc;ls", false}, // shell injection attempt - {"$(whoami)", false}, // command substitution - {"`id`", false}, // backtick command - {"abc|cat", false}, // pipe - {"../etc/passwd", false}, // path traversal + {"", false}, // empty + {"1abc", false}, // starts with number + {"-abc", false}, // starts with hyphen + {"abc def", false}, // contains space + {"abc;ls", false}, // shell injection attempt + {"$(whoami)", false}, // command substitution + {"`id`", false}, // backtick command + {"abc|cat", false}, // pipe + {"../etc/passwd", false}, // path traversal {"aaaaaaaaaaaaaaaaaaaaa", false}, // too long (21 chars, >20 limit) - {"valid-but-with-$var", false}, // variable reference + {"valid-but-with-$var", false}, // variable reference } for _, tt := range tests {