Some checks failed
CI / Check for .beads changes (push) Has been skipped
CI / Check embedded formulas (push) Failing after 24s
CI / Test (push) Failing after 1m33s
CI / Lint (push) Failing after 22s
CI / Coverage Report (push) Has been cancelled
Windows CI / Windows Build and Unit Tests (push) Has been cancelled
CI / Integration Tests (push) Has been cancelled
storeDispatcherInBead and storeAttachedMoleculeInBead were calling bd show/update without --no-daemon, while all other sling operations used --no-daemon. This inconsistency could cause daemon socket hangs if the daemon was in a bad state during sling operations. Changes: - Add --no-daemon --allow-stale to bd show calls in both functions - Add --no-daemon to bd update calls in both functions - Add empty stdout check for bd --no-daemon exit 0 bug Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
557 lines
19 KiB
Go
557 lines
19 KiB
Go
package cmd
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
"github.com/steveyegge/gastown/internal/constants"
|
|
"github.com/steveyegge/gastown/internal/tmux"
|
|
"github.com/steveyegge/gastown/internal/workspace"
|
|
)
|
|
|
|
// beadInfo holds status and assignee for a bead.
|
|
type beadInfo struct {
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
Assignee string `json:"assignee"`
|
|
}
|
|
|
|
// verifyBeadExists checks that the bead exists using bd show.
|
|
// Uses bd's native prefix-based routing via routes.jsonl - do NOT set BEADS_DIR
|
|
// as that overrides routing and breaks resolution of rig-level beads.
|
|
//
|
|
// Uses --no-daemon with --allow-stale to avoid daemon socket timing issues
|
|
// while still finding beads when database is out of sync with JSONL.
|
|
// For existence checks, stale data is acceptable - we just need to know it exists.
|
|
func verifyBeadExists(beadID string) error {
|
|
cmd := exec.Command("bd", "--no-daemon", "show", beadID, "--json", "--allow-stale")
|
|
// Run from town root so bd can find routes.jsonl for prefix-based routing.
|
|
// Do NOT set BEADS_DIR - that overrides routing and breaks rig bead resolution.
|
|
if townRoot, err := workspace.FindFromCwd(); err == nil {
|
|
cmd.Dir = townRoot
|
|
}
|
|
// Use Output() instead of Run() to detect bd --no-daemon exit 0 bug:
|
|
// when issue not found, --no-daemon exits 0 but produces empty stdout.
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return fmt.Errorf("bead '%s' not found (bd show failed)", beadID)
|
|
}
|
|
if len(out) == 0 {
|
|
return fmt.Errorf("bead '%s' not found", beadID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// getBeadInfo returns status and assignee for a bead.
|
|
// Uses bd's native prefix-based routing via routes.jsonl.
|
|
// Uses --no-daemon with --allow-stale for consistency with verifyBeadExists.
|
|
func getBeadInfo(beadID string) (*beadInfo, error) {
|
|
cmd := exec.Command("bd", "--no-daemon", "show", beadID, "--json", "--allow-stale")
|
|
// Run from town root so bd can find routes.jsonl for prefix-based routing.
|
|
if townRoot, err := workspace.FindFromCwd(); err == nil {
|
|
cmd.Dir = townRoot
|
|
}
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("bead '%s' not found", beadID)
|
|
}
|
|
// Handle bd --no-daemon exit 0 bug: when issue not found,
|
|
// --no-daemon exits 0 but produces empty stdout (error goes to stderr).
|
|
if len(out) == 0 {
|
|
return nil, fmt.Errorf("bead '%s' not found", beadID)
|
|
}
|
|
// bd show --json returns an array (issue + dependents), take first element
|
|
var infos []beadInfo
|
|
if err := json.Unmarshal(out, &infos); err != nil {
|
|
return nil, fmt.Errorf("parsing bead info: %w", err)
|
|
}
|
|
if len(infos) == 0 {
|
|
return nil, fmt.Errorf("bead '%s' not found", beadID)
|
|
}
|
|
return &infos[0], nil
|
|
}
|
|
|
|
// storeArgsInBead stores args in the bead's description using attached_args field.
|
|
// This enables no-tmux mode where agents discover args via gt prime / bd show.
|
|
func storeArgsInBead(beadID, args string) error {
|
|
// Get the bead to preserve existing description content
|
|
showCmd := exec.Command("bd", "--no-daemon", "show", beadID, "--json", "--allow-stale")
|
|
out, err := showCmd.Output()
|
|
if err != nil {
|
|
return fmt.Errorf("fetching bead: %w", err)
|
|
}
|
|
// Handle bd --no-daemon exit 0 bug: empty stdout means not found
|
|
if len(out) == 0 {
|
|
return fmt.Errorf("bead not found")
|
|
}
|
|
|
|
// Parse the bead
|
|
var issues []beads.Issue
|
|
if err := json.Unmarshal(out, &issues); err != nil {
|
|
if os.Getenv("GT_TEST_ATTACHED_MOLECULE_LOG") == "" {
|
|
return fmt.Errorf("parsing bead: %w", err)
|
|
}
|
|
}
|
|
issue := &beads.Issue{}
|
|
if len(issues) > 0 {
|
|
issue = &issues[0]
|
|
} else if os.Getenv("GT_TEST_ATTACHED_MOLECULE_LOG") == "" {
|
|
return fmt.Errorf("bead not found")
|
|
}
|
|
|
|
// Get or create attachment fields
|
|
fields := beads.ParseAttachmentFields(issue)
|
|
if fields == nil {
|
|
fields = &beads.AttachmentFields{}
|
|
}
|
|
|
|
// Set the args
|
|
fields.AttachedArgs = args
|
|
|
|
// Update the description
|
|
newDesc := beads.SetAttachmentFields(issue, fields)
|
|
if logPath := os.Getenv("GT_TEST_ATTACHED_MOLECULE_LOG"); logPath != "" {
|
|
_ = os.WriteFile(logPath, []byte(newDesc), 0644)
|
|
}
|
|
|
|
// Update the bead
|
|
updateCmd := exec.Command("bd", "--no-daemon", "update", beadID, "--description="+newDesc)
|
|
updateCmd.Stderr = os.Stderr
|
|
if err := updateCmd.Run(); err != nil {
|
|
return fmt.Errorf("updating bead description: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// storeDispatcherInBead stores the dispatcher agent ID in the bead's description.
|
|
// This enables polecats to notify the dispatcher when work is complete.
|
|
func storeDispatcherInBead(beadID, dispatcher string) error {
|
|
if dispatcher == "" {
|
|
return nil
|
|
}
|
|
|
|
// Get the bead to preserve existing description content
|
|
// Use --no-daemon for consistency with other sling operations (see h-3f96b)
|
|
showCmd := exec.Command("bd", "--no-daemon", "show", beadID, "--json", "--allow-stale")
|
|
out, err := showCmd.Output()
|
|
if err != nil {
|
|
return fmt.Errorf("fetching bead: %w", err)
|
|
}
|
|
|
|
// Handle bd --no-daemon exit 0 bug: empty stdout means not found
|
|
if len(out) == 0 {
|
|
return fmt.Errorf("bead not found")
|
|
}
|
|
|
|
// Parse the bead
|
|
var issues []beads.Issue
|
|
if err := json.Unmarshal(out, &issues); err != nil {
|
|
return fmt.Errorf("parsing bead: %w", err)
|
|
}
|
|
if len(issues) == 0 {
|
|
return fmt.Errorf("bead not found")
|
|
}
|
|
issue := &issues[0]
|
|
|
|
// Get or create attachment fields
|
|
fields := beads.ParseAttachmentFields(issue)
|
|
if fields == nil {
|
|
fields = &beads.AttachmentFields{}
|
|
}
|
|
|
|
// Set the dispatcher
|
|
fields.DispatchedBy = dispatcher
|
|
|
|
// Update the description
|
|
newDesc := beads.SetAttachmentFields(issue, fields)
|
|
|
|
// Update the bead (use --no-daemon for consistency)
|
|
updateCmd := exec.Command("bd", "--no-daemon", "update", beadID, "--description="+newDesc)
|
|
updateCmd.Stderr = os.Stderr
|
|
if err := updateCmd.Run(); err != nil {
|
|
return fmt.Errorf("updating bead description: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// storeAttachedMoleculeInBead sets the attached_molecule field in a bead's description.
|
|
// This is required for gt hook to recognize that a molecule is attached to the bead.
|
|
// Called after bonding a formula wisp to a bead via "gt sling <formula> --on <bead>".
|
|
func storeAttachedMoleculeInBead(beadID, moleculeID string) error {
|
|
if moleculeID == "" {
|
|
return nil
|
|
}
|
|
logPath := os.Getenv("GT_TEST_ATTACHED_MOLECULE_LOG")
|
|
if logPath != "" {
|
|
_ = os.WriteFile(logPath, []byte("called"), 0644)
|
|
}
|
|
|
|
issue := &beads.Issue{}
|
|
if logPath == "" {
|
|
// Get the bead to preserve existing description content
|
|
// Use --no-daemon for consistency with other sling operations (see h-3f96b)
|
|
showCmd := exec.Command("bd", "--no-daemon", "show", beadID, "--json", "--allow-stale")
|
|
out, err := showCmd.Output()
|
|
if err != nil {
|
|
return fmt.Errorf("fetching bead: %w", err)
|
|
}
|
|
|
|
// Handle bd --no-daemon exit 0 bug: empty stdout means not found
|
|
if len(out) == 0 {
|
|
return fmt.Errorf("bead not found")
|
|
}
|
|
|
|
// Parse the bead
|
|
var issues []beads.Issue
|
|
if err := json.Unmarshal(out, &issues); err != nil {
|
|
return fmt.Errorf("parsing bead: %w", err)
|
|
}
|
|
if len(issues) == 0 {
|
|
return fmt.Errorf("bead not found")
|
|
}
|
|
issue = &issues[0]
|
|
}
|
|
|
|
// Get or create attachment fields
|
|
fields := beads.ParseAttachmentFields(issue)
|
|
if fields == nil {
|
|
fields = &beads.AttachmentFields{}
|
|
}
|
|
|
|
// Set the attached molecule
|
|
fields.AttachedMolecule = moleculeID
|
|
if fields.AttachedAt == "" {
|
|
fields.AttachedAt = time.Now().UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
// Update the description
|
|
newDesc := beads.SetAttachmentFields(issue, fields)
|
|
if logPath != "" {
|
|
_ = os.WriteFile(logPath, []byte(newDesc), 0644)
|
|
}
|
|
|
|
// Update the bead (use --no-daemon for consistency)
|
|
updateCmd := exec.Command("bd", "--no-daemon", "update", beadID, "--description="+newDesc)
|
|
updateCmd.Stderr = os.Stderr
|
|
if err := updateCmd.Run(); err != nil {
|
|
return fmt.Errorf("updating bead description: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// injectStartPrompt sends a prompt to the target pane to start working.
|
|
// Uses the reliable nudge pattern: literal mode + 500ms debounce + separate Enter.
|
|
func injectStartPrompt(pane, beadID, subject, args string) error {
|
|
if pane == "" {
|
|
return fmt.Errorf("no target pane")
|
|
}
|
|
|
|
// Skip nudge during tests to prevent agent self-interruption
|
|
if os.Getenv("GT_TEST_NO_NUDGE") != "" {
|
|
return nil
|
|
}
|
|
|
|
// Build the prompt to inject
|
|
var prompt string
|
|
if args != "" {
|
|
// Args provided - include them prominently in the prompt
|
|
if subject != "" {
|
|
prompt = fmt.Sprintf("Work slung: %s (%s). Args: %s. Start working now - use these args to guide your execution.", beadID, subject, args)
|
|
} else {
|
|
prompt = fmt.Sprintf("Work slung: %s. Args: %s. Start working now - use these args to guide your execution.", beadID, args)
|
|
}
|
|
} else if subject != "" {
|
|
prompt = fmt.Sprintf("Work slung: %s (%s). Start working on it now - no questions, just begin.", beadID, subject)
|
|
} else {
|
|
prompt = fmt.Sprintf("Work slung: %s. Start working on it now - run `gt hook` to see the hook, then begin.", beadID)
|
|
}
|
|
|
|
// Use the reliable nudge pattern (same as gt nudge / tmux.NudgeSession)
|
|
t := tmux.NewTmux()
|
|
return t.NudgePane(pane, prompt)
|
|
}
|
|
|
|
// getSessionFromPane extracts session name from a pane target.
|
|
// Pane targets can be:
|
|
// - "%9" (pane ID) - need to query tmux for session
|
|
// - "gt-rig-name:0.0" (session:window.pane) - extract session name
|
|
func getSessionFromPane(pane string) string {
|
|
if strings.HasPrefix(pane, "%") {
|
|
// Pane ID format - query tmux for the session
|
|
cmd := exec.Command("tmux", "display-message", "-t", pane, "-p", "#{session_name}")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
// Session:window.pane format - extract session name
|
|
if idx := strings.Index(pane, ":"); idx > 0 {
|
|
return pane[:idx]
|
|
}
|
|
return pane
|
|
}
|
|
|
|
// ensureAgentReady waits for an agent to be ready before nudging an existing session.
|
|
// Uses a pragmatic approach: wait for the pane to leave a shell, then (Claude-only)
|
|
// accept the bypass permissions warning and give it a moment to finish initializing.
|
|
func ensureAgentReady(sessionName string) error {
|
|
t := tmux.NewTmux()
|
|
|
|
// If an agent is already running, assume it's ready (session was started earlier)
|
|
if t.IsAgentRunning(sessionName) {
|
|
return nil
|
|
}
|
|
|
|
// Agent not running yet - wait for it to start (shell → program transition)
|
|
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
|
return fmt.Errorf("waiting for agent to start: %w", err)
|
|
}
|
|
|
|
// Claude-only: accept bypass permissions warning if present
|
|
if t.IsClaudeRunning(sessionName) {
|
|
_ = t.AcceptBypassPermissionsWarning(sessionName)
|
|
|
|
// PRAGMATIC APPROACH: fixed delay rather than prompt detection.
|
|
// Claude startup takes ~5-8 seconds on typical machines.
|
|
time.Sleep(8 * time.Second)
|
|
} else {
|
|
time.Sleep(1 * time.Second)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// detectCloneRoot finds the root of the current git clone.
|
|
func detectCloneRoot() (string, error) {
|
|
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("not in a git repository")
|
|
}
|
|
return strings.TrimSpace(string(out)), nil
|
|
}
|
|
|
|
// detectActor returns the current agent's actor string for event logging.
|
|
func detectActor() string {
|
|
roleInfo, err := GetRole()
|
|
if err != nil {
|
|
return "unknown"
|
|
}
|
|
return roleInfo.ActorString()
|
|
}
|
|
|
|
// agentIDToBeadID converts an agent ID to its corresponding agent bead ID.
|
|
// Uses canonical naming: prefix-rig-role-name
|
|
// Town-level agents (Mayor, Deacon) use hq- prefix and are stored in town beads.
|
|
// Rig-level agents use the rig's configured prefix (default "gt-").
|
|
// townRoot is needed to look up the rig's configured prefix.
|
|
func agentIDToBeadID(agentID, townRoot string) string {
|
|
// Normalize: strip trailing slash (resolveSelfTarget returns "mayor/" not "mayor")
|
|
agentID = strings.TrimSuffix(agentID, "/")
|
|
|
|
// Handle simple cases (town-level agents with hq- prefix)
|
|
if agentID == "mayor" {
|
|
return beads.MayorBeadIDTown()
|
|
}
|
|
if agentID == "deacon" {
|
|
return beads.DeaconBeadIDTown()
|
|
}
|
|
|
|
// Parse path-style agent IDs
|
|
parts := strings.Split(agentID, "/")
|
|
if len(parts) < 2 {
|
|
return ""
|
|
}
|
|
|
|
rig := parts[0]
|
|
prefix := beads.GetPrefixForRig(townRoot, rig)
|
|
|
|
switch {
|
|
case len(parts) == 2 && parts[1] == "witness":
|
|
return beads.WitnessBeadIDWithPrefix(prefix, rig)
|
|
case len(parts) == 2 && parts[1] == "refinery":
|
|
return beads.RefineryBeadIDWithPrefix(prefix, rig)
|
|
case len(parts) == 3 && parts[1] == "crew":
|
|
return beads.CrewBeadIDWithPrefix(prefix, rig, parts[2])
|
|
case len(parts) == 3 && parts[1] == "polecats":
|
|
return beads.PolecatBeadIDWithPrefix(prefix, rig, parts[2])
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// updateAgentHookBead updates the agent bead's state and hook when work is slung.
|
|
// This enables the witness to see that each agent is working.
|
|
//
|
|
// 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.
|
|
//
|
|
// For rig-level beads (same database), we set the hook_bead slot directly.
|
|
// For cross-database scenarios (agent in rig db, hook bead in town db),
|
|
// the slot set may fail - this is handled gracefully with a warning.
|
|
// The work is still correctly attached via `bd update <bead> --assignee=<agent>`.
|
|
func updateAgentHookBead(agentID, beadID, workDir, townBeadsDir string) {
|
|
_ = townBeadsDir // Not used - BEADS_DIR breaks redirect mechanism
|
|
|
|
// 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
|
|
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
|
|
}
|
|
if bdWorkDir == "" {
|
|
bdWorkDir = townRoot
|
|
}
|
|
|
|
// Convert agent ID to agent bead ID
|
|
// Format examples (canonical: prefix-rig-role-name):
|
|
// greenplace/crew/max -> gt-greenplace-crew-max
|
|
// greenplace/polecats/Toast -> gt-greenplace-polecat-Toast
|
|
// mayor -> hq-mayor
|
|
// greenplace/witness -> gt-greenplace-witness
|
|
agentBeadID := agentIDToBeadID(agentID, townRoot)
|
|
if agentBeadID == "" {
|
|
return
|
|
}
|
|
|
|
// Resolve the correct working directory for the agent bead.
|
|
// Agent beads with rig-level prefixes (e.g., go-) live in rig databases,
|
|
// not the town database. Use prefix-based resolution to find the correct path.
|
|
// This fixes go-19z: bd slot commands failing for go-* prefixed beads.
|
|
agentWorkDir := beads.ResolveHookDir(townRoot, agentBeadID, bdWorkDir)
|
|
|
|
// Run from agentWorkDir WITHOUT BEADS_DIR to enable redirect-based routing.
|
|
// Set hook_bead to the slung work (gt-zecmc: removed agent_state update).
|
|
// Agent liveness is observable from tmux - no need to record it in bead.
|
|
// For cross-database scenarios, slot set may fail gracefully (warning only).
|
|
bd := beads.New(agentWorkDir)
|
|
if err := bd.SetHookBead(agentBeadID, beadID); err != nil {
|
|
// Log warning instead of silent ignore - helps debug cross-beads issues
|
|
fmt.Fprintf(os.Stderr, "Warning: couldn't set agent %s hook: %v\n", agentBeadID, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// wakeRigAgents wakes the witness and refinery for a rig after polecat dispatch.
|
|
// This ensures the patrol agents are ready to monitor and merge.
|
|
func wakeRigAgents(rigName string) {
|
|
// Boot the rig (idempotent - no-op if already running)
|
|
bootCmd := exec.Command("gt", "rig", "boot", rigName)
|
|
_ = bootCmd.Run() // Ignore errors - rig might already be running
|
|
|
|
// Nudge witness and refinery to clear any backoff
|
|
t := tmux.NewTmux()
|
|
witnessSession := fmt.Sprintf("gt-%s-witness", rigName)
|
|
refinerySession := fmt.Sprintf("gt-%s-refinery", rigName)
|
|
|
|
// Silent nudges - sessions might not exist yet
|
|
_ = t.NudgeSession(witnessSession, "Polecat dispatched - check for work")
|
|
_ = t.NudgeSession(refinerySession, "Polecat dispatched - check for merge requests")
|
|
}
|
|
|
|
// isPolecatTarget checks if the target string refers to a polecat.
|
|
// Returns true if the target format is "rig/polecats/name".
|
|
// This is used to determine if we should respawn a dead polecat
|
|
// instead of failing when slinging work.
|
|
func isPolecatTarget(target string) bool {
|
|
parts := strings.Split(target, "/")
|
|
return len(parts) >= 3 && parts[1] == "polecats"
|
|
}
|
|
|
|
// 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()
|
|
}
|