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>
This commit is contained in:
Steve Yegge
2025-12-25 16:39:12 -08:00
parent d2f71773cb
commit cb6f3cdb5e

View File

@@ -7,6 +7,7 @@ import (
"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"
@@ -21,10 +22,16 @@ var molBondCmd = &cobra.Command{
The bond command is polymorphic - it handles different operand types:
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 + 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
@@ -127,30 +134,36 @@ func runMolBond(cmd *cobra.Command, args []string) {
vars[parts[0]] = parts[1]
}
// Resolve both IDs
idA, err := utils.ResolvePartialID(ctx, store, args[0])
// 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: '%s' not found\n", args[0])
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
idB, err := utils.ResolvePartialID(ctx, store, args[1])
issueB, cookedB, err := resolveOrCookFormula(ctx, store, args[1], vars, actor)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: '%s' not found\n", args[1])
// 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)
}
// Load both issues
issueA, err := store.GetIssue(ctx, idA)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading %s: %v\n", idA, err)
os.Exit(1)
}
issueB, err := store.GetIssue(ctx, idB)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading %s: %v\n", idB, 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)
@@ -219,6 +232,7 @@ func runMolBond(cmd *cobra.Command, args []string) {
}
if err != nil {
cleanupCooked()
fmt.Fprintf(os.Stderr, "Error bonding: %v\n", err)
os.Exit(1)
}
@@ -490,6 +504,111 @@ func minPriority(a, b int) int {
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)")