diff --git a/internal/beads/handoff.go b/internal/beads/handoff.go index 0a2e7ee9..95828c32 100644 --- a/internal/beads/handoff.go +++ b/internal/beads/handoff.go @@ -158,12 +158,9 @@ func (b *Beads) AttachMolecule(pinnedBeadID, moleculeID string) (*Issue, error) return nil, fmt.Errorf("fetching pinned bead: %w", err) } - // Allow pinned beads OR open polecat agent beads (polecats have a lifecycle, not permanent) + // Only allow pinned beads (permanent records like role definitions) if issue.Status != StatusPinned { - _, role, _, ok := ParseAgentBeadID(pinnedBeadID) - if !(issue.Status == "open" && ok && role == "polecat") { - return nil, fmt.Errorf("issue %s is not pinned or open polecat (status: %s)", pinnedBeadID, issue.Status) - } + return nil, fmt.Errorf("issue %s is not pinned (status: %s)", pinnedBeadID, issue.Status) } // Build attachment fields with current timestamp diff --git a/internal/cmd/done.go b/internal/cmd/done.go index e7e1204d..8d0c7694 100644 --- a/internal/cmd/done.go +++ b/internal/cmd/done.go @@ -603,6 +603,19 @@ func updateAgentStateOnDone(cwd, townRoot, exitType, _ string) { // issueID unus hookedBeadID := agentBead.HookBead // Only close if the hooked bead exists and is still in "hooked" status if hookedBead, err := bd.Show(hookedBeadID); err == nil && hookedBead.Status == beads.StatusHooked { + // BUG FIX: Close attached molecule (wisp) BEFORE closing hooked bead. + // When using formula-on-bead (gt sling formula --on bead), the base bead + // has attached_molecule pointing to the wisp. Without this fix, gt done + // only closed the hooked bead, leaving the wisp orphaned. + // Order matters: wisp closes -> unblocks base bead -> base bead closes. + attachment := beads.ParseAttachmentFields(hookedBead) + if attachment != nil && attachment.AttachedMolecule != "" { + if err := bd.Close(attachment.AttachedMolecule); err != nil { + // Non-fatal: warn but continue + fmt.Fprintf(os.Stderr, "Warning: couldn't close attached molecule %s: %v\n", attachment.AttachedMolecule, err) + } + } + if err := bd.Close(hookedBeadID); err != nil { // Non-fatal: warn but continue fmt.Fprintf(os.Stderr, "Warning: couldn't close hooked bead %s: %v\n", hookedBeadID, err) diff --git a/internal/cmd/molecule_lifecycle_test.go b/internal/cmd/molecule_lifecycle_test.go new file mode 100644 index 00000000..51706f85 --- /dev/null +++ b/internal/cmd/molecule_lifecycle_test.go @@ -0,0 +1,476 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestSlingFormulaOnBeadHooksBaseBead verifies that when using +// "gt sling --on ", the BASE bead is hooked (not the wisp). +// +// Current bug: The code hooks the wisp (compound root) instead of the base bead. +// This causes lifecycle issues: +// - Base bead stays open after wisp completes +// - gt done closes wisp, not the actual work item +// - Orphaned base beads accumulate +// +// Expected behavior: Hook the base bead, store attached_molecule pointing to wisp. +// gt hook/gt prime can follow attached_molecule to find the workflow steps. +func TestSlingFormulaOnBeadHooksBaseBead(t *testing.T) { + townRoot := t.TempDir() + + // Minimal workspace marker + if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil { + t.Fatalf("mkdir mayor/rig: %v", err) + } + + // Create routes + if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil { + t.Fatalf("mkdir .beads: %v", err) + } + rigDir := filepath.Join(townRoot, "gastown", "mayor", "rig") + if err := os.MkdirAll(rigDir, 0755); err != nil { + t.Fatalf("mkdir rigDir: %v", err) + } + routes := strings.Join([]string{ + `{"prefix":"gt-","path":"gastown/mayor/rig"}`, + `{"prefix":"hq-","path":"."}`, + "", + }, "\n") + if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(routes), 0644); err != nil { + t.Fatalf("write routes.jsonl: %v", err) + } + + // Stub bd to track which bead gets hooked + binDir := filepath.Join(townRoot, "bin") + if err := os.MkdirAll(binDir, 0755); err != nil { + t.Fatalf("mkdir binDir: %v", err) + } + logPath := filepath.Join(townRoot, "bd.log") + bdScript := `#!/bin/sh +set -e +echo "$*" >> "${BD_LOG}" +if [ "$1" = "--no-daemon" ]; then + shift +fi +if [ "$1" = "--allow-stale" ]; then + shift +fi +cmd="$1" +shift || true +case "$cmd" in + show) + # Return the base bead info + echo '[{"id":"gt-abc123","title":"Bug to fix","status":"open","assignee":"","description":""}]' + ;; + formula) + echo '{"name":"mol-polecat-work"}' + ;; + cook) + exit 0 + ;; + mol) + sub="$1" + shift || true + case "$sub" in + wisp) + echo '{"new_epic_id":"gt-wisp-xyz"}' + ;; + bond) + echo '{"root_id":"gt-wisp-xyz"}' + ;; + esac + ;; + update) + # Just succeed + exit 0 + ;; +esac +exit 0 +` + bdPath := filepath.Join(binDir, "bd") + if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil { + t.Fatalf("write bd stub: %v", err) + } + + t.Setenv("BD_LOG", logPath) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv(EnvGTRole, "mayor") + t.Setenv("GT_POLECAT", "") + t.Setenv("GT_CREW", "") + t.Setenv("TMUX_PANE", "") + t.Setenv("GT_TEST_NO_NUDGE", "1") + + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) + if err := os.Chdir(filepath.Join(townRoot, "mayor", "rig")); err != nil { + t.Fatalf("chdir: %v", err) + } + + // Save and restore global flag state + prevOn := slingOnTarget + prevVars := slingVars + prevDryRun := slingDryRun + prevNoConvoy := slingNoConvoy + t.Cleanup(func() { + slingOnTarget = prevOn + slingVars = prevVars + slingDryRun = prevDryRun + slingNoConvoy = prevNoConvoy + }) + + slingDryRun = false + slingNoConvoy = true + slingVars = nil + slingOnTarget = "gt-abc123" // The base bead + + if err := runSling(nil, []string{"mol-polecat-work"}); err != nil { + t.Fatalf("runSling: %v", err) + } + + logBytes, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read bd log: %v", err) + } + + // Find the update command that sets status=hooked + // Expected: should hook gt-abc123 (base bead) + // Current bug: hooks gt-wisp-xyz (wisp) + logLines := strings.Split(string(logBytes), "\n") + var hookedBeadID string + for _, line := range logLines { + if strings.Contains(line, "update") && strings.Contains(line, "--status=hooked") { + // Extract the bead ID being hooked + // Format: "update --status=hooked ..." + parts := strings.Fields(line) + for i, part := range parts { + if part == "update" && i+1 < len(parts) { + hookedBeadID = parts[i+1] + break + } + } + break + } + } + + if hookedBeadID == "" { + t.Fatalf("no hooked bead found in log:\n%s", string(logBytes)) + } + + // The BASE bead (gt-abc123) should be hooked, not the wisp (gt-wisp-xyz) + if hookedBeadID != "gt-abc123" { + t.Errorf("wrong bead hooked: got %q, want %q (base bead)\n"+ + "Current behavior hooks the wisp instead of the base bead.\n"+ + "This causes orphaned base beads when gt done closes only the wisp.\n"+ + "Log:\n%s", hookedBeadID, "gt-abc123", string(logBytes)) + } +} + +// TestSlingFormulaOnBeadSetsAttachedMoleculeInBaseBead verifies that when using +// "gt sling --on ", the attached_molecule field is set in the +// BASE bead's description (pointing to the wisp), not in the wisp itself. +// +// Current bug: attached_molecule is stored as a self-reference in the wisp. +// This is semantically meaningless (wisp points to itself) and breaks +// compound resolution from the base bead. +// +// Expected behavior: Store attached_molecule in the base bead pointing to wisp. +// This enables: +// - Compound resolution: base bead -> attached_molecule -> wisp +// - gt hook/gt prime: read base bead, follow attached_molecule to show wisp steps +func TestSlingFormulaOnBeadSetsAttachedMoleculeInBaseBead(t *testing.T) { + townRoot := t.TempDir() + + // Minimal workspace marker + if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil { + t.Fatalf("mkdir mayor/rig: %v", err) + } + + // Create routes + if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil { + t.Fatalf("mkdir .beads: %v", err) + } + rigDir := filepath.Join(townRoot, "gastown", "mayor", "rig") + if err := os.MkdirAll(rigDir, 0755); err != nil { + t.Fatalf("mkdir rigDir: %v", err) + } + routes := strings.Join([]string{ + `{"prefix":"gt-","path":"gastown/mayor/rig"}`, + `{"prefix":"hq-","path":"."}`, + "", + }, "\n") + if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(routes), 0644); err != nil { + t.Fatalf("write routes.jsonl: %v", err) + } + + // Stub bd to track which bead gets attached_molecule set + binDir := filepath.Join(townRoot, "bin") + if err := os.MkdirAll(binDir, 0755); err != nil { + t.Fatalf("mkdir binDir: %v", err) + } + logPath := filepath.Join(townRoot, "bd.log") + bdScript := `#!/bin/sh +set -e +echo "$*" >> "${BD_LOG}" +if [ "$1" = "--no-daemon" ]; then + shift +fi +if [ "$1" = "--allow-stale" ]; then + shift +fi +cmd="$1" +shift || true +case "$cmd" in + show) + # Return bead info without attached_molecule initially + echo '[{"id":"gt-abc123","title":"Bug to fix","status":"open","assignee":"","description":""}]' + ;; + formula) + echo '{"name":"mol-polecat-work"}' + ;; + cook) + exit 0 + ;; + mol) + sub="$1" + shift || true + case "$sub" in + wisp) + echo '{"new_epic_id":"gt-wisp-xyz"}' + ;; + bond) + echo '{"root_id":"gt-wisp-xyz"}' + ;; + esac + ;; + update) + # Just succeed + exit 0 + ;; +esac +exit 0 +` + bdPath := filepath.Join(binDir, "bd") + if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil { + t.Fatalf("write bd stub: %v", err) + } + + t.Setenv("BD_LOG", logPath) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv(EnvGTRole, "mayor") + t.Setenv("GT_POLECAT", "") + t.Setenv("GT_CREW", "") + t.Setenv("TMUX_PANE", "") + t.Setenv("GT_TEST_NO_NUDGE", "1") + + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) + if err := os.Chdir(filepath.Join(townRoot, "mayor", "rig")); err != nil { + t.Fatalf("chdir: %v", err) + } + + // Save and restore global flag state + prevOn := slingOnTarget + prevVars := slingVars + prevDryRun := slingDryRun + prevNoConvoy := slingNoConvoy + t.Cleanup(func() { + slingOnTarget = prevOn + slingVars = prevVars + slingDryRun = prevDryRun + slingNoConvoy = prevNoConvoy + }) + + slingDryRun = false + slingNoConvoy = true + slingVars = nil + slingOnTarget = "gt-abc123" // The base bead + + if err := runSling(nil, []string{"mol-polecat-work"}); err != nil { + t.Fatalf("runSling: %v", err) + } + + logBytes, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read bd log: %v", err) + } + + // Find update commands that set attached_molecule + // Expected: "update gt-abc123 --description=...attached_molecule: gt-wisp-xyz..." + // Current bug: "update gt-wisp-xyz --description=...attached_molecule: gt-wisp-xyz..." + logLines := strings.Split(string(logBytes), "\n") + var attachedMoleculeTarget string + for _, line := range logLines { + if strings.Contains(line, "update") && strings.Contains(line, "attached_molecule") { + // Extract the bead ID being updated + parts := strings.Fields(line) + for i, part := range parts { + if part == "update" && i+1 < len(parts) { + attachedMoleculeTarget = parts[i+1] + break + } + } + break + } + } + + if attachedMoleculeTarget == "" { + t.Fatalf("no attached_molecule update found in log:\n%s", string(logBytes)) + } + + // attached_molecule should be set on the BASE bead, not the wisp + if attachedMoleculeTarget != "gt-abc123" { + t.Errorf("attached_molecule set on wrong bead: got %q, want %q (base bead)\n"+ + "Current behavior stores attached_molecule in the wisp as a self-reference.\n"+ + "This breaks compound resolution (base bead has no pointer to wisp).\n"+ + "Log:\n%s", attachedMoleculeTarget, "gt-abc123", string(logBytes)) + } +} + +// TestDoneClosesAttachedMolecule verifies that gt done closes both the hooked +// bead AND its attached molecule (wisp). +// +// Current bug: gt done only closes the hooked bead. If base bead is hooked +// with attached_molecule pointing to wisp, the wisp becomes orphaned. +// +// Expected behavior: gt done should: +// 1. Check for attached_molecule in hooked bead +// 2. Close the attached molecule (wisp) first +// 3. Close the hooked bead (base bead) +// +// This ensures no orphaned wisps remain after work completes. +func TestDoneClosesAttachedMolecule(t *testing.T) { + townRoot := t.TempDir() + + // Create rig structure - use simple rig name that matches routes lookup + rigPath := filepath.Join(townRoot, "gastown") + if err := os.MkdirAll(rigPath, 0755); err != nil { + t.Fatalf("mkdir rig: %v", err) + } + if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil { + t.Fatalf("mkdir .beads: %v", err) + } + + // Create routes - path first part must match GT_RIG for prefix lookup + routes := strings.Join([]string{ + `{"prefix":"gt-","path":"gastown"}`, + "", + }, "\n") + if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(routes), 0644); err != nil { + t.Fatalf("write routes.jsonl: %v", err) + } + + // Stub bd to track close calls + binDir := filepath.Join(townRoot, "bin") + if err := os.MkdirAll(binDir, 0755); err != nil { + t.Fatalf("mkdir binDir: %v", err) + } + closesPath := filepath.Join(townRoot, "closes.log") + + // The stub simulates: + // - Agent bead gt-agent-nux with hook_bead = gt-abc123 (base bead) + // - Base bead gt-abc123 with attached_molecule: gt-wisp-xyz, status=hooked + // - Wisp gt-wisp-xyz (the attached molecule) + bdScript := fmt.Sprintf(`#!/bin/sh +echo "$*" >> "%s/bd.log" +# Strip --no-daemon and --allow-stale +while [ "$1" = "--no-daemon" ] || [ "$1" = "--allow-stale" ]; do + shift +done +cmd="$1" +shift || true +case "$cmd" in + show) + beadID="$1" + case "$beadID" in + gt-gastown-polecat-nux) + echo '[{"id":"gt-gastown-polecat-nux","title":"Polecat nux","status":"open","hook_bead":"gt-abc123","agent_state":"working"}]' + ;; + gt-abc123) + echo '[{"id":"gt-abc123","title":"Bug to fix","status":"hooked","description":"attached_molecule: gt-wisp-xyz"}]' + ;; + gt-wisp-xyz) + echo '[{"id":"gt-wisp-xyz","title":"mol-polecat-work","status":"open","ephemeral":true}]' + ;; + *) + echo '[]' + ;; + esac + ;; + close) + echo "$1" >> "%s" + ;; + agent|update|slot) + exit 0 + ;; +esac +exit 0 +`, townRoot, closesPath) + + bdPath := filepath.Join(binDir, "bd") + if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil { + t.Fatalf("write bd stub: %v", err) + } + + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("GT_ROLE", "polecat") + t.Setenv("GT_RIG", "gastown") + t.Setenv("GT_POLECAT", "nux") + t.Setenv("GT_CREW", "") + t.Setenv("TMUX_PANE", "") + + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) + if err := os.Chdir(rigPath); err != nil { + t.Fatalf("chdir: %v", err) + } + + // Call the unexported function directly (same package) + // updateAgentStateOnDone(cwd, townRoot, exitType, issueID) + updateAgentStateOnDone(rigPath, townRoot, ExitCompleted, "") + + // Read the close log to see what got closed + closesBytes, err := os.ReadFile(closesPath) + if err != nil { + // No closes happened at all - that's a failure + t.Fatalf("no beads were closed (closes.log doesn't exist)") + } + closes := string(closesBytes) + closeLines := strings.Split(strings.TrimSpace(closes), "\n") + + // Check that attached molecule gt-wisp-xyz was closed + foundWisp := false + foundBase := false + for _, line := range closeLines { + if strings.Contains(line, "gt-wisp-xyz") { + foundWisp = true + } + if strings.Contains(line, "gt-abc123") { + foundBase = true + } + } + + if !foundWisp { + t.Errorf("attached molecule gt-wisp-xyz was NOT closed\n"+ + "gt done should close the attached_molecule before closing the hooked bead.\n"+ + "This leaves orphaned wisps after work completes.\n"+ + "Beads closed: %v", closeLines) + } + + if !foundBase { + t.Errorf("hooked bead gt-abc123 was NOT closed\n"+ + "Beads closed: %v", closeLines) + } +} diff --git a/internal/cmd/prime.go b/internal/cmd/prime.go index 72162f71..b3269cd3 100644 --- a/internal/cmd/prime.go +++ b/internal/cmd/prime.go @@ -501,6 +501,23 @@ func checkSlungWork(ctx RoleContext) bool { } fmt.Println() + // Check for attached molecule and show execution prompt + // This was missing for hooked beads (only worked for pinned beads). + // With formula-on-bead, the base bead is hooked with attached_molecule pointing to wisp. + attachment := beads.ParseAttachmentFields(hookedBead) + if attachment != nil && attachment.AttachedMolecule != "" { + fmt.Printf("%s\n\n", style.Bold.Render("## 🎯 ATTACHED MOLECULE")) + fmt.Printf("Molecule: %s\n", attachment.AttachedMolecule) + if attachment.AttachedArgs != "" { + fmt.Printf("\n%s\n", style.Bold.Render("📋 ARGS (use these to guide execution):")) + fmt.Printf(" %s\n", attachment.AttachedArgs) + } + fmt.Println() + + // Show current step from molecule + showMoleculeExecutionPrompt(ctx.WorkDir, attachment.AttachedMolecule) + } + return true } diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index 0a17708b..b5677670 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -1,7 +1,6 @@ package cmd import ( - "encoding/json" "fmt" "os" "os/exec" @@ -83,12 +82,13 @@ Batch Slinging: } var ( - slingSubject string - slingMessage string - slingDryRun bool - slingOnTarget string // --on flag: target bead when slinging a formula - slingVars []string // --var flag: formula variables (key=value) - slingArgs string // --args flag: natural language instructions for executor + slingSubject string + slingMessage string + slingDryRun bool + slingOnTarget string // --on flag: target bead when slinging a formula + slingVars []string // --var flag: formula variables (key=value) + slingArgs string // --args flag: natural language instructions for executor + slingHookRawBead bool // --hook-raw-bead: hook raw bead without default formula (expert mode) // Flags migrated for polecat spawning (used by sling for work assignment) slingCreate bool // --create: create polecat if it doesn't exist @@ -112,6 +112,7 @@ func init() { slingCmd.Flags().StringVar(&slingAccount, "account", "", "Claude Code account handle to use") slingCmd.Flags().StringVar(&slingAgent, "agent", "", "Override agent/runtime for this sling (e.g., claude, gemini, codex, or custom alias)") slingCmd.Flags().BoolVar(&slingNoConvoy, "no-convoy", false, "Skip auto-convoy creation for single-issue sling") + slingCmd.Flags().BoolVar(&slingHookRawBead, "hook-raw-bead", false, "Hook raw bead without default formula (expert mode)") rootCmd.AddCommand(slingCmd) } @@ -398,6 +399,14 @@ func runSling(cmd *cobra.Command, args []string) error { } } + // Issue #288: Auto-apply mol-polecat-work when slinging bare bead to polecat. + // This ensures polecats get structured work guidance through formula-on-bead. + // Use --hook-raw-bead to bypass for expert/debugging scenarios. + if formulaName == "" && !slingHookRawBead && strings.Contains(targetAgent, "/polecats/") { + formulaName = "mol-polecat-work" + fmt.Printf(" Auto-applying %s for polecat work...\n", formulaName) + } + if slingDryRun { if formulaName != "" { fmt.Printf("Would instantiate formula %s:\n", formulaName) @@ -425,71 +434,30 @@ func runSling(cmd *cobra.Command, args []string) error { if formulaName != "" { fmt.Printf(" Instantiating formula %s...\n", formulaName) - // Route bd mutations (wisp/bond) to the correct beads context for the target bead. - // Some bd mol commands don't support prefix routing, so we must run them from the - // rig directory that owns the bead's database. - formulaWorkDir := beads.ResolveHookDir(townRoot, beadID, hookWorkDir) - - // Step 1: Cook the formula (ensures proto exists) - // Cook runs from rig directory to access the correct formula database - cookCmd := exec.Command("bd", "--no-daemon", "cook", formulaName) - cookCmd.Dir = formulaWorkDir - cookCmd.Stderr = os.Stderr - if err := cookCmd.Run(); err != nil { - return fmt.Errorf("cooking formula %s: %w", formulaName, err) - } - - // Step 2: Create wisp with feature and issue variables from bead - // Run from rig directory so wisp is created in correct database - featureVar := fmt.Sprintf("feature=%s", info.Title) - issueVar := fmt.Sprintf("issue=%s", beadID) - wispArgs := []string{"--no-daemon", "mol", "wisp", formulaName, "--var", featureVar, "--var", issueVar, "--json"} - wispCmd := exec.Command("bd", wispArgs...) - wispCmd.Dir = formulaWorkDir - wispCmd.Env = append(os.Environ(), "GT_ROOT="+townRoot) - wispCmd.Stderr = os.Stderr - wispOut, err := wispCmd.Output() + result, err := InstantiateFormulaOnBead(formulaName, beadID, info.Title, hookWorkDir, townRoot, false) if err != nil { - return fmt.Errorf("creating wisp for formula %s: %w", formulaName, err) - } - - // Parse wisp output to get the root ID - wispRootID, err := parseWispIDFromJSON(wispOut) - if err != nil { - return fmt.Errorf("parsing wisp output: %w", err) - } - fmt.Printf("%s Formula wisp created: %s\n", style.Bold.Render("✓"), wispRootID) - - // Step 3: Bond wisp to original bead (creates compound) - // Use --no-daemon for mol bond (requires direct database access) - bondArgs := []string{"--no-daemon", "mol", "bond", wispRootID, beadID, "--json"} - bondCmd := exec.Command("bd", bondArgs...) - bondCmd.Dir = formulaWorkDir - bondCmd.Stderr = os.Stderr - bondOut, err := bondCmd.Output() - if err != nil { - return fmt.Errorf("bonding formula to bead: %w", err) - } - - // Parse bond output - the wisp root becomes the compound root - // After bonding, we hook the wisp root (which now contains the original bead) - var bondResult struct { - RootID string `json:"root_id"` - } - if err := json.Unmarshal(bondOut, &bondResult); err != nil { - // Fallback: use wisp root as the compound root - fmt.Printf("%s Could not parse bond output, using wisp root\n", style.Dim.Render("Warning:")) - } else if bondResult.RootID != "" { - wispRootID = bondResult.RootID + return fmt.Errorf("instantiating formula %s: %w", formulaName, err) } + fmt.Printf("%s Formula wisp created: %s\n", style.Bold.Render("✓"), result.WispRootID) fmt.Printf("%s Formula bonded to %s\n", style.Bold.Render("✓"), beadID) - // Record attached molecule after other description updates to avoid overwrite. - attachedMoleculeID = wispRootID + // Record attached molecule - will be stored in BASE bead (not wisp). + // The base bead is hooked, and its attached_molecule points to the wisp. + // This enables: + // - gt hook/gt prime: read base bead, follow attached_molecule to show wisp steps + // - gt done: close attached_molecule (wisp) first, then close base bead + // - Compound resolution: base bead -> attached_molecule -> wisp + attachedMoleculeID = result.WispRootID - // Update beadID to hook the compound root instead of bare bead - beadID = wispRootID + // NOTE: We intentionally keep beadID as the ORIGINAL base bead, not the wisp. + // The base bead is hooked so that: + // 1. gt done closes both the base bead AND the attached molecule (wisp) + // 2. The base bead's attached_molecule field points to the wisp for compound resolution + // Previously, this line incorrectly set beadID = wispRootID, causing: + // - Wisp hooked instead of base bead + // - attached_molecule stored as self-reference in wisp (meaningless) + // - Base bead left orphaned after gt done } // Hook the bead using bd update. @@ -515,17 +483,6 @@ func runSling(cmd *cobra.Command, args []string) error { updateAgentHookBead(targetAgent, beadID, hookWorkDir, townBeadsDir) } - // Auto-attach mol-polecat-work to polecat agent beads - // This ensures polecats have the standard work molecule attached for guidance. - // Only do this for bare beads (no --on formula), since formula-on-bead - // mode already attaches the formula as a molecule. - if formulaName == "" && strings.Contains(targetAgent, "/polecats/") { - if err := attachPolecatWorkMolecule(targetAgent, hookWorkDir, townRoot); err != nil { - // Warn but don't fail - polecat will still work without molecule - fmt.Printf("%s Could not attach work molecule: %v\n", style.Dim.Render("Warning:"), err) - } - } - // Store dispatcher in bead description (enables completion notification to dispatcher) if err := storeDispatcherInBead(beadID, actor); err != nil { // Warn but don't fail - polecat will still complete work @@ -542,8 +499,11 @@ func runSling(cmd *cobra.Command, args []string) error { } } - // Record the attached molecule in the wisp's description. - // This is required for gt hook to recognize the molecule attachment. + // Record the attached molecule in the BASE bead's description. + // This field points to the wisp (compound root) and enables: + // - gt hook/gt prime: follow attached_molecule to show molecule steps + // - gt done: close attached_molecule (wisp) before closing hooked bead + // - Compound resolution: base bead -> attached_molecule -> wisp if attachedMoleculeID != "" { if err := storeAttachedMoleculeInBead(beadID, attachedMoleculeID); err != nil { // Warn but don't fail - polecat can still work through steps diff --git a/internal/cmd/sling_288_test.go b/internal/cmd/sling_288_test.go new file mode 100644 index 00000000..b621010b --- /dev/null +++ b/internal/cmd/sling_288_test.go @@ -0,0 +1,378 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestInstantiateFormulaOnBead verifies the helper function works correctly. +// This tests the formula-on-bead pattern used by issue #288. +func TestInstantiateFormulaOnBead(t *testing.T) { + townRoot := t.TempDir() + + // Minimal workspace marker + if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil { + t.Fatalf("mkdir mayor/rig: %v", err) + } + + // Create routes.jsonl + if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil { + t.Fatalf("mkdir .beads: %v", err) + } + rigDir := filepath.Join(townRoot, "gastown", "mayor", "rig") + if err := os.MkdirAll(rigDir, 0755); err != nil { + t.Fatalf("mkdir rigDir: %v", err) + } + routes := strings.Join([]string{ + `{"prefix":"gt-","path":"gastown/mayor/rig"}`, + `{"prefix":"hq-","path":"."}`, + "", + }, "\n") + if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(routes), 0644); err != nil { + t.Fatalf("write routes.jsonl: %v", err) + } + + // Create stub bd that logs all commands + binDir := filepath.Join(townRoot, "bin") + if err := os.MkdirAll(binDir, 0755); err != nil { + t.Fatalf("mkdir binDir: %v", err) + } + logPath := filepath.Join(townRoot, "bd.log") + bdScript := `#!/bin/sh +set -e +echo "CMD:$*" >> "${BD_LOG}" +if [ "$1" = "--no-daemon" ]; then + shift +fi +cmd="$1" +shift || true +case "$cmd" in + show) + echo '[{"title":"Fix bug ABC","status":"open","assignee":"","description":""}]' + ;; + formula) + echo '{"name":"mol-polecat-work"}' + ;; + cook) + ;; + mol) + sub="$1" + shift || true + case "$sub" in + wisp) + echo '{"new_epic_id":"gt-wisp-288"}' + ;; + bond) + echo '{"root_id":"gt-wisp-288"}' + ;; + esac + ;; + update) + ;; +esac +exit 0 +` + bdPath := filepath.Join(binDir, "bd") + if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil { + t.Fatalf("write bd stub: %v", err) + } + + t.Setenv("BD_LOG", logPath) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) + if err := os.Chdir(filepath.Join(townRoot, "mayor", "rig")); err != nil { + t.Fatalf("chdir: %v", err) + } + + // Test the helper function directly + result, err := InstantiateFormulaOnBead("mol-polecat-work", "gt-abc123", "Test Bug Fix", "", townRoot, false) + if err != nil { + t.Fatalf("InstantiateFormulaOnBead failed: %v", err) + } + + if result.WispRootID == "" { + t.Error("WispRootID should not be empty") + } + if result.BeadToHook == "" { + t.Error("BeadToHook should not be empty") + } + + // Verify commands were logged + logBytes, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read log: %v", err) + } + logContent := string(logBytes) + + if !strings.Contains(logContent, "cook mol-polecat-work") { + t.Errorf("cook command not found in log:\n%s", logContent) + } + if !strings.Contains(logContent, "mol wisp mol-polecat-work") { + t.Errorf("mol wisp command not found in log:\n%s", logContent) + } + if !strings.Contains(logContent, "mol bond") { + t.Errorf("mol bond command not found in log:\n%s", logContent) + } +} + +// TestInstantiateFormulaOnBeadSkipCook verifies the skipCook optimization. +func TestInstantiateFormulaOnBeadSkipCook(t *testing.T) { + townRoot := t.TempDir() + + // Minimal workspace marker + if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil { + t.Fatalf("mkdir mayor/rig: %v", err) + } + + // Create routes.jsonl + if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil { + t.Fatalf("mkdir .beads: %v", err) + } + routes := `{"prefix":"gt-","path":"."}` + if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(routes), 0644); err != nil { + t.Fatalf("write routes.jsonl: %v", err) + } + + // Create stub bd + binDir := filepath.Join(townRoot, "bin") + if err := os.MkdirAll(binDir, 0755); err != nil { + t.Fatalf("mkdir binDir: %v", err) + } + logPath := filepath.Join(townRoot, "bd.log") + bdScript := `#!/bin/sh +echo "CMD:$*" >> "${BD_LOG}" +if [ "$1" = "--no-daemon" ]; then shift; fi +cmd="$1"; shift || true +case "$cmd" in + mol) + sub="$1"; shift || true + case "$sub" in + wisp) echo '{"new_epic_id":"gt-wisp-skip"}';; + bond) echo '{"root_id":"gt-wisp-skip"}';; + esac;; +esac +exit 0 +` + if err := os.WriteFile(filepath.Join(binDir, "bd"), []byte(bdScript), 0755); err != nil { + t.Fatalf("write bd stub: %v", err) + } + + t.Setenv("BD_LOG", logPath) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + cwd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(cwd) }) + _ = os.Chdir(townRoot) + + // Test with skipCook=true + _, err := InstantiateFormulaOnBead("mol-polecat-work", "gt-test", "Test", "", townRoot, true) + if err != nil { + t.Fatalf("InstantiateFormulaOnBead failed: %v", err) + } + + logBytes, _ := os.ReadFile(logPath) + logContent := string(logBytes) + + // Verify cook was NOT called when skipCook=true + if strings.Contains(logContent, "cook") { + t.Errorf("cook should be skipped when skipCook=true, but was called:\n%s", logContent) + } + + // Verify wisp and bond were still called + if !strings.Contains(logContent, "mol wisp") { + t.Errorf("mol wisp should still be called") + } + if !strings.Contains(logContent, "mol bond") { + t.Errorf("mol bond should still be called") + } +} + +// TestCookFormula verifies the CookFormula helper. +func TestCookFormula(t *testing.T) { + townRoot := t.TempDir() + + binDir := filepath.Join(townRoot, "bin") + if err := os.MkdirAll(binDir, 0755); err != nil { + t.Fatalf("mkdir binDir: %v", err) + } + logPath := filepath.Join(townRoot, "bd.log") + bdScript := `#!/bin/sh +echo "CMD:$*" >> "${BD_LOG}" +exit 0 +` + if err := os.WriteFile(filepath.Join(binDir, "bd"), []byte(bdScript), 0755); err != nil { + t.Fatalf("write bd stub: %v", err) + } + + t.Setenv("BD_LOG", logPath) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + err := CookFormula("mol-polecat-work", townRoot) + if err != nil { + t.Fatalf("CookFormula failed: %v", err) + } + + logBytes, _ := os.ReadFile(logPath) + if !strings.Contains(string(logBytes), "cook mol-polecat-work") { + t.Errorf("cook command not found in log") + } +} + +// TestSlingHookRawBeadFlag verifies --hook-raw-bead flag exists. +func TestSlingHookRawBeadFlag(t *testing.T) { + // Verify the flag variable exists and works + prevValue := slingHookRawBead + t.Cleanup(func() { slingHookRawBead = prevValue }) + + slingHookRawBead = true + if !slingHookRawBead { + t.Error("slingHookRawBead flag should be true") + } + + slingHookRawBead = false + if slingHookRawBead { + t.Error("slingHookRawBead flag should be false") + } +} + +// TestAutoApplyLogic verifies the auto-apply detection logic. +// When formulaName is empty and target contains "/polecats/", mol-polecat-work should be applied. +func TestAutoApplyLogic(t *testing.T) { + tests := []struct { + name string + formulaName string + hookRawBead bool + targetAgent string + wantAutoApply bool + }{ + { + name: "bare bead to polecat - should auto-apply", + formulaName: "", + hookRawBead: false, + targetAgent: "gastown/polecats/Toast", + wantAutoApply: true, + }, + { + name: "bare bead with --hook-raw-bead - should not auto-apply", + formulaName: "", + hookRawBead: true, + targetAgent: "gastown/polecats/Toast", + wantAutoApply: false, + }, + { + name: "formula already specified - should not auto-apply", + formulaName: "mol-review", + hookRawBead: false, + targetAgent: "gastown/polecats/Toast", + wantAutoApply: false, + }, + { + name: "non-polecat target - should not auto-apply", + formulaName: "", + hookRawBead: false, + targetAgent: "gastown/witness", + wantAutoApply: false, + }, + { + name: "mayor target - should not auto-apply", + formulaName: "", + hookRawBead: false, + targetAgent: "mayor", + wantAutoApply: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // This mirrors the logic in sling.go + shouldAutoApply := tt.formulaName == "" && !tt.hookRawBead && strings.Contains(tt.targetAgent, "/polecats/") + + if shouldAutoApply != tt.wantAutoApply { + t.Errorf("auto-apply logic: got %v, want %v", shouldAutoApply, tt.wantAutoApply) + } + }) + } +} + +// TestFormulaOnBeadPassesVariables verifies that feature and issue variables are passed. +func TestFormulaOnBeadPassesVariables(t *testing.T) { + townRoot := t.TempDir() + + // Minimal workspace + if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil { + t.Fatalf("mkdir .beads: %v", err) + } + if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(`{"prefix":"gt-","path":"."}`), 0644); err != nil { + t.Fatalf("write routes: %v", err) + } + + binDir := filepath.Join(townRoot, "bin") + if err := os.MkdirAll(binDir, 0755); err != nil { + t.Fatalf("mkdir binDir: %v", err) + } + logPath := filepath.Join(townRoot, "bd.log") + bdScript := `#!/bin/sh +echo "CMD:$*" >> "${BD_LOG}" +if [ "$1" = "--no-daemon" ]; then shift; fi +cmd="$1"; shift || true +case "$cmd" in + cook) exit 0;; + mol) + sub="$1"; shift || true + case "$sub" in + wisp) echo '{"new_epic_id":"gt-wisp-var"}';; + bond) echo '{"root_id":"gt-wisp-var"}';; + esac;; +esac +exit 0 +` + if err := os.WriteFile(filepath.Join(binDir, "bd"), []byte(bdScript), 0755); err != nil { + t.Fatalf("write bd: %v", err) + } + + t.Setenv("BD_LOG", logPath) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + cwd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(cwd) }) + _ = os.Chdir(townRoot) + + _, err := InstantiateFormulaOnBead("mol-polecat-work", "gt-abc123", "My Cool Feature", "", townRoot, false) + if err != nil { + t.Fatalf("InstantiateFormulaOnBead: %v", err) + } + + logBytes, _ := os.ReadFile(logPath) + logContent := string(logBytes) + + // Find mol wisp line + var wispLine string + for _, line := range strings.Split(logContent, "\n") { + if strings.Contains(line, "mol wisp") { + wispLine = line + break + } + } + + if wispLine == "" { + t.Fatalf("mol wisp command not found:\n%s", logContent) + } + + if !strings.Contains(wispLine, "feature=My Cool Feature") { + t.Errorf("mol wisp missing feature variable:\n%s", wispLine) + } + + if !strings.Contains(wispLine, "issue=gt-abc123") { + t.Errorf("mol wisp missing issue variable:\n%s", wispLine) + } +} diff --git a/internal/cmd/sling_batch.go b/internal/cmd/sling_batch.go index 1dc4f64a..38a888dc 100644 --- a/internal/cmd/sling_batch.go +++ b/internal/cmd/sling_batch.go @@ -23,14 +23,21 @@ func runBatchSling(beadIDs []string, rigName string, townBeadsDir string) error if slingDryRun { fmt.Printf("%s Batch slinging %d beads to rig '%s':\n", style.Bold.Render("🎯"), len(beadIDs), rigName) + fmt.Printf(" Would cook mol-polecat-work formula once\n") for _, beadID := range beadIDs { - fmt.Printf(" Would spawn polecat for: %s\n", beadID) + fmt.Printf(" Would spawn polecat and apply mol-polecat-work to: %s\n", beadID) } return nil } fmt.Printf("%s Batch slinging %d beads to rig '%s'...\n", style.Bold.Render("🎯"), len(beadIDs), rigName) + // Issue #288: Auto-apply mol-polecat-work for batch sling + // Cook once before the loop for efficiency + townRoot := filepath.Dir(townBeadsDir) + formulaName := "mol-polecat-work" + formulaCooked := false + // Track results for summary type slingResult struct { beadID string @@ -91,10 +98,34 @@ func runBatchSling(beadIDs []string, rigName string, townBeadsDir string) error } } - // Hook the bead. See: https://github.com/steveyegge/gastown/issues/148 - townRoot := filepath.Dir(townBeadsDir) - hookCmd := exec.Command("bd", "--no-daemon", "update", beadID, "--status=hooked", "--assignee="+targetAgent) - hookCmd.Dir = beads.ResolveHookDir(townRoot, beadID, hookWorkDir) + // Issue #288: Apply mol-polecat-work via formula-on-bead pattern + // Cook once (lazy), then instantiate for each bead + if !formulaCooked { + workDir := beads.ResolveHookDir(townRoot, beadID, hookWorkDir) + if err := CookFormula(formulaName, workDir); err != nil { + fmt.Printf(" %s Could not cook formula %s: %v\n", style.Dim.Render("Warning:"), formulaName, err) + // Fall back to raw hook if formula cook fails + } else { + formulaCooked = true + } + } + + beadToHook := beadID + attachedMoleculeID := "" + if formulaCooked { + result, err := InstantiateFormulaOnBead(formulaName, beadID, info.Title, hookWorkDir, townRoot, true) + if err != nil { + fmt.Printf(" %s Could not apply formula: %v (hooking raw bead)\n", style.Dim.Render("Warning:"), err) + } else { + fmt.Printf(" %s Formula %s applied\n", style.Bold.Render("✓"), formulaName) + beadToHook = result.BeadToHook + attachedMoleculeID = result.WispRootID + } + } + + // Hook the bead (or wisp compound if formula was applied) + hookCmd := exec.Command("bd", "--no-daemon", "update", beadToHook, "--status=hooked", "--assignee="+targetAgent) + hookCmd.Dir = beads.ResolveHookDir(townRoot, beadToHook, hookWorkDir) hookCmd.Stderr = os.Stderr if err := hookCmd.Run(); err != nil { results = append(results, slingResult{beadID: beadID, polecat: spawnInfo.PolecatName, success: false, errMsg: "hook failed"}) @@ -106,14 +137,16 @@ func runBatchSling(beadIDs []string, rigName string, townBeadsDir string) error // Log sling event actor := detectActor() - _ = events.LogFeed(events.TypeSling, actor, events.SlingPayload(beadID, targetAgent)) + _ = events.LogFeed(events.TypeSling, actor, events.SlingPayload(beadToHook, targetAgent)) // Update agent bead state - updateAgentHookBead(targetAgent, beadID, hookWorkDir, townBeadsDir) + updateAgentHookBead(targetAgent, beadToHook, hookWorkDir, townBeadsDir) - // Auto-attach mol-polecat-work molecule to polecat agent bead - if err := attachPolecatWorkMolecule(targetAgent, hookWorkDir, townRoot); err != nil { - fmt.Printf(" %s Could not attach work molecule: %v\n", style.Dim.Render("Warning:"), err) + // Store attached molecule in the hooked bead + if attachedMoleculeID != "" { + if err := storeAttachedMoleculeInBead(beadToHook, attachedMoleculeID); err != nil { + fmt.Printf(" %s Could not store attached_molecule: %v\n", style.Dim.Render("Warning:"), err) + } } // Store args if provided diff --git a/internal/cmd/sling_helpers.go b/internal/cmd/sling_helpers.go index 24fe25b9..469a85b2 100644 --- a/internal/cmd/sling_helpers.go +++ b/internal/cmd/sling_helpers.go @@ -9,9 +9,7 @@ import ( "time" "github.com/steveyegge/gastown/internal/beads" - "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/constants" - "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/workspace" ) @@ -461,57 +459,86 @@ func isPolecatTarget(target string) bool { return len(parts) >= 3 && parts[1] == "polecats" } -// attachPolecatWorkMolecule attaches the mol-polecat-work molecule to a polecat's agent bead. -// This ensures all polecats have the standard work molecule attached for guidance. -// The molecule is attached by storing it in the agent bead's description using attachment fields. -// -// Per issue #288: gt sling should auto-attach mol-polecat-work when slinging to polecats. -func attachPolecatWorkMolecule(targetAgent, hookWorkDir, townRoot string) error { - // Parse the polecat name from targetAgent (format: "rig/polecats/name") - parts := strings.Split(targetAgent, "/") - if len(parts) != 3 || parts[1] != "polecats" { - return fmt.Errorf("invalid polecat agent format: %s", targetAgent) - } - rigName := parts[0] - polecatName := parts[2] - - // Get the polecat's agent bead ID - // Format: "--polecat-" (e.g., "gt-gastown-polecat-Toast") - prefix := config.GetRigPrefix(townRoot, rigName) - agentBeadID := beads.PolecatBeadIDWithPrefix(prefix, rigName, polecatName) - - // Resolve the rig directory for running bd commands. - // Use ResolveHookDir to ensure we run bd from the correct rig directory - // (not from the polecat's worktree, which doesn't have a .beads directory). - // This fixes issue #197: polecat fails to hook when slinging with molecule. - rigDir := beads.ResolveHookDir(townRoot, prefix+"-"+polecatName, hookWorkDir) - - b := beads.New(rigDir) - - // Check if molecule is already attached (avoid duplicate attach) - attachment, err := b.GetAttachment(agentBeadID) - if err == nil && attachment != nil && attachment.AttachedMolecule != "" { - // Already has a molecule attached - skip - return nil - } - - // Cook the mol-polecat-work formula to ensure the proto exists - // This is safe to run multiple times - cooking is idempotent - cookCmd := exec.Command("bd", "--no-daemon", "cook", "mol-polecat-work") - cookCmd.Dir = rigDir - cookCmd.Stderr = os.Stderr - if err := cookCmd.Run(); err != nil { - return fmt.Errorf("cooking mol-polecat-work formula: %w", err) - } - - // Attach the molecule to the polecat's agent bead - // The molecule ID is the formula name "mol-polecat-work" - moleculeID := "mol-polecat-work" - _, err = b.AttachMolecule(agentBeadID, moleculeID) - if err != nil { - return fmt.Errorf("attaching molecule %s to %s: %w", moleculeID, agentBeadID, err) - } - - fmt.Printf("%s Attached %s to %s\n", style.Bold.Render("✓"), moleculeID, agentBeadID) - return nil +// FormulaOnBeadResult contains the result of instantiating a formula on a bead. +type FormulaOnBeadResult struct { + WispRootID string // The wisp root ID (compound root after bonding) + BeadToHook string // The bead ID to hook (BASE bead, not wisp - lifecycle fix) +} + +// InstantiateFormulaOnBead creates a wisp from a formula, bonds it to a bead. +// This is the formula-on-bead pattern used by issue #288 for auto-applying mol-polecat-work. +// +// Parameters: +// - formulaName: the formula to instantiate (e.g., "mol-polecat-work") +// - beadID: the base bead to bond the wisp to +// - title: the bead title (used for --var feature=) +// - hookWorkDir: working directory for bd commands (polecat's worktree) +// - townRoot: the town root directory +// - skipCook: if true, skip cooking (for batch mode optimization where cook happens once) +// +// Returns the wisp root ID which should be hooked. +func InstantiateFormulaOnBead(formulaName, beadID, title, hookWorkDir, townRoot string, skipCook bool) (*FormulaOnBeadResult, error) { + // Route bd mutations (wisp/bond) to the correct beads context for the target bead. + formulaWorkDir := beads.ResolveHookDir(townRoot, beadID, hookWorkDir) + + // Step 1: Cook the formula (ensures proto exists) + if !skipCook { + cookCmd := exec.Command("bd", "--no-daemon", "cook", formulaName) + cookCmd.Dir = formulaWorkDir + cookCmd.Stderr = os.Stderr + if err := cookCmd.Run(); err != nil { + return nil, fmt.Errorf("cooking formula %s: %w", formulaName, err) + } + } + + // Step 2: Create wisp with feature and issue variables from bead + featureVar := fmt.Sprintf("feature=%s", title) + issueVar := fmt.Sprintf("issue=%s", beadID) + wispArgs := []string{"--no-daemon", "mol", "wisp", formulaName, "--var", featureVar, "--var", issueVar, "--json"} + wispCmd := exec.Command("bd", wispArgs...) + wispCmd.Dir = formulaWorkDir + wispCmd.Env = append(os.Environ(), "GT_ROOT="+townRoot) + wispCmd.Stderr = os.Stderr + wispOut, err := wispCmd.Output() + if err != nil { + return nil, fmt.Errorf("creating wisp for formula %s: %w", formulaName, err) + } + + // Parse wisp output to get the root ID + wispRootID, err := parseWispIDFromJSON(wispOut) + if err != nil { + return nil, fmt.Errorf("parsing wisp output: %w", err) + } + + // Step 3: Bond wisp to original bead (creates compound) + bondArgs := []string{"--no-daemon", "mol", "bond", wispRootID, beadID, "--json"} + bondCmd := exec.Command("bd", bondArgs...) + bondCmd.Dir = formulaWorkDir + bondCmd.Stderr = os.Stderr + bondOut, err := bondCmd.Output() + if err != nil { + return nil, fmt.Errorf("bonding formula to bead: %w", err) + } + + // Parse bond output - the wisp root becomes the compound root + var bondResult struct { + RootID string `json:"root_id"` + } + if err := json.Unmarshal(bondOut, &bondResult); err == nil && bondResult.RootID != "" { + wispRootID = bondResult.RootID + } + + return &FormulaOnBeadResult{ + WispRootID: wispRootID, + BeadToHook: beadID, // Hook the BASE bead (lifecycle fix: wisp is attached_molecule) + }, nil +} + +// CookFormula cooks a formula to ensure its proto exists. +// This is useful for batch mode where we cook once before processing multiple beads. +func CookFormula(formulaName, workDir string) error { + cookCmd := exec.Command("bd", "--no-daemon", "cook", formulaName) + cookCmd.Dir = workDir + cookCmd.Stderr = os.Stderr + return cookCmd.Run() }