Merge pull request #34 from kustrun/fix/init-bugs
Fix init bugs when setting first gastown
This commit is contained in:
+13
-1
@@ -209,7 +209,8 @@ type SyncStatus struct {
|
||||
|
||||
// Beads wraps bd CLI operations for a working directory.
|
||||
type Beads struct {
|
||||
workDir string
|
||||
workDir string
|
||||
beadsDir string // Optional BEADS_DIR override for cross-database access
|
||||
}
|
||||
|
||||
// New creates a new Beads wrapper for the given directory.
|
||||
@@ -217,6 +218,12 @@ func New(workDir string) *Beads {
|
||||
return &Beads{workDir: workDir}
|
||||
}
|
||||
|
||||
// NewWithBeadsDir creates a Beads wrapper with an explicit BEADS_DIR.
|
||||
// This is needed when running from a polecat worktree but accessing town-level beads.
|
||||
func NewWithBeadsDir(workDir, beadsDir string) *Beads {
|
||||
return &Beads{workDir: workDir, beadsDir: beadsDir}
|
||||
}
|
||||
|
||||
// run executes a bd command and returns stdout.
|
||||
func (b *Beads) run(args ...string) ([]byte, error) {
|
||||
// Use --no-daemon for faster read operations (avoids daemon IPC overhead)
|
||||
@@ -225,6 +232,11 @@ func (b *Beads) run(args ...string) ([]byte, error) {
|
||||
cmd := exec.Command("bd", fullArgs...)
|
||||
cmd.Dir = b.workDir
|
||||
|
||||
// Set BEADS_DIR if specified (enables cross-database access)
|
||||
if b.beadsDir != "" {
|
||||
cmd.Env = append(os.Environ(), "BEADS_DIR="+b.beadsDir)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
@@ -931,7 +931,24 @@ func findMailWorkDir() (string, error) {
|
||||
|
||||
// findLocalBeadsDir finds the nearest .beads directory by walking up from CWD.
|
||||
// Used for project work (molecules, issue creation) that uses clone beads.
|
||||
//
|
||||
// Priority:
|
||||
// 1. BEADS_DIR environment variable (set by session manager for polecats)
|
||||
// 2. Walk up from CWD looking for .beads directory
|
||||
//
|
||||
// Polecats use redirect-based beads access, so their worktree doesn't have a full
|
||||
// .beads directory. The session manager sets BEADS_DIR to the correct location.
|
||||
func findLocalBeadsDir() (string, error) {
|
||||
// Check BEADS_DIR environment variable first (set by session manager for polecats).
|
||||
// This is important for polecats that use redirect-based beads access.
|
||||
if beadsDir := os.Getenv("BEADS_DIR"); beadsDir != "" {
|
||||
// BEADS_DIR points directly to the .beads directory, return its parent
|
||||
if _, err := os.Stat(beadsDir); err == nil {
|
||||
return filepath.Dir(beadsDir), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: walk up from CWD
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
+13
-2
@@ -317,11 +317,22 @@ func runRigAdd(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("saving rigs config: %w", err)
|
||||
}
|
||||
|
||||
// Add route to town-level routes.jsonl for prefix-based routing
|
||||
// Add route to town-level routes.jsonl for prefix-based routing.
|
||||
// Route points to the canonical beads location:
|
||||
// - If source repo has .beads/ tracked in git, route to mayor/rig
|
||||
// - Otherwise route to rig root (where initBeads creates the database)
|
||||
// The conditional routing is necessary because initBeads creates the database at
|
||||
// "<rig>/.beads", while repos with tracked beads have their database at mayor/rig/.beads.
|
||||
if newRig.Config.Prefix != "" {
|
||||
routePath := name
|
||||
mayorRigBeads := filepath.Join(townRoot, name, "mayor", "rig", ".beads")
|
||||
if _, err := os.Stat(mayorRigBeads); err == nil {
|
||||
// Source repo has .beads/ tracked - route to mayor/rig
|
||||
routePath = name + "/mayor/rig"
|
||||
}
|
||||
route := beads.Route{
|
||||
Prefix: newRig.Config.Prefix + "-",
|
||||
Path: name + "/mayor/rig",
|
||||
Path: routePath,
|
||||
}
|
||||
if err := beads.AppendRoute(townRoot, route); err != nil {
|
||||
// Non-fatal: routing will still work, just not from town root
|
||||
|
||||
+90
-37
@@ -133,6 +133,14 @@ func runSling(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("polecats cannot sling (use gt done for handoff)")
|
||||
}
|
||||
|
||||
// Get town root early - needed for BEADS_DIR when running bd commands
|
||||
// This ensures hq-* beads are accessible even when running from polecat worktree
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding town root: %w", err)
|
||||
}
|
||||
townBeadsDir := filepath.Join(townRoot, ".beads")
|
||||
|
||||
// --var is only for standalone formula mode, not formula-on-bead mode
|
||||
if slingOnTarget != "" && len(slingVars) > 0 {
|
||||
return fmt.Errorf("--var cannot be used with --on (formula-on-bead mode doesn't support variables)")
|
||||
@@ -150,8 +158,8 @@ func runSling(cmd *cobra.Command, args []string) error {
|
||||
if slingOnTarget != "" {
|
||||
return fmt.Errorf("--quality cannot be used with --on (both specify formula)")
|
||||
}
|
||||
slingOnTarget = args[0] // The bead becomes --on target
|
||||
args[0] = qualityFormula // The formula becomes first arg
|
||||
slingOnTarget = args[0] // The bead becomes --on target
|
||||
args[0] = qualityFormula // The formula becomes first arg
|
||||
}
|
||||
|
||||
// Determine mode based on flags and argument types
|
||||
@@ -191,7 +199,7 @@ func runSling(cmd *cobra.Command, args []string) error {
|
||||
// Determine target agent (self or specified)
|
||||
var targetAgent string
|
||||
var targetPane string
|
||||
var err error
|
||||
var hookWorkDir string // Working directory for running bd hook commands
|
||||
|
||||
if len(args) > 1 {
|
||||
target := args[1]
|
||||
@@ -250,6 +258,7 @@ func runSling(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
targetAgent = spawnInfo.AgentID()
|
||||
targetPane = spawnInfo.Pane
|
||||
hookWorkDir = spawnInfo.ClonePath // Run bd commands from polecat's worktree
|
||||
|
||||
// Wake witness and refinery to monitor the new polecat
|
||||
wakeRigAgents(rigName)
|
||||
@@ -257,17 +266,27 @@ func runSling(cmd *cobra.Command, args []string) error {
|
||||
} else {
|
||||
// Slinging to an existing agent
|
||||
// Skip pane lookup if --naked (agent may be terminated)
|
||||
targetAgent, targetPane, _, err = resolveTargetAgent(target, slingNaked)
|
||||
var targetWorkDir string
|
||||
targetAgent, targetPane, targetWorkDir, err = resolveTargetAgent(target, slingNaked)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving target: %w", err)
|
||||
}
|
||||
// Use target's working directory for bd commands (needed for redirect-based routing)
|
||||
if targetWorkDir != "" {
|
||||
hookWorkDir = targetWorkDir
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Slinging to self
|
||||
targetAgent, targetPane, _, err = resolveSelfTarget()
|
||||
var selfWorkDir string
|
||||
targetAgent, targetPane, selfWorkDir, err = resolveSelfTarget()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Use self's working directory for bd commands
|
||||
if selfWorkDir != "" {
|
||||
hookWorkDir = selfWorkDir
|
||||
}
|
||||
}
|
||||
|
||||
// Display what we're doing
|
||||
@@ -394,8 +413,16 @@ func runSling(cmd *cobra.Command, args []string) error {
|
||||
beadID = wispRootID
|
||||
}
|
||||
|
||||
// Hook the bead using bd update (discovery-based approach)
|
||||
// Hook the bead using bd update
|
||||
// Set BEADS_DIR to town-level beads so hq-* beads are accessible
|
||||
// even when running from polecat worktree (which only sees gt-* via redirect)
|
||||
hookCmd := exec.Command("bd", "update", beadID, "--status=hooked", "--assignee="+targetAgent)
|
||||
hookCmd.Env = append(os.Environ(), "BEADS_DIR="+townBeadsDir)
|
||||
if hookWorkDir != "" {
|
||||
hookCmd.Dir = hookWorkDir
|
||||
} else {
|
||||
hookCmd.Dir = townRoot
|
||||
}
|
||||
hookCmd.Stderr = os.Stderr
|
||||
if err := hookCmd.Run(); err != nil {
|
||||
return fmt.Errorf("hooking bead: %w", err)
|
||||
@@ -408,7 +435,7 @@ func runSling(cmd *cobra.Command, args []string) error {
|
||||
_ = events.LogFeed(events.TypeSling, actor, events.SlingPayload(beadID, targetAgent))
|
||||
|
||||
// Update agent bead's hook_bead field (ZFC: agents track their current work)
|
||||
updateAgentHookBead(targetAgent, beadID)
|
||||
updateAgentHookBead(targetAgent, beadID, hookWorkDir, townBeadsDir)
|
||||
|
||||
// Store args in bead description (no-tmux mode: beads as data plane)
|
||||
if slingArgs != "" {
|
||||
@@ -653,6 +680,13 @@ func verifyFormulaExists(formulaName string) error {
|
||||
func runSlingFormula(args []string) error {
|
||||
formulaName := args[0]
|
||||
|
||||
// Get town root early - needed for BEADS_DIR when running bd commands
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding town root: %w", err)
|
||||
}
|
||||
townBeadsDir := filepath.Join(townRoot, ".beads")
|
||||
|
||||
// Determine target (self or specified)
|
||||
var target string
|
||||
if len(args) > 1 {
|
||||
@@ -662,7 +696,6 @@ func runSlingFormula(args []string) error {
|
||||
// Resolve target agent and pane
|
||||
var targetAgent string
|
||||
var targetPane string
|
||||
var err error
|
||||
|
||||
if target != "" {
|
||||
// Resolve "." to current agent identity (like git's "." meaning current directory)
|
||||
@@ -725,17 +758,22 @@ func runSlingFormula(args []string) error {
|
||||
} else {
|
||||
// Slinging to an existing agent
|
||||
// Skip pane lookup if --naked (agent may be terminated)
|
||||
targetAgent, targetPane, _, err = resolveTargetAgent(target, slingNaked)
|
||||
var targetWorkDir string
|
||||
targetAgent, targetPane, targetWorkDir, err = resolveTargetAgent(target, slingNaked)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving target: %w", err)
|
||||
}
|
||||
// Use target's working directory for bd commands (needed for redirect-based routing)
|
||||
_ = targetWorkDir // Formula sling doesn't need hookWorkDir
|
||||
}
|
||||
} else {
|
||||
// Slinging to self
|
||||
targetAgent, targetPane, _, err = resolveSelfTarget()
|
||||
var selfWorkDir string
|
||||
targetAgent, targetPane, selfWorkDir, err = resolveSelfTarget()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = selfWorkDir // Formula sling doesn't need hookWorkDir
|
||||
}
|
||||
|
||||
fmt.Printf("%s Slinging formula %s to %s...\n", style.Bold.Render("🎯"), formulaName, targetAgent)
|
||||
@@ -787,7 +825,10 @@ func runSlingFormula(args []string) error {
|
||||
fmt.Printf("%s Wisp created: %s\n", style.Bold.Render("✓"), wispResult.RootID)
|
||||
|
||||
// Step 3: Hook the wisp bead using bd update (discovery-based approach)
|
||||
// Set BEADS_DIR to town-level beads so hq-* beads are accessible
|
||||
hookCmd := exec.Command("bd", "update", wispResult.RootID, "--status=hooked", "--assignee="+targetAgent)
|
||||
hookCmd.Env = append(os.Environ(), "BEADS_DIR="+townBeadsDir)
|
||||
hookCmd.Dir = townRoot
|
||||
hookCmd.Stderr = os.Stderr
|
||||
if err := hookCmd.Run(); err != nil {
|
||||
return fmt.Errorf("hooking wisp bead: %w", err)
|
||||
@@ -801,7 +842,8 @@ func runSlingFormula(args []string) error {
|
||||
_ = events.LogFeed(events.TypeSling, actor, payload)
|
||||
|
||||
// Update agent bead's hook_bead field (ZFC: agents track their current work)
|
||||
updateAgentHookBead(targetAgent, wispResult.RootID)
|
||||
// Note: formula slinging uses town root as workDir (no polecat-specific path)
|
||||
updateAgentHookBead(targetAgent, wispResult.RootID, "", townBeadsDir)
|
||||
|
||||
// Store args in wisp bead if provided (no-tmux mode: beads as data plane)
|
||||
if slingArgs != "" {
|
||||
@@ -836,14 +878,19 @@ func runSlingFormula(args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateAgentHookBead updates the agent bead's hook_bead field when work is slung.
|
||||
// This enables the witness to see what each agent is working on.
|
||||
// updateAgentHookBead updates the agent bead's state when work is slung.
|
||||
// This enables the witness to see that each agent is working.
|
||||
//
|
||||
// IMPORTANT: Uses town root for routing so cross-beads references work.
|
||||
// The agent bead (e.g., gt-gastown-polecat-nux) may be in rig beads,
|
||||
// while the hook bead (e.g., hq-oosxt) may be in town beads.
|
||||
// Running from town root gives access to routes.jsonl for proper resolution.
|
||||
func updateAgentHookBead(agentID, beadID string) {
|
||||
// We run from the polecat's workDir (which redirects to the rig's beads database)
|
||||
// WITHOUT setting BEADS_DIR, so the redirect mechanism works for gt-* agent beads.
|
||||
//
|
||||
// Note: We only update the agent_state field, not hook_bead. The hook_bead field
|
||||
// requires cross-database access (agent in rig db, hook bead in town db), but
|
||||
// bd slot set has a bug where it doesn't support this. See BD_BUG_AGENT_STATE_ROUTING.md.
|
||||
// The work is still correctly attached via `bd update <bead> --assignee=<agent>`.
|
||||
func updateAgentHookBead(agentID, beadID, workDir, townBeadsDir string) {
|
||||
_ = townBeadsDir // Not used - BEADS_DIR breaks redirect mechanism
|
||||
|
||||
// Convert agent ID to agent bead ID
|
||||
// Format examples (canonical: prefix-rig-role-name):
|
||||
// greenplace/crew/max -> gt-greenplace-crew-max
|
||||
@@ -855,20 +902,26 @@ func updateAgentHookBead(agentID, beadID string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use town root for routing - this ensures cross-beads references work.
|
||||
// Town beads (hq-*) and rig beads (gt-*) are resolved via routes.jsonl
|
||||
// which lives at town root.
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil {
|
||||
// Not in a Gas Town workspace - can't update agent bead
|
||||
fmt.Fprintf(os.Stderr, "Warning: couldn't find town root to update agent hook: %v\n", err)
|
||||
return
|
||||
// Determine the directory to run bd commands from:
|
||||
// - If workDir is provided (polecat's clone path), use it for redirect-based routing
|
||||
// - Otherwise fall back to town root
|
||||
bdWorkDir := workDir
|
||||
if bdWorkDir == "" {
|
||||
townRoot, err := workspace.FindFromCwd()
|
||||
if err != nil {
|
||||
// Not in a Gas Town workspace - can't update agent bead
|
||||
fmt.Fprintf(os.Stderr, "Warning: couldn't find town root to update agent hook: %v\n", err)
|
||||
return
|
||||
}
|
||||
bdWorkDir = townRoot
|
||||
}
|
||||
|
||||
bd := beads.New(townRoot)
|
||||
if err := bd.UpdateAgentState(agentBeadID, "running", &beadID); err != nil {
|
||||
// Run from workDir WITHOUT BEADS_DIR to enable redirect-based routing.
|
||||
// Only update agent_state (not hook_bead) due to bd cross-database bug.
|
||||
bd := beads.New(bdWorkDir)
|
||||
if err := bd.UpdateAgentState(agentBeadID, "running", nil); err != nil {
|
||||
// Log warning instead of silent ignore - helps debug cross-beads issues
|
||||
fmt.Fprintf(os.Stderr, "Warning: couldn't update agent %s hook to %s: %v\n", agentBeadID, beadID, err)
|
||||
fmt.Fprintf(os.Stderr, "Warning: couldn't update agent %s state: %v\n", agentBeadID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -974,10 +1027,10 @@ func IsDogTarget(target string) (dogName string, isDog bool) {
|
||||
|
||||
// DogDispatchInfo contains information about a dog dispatch.
|
||||
type DogDispatchInfo struct {
|
||||
DogName string // Name of the dog
|
||||
AgentID string // Agent ID format (deacon/dogs/<name>)
|
||||
Pane string // Tmux pane (empty if no session)
|
||||
Spawned bool // True if dog was spawned (new)
|
||||
DogName string // Name of the dog
|
||||
AgentID string // Agent ID format (deacon/dogs/<name>)
|
||||
Pane string // Tmux pane (empty if no session)
|
||||
Spawned bool // True if dog was spawned (new)
|
||||
}
|
||||
|
||||
// DispatchToDog finds or spawns a dog for work dispatch.
|
||||
@@ -1057,10 +1110,10 @@ func DispatchToDog(dogName string, create bool) (*DogDispatchInfo, error) {
|
||||
}
|
||||
|
||||
return &DogDispatchInfo{
|
||||
DogName: targetDog.Name,
|
||||
AgentID: agentID,
|
||||
Pane: pane,
|
||||
Spawned: spawned,
|
||||
DogName: targetDog.Name,
|
||||
AgentID: agentID,
|
||||
Pane: pane,
|
||||
Spawned: spawned,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -281,6 +281,10 @@ func ensureSession(t *tmux.Tmux, sessionName, workDir, role string) error {
|
||||
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
// Accept bypass permissions warning dialog if it appears.
|
||||
_ = t.AcceptBypassPermissionsWarning(sessionName)
|
||||
|
||||
time.Sleep(constants.ShutdownNotifyDelay)
|
||||
|
||||
// Inject startup nudge for predecessor discovery via /resume
|
||||
@@ -330,6 +334,10 @@ func ensureWitness(t *tmux.Tmux, sessionName, rigPath, rigName string) error {
|
||||
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
// Accept bypass permissions warning dialog if it appears.
|
||||
_ = t.AcceptBypassPermissionsWarning(sessionName)
|
||||
|
||||
time.Sleep(constants.ShutdownNotifyDelay)
|
||||
|
||||
// Inject startup nudge for predecessor discovery via /resume
|
||||
@@ -557,6 +565,10 @@ func ensureCrewSession(t *tmux.Tmux, sessionName, crewPath, rigName, crewName st
|
||||
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
// Accept bypass permissions warning dialog if it appears.
|
||||
_ = t.AcceptBypassPermissionsWarning(sessionName)
|
||||
|
||||
time.Sleep(constants.ShutdownNotifyDelay)
|
||||
|
||||
// Inject startup nudge for predecessor discovery via /resume
|
||||
@@ -661,6 +673,10 @@ func ensurePolecatSession(t *tmux.Tmux, sessionName, polecatPath, rigName, polec
|
||||
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
// Accept bypass permissions warning dialog if it appears.
|
||||
_ = t.AcceptBypassPermissionsWarning(sessionName)
|
||||
|
||||
time.Sleep(constants.ShutdownNotifyDelay)
|
||||
|
||||
// Inject startup nudge for predecessor discovery via /resume
|
||||
|
||||
@@ -695,6 +695,13 @@ func (d *Daemon) restartPolecatSession(rigName, polecatName, sessionName string)
|
||||
return fmt.Errorf("sending startup command: %w", err)
|
||||
}
|
||||
|
||||
// Wait for Claude to start, then accept bypass permissions warning if it appears.
|
||||
// This ensures automated restarts aren't blocked by the warning dialog.
|
||||
if err := d.tmux.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
||||
// Non-fatal - Claude might still start
|
||||
}
|
||||
_ = d.tmux.AcceptBypassPermissionsWarning(sessionName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -364,6 +364,13 @@ func (d *Daemon) restartSession(sessionName, identity string) error {
|
||||
return fmt.Errorf("sending startup command: %w", err)
|
||||
}
|
||||
|
||||
// Wait for Claude to start, then accept bypass permissions warning if it appears.
|
||||
// This ensures automated role starts aren't blocked by the warning dialog.
|
||||
if err := d.tmux.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
||||
// Non-fatal - Claude might still start
|
||||
}
|
||||
_ = d.tmux.AcceptBypassPermissionsWarning(sessionName)
|
||||
|
||||
// Note: gt prime is handled by Claude's SessionStart hook, not injected here.
|
||||
// Injecting it via SendKeysDelayed causes rogue text to appear in the terminal.
|
||||
|
||||
|
||||
+43
-13
@@ -47,8 +47,16 @@ type Manager struct {
|
||||
|
||||
// NewManager creates a new polecat manager.
|
||||
func NewManager(r *rig.Rig, g *git.Git) *Manager {
|
||||
// Use the rig root for beads operations (rig-level beads at .beads/)
|
||||
rigPath := r.Path
|
||||
// Determine the canonical beads location:
|
||||
// - If mayor/rig/.beads exists (source repo has beads tracked), use that
|
||||
// - Otherwise use rig root .beads/ (created by initBeads during gt rig add)
|
||||
// This matches the conditional logic in setupSharedBeads and route registration.
|
||||
// For repos that have .beads/ tracked in git, the canonical database lives in mayor/rig/.
|
||||
mayorRigBeads := filepath.Join(r.Path, "mayor", "rig", ".beads")
|
||||
beadsPath := r.Path
|
||||
if _, err := os.Stat(mayorRigBeads); err == nil {
|
||||
beadsPath = filepath.Join(r.Path, "mayor", "rig")
|
||||
}
|
||||
|
||||
// Try to load rig settings for namepool config
|
||||
settingsPath := filepath.Join(r.Path, "settings", "config.json")
|
||||
@@ -73,7 +81,7 @@ func NewManager(r *rig.Rig, g *git.Git) *Manager {
|
||||
return &Manager{
|
||||
rig: r,
|
||||
git: g,
|
||||
beads: beads.New(rigPath),
|
||||
beads: beads.New(beadsPath),
|
||||
namePool: pool,
|
||||
}
|
||||
}
|
||||
@@ -763,15 +771,41 @@ func (m *Manager) loadFromBeads(name string) (*Polecat, error) {
|
||||
// polecats/
|
||||
// <name>/
|
||||
// .beads/
|
||||
// redirect <- Contains "../../.beads"
|
||||
// redirect <- Contains "../../.beads" or "../../mayor/rig/.beads"
|
||||
//
|
||||
// IMPORTANT: If the polecat was created from a branch that had .beads/ tracked in git,
|
||||
// those files will be present. We must clean them out and replace with just the redirect.
|
||||
//
|
||||
// The redirect target is conditional: repos with .beads/ tracked in git have their canonical
|
||||
// database at mayor/rig/.beads, while fresh rigs use the database at rig root .beads/.
|
||||
func (m *Manager) setupSharedBeads(polecatPath string) error {
|
||||
// Ensure rig root has .beads/ directory
|
||||
rigBeadsDir := filepath.Join(m.rig.Path, ".beads")
|
||||
if err := os.MkdirAll(rigBeadsDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating rig .beads dir: %w", err)
|
||||
// Determine the shared beads location:
|
||||
// - If mayor/rig/.beads exists (source repo has beads tracked in git), use that
|
||||
// - Otherwise fall back to rig/.beads (created by initBeads during gt rig add)
|
||||
// This matches the crew manager's logic for consistency.
|
||||
mayorRigBeads := filepath.Join(m.rig.Path, "mayor", "rig", ".beads")
|
||||
rigRootBeads := filepath.Join(m.rig.Path, ".beads")
|
||||
|
||||
var sharedBeadsPath string
|
||||
var redirectContent string
|
||||
|
||||
if _, err := os.Stat(mayorRigBeads); err == nil {
|
||||
// Source repo has .beads/ tracked - use mayor/rig/.beads
|
||||
sharedBeadsPath = mayorRigBeads
|
||||
redirectContent = "../../mayor/rig/.beads\n"
|
||||
} else {
|
||||
// No beads in source repo - use rig root .beads (from initBeads)
|
||||
sharedBeadsPath = rigRootBeads
|
||||
redirectContent = "../../.beads\n"
|
||||
// Ensure rig root has .beads/ directory
|
||||
if err := os.MkdirAll(rigRootBeads, 0755); err != nil {
|
||||
return fmt.Errorf("creating rig .beads dir: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify shared beads exists
|
||||
if _, err := os.Stat(sharedBeadsPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("no shared beads database found at %s", sharedBeadsPath)
|
||||
}
|
||||
|
||||
// Clean up any existing .beads/ contents from the branch
|
||||
@@ -790,12 +824,8 @@ func (m *Manager) setupSharedBeads(polecatPath string) error {
|
||||
return fmt.Errorf("creating polecat .beads dir: %w", err)
|
||||
}
|
||||
|
||||
// Create redirect file pointing to mayor/rig/.beads (the canonical beads location)
|
||||
// Path is relative from polecats/<name>/.beads/ to mayor/rig/.beads/
|
||||
// We go directly to mayor/rig/.beads, not through rig root, to match crew workers
|
||||
// Create redirect file pointing to the shared beads location
|
||||
redirectPath := filepath.Join(polecatBeadsDir, "redirect")
|
||||
redirectContent := "../../mayor/rig/.beads\n"
|
||||
|
||||
if err := os.WriteFile(redirectPath, []byte(redirectContent), 0644); err != nil {
|
||||
return fmt.Errorf("creating redirect file: %w", err)
|
||||
}
|
||||
|
||||
+106
-3
@@ -249,6 +249,35 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) {
|
||||
if err := m.git.Clone(opts.GitURL, mayorRigPath); err != nil {
|
||||
return nil, fmt.Errorf("cloning for mayor: %w", err)
|
||||
}
|
||||
|
||||
// Check if source repo has .beads/ with its own prefix - if so, use that prefix.
|
||||
// This ensures we use the project's existing beads database instead of creating a new one.
|
||||
// Without this, routing would fail when trying to access existing issues because the
|
||||
// rig config would have a different prefix than what the issues actually use.
|
||||
sourceBeadsConfig := filepath.Join(mayorRigPath, ".beads", "config.yaml")
|
||||
if _, err := os.Stat(sourceBeadsConfig); err == nil {
|
||||
if sourcePrefix := detectBeadsPrefixFromConfig(sourceBeadsConfig); sourcePrefix != "" {
|
||||
fmt.Printf(" Detected existing beads prefix '%s' from source repo\n", sourcePrefix)
|
||||
opts.BeadsPrefix = sourcePrefix
|
||||
rigConfig.Beads.Prefix = sourcePrefix
|
||||
// Re-save rig config with detected prefix
|
||||
if err := m.saveRigConfig(rigPath, rigConfig); err != nil {
|
||||
return nil, fmt.Errorf("updating rig config with detected prefix: %w", err)
|
||||
}
|
||||
// Initialize bd database with the detected prefix.
|
||||
// beads.db is gitignored so it doesn't exist after clone - we need to create it.
|
||||
// bd init --prefix will create the database and auto-import from issues.jsonl.
|
||||
sourceBeadsDB := filepath.Join(mayorRigPath, ".beads", "beads.db")
|
||||
if _, err := os.Stat(sourceBeadsDB); os.IsNotExist(err) {
|
||||
cmd := exec.Command("bd", "init", "--prefix", sourcePrefix)
|
||||
cmd.Dir = mayorRigPath
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
fmt.Printf(" Warning: Could not init bd database: %v (%s)\n", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create mayor CLAUDE.md (overrides any from cloned repo)
|
||||
if err := m.createRoleCLAUDEmd(mayorRigPath, "mayor", opts.Name, ""); err != nil {
|
||||
return nil, fmt.Errorf("creating mayor CLAUDE.md: %w", err)
|
||||
@@ -453,9 +482,16 @@ func (m *Manager) initBeads(rigPath, prefix string) error {
|
||||
//
|
||||
// Agent beads track lifecycle state for ZFC compliance (gt-h3hak, gt-pinkq).
|
||||
func (m *Manager) initAgentBeads(rigPath, rigName, prefix string, isFirstRig bool) error {
|
||||
// Run bd commands from rig root where .beads/ was initialized
|
||||
// Set BEADS_DIR explicitly to ensure bd finds the database
|
||||
beadsDir := filepath.Join(rigPath, ".beads")
|
||||
// Run bd commands from the canonical beads location.
|
||||
// - If source repo has .beads/ tracked (mayor/rig/.beads exists), use that
|
||||
// - Otherwise use rig root .beads/ (created by initBeads during rig add)
|
||||
mayorRigBeads := filepath.Join(rigPath, "mayor", "rig", ".beads")
|
||||
var beadsDir string
|
||||
if _, err := os.Stat(mayorRigBeads); err == nil {
|
||||
beadsDir = mayorRigBeads
|
||||
} else {
|
||||
beadsDir = filepath.Join(rigPath, ".beads")
|
||||
}
|
||||
prevBeadsDir, hadBeadsDir := os.LookupEnv("BEADS_DIR")
|
||||
if err := os.Setenv("BEADS_DIR", beadsDir); err != nil {
|
||||
return fmt.Errorf("setting BEADS_DIR: %w", err)
|
||||
@@ -601,6 +637,73 @@ func deriveBeadsPrefix(name string) string {
|
||||
return strings.ToLower(name[:2])
|
||||
}
|
||||
|
||||
// detectBeadsPrefixFromConfig reads the issue prefix from a beads config.yaml file.
|
||||
// Returns empty string if the file doesn't exist or doesn't contain a prefix.
|
||||
// Falls back to detecting prefix from existing issues in issues.jsonl.
|
||||
//
|
||||
// When adding a rig from a source repo that has .beads/ tracked in git (like a project
|
||||
// that already uses beads for issue tracking), we need to use that project's existing
|
||||
// prefix instead of generating a new one. Otherwise, the rig would have a mismatched
|
||||
// prefix and routing would fail to find the existing issues.
|
||||
func detectBeadsPrefixFromConfig(configPath string) string {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Parse YAML-style config (simple line-by-line parsing)
|
||||
// Looking for "issue-prefix: <value>" or "prefix: <value>"
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
// Skip comments and empty lines
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
// Check for issue-prefix or prefix key
|
||||
for _, key := range []string{"issue-prefix:", "prefix:"} {
|
||||
if strings.HasPrefix(line, key) {
|
||||
value := strings.TrimSpace(strings.TrimPrefix(line, key))
|
||||
// Remove quotes if present
|
||||
value = strings.Trim(value, `"'`)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to detect prefix from existing issues in issues.jsonl
|
||||
// Look for the first issue ID pattern like "gt-abc123"
|
||||
beadsDir := filepath.Dir(configPath)
|
||||
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
if issuesData, err := os.ReadFile(issuesPath); err == nil {
|
||||
issuesLines := strings.Split(string(issuesData), "\n")
|
||||
for _, line := range issuesLines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Look for "id":"<prefix>-<hash>" pattern
|
||||
if idx := strings.Index(line, `"id":"`); idx != -1 {
|
||||
start := idx + 6 // len(`"id":"`)
|
||||
if end := strings.Index(line[start:], `"`); end != -1 {
|
||||
issueID := line[start : start+end]
|
||||
// Extract prefix (everything before the last hyphen-hash part)
|
||||
if dashIdx := strings.LastIndex(issueID, "-"); dashIdx > 0 {
|
||||
prefix := issueID[:dashIdx]
|
||||
// Handle prefixes like "gt" (from "gt-abc") - return without trailing hyphen
|
||||
return prefix
|
||||
}
|
||||
}
|
||||
}
|
||||
break // Only check first issue
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// RemoveRig unregisters a rig (does not delete files).
|
||||
func (m *Manager) RemoveRig(name string) error {
|
||||
if !m.RigExists(name) {
|
||||
|
||||
@@ -148,9 +148,12 @@ func (m *Manager) Start(polecat string, opts StartOptions) error {
|
||||
}
|
||||
|
||||
// CRITICAL: Set beads environment for worktree polecats (non-fatal: session works without)
|
||||
// Polecats share the rig's beads directory (at rig root, not mayor/rig)
|
||||
// BEADS_NO_DAEMON=1 prevents daemon from committing to wrong branch
|
||||
beadsDir := filepath.Join(m.rig.Path, ".beads")
|
||||
// Polecats need access to TOWN-level beads (parent of rig) for hooks and convoys.
|
||||
// Town beads use hq- prefix and store hooks, mail, and cross-rig coordination.
|
||||
// BEADS_NO_DAEMON=1 prevents daemon from committing to wrong branch.
|
||||
// Using town-level beads ensures gt prime and bd commands can find hooked work.
|
||||
townRoot := filepath.Dir(m.rig.Path) // Town root is parent of rig directory
|
||||
beadsDir := filepath.Join(townRoot, ".beads")
|
||||
_ = m.tmux.SetEnvironment(sessionID, "BEADS_DIR", beadsDir)
|
||||
_ = m.tmux.SetEnvironment(sessionID, "BEADS_NO_DAEMON", "1")
|
||||
_ = m.tmux.SetEnvironment(sessionID, "BEADS_AGENT_NAME", fmt.Sprintf("%s/%s", m.rig.Name, polecat))
|
||||
@@ -190,12 +193,18 @@ func (m *Manager) Start(polecat string, opts StartOptions) error {
|
||||
// Non-fatal warning - Claude might still start
|
||||
}
|
||||
|
||||
// Accept bypass permissions warning dialog if it appears.
|
||||
// When Claude starts with --dangerously-skip-permissions, it shows a warning that
|
||||
// requires pressing Down to select "Yes, I accept" and Enter to confirm.
|
||||
// This is needed for automated polecat startup.
|
||||
_ = m.tmux.AcceptBypassPermissionsWarning(sessionID)
|
||||
|
||||
// Wait for Claude to be fully ready at the prompt (not just started)
|
||||
// PRAGMATIC APPROACH: Use fixed delay rather than detection.
|
||||
// WaitForClaudeReady has false positives (detects > in various contexts).
|
||||
// Claude startup takes ~5-8 seconds on typical machines.
|
||||
// 10 second delay is conservative but reliable.
|
||||
time.Sleep(10 * time.Second)
|
||||
// Reduced from 10s to 8s since AcceptBypassPermissionsWarning already adds ~1.2s.
|
||||
time.Sleep(8 * time.Second)
|
||||
|
||||
// Inject startup nudge for predecessor discovery via /resume
|
||||
// This becomes the session title in Claude Code's session picker
|
||||
|
||||
@@ -265,6 +265,46 @@ func (t *Tmux) NudgePane(pane, message string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AcceptBypassPermissionsWarning dismisses the Claude Code bypass permissions warning dialog.
|
||||
// When Claude starts with --dangerously-skip-permissions, it shows a warning dialog that
|
||||
// requires pressing Down arrow to select "Yes, I accept" and then Enter to confirm.
|
||||
// This function checks if the warning is present before sending keys to avoid interfering
|
||||
// with sessions that don't show the warning (e.g., already accepted or different config).
|
||||
//
|
||||
// Call this after starting Claude and waiting for it to initialize (WaitForCommand),
|
||||
// but before sending any prompts.
|
||||
func (t *Tmux) AcceptBypassPermissionsWarning(session string) error {
|
||||
// Wait for the dialog to potentially render
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// Check if the bypass permissions warning is present
|
||||
content, err := t.CapturePane(session, 30)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Look for the characteristic warning text
|
||||
if !strings.Contains(content, "Bypass Permissions mode") {
|
||||
// Warning not present, nothing to do
|
||||
return nil
|
||||
}
|
||||
|
||||
// Press Down to select "Yes, I accept" (option 2)
|
||||
if _, err := t.run("send-keys", "-t", session, "Down"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Small delay to let selection update
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Press Enter to confirm
|
||||
if _, err := t.run("send-keys", "-t", session, "Enter"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPaneCommand returns the current command running in a pane.
|
||||
// Returns "bash", "zsh", "claude", "node", etc.
|
||||
func (t *Tmux) GetPaneCommand(session string) (string, error) {
|
||||
|
||||
Reference in New Issue
Block a user