fix: ignore hidden directories when enumerating polecats (#258)

* fix(sling): route bd mol commands to target rig directory

* Fix daemon polecat enumeration to ignore hidden dirs

* Ignore hidden dirs when discovering rig polecats

* Fix CI: enable beads custom types during install

---------

Co-authored-by: joshuavial <git@codewithjv.com>
This commit is contained in:
Joshua Vial
2026-01-08 17:48:09 +13:00
committed by GitHub
parent f9e788ccfb
commit a9ed342be6
8 changed files with 284 additions and 17 deletions

View File

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

View File

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

View File

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