Files
beads/cmd/bd/mol_bond.go
Steve Yegge cb6f3cdb5e feat: Add formula name support to bd mol bond (gt-8tmz.25)
Bond command now accepts formula names (e.g., mol-polecat-arm) in addition
to issue IDs. When a formula name is given:

1. Looks up the formula using formula.Parser
2. Resolves inheritance and applies transformations (control flow, advice,
   expansions, aspects)
3. Cooks the formula inline to create an ephemeral proto
4. Uses the cooked proto for bonding

This eliminates the need for pre-cooked proto beads in the database,
enabling more dynamic workflow composition.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 16:39:27 -08:00

623 lines
21 KiB
Go

package main
import (
"context"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/formula"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/utils"
)
var molBondCmd = &cobra.Command{
Use: "bond <A> <B>",
Aliases: []string{"fart"}, // Easter egg: molecules can produce gas
Short: "Bond two protos or molecules together",
Long: `Bond two protos or molecules to create a compound.
The bond command is polymorphic - it handles different operand types:
formula + formula → cook both, compound proto
formula + proto → cook formula, compound proto
formula + mol → cook formula, spawn and attach
proto + proto → compound proto (reusable template)
proto + mol → spawn proto, attach to molecule
mol + proto → spawn proto, attach to molecule
mol + mol → join into compound molecule
Formula names (e.g., mol-polecat-arm) are cooked inline as ephemeral protos.
This avoids needing pre-cooked proto beads in the database.
Bond types:
sequential (default) - B runs after A completes
parallel - B runs alongside A
conditional - B runs only if A fails
Phase control:
By default, spawned protos follow the target's phase:
- Attaching to mol (Wisp=false) → spawns as persistent (Wisp=false)
- Attaching to wisp (Wisp=true) → spawns as ephemeral (Wisp=true)
Override with:
--pour Force spawn as liquid (persistent, Wisp=false)
--wisp Force spawn as vapor (ephemeral, Wisp=true, excluded from JSONL export)
Dynamic bonding (Christmas Ornament pattern):
Use --ref to specify a custom child reference with variable substitution.
This creates IDs like "parent.child-ref" instead of random hashes.
Example:
bd mol bond mol-polecat-arm bd-patrol --ref arm-{{polecat_name}} --var polecat_name=ace
# Creates: bd-patrol.arm-ace (and children like bd-patrol.arm-ace.capture)
Use cases:
- Found important bug during patrol? Use --pour to persist it
- Need ephemeral diagnostic on persistent feature? Use --wisp
- Spawning per-worker arms on a patrol? Use --ref for readable IDs
Examples:
bd mol bond mol-feature mol-deploy # Compound proto
bd mol bond mol-feature mol-deploy --type parallel # Run in parallel
bd mol bond mol-feature bd-abc123 # Attach proto to molecule
bd mol bond bd-abc123 bd-def456 # Join two molecules
bd mol bond mol-critical-bug wisp-patrol --pour # Persist found bug
bd mol bond mol-temp-check bd-feature --wisp # Ephemeral diagnostic
bd mol bond mol-arm bd-patrol --ref arm-{{name}} --var name=ace # Dynamic child ID`,
Args: cobra.ExactArgs(2),
Run: runMolBond,
}
// BondResult holds the result of a bond operation
type BondResult struct {
ResultID string `json:"result_id"`
ResultType string `json:"result_type"` // "compound_proto" or "compound_molecule"
BondType string `json:"bond_type"`
Spawned int `json:"spawned,omitempty"` // Number of issues spawned (if proto was involved)
IDMapping map[string]string `json:"id_mapping,omitempty"` // Old ID -> new ID for spawned issues
}
// runMolBond implements the polymorphic bond command
func runMolBond(cmd *cobra.Command, args []string) {
CheckReadonly("mol bond")
ctx := rootCtx
// mol bond requires direct store access
if store == nil {
if daemonClient != nil {
fmt.Fprintf(os.Stderr, "Error: mol bond requires direct database access\n")
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol bond %s %s ...\n", args[0], args[1])
} else {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
}
os.Exit(1)
}
bondType, _ := cmd.Flags().GetString("type")
customTitle, _ := cmd.Flags().GetString("as")
dryRun, _ := cmd.Flags().GetBool("dry-run")
varFlags, _ := cmd.Flags().GetStringSlice("var")
wisp, _ := cmd.Flags().GetBool("wisp")
pour, _ := cmd.Flags().GetBool("pour")
childRef, _ := cmd.Flags().GetString("ref")
// Validate phase flags are not both set
if wisp && pour {
fmt.Fprintf(os.Stderr, "Error: cannot use both --wisp and --pour\n")
os.Exit(1)
}
// All issues go in the main store; wisp vs pour determines the Wisp flag
// --wisp: create with Wisp=true (ephemeral, excluded from JSONL export)
// --pour: create with Wisp=false (persistent, exported to JSONL)
// Default: follow target's phase (wisp if target is wisp, otherwise persistent)
// Validate bond type
if bondType != types.BondTypeSequential && bondType != types.BondTypeParallel && bondType != types.BondTypeConditional {
fmt.Fprintf(os.Stderr, "Error: invalid bond type '%s', must be: sequential, parallel, or conditional\n", bondType)
os.Exit(1)
}
// Parse variables
vars := make(map[string]string)
for _, v := range varFlags {
parts := strings.SplitN(v, "=", 2)
if len(parts) != 2 {
fmt.Fprintf(os.Stderr, "Error: invalid variable format '%s', expected 'key=value'\n", v)
os.Exit(1)
}
vars[parts[0]] = parts[1]
}
// Resolve both operands - can be issue IDs or formula names
// Formula names are cooked inline to ephemeral protos (gt-8tmz.25)
issueA, cookedA, err := resolveOrCookFormula(ctx, store, args[0], vars, actor)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
issueB, cookedB, err := resolveOrCookFormula(ctx, store, args[1], vars, actor)
if err != nil {
// Clean up first cooked formula if second one fails
if cookedA {
_ = deleteProtoSubgraph(ctx, store, issueA.ID)
}
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Track if we cooked formulas for cleanup on error
cleanupCooked := func() {
if cookedA {
_ = deleteProtoSubgraph(ctx, store, issueA.ID)
}
if cookedB {
_ = deleteProtoSubgraph(ctx, store, issueB.ID)
}
}
idA := issueA.ID
idB := issueB.ID
// Determine operand types
aIsProto := isProto(issueA)
bIsProto := isProto(issueB)
if dryRun {
fmt.Printf("\nDry run: bond %s + %s\n", idA, idB)
fmt.Printf(" A: %s (%s)\n", issueA.Title, operandType(aIsProto))
fmt.Printf(" B: %s (%s)\n", issueB.Title, operandType(bIsProto))
fmt.Printf(" Bond type: %s\n", bondType)
if wisp {
fmt.Printf(" Phase override: vapor (--wisp)\n")
} else if pour {
fmt.Printf(" Phase override: liquid (--pour)\n")
}
if childRef != "" {
resolvedRef := substituteVariables(childRef, vars)
fmt.Printf(" Child ref: %s (resolved: %s)\n", childRef, resolvedRef)
}
if aIsProto && bIsProto {
fmt.Printf(" Result: compound proto\n")
if customTitle != "" {
fmt.Printf(" Custom title: %s\n", customTitle)
}
if wisp || pour {
fmt.Printf(" Note: phase flags ignored for proto+proto (templates stay in permanent storage)\n")
}
if childRef != "" {
fmt.Printf(" Note: --ref ignored for proto+proto (use when bonding to molecule)\n")
}
} else if aIsProto || bIsProto {
fmt.Printf(" Result: spawn proto, attach to molecule\n")
if !wisp && !pour {
fmt.Printf(" Phase: follows target's phase\n")
}
if childRef != "" {
// Determine parent molecule
parentID := idB
if bIsProto {
parentID = idA
}
resolvedRef := substituteVariables(childRef, vars)
fmt.Printf(" Bonded ID: %s.%s\n", parentID, resolvedRef)
}
} else {
fmt.Printf(" Result: compound molecule\n")
if childRef != "" {
fmt.Printf(" Note: --ref ignored for mol+mol (use when bonding proto to molecule)\n")
}
}
return
}
// Dispatch based on operand types
// All operations use the main store; wisp flag determines ephemeral vs persistent
var result *BondResult
switch {
case aIsProto && bIsProto:
// Compound protos are templates - always persistent
result, err = bondProtoProto(ctx, store, issueA, issueB, bondType, customTitle, actor)
case aIsProto && !bIsProto:
result, err = bondProtoMol(ctx, store, issueA, issueB, bondType, vars, childRef, actor, wisp, pour)
case !aIsProto && bIsProto:
result, err = bondMolProto(ctx, store, issueA, issueB, bondType, vars, childRef, actor, wisp, pour)
default:
result, err = bondMolMol(ctx, store, issueA, issueB, bondType, actor)
}
if err != nil {
cleanupCooked()
fmt.Fprintf(os.Stderr, "Error bonding: %v\n", err)
os.Exit(1)
}
// Schedule auto-flush - wisps are in main DB now, but JSONL export skips them
markDirtyAndScheduleFlush()
if jsonOutput {
outputJSON(result)
return
}
fmt.Printf("%s Bonded: %s + %s\n", ui.RenderPass("✓"), idA, idB)
fmt.Printf(" Result: %s (%s)\n", result.ResultID, result.ResultType)
if result.Spawned > 0 {
fmt.Printf(" Spawned: %d issues\n", result.Spawned)
}
if wisp {
fmt.Printf(" Phase: vapor (ephemeral, Wisp=true)\n")
} else if pour {
fmt.Printf(" Phase: liquid (persistent, Wisp=false)\n")
}
}
// isProto checks if an issue is a proto (has the template label)
func isProto(issue *types.Issue) bool {
for _, label := range issue.Labels {
if label == MoleculeLabel {
return true
}
}
return false
}
// operandType returns a human-readable type string
func operandType(isProtoIssue bool) string {
if isProtoIssue {
return "proto"
}
return "molecule"
}
// bondProtoProto bonds two protos to create a compound proto
func bondProtoProto(ctx context.Context, s storage.Storage, protoA, protoB *types.Issue, bondType, customTitle, actorName string) (*BondResult, error) {
// Create compound proto: a new root that references both protos as children
// The compound root will be a new issue that ties them together
compoundTitle := fmt.Sprintf("Compound: %s + %s", protoA.Title, protoB.Title)
if customTitle != "" {
compoundTitle = customTitle
}
var compoundID string
err := s.RunInTransaction(ctx, func(tx storage.Transaction) error {
// Create compound root issue
compound := &types.Issue{
Title: compoundTitle,
Description: fmt.Sprintf("Compound proto bonding %s and %s", protoA.ID, protoB.ID),
Status: types.StatusOpen,
Priority: minPriority(protoA.Priority, protoB.Priority),
IssueType: types.TypeEpic,
BondedFrom: []types.BondRef{
{ProtoID: protoA.ID, BondType: bondType, BondPoint: ""},
{ProtoID: protoB.ID, BondType: bondType, BondPoint: ""},
},
}
if err := tx.CreateIssue(ctx, compound, actorName); err != nil {
return fmt.Errorf("creating compound: %w", err)
}
compoundID = compound.ID
// Add template label (labels are stored separately, not in issue table)
if err := tx.AddLabel(ctx, compoundID, MoleculeLabel, actorName); err != nil {
return fmt.Errorf("adding template label: %w", err)
}
// Add parent-child dependencies from compound to both proto roots
depA := &types.Dependency{
IssueID: protoA.ID,
DependsOnID: compoundID,
Type: types.DepParentChild,
}
if err := tx.AddDependency(ctx, depA, actorName); err != nil {
return fmt.Errorf("linking proto A: %w", err)
}
depB := &types.Dependency{
IssueID: protoB.ID,
DependsOnID: compoundID,
Type: types.DepParentChild,
}
if err := tx.AddDependency(ctx, depB, actorName); err != nil {
return fmt.Errorf("linking proto B: %w", err)
}
// For sequential/conditional bonding, add blocking dependency: B blocks on A
// Sequential: B runs after A completes (any outcome)
// Conditional: B runs only if A fails (bd-kzda)
if bondType == types.BondTypeSequential || bondType == types.BondTypeConditional {
depType := types.DepBlocks
if bondType == types.BondTypeConditional {
depType = types.DepConditionalBlocks
}
seqDep := &types.Dependency{
IssueID: protoB.ID,
DependsOnID: protoA.ID,
Type: depType,
}
if err := tx.AddDependency(ctx, seqDep, actorName); err != nil {
return fmt.Errorf("adding sequence dep: %w", err)
}
}
return nil
})
if err != nil {
return nil, err
}
return &BondResult{
ResultID: compoundID,
ResultType: "compound_proto",
BondType: bondType,
Spawned: 0,
}, nil
}
// bondProtoMol bonds a proto to an existing molecule by spawning the proto.
// If childRef is provided, generates custom IDs like "parent.childref" (dynamic bonding).
func bondProtoMol(ctx context.Context, s storage.Storage, proto, mol *types.Issue, bondType string, vars map[string]string, childRef string, actorName string, wispFlag, pourFlag bool) (*BondResult, error) {
// Load proto subgraph
subgraph, err := loadTemplateSubgraph(ctx, s, proto.ID)
if err != nil {
return nil, fmt.Errorf("loading proto: %w", err)
}
// Check for missing variables
requiredVars := extractAllVariables(subgraph)
var missingVars []string
for _, v := range requiredVars {
if _, ok := vars[v]; !ok {
missingVars = append(missingVars, v)
}
}
if len(missingVars) > 0 {
return nil, fmt.Errorf("missing required variables: %s (use --var)", strings.Join(missingVars, ", "))
}
// Determine wisp flag based on explicit flags or target's phase
// --wisp: force wisp=true, --pour: force wisp=false, neither: follow target
makeWisp := mol.Wisp // Default: follow target's phase
if wispFlag {
makeWisp = true
} else if pourFlag {
makeWisp = false
}
// Build CloneOptions for spawning
opts := CloneOptions{
Vars: vars,
Actor: actorName,
Wisp: makeWisp,
}
// Dynamic bonding: use custom IDs if childRef is provided
if childRef != "" {
opts.ParentID = mol.ID
opts.ChildRef = childRef
}
// Spawn the proto with options
spawnResult, err := spawnMoleculeWithOptions(ctx, s, subgraph, opts)
if err != nil {
return nil, fmt.Errorf("spawning proto: %w", err)
}
// Attach spawned molecule to existing molecule
err = s.RunInTransaction(ctx, func(tx storage.Transaction) error {
// Add dependency from spawned root to molecule
// Sequential: use blocks (B runs after A completes)
// Conditional: use conditional-blocks (B runs only if A fails) (bd-kzda)
// Parallel: use parent-child (organizational, no blocking)
// Note: Schema only allows one dependency per (issue_id, depends_on_id) pair
var depType types.DependencyType
switch bondType {
case types.BondTypeSequential:
depType = types.DepBlocks
case types.BondTypeConditional:
depType = types.DepConditionalBlocks
default:
depType = types.DepParentChild
}
dep := &types.Dependency{
IssueID: spawnResult.NewEpicID,
DependsOnID: mol.ID,
Type: depType,
}
return tx.AddDependency(ctx, dep, actorName)
// Note: bonded_from field tracking is not yet supported by storage layer.
// The dependency relationship captures the bonding semantics.
})
if err != nil {
return nil, fmt.Errorf("attaching to molecule: %w", err)
}
return &BondResult{
ResultID: mol.ID,
ResultType: "compound_molecule",
BondType: bondType,
Spawned: spawnResult.Created,
IDMapping: spawnResult.IDMapping,
}, nil
}
// bondMolProto bonds a molecule to a proto (symmetric with bondProtoMol)
func bondMolProto(ctx context.Context, s storage.Storage, mol, proto *types.Issue, bondType string, vars map[string]string, childRef string, actorName string, wispFlag, pourFlag bool) (*BondResult, error) {
// Same as bondProtoMol but with arguments swapped
return bondProtoMol(ctx, s, proto, mol, bondType, vars, childRef, actorName, wispFlag, pourFlag)
}
// bondMolMol bonds two molecules together
func bondMolMol(ctx context.Context, s storage.Storage, molA, molB *types.Issue, bondType, actorName string) (*BondResult, error) {
err := s.RunInTransaction(ctx, func(tx storage.Transaction) error {
// Add dependency: B links to A
// Sequential: use blocks (B runs after A completes)
// Conditional: use conditional-blocks (B runs only if A fails) (bd-kzda)
// Parallel: use parent-child (organizational, no blocking)
// Note: Schema only allows one dependency per (issue_id, depends_on_id) pair
var depType types.DependencyType
switch bondType {
case types.BondTypeSequential:
depType = types.DepBlocks
case types.BondTypeConditional:
depType = types.DepConditionalBlocks
default:
depType = types.DepParentChild
}
dep := &types.Dependency{
IssueID: molB.ID,
DependsOnID: molA.ID,
Type: depType,
}
if err := tx.AddDependency(ctx, dep, actorName); err != nil {
return fmt.Errorf("linking molecules: %w", err)
}
// Note: bonded_from field tracking is not yet supported by storage layer.
// The dependency relationship captures the bonding semantics.
return nil
})
if err != nil {
return nil, fmt.Errorf("linking molecules: %w", err)
}
return &BondResult{
ResultID: molA.ID,
ResultType: "compound_molecule",
BondType: bondType,
}, nil
}
// minPriority returns the higher priority (lower number)
func minPriority(a, b int) int {
if a < b {
return a
}
return b
}
// resolveOrCookFormula tries to resolve an operand as an issue ID.
// If not found and it looks like a formula name, cooks the formula inline.
// Returns the issue, whether it was cooked (ephemeral proto), and any error.
//
// This implements gt-8tmz.25: formula names are cooked inline as ephemeral protos.
func resolveOrCookFormula(ctx context.Context, s storage.Storage, operand string, vars map[string]string, actorName string) (*types.Issue, bool, error) {
// First, try to resolve as an existing issue
id, err := utils.ResolvePartialID(ctx, s, operand)
if err == nil {
issue, err := s.GetIssue(ctx, id)
if err == nil {
return issue, false, nil
}
}
// Not found as issue - check if it looks like a formula name
if !looksLikeFormulaName(operand) {
return nil, false, fmt.Errorf("'%s' not found (not an issue ID or formula name)", operand)
}
// Try to load and cook the formula
parser := formula.NewParser()
f, err := parser.LoadByName(operand)
if err != nil {
return nil, false, fmt.Errorf("'%s' not found as issue or formula: %w", operand, err)
}
// Resolve formula (inheritance, etc)
resolved, err := parser.Resolve(f)
if err != nil {
return nil, false, fmt.Errorf("resolving formula '%s': %w", operand, err)
}
// Apply control flow operators (gt-8tmz.4)
controlFlowSteps, err := formula.ApplyControlFlow(resolved.Steps, resolved.Compose)
if err != nil {
return nil, false, fmt.Errorf("applying control flow to '%s': %w", operand, err)
}
resolved.Steps = controlFlowSteps
// Apply advice transformations (gt-8tmz.2)
if len(resolved.Advice) > 0 {
resolved.Steps = formula.ApplyAdvice(resolved.Steps, resolved.Advice)
}
// Apply expansion operators (gt-8tmz.3)
if resolved.Compose != nil && (len(resolved.Compose.Expand) > 0 || len(resolved.Compose.Map) > 0) {
expandedSteps, err := formula.ApplyExpansions(resolved.Steps, resolved.Compose, parser)
if err != nil {
return nil, false, fmt.Errorf("applying expansions to '%s': %w", operand, err)
}
resolved.Steps = expandedSteps
}
// Apply aspects (gt-8tmz.5)
if resolved.Compose != nil && len(resolved.Compose.Aspects) > 0 {
for _, aspectName := range resolved.Compose.Aspects {
aspectFormula, err := parser.LoadByName(aspectName)
if err != nil {
return nil, false, fmt.Errorf("loading aspect '%s': %w", aspectName, err)
}
if aspectFormula.Type != formula.TypeAspect {
return nil, false, fmt.Errorf("'%s' is not an aspect formula (type=%s)", aspectName, aspectFormula.Type)
}
if len(aspectFormula.Advice) > 0 {
resolved.Steps = formula.ApplyAdvice(resolved.Steps, aspectFormula.Advice)
}
}
}
// Cook the formula to create an ephemeral proto
// Use formula name as proto ID for clarity
protoID := resolved.Formula
result, err := cookFormula(ctx, s, resolved, protoID)
if err != nil {
return nil, false, fmt.Errorf("cooking formula '%s': %w", operand, err)
}
// Load the cooked proto
issue, err := s.GetIssue(ctx, result.ProtoID)
if err != nil {
return nil, false, fmt.Errorf("loading cooked proto '%s': %w", result.ProtoID, err)
}
return issue, true, nil
}
// looksLikeFormulaName checks if an operand looks like a formula name.
// Formula names typically start with "mol-" or contain ".formula" patterns.
func looksLikeFormulaName(operand string) bool {
// Common formula prefixes
if strings.HasPrefix(operand, "mol-") {
return true
}
// Formula file references
if strings.Contains(operand, ".formula") {
return true
}
// If it contains a path separator, might be a formula path
if strings.Contains(operand, "/") || strings.Contains(operand, "\\") {
return true
}
return false
}
func init() {
molBondCmd.Flags().String("type", types.BondTypeSequential, "Bond type: sequential, parallel, or conditional")
molBondCmd.Flags().String("as", "", "Custom title for compound proto (proto+proto only)")
molBondCmd.Flags().Bool("dry-run", false, "Preview what would be created")
molBondCmd.Flags().StringSlice("var", []string{}, "Variable substitution for spawned protos (key=value)")
molBondCmd.Flags().Bool("wisp", false, "Force spawn as vapor (ephemeral, Wisp=true)")
molBondCmd.Flags().Bool("pour", false, "Force spawn as liquid (persistent, Wisp=false)")
molBondCmd.Flags().String("ref", "", "Custom child reference with {{var}} substitution (e.g., arm-{{polecat_name}})")
molCmd.AddCommand(molBondCmd)
}