diff --git a/internal/beads/beads.go b/internal/beads/beads.go index d45c24cb..a1873275 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -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 diff --git a/internal/cmd/mail.go b/internal/cmd/mail.go index f131a678..5b9d3e77 100644 --- a/internal/cmd/mail.go +++ b/internal/cmd/mail.go @@ -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 diff --git a/internal/cmd/rig.go b/internal/cmd/rig.go index 7d3fde3a..dbd3c4b0 100644 --- a/internal/cmd/rig.go +++ b/internal/cmd/rig.go @@ -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 + // "/.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 diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index 5ef8d80a..bfab1dda 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -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 --assignee=`. +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/) - 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/) + 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 } diff --git a/internal/cmd/up.go b/internal/cmd/up.go index 4648bf51..c7912f9f 100644 --- a/internal/cmd/up.go +++ b/internal/cmd/up.go @@ -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 diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 23c43138..49630163 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -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 } diff --git a/internal/daemon/lifecycle.go b/internal/daemon/lifecycle.go index 2f794dd4..7f5e7bc2 100644 --- a/internal/daemon/lifecycle.go +++ b/internal/daemon/lifecycle.go @@ -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. diff --git a/internal/polecat/manager.go b/internal/polecat/manager.go index 3a2a96ac..623f0e72 100644 --- a/internal/polecat/manager.go +++ b/internal/polecat/manager.go @@ -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/ // / // .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//.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) } diff --git a/internal/rig/manager.go b/internal/rig/manager.go index 0d9076d4..dec61fda 100644 --- a/internal/rig/manager.go +++ b/internal/rig/manager.go @@ -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: " or "prefix: " + 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":"-" 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) { diff --git a/internal/session/manager.go b/internal/session/manager.go index c80c0643..2b153581 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -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 diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 5f2a7522..2b1a0071 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -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) {