Merge branch 'fix/multi-repo-routing-pr811-v2'

This commit is contained in:
gastown/crew/dennis
2026-01-21 10:44:46 -08:00
committed by beads/crew/emma
12 changed files with 630 additions and 23 deletions

5
internal/.events.jsonl Normal file
View File

@@ -0,0 +1,5 @@
{"ts":"2026-01-18T09:52:29Z","source":"gt","type":"session_death","actor":"gt-gastown-witness","payload":{"agent":"unknown","caller":"gt doctor","reason":"zombie cleanup","session":"gt-gastown-witness"},"visibility":"feed"}
{"ts":"2026-01-18T10:00:24Z","source":"gt","type":"session_death","actor":"gt-gastown-witness","payload":{"agent":"unknown","caller":"gt doctor","reason":"zombie cleanup","session":"gt-gastown-witness"},"visibility":"feed"}
{"ts":"2026-01-18T10:06:48Z","source":"gt","type":"session_death","actor":"gt-gastown-witness","payload":{"agent":"unknown","caller":"gt doctor","reason":"zombie cleanup","session":"gt-gastown-witness"},"visibility":"feed"}
{"ts":"2026-01-18T14:26:38Z","source":"gt","type":"session_death","actor":"gt-gastown-witness","payload":{"agent":"unknown","caller":"gt doctor","reason":"zombie cleanup","session":"gt-gastown-witness"},"visibility":"feed"}
{"ts":"2026-01-18T14:30:02Z","source":"gt","type":"session_death","actor":"gt-gastown-witness","payload":{"agent":"unknown","caller":"gt doctor","reason":"zombie cleanup","session":"gt-gastown-witness"},"visibility":"feed"}

View File

@@ -114,6 +114,11 @@ type Beads struct {
workDir string
beadsDir string // Optional BEADS_DIR override for cross-database access
isolated bool // If true, suppress inherited beads env vars (for test isolation)
// Lazy-cached town root for routing resolution.
// Populated on first call to getTownRoot() to avoid filesystem walk on every operation.
townRoot string
searchedRoot bool
}
// New creates a new Beads wrapper for the given directory.
@@ -144,6 +149,26 @@ func (b *Beads) getActor() string {
return os.Getenv("BD_ACTOR")
}
// getTownRoot returns the Gas Town root directory, using lazy caching.
// The town root is found by walking up from workDir looking for mayor/town.json.
// Returns empty string if not in a Gas Town project.
func (b *Beads) getTownRoot() string {
if !b.searchedRoot {
b.townRoot = FindTownRoot(b.workDir)
b.searchedRoot = true
}
return b.townRoot
}
// getResolvedBeadsDir returns the beads directory this wrapper is operating on.
// This follows any redirects and returns the actual beads directory path.
func (b *Beads) getResolvedBeadsDir() string {
if b.beadsDir != "" {
return b.beadsDir
}
return ResolveBeadsDir(b.workDir)
}
// Init initializes a new beads database in the working directory.
// This uses the same environment isolation as other commands.
func (b *Beads) Init(prefix string) error {

View File

@@ -5,9 +5,32 @@ import (
"encoding/json"
"errors"
"fmt"
"os/exec"
"strings"
)
// runSlotSet runs `bd slot set` from a specific directory.
// This is needed when the agent bead was created via routing to a different
// database than the Beads wrapper's default directory.
func runSlotSet(workDir, beadID, slotName, slotValue string) error {
cmd := exec.Command("bd", "slot", "set", beadID, slotName, slotValue)
cmd.Dir = workDir
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err)
}
return nil
}
// runSlotClear runs `bd slot clear` from a specific directory.
func runSlotClear(workDir, beadID, slotName string) error {
cmd := exec.Command("bd", "slot", "clear", beadID, slotName)
cmd.Dir = workDir
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err)
}
return nil
}
// AgentFields holds structured fields for agent beads.
// These are stored as "key: value" lines in the description.
type AgentFields struct {
@@ -125,7 +148,21 @@ func ParseAgentFields(description string) *AgentFields {
// The ID format is: <prefix>-<rig>-<role>-<name> (e.g., gt-gastown-polecat-Toast)
// Use AgentBeadID() helper to generate correct IDs.
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
//
// This function automatically ensures custom types are configured in the target
// database before creating the bead. This handles multi-repo routing scenarios
// where the bead may be routed to a different database than the one this wrapper
// is connected to.
func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue, error) {
// Resolve where this bead will actually be written (handles multi-repo routing)
targetDir := ResolveRoutingTarget(b.getTownRoot(), id, b.getResolvedBeadsDir())
// Ensure target database has custom types configured
// This is cached (sentinel file + in-memory) so repeated calls are fast
if err := EnsureCustomTypes(targetDir); err != nil {
return nil, fmt.Errorf("prepare target for agent bead %s: %w", id, err)
}
description := FormatAgentDescription(title, fields)
args := []string{"create", "--json",
@@ -160,8 +197,9 @@ func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue,
// Set the hook slot if specified (this is the authoritative storage)
// This fixes the slot inconsistency bug where bead status is 'hooked' but
// agent's hook slot is empty. See mi-619.
// Must run from targetDir since that's where the agent bead was created
if fields != nil && fields.HookBead != "" {
if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil {
if err := runSlotSet(targetDir, id, "hook", fields.HookBead); err != nil {
// Non-fatal: warn but continue - description text has the backup
fmt.Printf("Warning: could not set hook slot: %v\n", err)
}
@@ -195,6 +233,9 @@ func (b *Beads) CreateOrReopenAgentBead(id, title string, fields *AgentFields) (
return nil, err
}
// Resolve where this bead lives (for slot operations)
targetDir := ResolveRoutingTarget(b.getTownRoot(), id, b.getResolvedBeadsDir())
// The bead already exists (should be closed from previous polecat lifecycle)
// Reopen it and update its fields
if _, reopenErr := b.run("reopen", id, "--reason=re-spawning agent"); reopenErr != nil {
@@ -217,12 +258,14 @@ func (b *Beads) CreateOrReopenAgentBead(id, title string, fields *AgentFields) (
// Note: role slot no longer set - role definitions are config-based
// Clear any existing hook slot (handles stale state from previous lifecycle)
_, _ = b.run("slot", "clear", id, "hook")
// Must run from targetDir since that's where the agent bead lives
_ = runSlotClear(targetDir, id, "hook")
// Set the hook slot if specified
// Must run from targetDir since that's where the agent bead lives
if fields != nil && fields.HookBead != "" {
if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil {
// Non-fatal: warn but continue
if err := runSlotSet(targetDir, id, "hook", fields.HookBead); err != nil {
// Non-fatal: warn but continue - description text has the backup
fmt.Printf("Warning: could not set hook slot: %v\n", err)
}
}

View File

@@ -0,0 +1,129 @@
// Package beads provides custom type management for agent beads.
package beads
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"github.com/steveyegge/gastown/internal/constants"
)
// typesSentinel is a marker file indicating custom types have been configured.
// This persists across CLI invocations to avoid redundant bd config calls.
const typesSentinel = ".gt-types-configured"
// ensuredDirs tracks which beads directories have been ensured this session.
// This provides fast in-memory caching for multiple creates in the same CLI run.
var (
ensuredDirs = make(map[string]bool)
ensuredMu sync.Mutex
)
// FindTownRoot walks up from startDir to find the Gas Town root directory.
// The town root is identified by the presence of mayor/town.json.
// Returns empty string if not found (reached filesystem root).
func FindTownRoot(startDir string) string {
dir := startDir
for {
townFile := filepath.Join(dir, "mayor", "town.json")
if _, err := os.Stat(townFile); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return "" // Reached filesystem root
}
dir = parent
}
}
// ResolveRoutingTarget determines which beads directory a bead ID will route to.
// It extracts the prefix from the bead ID and looks up the corresponding route.
// Returns the resolved beads directory path, following any redirects.
//
// If townRoot is empty or prefix is not found, falls back to the provided fallbackDir.
func ResolveRoutingTarget(townRoot, beadID, fallbackDir string) string {
if townRoot == "" {
return fallbackDir
}
// Extract prefix from bead ID (e.g., "gt-gastown-polecat-Toast" -> "gt-")
prefix := ExtractPrefix(beadID)
if prefix == "" {
return fallbackDir
}
// Look up rig path for this prefix
rigPath := GetRigPathForPrefix(townRoot, prefix)
if rigPath == "" {
return fallbackDir
}
// Resolve redirects and get final beads directory
beadsDir := ResolveBeadsDir(rigPath)
if beadsDir == "" {
return fallbackDir
}
return beadsDir
}
// EnsureCustomTypes ensures the target beads directory has custom types configured.
// Uses a two-level caching strategy:
// - In-memory cache for multiple creates in the same CLI invocation
// - Sentinel file on disk for persistence across CLI invocations
//
// This function is thread-safe and idempotent.
func EnsureCustomTypes(beadsDir string) error {
if beadsDir == "" {
return fmt.Errorf("empty beads directory")
}
ensuredMu.Lock()
defer ensuredMu.Unlock()
// Fast path: in-memory cache (same CLI invocation)
if ensuredDirs[beadsDir] {
return nil
}
// Fast path: sentinel file exists (previous CLI invocation)
sentinelPath := filepath.Join(beadsDir, typesSentinel)
if _, err := os.Stat(sentinelPath); err == nil {
ensuredDirs[beadsDir] = true
return nil
}
// Verify beads directory exists
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
return fmt.Errorf("beads directory does not exist: %s", beadsDir)
}
// Configure custom types via bd CLI
typesList := strings.Join(constants.BeadsCustomTypesList(), ",")
cmd := exec.Command("bd", "config", "set", "types.custom", typesList)
cmd.Dir = beadsDir
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("configure custom types in %s: %s: %w",
beadsDir, strings.TrimSpace(string(output)), err)
}
// Write sentinel file (best effort - don't fail if this fails)
// The sentinel contains a version marker for future compatibility
_ = os.WriteFile(sentinelPath, []byte("v1\n"), 0644)
ensuredDirs[beadsDir] = true
return nil
}
// ResetEnsuredDirs clears the in-memory cache of ensured directories.
// This is primarily useful for testing.
func ResetEnsuredDirs() {
ensuredMu.Lock()
defer ensuredMu.Unlock()
ensuredDirs = make(map[string]bool)
}

View File

@@ -0,0 +1,234 @@
package beads
import (
"os"
"path/filepath"
"testing"
)
func TestFindTownRoot(t *testing.T) {
// Create a temporary town structure
tmpDir := t.TempDir()
mayorDir := filepath.Join(tmpDir, "mayor")
if err := os.MkdirAll(mayorDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mayorDir, "town.json"), []byte("{}"), 0644); err != nil {
t.Fatal(err)
}
// Create nested directories
deepDir := filepath.Join(tmpDir, "rig1", "crew", "worker1")
if err := os.MkdirAll(deepDir, 0755); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
startDir string
expected string
}{
{"from town root", tmpDir, tmpDir},
{"from mayor dir", mayorDir, tmpDir},
{"from deep nested dir", deepDir, tmpDir},
{"from non-town dir", t.TempDir(), ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := FindTownRoot(tc.startDir)
if result != tc.expected {
t.Errorf("FindTownRoot(%q) = %q, want %q", tc.startDir, result, tc.expected)
}
})
}
}
func TestResolveRoutingTarget(t *testing.T) {
// Create a temporary town with routes
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
// Create mayor/town.json for FindTownRoot
mayorDir := filepath.Join(tmpDir, "mayor")
if err := os.MkdirAll(mayorDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mayorDir, "town.json"), []byte("{}"), 0644); err != nil {
t.Fatal(err)
}
// Create routes.jsonl
routesContent := `{"prefix": "gt-", "path": "gastown/mayor/rig"}
{"prefix": "hq-", "path": "."}
`
if err := os.WriteFile(filepath.Join(beadsDir, "routes.jsonl"), []byte(routesContent), 0644); err != nil {
t.Fatal(err)
}
// Create the rig beads directory
rigBeadsDir := filepath.Join(tmpDir, "gastown", "mayor", "rig", ".beads")
if err := os.MkdirAll(rigBeadsDir, 0755); err != nil {
t.Fatal(err)
}
fallback := "/fallback/.beads"
tests := []struct {
name string
townRoot string
beadID string
expected string
}{
{
name: "rig-level bead routes to rig",
townRoot: tmpDir,
beadID: "gt-gastown-polecat-Toast",
expected: rigBeadsDir,
},
{
name: "town-level bead routes to town",
townRoot: tmpDir,
beadID: "hq-mayor",
expected: beadsDir,
},
{
name: "unknown prefix falls back",
townRoot: tmpDir,
beadID: "xx-unknown",
expected: fallback,
},
{
name: "empty townRoot falls back",
townRoot: "",
beadID: "gt-gastown-polecat-Toast",
expected: fallback,
},
{
name: "no prefix falls back",
townRoot: tmpDir,
beadID: "noprefixid",
expected: fallback,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := ResolveRoutingTarget(tc.townRoot, tc.beadID, fallback)
if result != tc.expected {
t.Errorf("ResolveRoutingTarget(%q, %q, %q) = %q, want %q",
tc.townRoot, tc.beadID, fallback, result, tc.expected)
}
})
}
}
func TestEnsureCustomTypes(t *testing.T) {
// Reset the in-memory cache before testing
ResetEnsuredDirs()
t.Run("empty beads dir returns error", func(t *testing.T) {
err := EnsureCustomTypes("")
if err == nil {
t.Error("expected error for empty beads dir")
}
})
t.Run("non-existent beads dir returns error", func(t *testing.T) {
err := EnsureCustomTypes("/nonexistent/path/.beads")
if err == nil {
t.Error("expected error for non-existent beads dir")
}
})
t.Run("sentinel file triggers cache hit", func(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
// Create sentinel file
sentinelPath := filepath.Join(beadsDir, typesSentinel)
if err := os.WriteFile(sentinelPath, []byte("v1\n"), 0644); err != nil {
t.Fatal(err)
}
// Reset cache to ensure we're testing sentinel detection
ResetEnsuredDirs()
// This should succeed without running bd (sentinel exists)
err := EnsureCustomTypes(beadsDir)
if err != nil {
t.Errorf("expected success with sentinel file, got: %v", err)
}
})
t.Run("in-memory cache prevents repeated calls", func(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
// Create sentinel to avoid bd call
sentinelPath := filepath.Join(beadsDir, typesSentinel)
if err := os.WriteFile(sentinelPath, []byte("v1\n"), 0644); err != nil {
t.Fatal(err)
}
ResetEnsuredDirs()
// First call
if err := EnsureCustomTypes(beadsDir); err != nil {
t.Fatal(err)
}
// Remove sentinel - second call should still succeed due to in-memory cache
os.Remove(sentinelPath)
if err := EnsureCustomTypes(beadsDir); err != nil {
t.Errorf("expected cache hit, got: %v", err)
}
})
}
func TestBeads_getTownRoot(t *testing.T) {
// Create a temporary town
tmpDir := t.TempDir()
mayorDir := filepath.Join(tmpDir, "mayor")
if err := os.MkdirAll(mayorDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mayorDir, "town.json"), []byte("{}"), 0644); err != nil {
t.Fatal(err)
}
// Create nested directory
rigDir := filepath.Join(tmpDir, "myrig", "mayor", "rig")
if err := os.MkdirAll(rigDir, 0755); err != nil {
t.Fatal(err)
}
b := New(rigDir)
// First call should find town root
root1 := b.getTownRoot()
if root1 != tmpDir {
t.Errorf("first getTownRoot() = %q, want %q", root1, tmpDir)
}
// Second call should return cached value
root2 := b.getTownRoot()
if root2 != root1 {
t.Errorf("second getTownRoot() = %q, want cached %q", root2, root1)
}
// Verify searchedRoot flag is set
if !b.searchedRoot {
t.Error("expected searchedRoot to be true after getTownRoot()")
}
}

View File

@@ -38,7 +38,7 @@
"hooks": [
{
"type": "command",
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime && gt mail check --inject && gt nudge deacon session-started"
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime --hook && gt mail check --inject && gt nudge deacon session-started"
}
]
}
@@ -49,7 +49,7 @@
"hooks": [
{
"type": "command",
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime"
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime --hook"
}
]
}

View File

@@ -38,7 +38,7 @@
"hooks": [
{
"type": "command",
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime && gt nudge deacon session-started"
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime --hook && gt nudge deacon session-started"
}
]
}
@@ -49,7 +49,7 @@
"hooks": [
{
"type": "command",
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime"
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime --hook"
}
]
}

View File

@@ -138,6 +138,11 @@ func runGitInit(cmd *cobra.Command, args []string) error {
fmt.Printf(" ✓ Git repository already exists\n")
}
// Install pre-checkout hook to prevent accidental branch switches
if err := InstallPreCheckoutHook(hqRoot); err != nil {
fmt.Printf(" %s Could not install pre-checkout hook: %v\n", style.Dim.Render("⚠"), err)
}
// Create GitHub repo if requested
if gitInitGitHub != "" {
if err := createGitHubRepo(hqRoot, gitInitGitHub, !gitInitPublic); err != nil {

View File

@@ -221,6 +221,30 @@ func runInstall(cmd *cobra.Command, args []string) error {
fmt.Printf(" ✓ Created deacon/.claude/settings.json\n")
}
// Create boot directory (deacon/dogs/boot/) for Boot watchdog.
// This avoids gt doctor warning on fresh install.
bootDir := filepath.Join(deaconDir, "dogs", "boot")
if err := os.MkdirAll(bootDir, 0755); err != nil {
fmt.Printf(" %s Could not create boot directory: %v\n", style.Dim.Render("⚠"), err)
}
// Create plugins directory for town-level patrol plugins.
// This avoids gt doctor warning on fresh install.
pluginsDir := filepath.Join(absPath, "plugins")
if err := os.MkdirAll(pluginsDir, 0755); err != nil {
fmt.Printf(" %s Could not create plugins directory: %v\n", style.Dim.Render("⚠"), err)
} else {
fmt.Printf(" ✓ Created plugins/\n")
}
// Create daemon.json patrol config.
// This avoids gt doctor warning on fresh install.
if err := config.EnsureDaemonPatrolConfig(absPath); err != nil {
fmt.Printf(" %s Could not create daemon.json: %v\n", style.Dim.Render("⚠"), err)
} else {
fmt.Printf(" ✓ Created mayor/daemon.json\n")
}
// Initialize git BEFORE beads so that bd can compute repository fingerprint.
// The fingerprint is required for the daemon to start properly.
if installGit || installGitHub != "" {
@@ -234,6 +258,12 @@ func runInstall(cmd *cobra.Command, args []string) error {
// Town beads (hq- prefix) stores mayor mail, cross-rig coordination, and handoffs.
// Rig beads are separate and have their own prefixes.
if !installNoBeads {
// Kill any orphaned bd daemons before initializing beads.
// Stale daemons can interfere with fresh database creation.
if killed, _, _ := beads.StopAllBdProcesses(false, true); killed > 0 {
fmt.Printf(" ✓ Stopped %d orphaned bd daemon(s)\n", killed)
}
if err := initTownBeads(absPath); err != nil {
fmt.Printf(" %s Could not initialize town beads: %v\n", style.Dim.Render("⚠"), err)
} else {
@@ -369,6 +399,19 @@ func initTownBeads(townPath string) error {
}
}
// Verify .beads directory was actually created (bd init can exit 0 without creating it)
beadsDir := filepath.Join(townPath, ".beads")
if _, statErr := os.Stat(beadsDir); os.IsNotExist(statErr) {
return fmt.Errorf("bd init succeeded but .beads directory not created (check bd daemon interference)")
}
// Explicitly set issue_prefix config (bd init --prefix may not persist it in newer versions).
prefixSetCmd := exec.Command("bd", "config", "set", "issue_prefix", "hq")
prefixSetCmd.Dir = townPath
if prefixOutput, prefixErr := prefixSetCmd.CombinedOutput(); prefixErr != nil {
return fmt.Errorf("bd config set issue_prefix failed: %s", strings.TrimSpace(string(prefixOutput)))
}
// Configure custom types for Gas Town (agent, role, rig, convoy, slot).
// These were extracted from beads core in v0.46.0 and now require explicit config.
configCmd := exec.Command("bd", "config", "set", "types.custom", constants.BeadsCustomTypes)
@@ -468,7 +511,7 @@ func initTownAgentBeads(townPath string) error {
// bd init doesn't enable "custom" issue types by default, but Gas Town uses
// agent 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", "rig", "convoy", "slot"}); err != nil {
if err := ensureBeadsCustomTypes(townPath, constants.BeadsCustomTypesList()); err != nil {
return err
}

View File

@@ -11,20 +11,37 @@ import (
// BootHealthCheck verifies Boot watchdog health.
// "The vet checks on the dog."
type BootHealthCheck struct {
BaseCheck
FixableCheck
missingDir bool // track if directory is missing for Fix()
}
// NewBootHealthCheck creates a new Boot health check.
func NewBootHealthCheck() *BootHealthCheck {
return &BootHealthCheck{
BaseCheck: BaseCheck{
CheckName: "boot-health",
CheckDescription: "Check Boot watchdog health (the vet checks on the dog)",
CheckCategory: CategoryInfrastructure,
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "boot-health",
CheckDescription: "Check Boot watchdog health (the vet checks on the dog)",
CheckCategory: CategoryInfrastructure,
},
},
}
}
// CanFix returns true only if the directory is missing (we can create it).
func (c *BootHealthCheck) CanFix() bool {
return c.missingDir
}
// Fix creates the missing boot directory.
func (c *BootHealthCheck) Fix(ctx *CheckContext) error {
if !c.missingDir {
return nil
}
b := boot.New(ctx.TownRoot)
return b.EnsureDir()
}
// Run checks Boot health: directory, session, status, and marker freshness.
func (c *BootHealthCheck) Run(ctx *CheckContext) *CheckResult {
b := boot.New(ctx.TownRoot)
@@ -33,12 +50,13 @@ func (c *BootHealthCheck) Run(ctx *CheckContext) *CheckResult {
// Check 1: Boot directory exists
bootDir := b.Dir()
if _, err := os.Stat(bootDir); os.IsNotExist(err) {
c.missingDir = true
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: "Boot directory not present",
Details: []string{fmt.Sprintf("Expected: %s", bootDir)},
FixHint: "Boot directory is created on first daemon run",
FixHint: "Run 'gt doctor --fix' to create it",
}
}

View File

@@ -458,11 +458,20 @@ func (c *PatrolRolesHavePromptsCheck) Run(ctx *CheckContext) *CheckResult {
}
var missingPrompts []string
rigsChecked := 0
for _, rigName := range rigs {
// Check in mayor's clone (canonical for the rig)
mayorRig := filepath.Join(ctx.TownRoot, rigName, "mayor", "rig")
templatesDir := filepath.Join(mayorRig, "internal", "templates", "roles")
// Skip rigs that don't have internal/templates structure.
// Most repos won't have this - templates are embedded in gastown binary.
// Only check rigs that explicitly have their own template overrides.
if _, err := os.Stat(filepath.Join(mayorRig, "internal", "templates")); os.IsNotExist(err) {
continue
}
rigsChecked++
var rigMissing []string
for _, roleFile := range requiredRolePrompts {
promptPath := filepath.Join(templatesDir, roleFile)
@@ -476,13 +485,21 @@ func (c *PatrolRolesHavePromptsCheck) Run(ctx *CheckContext) *CheckResult {
}
}
// Templates are embedded in gastown binary - missing files in rig repos is normal.
// Only report as informational, not a warning.
if rigsChecked == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "Using embedded role templates (no custom overrides)",
}
}
if len(missingPrompts) > 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: fmt.Sprintf("%d role prompt template(s) missing", len(missingPrompts)),
Details: missingPrompts,
FixHint: "Run 'gt doctor --fix' to copy embedded templates to rig repos",
Status: StatusOK,
Message: fmt.Sprintf("%d rig(s) using embedded templates for some roles", len(c.missingByRig)),
}
}

View File

@@ -116,13 +116,35 @@ func (g *Git) wrapError(err error, stdout, stderr string, args []string) error {
// Clone clones a repository to the destination.
func (g *Git) Clone(url, dest string) error {
cmd := exec.Command("git", "clone", url, dest)
// Ensure destination directory's parent exists
destParent := filepath.Dir(dest)
if err := os.MkdirAll(destParent, 0755); err != nil {
return fmt.Errorf("creating destination parent: %w", err)
}
// Run clone from a temporary directory to completely isolate from any
// git repo at the process cwd. Then move the result to the destination.
tmpDir, err := os.MkdirTemp("", "gt-clone-*")
if err != nil {
return fmt.Errorf("creating temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
tmpDest := filepath.Join(tmpDir, filepath.Base(dest))
cmd := exec.Command("git", "clone", url, tmpDest)
cmd.Dir = tmpDir
cmd.Env = append(os.Environ(), "GIT_CEILING_DIRECTORIES="+tmpDir)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return g.wrapError(err, stdout.String(), stderr.String(), []string{"clone", url})
}
// Move to final destination
if err := os.Rename(tmpDest, dest); err != nil {
return fmt.Errorf("moving clone to destination: %w", err)
}
// Configure hooks path for Gas Town clones
if err := configureHooksPath(dest); err != nil {
return err
@@ -134,13 +156,35 @@ func (g *Git) Clone(url, dest string) error {
// CloneWithReference clones a repository using a local repo as an object reference.
// This saves disk by sharing objects without changing remotes.
func (g *Git) CloneWithReference(url, dest, reference string) error {
cmd := exec.Command("git", "clone", "--reference-if-able", reference, url, dest)
// Ensure destination directory's parent exists
destParent := filepath.Dir(dest)
if err := os.MkdirAll(destParent, 0755); err != nil {
return fmt.Errorf("creating destination parent: %w", err)
}
// Run clone from a temporary directory to completely isolate from any
// git repo at the process cwd. Then move the result to the destination.
tmpDir, err := os.MkdirTemp("", "gt-clone-*")
if err != nil {
return fmt.Errorf("creating temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
tmpDest := filepath.Join(tmpDir, filepath.Base(dest))
cmd := exec.Command("git", "clone", "--reference-if-able", reference, url, tmpDest)
cmd.Dir = tmpDir
cmd.Env = append(os.Environ(), "GIT_CEILING_DIRECTORIES="+tmpDir)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return g.wrapError(err, stdout.String(), stderr.String(), []string{"clone", "--reference-if-able", url})
}
// Move to final destination
if err := os.Rename(tmpDest, dest); err != nil {
return fmt.Errorf("moving clone to destination: %w", err)
}
// Configure hooks path for Gas Town clones
if err := configureHooksPath(dest); err != nil {
return err
@@ -152,13 +196,35 @@ func (g *Git) CloneWithReference(url, dest, reference string) error {
// CloneBare clones a repository as a bare repo (no working directory).
// This is used for the shared repo architecture where all worktrees share a single git database.
func (g *Git) CloneBare(url, dest string) error {
cmd := exec.Command("git", "clone", "--bare", url, dest)
// Ensure destination directory's parent exists
destParent := filepath.Dir(dest)
if err := os.MkdirAll(destParent, 0755); err != nil {
return fmt.Errorf("creating destination parent: %w", err)
}
// Run clone from a temporary directory to completely isolate from any
// git repo at the process cwd. Then move the result to the destination.
tmpDir, err := os.MkdirTemp("", "gt-clone-*")
if err != nil {
return fmt.Errorf("creating temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
tmpDest := filepath.Join(tmpDir, filepath.Base(dest))
cmd := exec.Command("git", "clone", "--bare", url, tmpDest)
cmd.Dir = tmpDir
cmd.Env = append(os.Environ(), "GIT_CEILING_DIRECTORIES="+tmpDir)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return g.wrapError(err, stdout.String(), stderr.String(), []string{"clone", "--bare", url})
}
// Move to final destination
if err := os.Rename(tmpDest, dest); err != nil {
return fmt.Errorf("moving clone to destination: %w", err)
}
// Configure refspec so worktrees can fetch and see origin/* refs
return configureRefspec(dest)
}
@@ -212,13 +278,35 @@ func configureRefspec(repoPath string) error {
// CloneBareWithReference clones a bare repository using a local repo as an object reference.
func (g *Git) CloneBareWithReference(url, dest, reference string) error {
cmd := exec.Command("git", "clone", "--bare", "--reference-if-able", reference, url, dest)
// Ensure destination directory's parent exists
destParent := filepath.Dir(dest)
if err := os.MkdirAll(destParent, 0755); err != nil {
return fmt.Errorf("creating destination parent: %w", err)
}
// Run clone from a temporary directory to completely isolate from any
// git repo at the process cwd. Then move the result to the destination.
tmpDir, err := os.MkdirTemp("", "gt-clone-*")
if err != nil {
return fmt.Errorf("creating temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
tmpDest := filepath.Join(tmpDir, filepath.Base(dest))
cmd := exec.Command("git", "clone", "--bare", "--reference-if-able", reference, url, tmpDest)
cmd.Dir = tmpDir
cmd.Env = append(os.Environ(), "GIT_CEILING_DIRECTORIES="+tmpDir)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return g.wrapError(err, stdout.String(), stderr.String(), []string{"clone", "--bare", "--reference-if-able", url})
}
// Move to final destination
if err := os.Rename(tmpDest, dest); err != nil {
return fmt.Errorf("moving clone to destination: %w", err)
}
// Configure refspec so worktrees can fetch and see origin/* refs
return configureRefspec(dest)
}