Merge branch 'fix/multi-repo-routing-pr811-v2'
This commit is contained in:
5
internal/.events.jsonl
Normal file
5
internal/.events.jsonl
Normal 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"}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
129
internal/beads/beads_types.go
Normal file
129
internal/beads/beads_types.go
Normal 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)
|
||||
}
|
||||
234
internal/beads/beads_types_test.go
Normal file
234
internal/beads/beads_types_test.go
Normal 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()")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user