fix(sling): auto-apply mol-polecat-work (#288) and fix wisp orphan lifecycle bug (#842) (#859)

fix(sling): auto-apply mol-polecat-work (#288) and fix wisp orphan lifecycle bug (#842)

Fixes the formula-on-bead pattern to hook the base bead instead of the wisp:
- Auto-apply mol-polecat-work when slinging bare beads to polecats
- Hook BASE bead with attached_molecule pointing to wisp  
- gt done now closes attached molecule before closing hooked bead
- Convoys complete properly when work finishes

Fixes #288, #842, #858
This commit is contained in:
Julian Knutsen
2026-01-21 18:52:26 -10:00
committed by GitHub
parent 1feb48dd11
commit 0dfb0be368
8 changed files with 1050 additions and 149 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,476 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
// TestSlingFormulaOnBeadHooksBaseBead verifies that when using
// "gt sling <formula> --on <bead>", 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 <beadID> --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 <formula> --on <bead>", 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)
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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: "<prefix>-<rig>-polecat-<name>" (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=<title>)
// - 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()
}