gt-4v1eo: Implement ephemeral protos - cook formulas inline

Major refactor of molecular chemistry to make protos ephemeral:
- Formulas are now cooked directly to in-memory TemplateSubgraph
- No more proto beads stored in the database

Changes:
- cook.go: Add cookFormulaToSubgraph() and resolveAndCookFormula()
  for in-memory formula cooking
- template.go: Add VarDefs field to TemplateSubgraph for default
  value handling, add extractRequiredVariables() and
  applyVariableDefaults() helpers
- pour.go: Try formula loading first for any name (not just mol-)
- wisp.go: Same pattern as pour
- mol_bond.go: Use resolveOrCookToSubgraph() for in-memory subgraphs
- mol_catalog.go: List formulas from disk instead of DB proto beads
- mol_distill.go: Output .formula.json files instead of proto beads

Flow: Formula (.formula.json) -> pour/wisp (cook inline) -> Mol/Wisp

🤖 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 17:20:10 -08:00
parent ec85577589
commit 54a051aba3
7 changed files with 807 additions and 407 deletions

View File

@@ -262,6 +262,257 @@ type cookFormulaResult struct {
Created int Created int
} }
// cookFormulaToSubgraph creates an in-memory TemplateSubgraph from a resolved formula.
// This is the ephemeral proto implementation - no database storage.
// The returned subgraph can be passed directly to cloneSubgraph for instantiation.
func cookFormulaToSubgraph(f *formula.Formula, protoID string) (*TemplateSubgraph, error) {
// Map step ID -> created issue
issueMap := make(map[string]*types.Issue)
// Collect all issues and dependencies
var issues []*types.Issue
var deps []*types.Dependency
// Create root proto epic
rootIssue := &types.Issue{
ID: protoID,
Title: f.Formula, // Title is the original formula name
Description: f.Description,
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeEpic,
IsTemplate: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
issues = append(issues, rootIssue)
issueMap[protoID] = rootIssue
// Collect issues for each step (use protoID as parent for step IDs)
collectStepsToSubgraph(f.Steps, protoID, issueMap, &issues, &deps)
// Collect dependencies from depends_on
stepIDMapping := make(map[string]string)
for _, step := range f.Steps {
collectStepIDMappings(step, protoID, stepIDMapping)
}
for _, step := range f.Steps {
collectDependenciesToSubgraph(step, stepIDMapping, &deps)
}
return &TemplateSubgraph{
Root: rootIssue,
Issues: issues,
Dependencies: deps,
IssueMap: issueMap,
}, nil
}
// collectStepsToSubgraph collects issues and dependencies for steps and their children.
// This is the in-memory version that doesn't create labels (since those require DB).
func collectStepsToSubgraph(steps []*formula.Step, parentID string, issueMap map[string]*types.Issue,
issues *[]*types.Issue, deps *[]*types.Dependency) {
for _, step := range steps {
// Generate issue ID (formula-name.step-id)
issueID := fmt.Sprintf("%s.%s", parentID, step.ID)
// Determine issue type
issueType := types.TypeTask
if step.Type != "" {
switch step.Type {
case "task":
issueType = types.TypeTask
case "bug":
issueType = types.TypeBug
case "feature":
issueType = types.TypeFeature
case "epic":
issueType = types.TypeEpic
case "chore":
issueType = types.TypeChore
}
}
// If step has children, it's an epic
if len(step.Children) > 0 {
issueType = types.TypeEpic
}
// Determine priority
priority := 2
if step.Priority != nil {
priority = *step.Priority
}
issue := &types.Issue{
ID: issueID,
Title: step.Title, // Keep {{variables}} for substitution at pour time
Description: step.Description,
Status: types.StatusOpen,
Priority: priority,
IssueType: issueType,
Assignee: step.Assignee,
IsTemplate: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SourceFormula: step.SourceFormula, // Source tracing (gt-8tmz.18)
SourceLocation: step.SourceLocation, // Source tracing (gt-8tmz.18)
}
// Store labels in the issue's Labels field for in-memory use
issue.Labels = append(issue.Labels, step.Labels...)
// Add gate label for waits_for field (bd-j4cr)
if step.WaitsFor != "" {
gateLabel := fmt.Sprintf("gate:%s", step.WaitsFor)
issue.Labels = append(issue.Labels, gateLabel)
}
*issues = append(*issues, issue)
issueMap[issueID] = issue
// Add parent-child dependency
*deps = append(*deps, &types.Dependency{
IssueID: issueID,
DependsOnID: parentID,
Type: types.DepParentChild,
})
// Recursively collect children
if len(step.Children) > 0 {
collectStepsToSubgraph(step.Children, issueID, issueMap, issues, deps)
}
}
}
// collectStepIDMappings builds a map from step ID to full issue ID
func collectStepIDMappings(step *formula.Step, parentID string, mapping map[string]string) {
issueID := fmt.Sprintf("%s.%s", parentID, step.ID)
mapping[step.ID] = issueID
for _, child := range step.Children {
collectStepIDMappings(child, issueID, mapping)
}
}
// collectDependenciesToSubgraph collects blocking dependencies from depends_on and needs fields.
func collectDependenciesToSubgraph(step *formula.Step, idMapping map[string]string, deps *[]*types.Dependency) {
issueID := idMapping[step.ID]
// Process depends_on field
for _, depID := range step.DependsOn {
depIssueID, ok := idMapping[depID]
if !ok {
continue // Will be caught during validation
}
*deps = append(*deps, &types.Dependency{
IssueID: issueID,
DependsOnID: depIssueID,
Type: types.DepBlocks,
})
}
// Process needs field (bd-hr39) - simpler alias for sibling dependencies
for _, needID := range step.Needs {
needIssueID, ok := idMapping[needID]
if !ok {
continue // Will be caught during validation
}
*deps = append(*deps, &types.Dependency{
IssueID: issueID,
DependsOnID: needIssueID,
Type: types.DepBlocks,
})
}
// Recursively handle children
for _, child := range step.Children {
collectDependenciesToSubgraph(child, idMapping, deps)
}
}
// resolveAndCookFormula loads a formula by name, resolves it, applies all transformations,
// and returns an in-memory TemplateSubgraph ready for instantiation.
// This is the main entry point for ephemeral proto cooking.
func resolveAndCookFormula(formulaName string, searchPaths []string) (*TemplateSubgraph, error) {
// Create parser with search paths
parser := formula.NewParser(searchPaths...)
// Load formula by name
f, err := parser.LoadByName(formulaName)
if err != nil {
return nil, fmt.Errorf("loading formula %q: %w", formulaName, err)
}
// Resolve inheritance
resolved, err := parser.Resolve(f)
if err != nil {
return nil, fmt.Errorf("resolving formula %q: %w", formulaName, err)
}
// Apply control flow operators (gt-8tmz.4) - loops, branches, gates
controlFlowSteps, err := formula.ApplyControlFlow(resolved.Steps, resolved.Compose)
if err != nil {
return nil, fmt.Errorf("applying control flow to %q: %w", formulaName, 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, fmt.Errorf("applying expansions to %q: %w", formulaName, err)
}
resolved.Steps = expandedSteps
}
// Apply aspects from compose.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, fmt.Errorf("loading aspect %q: %w", aspectName, err)
}
if aspectFormula.Type != formula.TypeAspect {
return nil, fmt.Errorf("%q is not an aspect formula (type=%s)", aspectName, aspectFormula.Type)
}
if len(aspectFormula.Advice) > 0 {
resolved.Steps = formula.ApplyAdvice(resolved.Steps, aspectFormula.Advice)
}
}
}
// Cook to in-memory subgraph, including variable definitions for default handling
return cookFormulaToSubgraphWithVars(resolved, resolved.Formula, resolved.Vars)
}
// cookFormulaToSubgraphWithVars creates an in-memory subgraph with variable info attached
func cookFormulaToSubgraphWithVars(f *formula.Formula, protoID string, vars map[string]*formula.VarDef) (*TemplateSubgraph, error) {
subgraph, err := cookFormulaToSubgraph(f, protoID)
if err != nil {
return nil, err
}
// Attach variable definitions to the subgraph for default handling during pour
// Convert from *VarDef to VarDef for simpler handling
if vars != nil {
subgraph.VarDefs = make(map[string]formula.VarDef)
for k, v := range vars {
if v != nil {
subgraph.VarDefs[k] = *v
}
}
}
return subgraph, nil
}
// cookFormula creates a proto bead from a resolved formula. // cookFormula creates a proto bead from a resolved formula.
// protoID is the final ID for the proto (may include a prefix). // protoID is the final ID for the proto (may include a prefix).
func cookFormula(ctx context.Context, s storage.Storage, f *formula.Formula, protoID string) (*cookFormulaResult, error) { func cookFormula(ctx context.Context, s storage.Storage, f *formula.Formula, protoID string) (*cookFormulaResult, error) {

View File

@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"os" "os"
"strings" "strings"
"time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/formula" "github.com/steveyegge/beads/internal/formula"
@@ -208,38 +207,27 @@ func runMolBond(cmd *cobra.Command, args []string) {
} }
// Resolve both operands - can be issue IDs or formula names // Resolve both operands - can be issue IDs or formula names
// Formula names are cooked inline to ephemeral protos (gt-8tmz.25) // Formula names are cooked inline to in-memory subgraphs (gt-4v1eo)
issueA, cookedA, err := resolveOrCookFormula(ctx, store, args[0], actor) subgraphA, cookedA, err := resolveOrCookToSubgraph(ctx, store, args[0])
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
issueB, cookedB, err := resolveOrCookFormula(ctx, store, args[1], actor) subgraphB, cookedB, err := resolveOrCookToSubgraph(ctx, store, args[1])
if err != nil { 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) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Track cooked formulas for cleanup (ephemeral protos deleted after use) // No cleanup needed - in-memory subgraphs don't pollute the DB
cleanupCooked := func() { issueA := subgraphA.Root
if cookedA { issueB := subgraphB.Root
_ = deleteProtoSubgraph(ctx, store, issueA.ID)
}
if cookedB {
_ = deleteProtoSubgraph(ctx, store, issueB.ID)
}
}
idA := issueA.ID idA := issueA.ID
idB := issueB.ID idB := issueB.ID
// Determine operand types // Determine operand types
aIsProto := isProto(issueA) aIsProto := issueA.IsTemplate || cookedA
bIsProto := isProto(issueB) bIsProto := issueB.IsTemplate || cookedB
// Dispatch based on operand types // Dispatch based on operand types
// All operations use the main store; wisp flag determines ephemeral vs persistent // All operations use the main store; wisp flag determines ephemeral vs persistent
@@ -247,17 +235,27 @@ func runMolBond(cmd *cobra.Command, args []string) {
switch { switch {
case aIsProto && bIsProto: case aIsProto && bIsProto:
// Compound protos are templates - always persistent // Compound protos are templates - always persistent
// Note: Proto+proto bonding from formulas is a DB operation, not in-memory
result, err = bondProtoProto(ctx, store, issueA, issueB, bondType, customTitle, actor) result, err = bondProtoProto(ctx, store, issueA, issueB, bondType, customTitle, actor)
case aIsProto && !bIsProto: case aIsProto && !bIsProto:
result, err = bondProtoMol(ctx, store, issueA, issueB, bondType, vars, childRef, actor, wisp, pour) // Pass subgraph directly if cooked from formula
if cookedA {
result, err = bondProtoMolWithSubgraph(ctx, store, subgraphA, issueA, issueB, bondType, vars, childRef, actor, wisp, pour)
} else {
result, err = bondProtoMol(ctx, store, issueA, issueB, bondType, vars, childRef, actor, wisp, pour)
}
case !aIsProto && bIsProto: case !aIsProto && bIsProto:
result, err = bondMolProto(ctx, store, issueA, issueB, bondType, vars, childRef, actor, wisp, pour) // Pass subgraph directly if cooked from formula
if cookedB {
result, err = bondProtoMolWithSubgraph(ctx, store, subgraphB, issueB, issueA, bondType, vars, childRef, actor, wisp, pour)
} else {
result, err = bondMolProto(ctx, store, issueA, issueB, bondType, vars, childRef, actor, wisp, pour)
}
default: default:
result, err = bondMolMol(ctx, store, issueA, issueB, bondType, actor) result, err = bondMolMol(ctx, store, issueA, issueB, bondType, actor)
} }
if err != nil { if err != nil {
cleanupCooked()
fmt.Fprintf(os.Stderr, "Error bonding: %v\n", err) fmt.Fprintf(os.Stderr, "Error bonding: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -265,10 +263,6 @@ func runMolBond(cmd *cobra.Command, args []string) {
// Schedule auto-flush - wisps are in main DB now, but JSONL export skips them // Schedule auto-flush - wisps are in main DB now, but JSONL export skips them
markDirtyAndScheduleFlush() markDirtyAndScheduleFlush()
// Clean up ephemeral protos after successful bond
// These were only needed to get the proto structure; the spawned issues persist
cleanupCooked()
if jsonOutput { if jsonOutput {
outputJSON(result) outputJSON(result)
return return
@@ -284,9 +278,6 @@ func runMolBond(cmd *cobra.Command, args []string) {
} else if pour { } else if pour {
fmt.Printf(" Phase: liquid (persistent, Wisp=false)\n") fmt.Printf(" Phase: liquid (persistent, Wisp=false)\n")
} }
if cookedA || cookedB {
fmt.Printf(" Ephemeral protos cleaned up after use.\n")
}
} }
// isProto checks if an issue is a proto (has the template label) // isProto checks if an issue is a proto (has the template label)
@@ -394,11 +385,21 @@ func bondProtoProto(ctx context.Context, s storage.Storage, protoA, protoB *type
// bondProtoMol bonds a proto to an existing molecule by spawning the proto. // bondProtoMol bonds a proto to an existing molecule by spawning the proto.
// If childRef is provided, generates custom IDs like "parent.childref" (dynamic bonding). // If childRef is provided, generates custom IDs like "parent.childref" (dynamic bonding).
// protoSubgraph can be nil if proto is from DB (will be loaded), or pre-loaded for formulas.
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) { 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 return bondProtoMolWithSubgraph(ctx, s, nil, proto, mol, bondType, vars, childRef, actorName, wispFlag, pourFlag)
subgraph, err := loadTemplateSubgraph(ctx, s, proto.ID) }
if err != nil {
return nil, fmt.Errorf("loading proto: %w", err) // bondProtoMolWithSubgraph is the internal implementation that accepts a pre-loaded subgraph.
func bondProtoMolWithSubgraph(ctx context.Context, s storage.Storage, protoSubgraph *TemplateSubgraph, proto, mol *types.Issue, bondType string, vars map[string]string, childRef string, actorName string, wispFlag, pourFlag bool) (*BondResult, error) {
// Use provided subgraph or load from DB
subgraph := protoSubgraph
if subgraph == nil {
var err error
subgraph, err = loadTemplateSubgraph(ctx, s, proto.ID)
if err != nil {
return nil, fmt.Errorf("loading proto: %w", err)
}
} }
// Check for missing variables // Check for missing variables
@@ -564,18 +565,31 @@ func resolveOrDescribe(ctx context.Context, s storage.Storage, operand string) (
return nil, f.Formula, nil return nil, f.Formula, nil
} }
// resolveOrCookFormula tries to resolve an operand as an issue ID. // resolveOrCookToSubgraph tries to resolve an operand as an issue ID or formula.
// If not found and it looks like a formula name, cooks the formula inline. // If it's an issue, loads the subgraph from DB. If it's a formula, cooks inline to subgraph.
// Returns the issue, whether it was cooked (ephemeral proto), and any error. // Returns the subgraph, whether it was cooked from formula, and any error.
// //
// This implements gt-8tmz.25: formula names are cooked inline as ephemeral protos. // This implements gt-4v1eo: formulas are cooked to in-memory subgraphs (no DB storage).
func resolveOrCookFormula(ctx context.Context, s storage.Storage, operand string, actorName string) (*types.Issue, bool, error) { func resolveOrCookToSubgraph(ctx context.Context, s storage.Storage, operand string) (*TemplateSubgraph, bool, error) {
// First, try to resolve as an existing issue // First, try to resolve as an existing issue
id, err := utils.ResolvePartialID(ctx, s, operand) id, err := utils.ResolvePartialID(ctx, s, operand)
if err == nil { if err == nil {
issue, err := s.GetIssue(ctx, id) issue, err := s.GetIssue(ctx, id)
if err == nil { if err == nil {
return issue, false, nil // Check if it's a proto (template)
if isProto(issue) {
subgraph, err := loadTemplateSubgraph(ctx, s, id)
if err != nil {
return nil, false, fmt.Errorf("loading proto subgraph '%s': %w", id, err)
}
return subgraph, false, nil
}
// It's a molecule, not a proto - wrap it as a single-issue subgraph
return &TemplateSubgraph{
Root: issue,
Issues: []*types.Issue{issue},
IssueMap: map[string]*types.Issue{issue.ID: issue},
}, false, nil
} }
} }
@@ -584,72 +598,13 @@ func resolveOrCookFormula(ctx context.Context, s storage.Storage, operand string
return nil, false, fmt.Errorf("'%s' not found (not an issue ID or formula name)", operand) return nil, false, fmt.Errorf("'%s' not found (not an issue ID or formula name)", operand)
} }
// Try to load and cook the formula // Try to cook formula inline to in-memory subgraph (gt-4v1eo)
parser := formula.NewParser() subgraph, err := resolveAndCookFormula(operand, nil)
f, err := parser.LoadByName(operand)
if err != nil { if err != nil {
return nil, false, fmt.Errorf("'%s' not found as issue or formula: %w", operand, err) return nil, false, fmt.Errorf("'%s' not found as issue or formula: %w", operand, err)
} }
// Resolve formula (inheritance, etc) return subgraph, true, nil
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 a unique ID to avoid collision with existing protos
// Format: _ephemeral-<formula>-<timestamp> (underscore prefix marks it as ephemeral)
protoID := fmt.Sprintf("_ephemeral-%s-%d", resolved.Formula, time.Now().UnixNano())
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. // looksLikeFormulaName checks if an operand looks like a formula name.

View File

@@ -1,84 +1,134 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "sort"
"strings" "strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/ui"
) )
// CatalogEntry represents a formula in the catalog.
type CatalogEntry struct {
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
Source string `json:"source"`
Steps int `json:"steps"`
Vars []string `json:"vars,omitempty"`
}
var molCatalogCmd = &cobra.Command{ var molCatalogCmd = &cobra.Command{
Use: "catalog", Use: "catalog",
Aliases: []string{"list", "ls"}, Aliases: []string{"list", "ls"},
Short: "List available molecules", Short: "List available molecule formulas",
Run: func(cmd *cobra.Command, args []string) { Long: `List formulas available for bd pour / bd wisp create.
ctx := rootCtx
var molecules []*types.Issue
if daemonClient != nil { Formulas are ephemeral proto definitions stored as .formula.json files.
resp, err := daemonClient.List(&rpc.ListArgs{}) They are cooked inline when pouring, never stored as database beads.
Search paths (in priority order):
1. .beads/formulas/ (project-level)
2. ~/.beads/formulas/ (user-level)
3. ~/gt/.beads/formulas/ (Gas Town level)`,
Run: func(cmd *cobra.Command, args []string) {
typeFilter, _ := cmd.Flags().GetString("type")
// Get all search paths and scan for formulas
searchPaths := getFormulaSearchPaths()
seen := make(map[string]bool)
var entries []CatalogEntry
for _, dir := range searchPaths {
formulas, err := scanFormulaDir(dir)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error loading molecules: %v\n", err) continue // Skip inaccessible directories
os.Exit(1)
} }
var allIssues []*types.Issue
if err := json.Unmarshal(resp.Data, &allIssues); err == nil { for _, f := range formulas {
for _, issue := range allIssues { if seen[f.Formula] {
for _, label := range issue.Labels { continue // Skip shadowed formulas
if label == MoleculeLabel {
molecules = append(molecules, issue)
break
}
}
} }
seen[f.Formula] = true
// Apply type filter
if typeFilter != "" && string(f.Type) != typeFilter {
continue
}
// Extract variable names
var varNames []string
for name := range f.Vars {
varNames = append(varNames, name)
}
sort.Strings(varNames)
entries = append(entries, CatalogEntry{
Name: f.Formula,
Type: string(f.Type),
Description: truncateDescription(f.Description, 60),
Source: f.Source,
Steps: countSteps(f.Steps),
Vars: varNames,
})
} }
} else if store != nil {
var err error
molecules, err = store.GetIssuesByLabel(ctx, MoleculeLabel)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading molecules: %v\n", err)
os.Exit(1)
}
} else {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
os.Exit(1)
} }
// Sort by name
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name < entries[j].Name
})
if jsonOutput { if jsonOutput {
outputJSON(molecules) outputJSON(entries)
return return
} }
if len(molecules) == 0 { if len(entries) == 0 {
fmt.Println("No protos available.") fmt.Println("No formulas found.")
fmt.Println("\nTo create a proto:") fmt.Println("\nTo create a formula, write a .formula.json file:")
fmt.Println(" 1. Create an epic with child issues") fmt.Println(" .beads/formulas/my-workflow.formula.json")
fmt.Println(" 2. Add the 'template' label: bd label add <epic-id> template") fmt.Println("\nOr distill from existing work:")
fmt.Println(" 3. Use {{variable}} placeholders in titles/descriptions") fmt.Println(" bd mol distill <epic-id> my-workflow")
fmt.Println("\nTo instantiate a molecule from a proto:") fmt.Println("\nTo instantiate from formula:")
fmt.Println(" bd pour <id> --var key=value # persistent mol") fmt.Println(" bd pour <formula-name> --var key=value # persistent mol")
fmt.Println(" bd wisp create <id> --var key=value # ephemeral wisp") fmt.Println(" bd wisp create <formula-name> --var key=value # ephemeral wisp")
return return
} }
fmt.Printf("%s\n", ui.RenderPass("Protos (for bd pour / bd wisp create):")) fmt.Printf("%s\n\n", ui.RenderPass("Formulas (for bd pour / bd wisp create):"))
for _, mol := range molecules {
vars := extractVariables(mol.Title + " " + mol.Description) // Group by type for display
varStr := "" byType := make(map[string][]CatalogEntry)
if len(vars) > 0 { for _, e := range entries {
varStr = fmt.Sprintf(" (vars: %s)", strings.Join(vars, ", ")) byType[e.Type] = append(byType[e.Type], e)
}
// Print workflow types first (most common for pour/wisp)
typeOrder := []string{"workflow", "expansion", "aspect"}
for _, t := range typeOrder {
typeEntries := byType[t]
if len(typeEntries) == 0 {
continue
} }
fmt.Printf(" %s: %s%s\n", ui.RenderAccent(mol.ID), mol.Title, varStr)
typeIcon := getTypeIcon(t)
fmt.Printf("%s %s:\n", typeIcon, strings.Title(t))
for _, e := range typeEntries {
varInfo := ""
if len(e.Vars) > 0 {
varInfo = fmt.Sprintf(" (vars: %s)", strings.Join(e.Vars, ", "))
}
fmt.Printf(" %s: %s%s\n", ui.RenderAccent(e.Name), e.Description, varInfo)
}
fmt.Println()
} }
fmt.Println()
}, },
} }
func init() { func init() {
molCatalogCmd.Flags().String("type", "", "Filter by formula type (workflow, expansion, aspect)")
molCmd.AddCommand(molCatalogCmd) molCmd.AddCommand(molCatalogCmd)
} }

View File

@@ -1,28 +1,29 @@
package main package main
import ( import (
"context" "encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"regexp"
"strings" "strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/formula"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/utils" "github.com/steveyegge/beads/internal/utils"
) )
var molDistillCmd = &cobra.Command{ var molDistillCmd = &cobra.Command{
Use: "distill <epic-id>", Use: "distill <epic-id> [formula-name]",
Short: "Extract a reusable proto from an existing epic", Short: "Extract a formula from an existing epic",
Long: `Distill a molecule by extracting a reusable proto from an existing epic. Long: `Distill a molecule by extracting a reusable formula from an existing epic.
This is the reverse of spawn: instead of proto → molecule, it's molecule → proto. This is the reverse of pour: instead of formula → molecule, it's molecule → formula.
The distill command: The distill command:
1. Loads the existing epic and all its children 1. Loads the existing epic and all its children
2. Clones the structure as a new proto (adds "template" label) 2. Converts the structure to a .formula.json file
3. Replaces concrete values with {{variable}} placeholders (via --var flags) 3. Replaces concrete values with {{variable}} placeholders (via --var flags)
Use cases: Use cases:
@@ -34,19 +35,23 @@ Variable syntax (both work - we detect which side is the concrete value):
--var branch=feature-auth Spawn-style: variable=value (recommended) --var branch=feature-auth Spawn-style: variable=value (recommended)
--var feature-auth=branch Substitution-style: value=variable --var feature-auth=branch Substitution-style: value=variable
Output locations (first writable wins):
1. .beads/formulas/ (project-level, default)
2. ~/.beads/formulas/ (user-level, if project not writable)
Examples: Examples:
bd mol distill bd-o5xe --as "Release Workflow" bd mol distill bd-o5xe my-workflow
bd mol distill bd-abc --var feature_name=auth-refactor --var version=1.0.0`, bd mol distill bd-abc release-workflow --var feature_name=auth-refactor`,
Args: cobra.ExactArgs(1), Args: cobra.RangeArgs(1, 2),
Run: runMolDistill, Run: runMolDistill,
} }
// DistillResult holds the result of a distill operation // DistillResult holds the result of a distill operation
type DistillResult struct { type DistillResult struct {
ProtoID string `json:"proto_id"` FormulaName string `json:"formula_name"`
IDMapping map[string]string `json:"id_mapping"` // old ID -> new ID FormulaPath string `json:"formula_path"`
Created int `json:"created"` // number of issues created Steps int `json:"steps"` // number of steps in formula
Variables []string `json:"variables"` // variables introduced Variables []string `json:"variables"` // variables introduced
} }
// collectSubgraphText gathers all searchable text from a molecule subgraph // collectSubgraphText gathers all searchable text from a molecule subgraph
@@ -95,11 +100,9 @@ func parseDistillVar(varFlag, searchableText string) (string, string, error) {
// runMolDistill implements the distill command // runMolDistill implements the distill command
func runMolDistill(cmd *cobra.Command, args []string) { func runMolDistill(cmd *cobra.Command, args []string) {
CheckReadonly("mol distill")
ctx := rootCtx ctx := rootCtx
// mol distill requires direct store access // mol distill requires direct store access for reading the epic
if store == nil { if store == nil {
if daemonClient != nil { if daemonClient != nil {
fmt.Fprintf(os.Stderr, "Error: mol distill requires direct database access\n") fmt.Fprintf(os.Stderr, "Error: mol distill requires direct database access\n")
@@ -110,9 +113,9 @@ func runMolDistill(cmd *cobra.Command, args []string) {
os.Exit(1) os.Exit(1)
} }
customTitle, _ := cmd.Flags().GetString("as")
varFlags, _ := cmd.Flags().GetStringSlice("var") varFlags, _ := cmd.Flags().GetStringSlice("var")
dryRun, _ := cmd.Flags().GetBool("dry-run") dryRun, _ := cmd.Flags().GetBool("dry-run")
outputDir, _ := cmd.Flags().GetString("output")
// Resolve epic ID // Resolve epic ID
epicID, err := utils.ResolvePartialID(ctx, store, args[0]) epicID, err := utils.ResolvePartialID(ctx, store, args[0])
@@ -121,15 +124,23 @@ func runMolDistill(cmd *cobra.Command, args []string) {
os.Exit(1) os.Exit(1)
} }
// Load the epic subgraph (needed for smart var detection) // Load the epic subgraph
subgraph, err := loadTemplateSubgraph(ctx, store, epicID) subgraph, err := loadTemplateSubgraph(ctx, store, epicID)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error loading epic: %v\n", err) fmt.Fprintf(os.Stderr, "Error loading epic: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Determine formula name
formulaName := ""
if len(args) > 1 {
formulaName = args[1]
} else {
// Derive from epic title
formulaName = sanitizeFormulaName(subgraph.Root.Title)
}
// Parse variable substitutions with smart detection // Parse variable substitutions with smart detection
// Accepts both spawn-style (variable=value) and substitution-style (value=variable)
replacements := make(map[string]string) replacements := make(map[string]string)
if len(varFlags) > 0 { if len(varFlags) > 0 {
searchableText := collectSubgraphText(subgraph) searchableText := collectSubgraphText(subgraph)
@@ -143,76 +154,127 @@ func runMolDistill(cmd *cobra.Command, args []string) {
} }
} }
if dryRun { // Convert to formula
fmt.Printf("\nDry run: would distill %d issues from %s into a proto\n\n", len(subgraph.Issues), epicID) f := subgraphToFormula(subgraph, formulaName, replacements)
fmt.Printf("Source: %s\n", subgraph.Root.Title)
if customTitle != "" { // Determine output path
fmt.Printf("Proto title: %s\n", customTitle) outputPath := ""
if outputDir != "" {
outputPath = filepath.Join(outputDir, formulaName+formula.FormulaExt)
} else {
// Find first writable formula directory
outputPath = findWritableFormulaDir(formulaName)
if outputPath == "" {
fmt.Fprintf(os.Stderr, "Error: no writable formula directory found\n")
fmt.Fprintf(os.Stderr, "Try: mkdir -p .beads/formulas\n")
os.Exit(1)
} }
}
if dryRun {
fmt.Printf("\nDry run: would distill %d steps from %s into formula\n\n", countSteps(f.Steps), epicID)
fmt.Printf("Formula: %s\n", formulaName)
fmt.Printf("Output: %s\n", outputPath)
if len(replacements) > 0 { if len(replacements) > 0 {
fmt.Printf("\nVariable substitutions:\n") fmt.Printf("\nVariables:\n")
for value, varName := range replacements { for value, varName := range replacements {
fmt.Printf(" \"%s\" → {{%s}}\n", value, varName) fmt.Printf(" %s: \"%s\" → {{%s}}\n", varName, value, varName)
} }
} }
fmt.Printf("\nStructure:\n") fmt.Printf("\nStructure:\n")
for _, issue := range subgraph.Issues { printFormulaStepsTree(f.Steps, "")
title := issue.Title
for value, varName := range replacements {
title = strings.ReplaceAll(title, value, "{{"+varName+"}}")
}
prefix := " "
if issue.ID == subgraph.Root.ID {
prefix = "→ "
}
fmt.Printf("%s%s\n", prefix, title)
}
return return
} }
// Distill the molecule into a proto // Ensure output directory exists
result, err := distillMolecule(ctx, store, subgraph, customTitle, replacements, actor) dir := filepath.Dir(outputPath)
if err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error distilling molecule: %v\n", err) fmt.Fprintf(os.Stderr, "Error creating directory %s: %v\n", dir, err)
os.Exit(1) os.Exit(1)
} }
// Schedule auto-flush // Write formula
markDirtyAndScheduleFlush() data, err := json.MarshalIndent(f, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "Error encoding formula: %v\n", err)
os.Exit(1)
}
// #nosec G306 -- Formula files are not sensitive
if err := os.WriteFile(outputPath, data, 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error writing formula: %v\n", err)
os.Exit(1)
}
result := &DistillResult{
FormulaName: formulaName,
FormulaPath: outputPath,
Steps: countSteps(f.Steps),
Variables: getVarNames(replacements),
}
if jsonOutput { if jsonOutput {
outputJSON(result) outputJSON(result)
return return
} }
fmt.Printf("%s Distilled proto: created %d issues\n", ui.RenderPass("✓"), result.Created) fmt.Printf("%s Distilled formula: %d steps\n", ui.RenderPass("✓"), result.Steps)
fmt.Printf(" Proto ID: %s\n", result.ProtoID) fmt.Printf(" Formula: %s\n", result.FormulaName)
fmt.Printf(" Path: %s\n", result.FormulaPath)
if len(result.Variables) > 0 { if len(result.Variables) > 0 {
fmt.Printf(" Variables: %s\n", strings.Join(result.Variables, ", ")) fmt.Printf(" Variables: %s\n", strings.Join(result.Variables, ", "))
} }
fmt.Printf("\nTo instantiate this proto:\n") fmt.Printf("\nTo instantiate:\n")
fmt.Printf(" bd pour %s", result.ProtoID[:8]) fmt.Printf(" bd pour %s", result.FormulaName)
for _, v := range result.Variables { for _, v := range result.Variables {
fmt.Printf(" --var %s=<value>", v) fmt.Printf(" --var %s=<value>", v)
} }
fmt.Println() fmt.Println()
} }
// distillMolecule creates a new proto from an existing epic // sanitizeFormulaName converts a title to a valid formula name
func distillMolecule(ctx context.Context, s storage.Storage, subgraph *MoleculeSubgraph, customTitle string, replacements map[string]string, actorName string) (*DistillResult, error) { func sanitizeFormulaName(title string) string {
if s == nil { // Convert to lowercase and replace spaces/special chars with hyphens
return nil, fmt.Errorf("no database connection") re := regexp.MustCompile(`[^a-zA-Z0-9-]+`)
name := re.ReplaceAllString(strings.ToLower(title), "-")
// Remove leading/trailing hyphens and collapse multiple hyphens
name = regexp.MustCompile(`-+`).ReplaceAllString(name, "-")
name = strings.Trim(name, "-")
if name == "" {
name = "untitled"
} }
return name
}
// Build the reverse mapping for tracking variables introduced // findWritableFormulaDir finds the first writable formula directory
var variables []string func findWritableFormulaDir(formulaName string) string {
searchPaths := getFormulaSearchPaths()
for _, dir := range searchPaths {
// Try to create the directory if it doesn't exist
if err := os.MkdirAll(dir, 0755); err == nil {
// Check if we can write to it
testPath := filepath.Join(dir, ".write-test")
if f, err := os.Create(testPath); err == nil {
f.Close()
os.Remove(testPath)
return filepath.Join(dir, formulaName+formula.FormulaExt)
}
}
}
return ""
}
// getVarNames extracts variable names from replacements map
func getVarNames(replacements map[string]string) []string {
var names []string
for _, varName := range replacements { for _, varName := range replacements {
variables = append(variables, varName) names = append(names, varName)
} }
return names
}
// Generate new IDs and create mapping // subgraphToFormula converts a molecule subgraph to a formula
idMapping := make(map[string]string) func subgraphToFormula(subgraph *TemplateSubgraph, name string, replacements map[string]string) *formula.Formula {
// Helper to apply replacements // Helper to apply replacements
applyReplacements := func(text string) string { applyReplacements := func(text string) string {
result := text result := text
@@ -222,87 +284,88 @@ func distillMolecule(ctx context.Context, s storage.Storage, subgraph *MoleculeS
return result return result
} }
// Use transaction for atomicity // Build ID mapping for step references
err := s.RunInTransaction(ctx, func(tx storage.Transaction) error { idToStepID := make(map[string]string)
// First pass: create all issues with new IDs for _, issue := range subgraph.Issues {
for _, oldIssue := range subgraph.Issues { // Create a sanitized step ID from the issue ID
// Determine title stepID := sanitizeFormulaName(issue.Title)
title := applyReplacements(oldIssue.Title) if stepID == "" {
if oldIssue.ID == subgraph.Root.ID && customTitle != "" { stepID = issue.ID
title = customTitle
}
// Add template label to all issues
labels := append([]string{}, oldIssue.Labels...)
hasTemplateLabel := false
for _, l := range labels {
if l == MoleculeLabel {
hasTemplateLabel = true
break
}
}
if !hasTemplateLabel {
labels = append(labels, MoleculeLabel)
}
newIssue := &types.Issue{
Title: title,
Description: applyReplacements(oldIssue.Description),
Design: applyReplacements(oldIssue.Design),
AcceptanceCriteria: applyReplacements(oldIssue.AcceptanceCriteria),
Notes: applyReplacements(oldIssue.Notes),
Status: types.StatusOpen, // Protos start fresh
Priority: oldIssue.Priority,
IssueType: oldIssue.IssueType,
Labels: labels,
EstimatedMinutes: oldIssue.EstimatedMinutes,
IDPrefix: "proto", // bd-hobo: distinct prefix for protos
}
if err := tx.CreateIssue(ctx, newIssue, actorName); err != nil {
return fmt.Errorf("failed to create proto issue from %s: %w", oldIssue.ID, err)
}
idMapping[oldIssue.ID] = newIssue.ID
} }
idToStepID[issue.ID] = stepID
// Second pass: recreate dependencies with new IDs
for _, dep := range subgraph.Dependencies {
newFromID, ok1 := idMapping[dep.IssueID]
newToID, ok2 := idMapping[dep.DependsOnID]
if !ok1 || !ok2 {
continue // Skip if either end is outside the subgraph
}
newDep := &types.Dependency{
IssueID: newFromID,
DependsOnID: newToID,
Type: dep.Type,
}
if err := tx.AddDependency(ctx, newDep, actorName); err != nil {
return fmt.Errorf("failed to create dependency: %w", err)
}
}
return nil
})
if err != nil {
return nil, err
} }
return &DistillResult{ // Build dependency map (issue ID -> list of depends-on IDs)
ProtoID: idMapping[subgraph.Root.ID], depsByIssue := make(map[string][]string)
IDMapping: idMapping, for _, dep := range subgraph.Dependencies {
Created: len(subgraph.Issues), depsByIssue[dep.IssueID] = append(depsByIssue[dep.IssueID], dep.DependsOnID)
Variables: variables, }
}, nil
// Convert issues to steps
var steps []*formula.Step
for _, issue := range subgraph.Issues {
if issue.ID == subgraph.Root.ID {
continue // Root becomes the formula itself
}
step := &formula.Step{
ID: idToStepID[issue.ID],
Title: applyReplacements(issue.Title),
Description: applyReplacements(issue.Description),
Type: string(issue.IssueType),
}
// Copy priority if set
if issue.Priority > 0 {
p := issue.Priority
step.Priority = &p
}
// Copy labels (excluding internal ones)
for _, label := range issue.Labels {
if label != MoleculeLabel && !strings.HasPrefix(label, "mol:") {
step.Labels = append(step.Labels, label)
}
}
// Convert dependencies to depends_on (skip root)
if deps, ok := depsByIssue[issue.ID]; ok {
for _, depID := range deps {
if depID == subgraph.Root.ID {
continue // Skip dependency on root (becomes formula itself)
}
if stepID, ok := idToStepID[depID]; ok {
step.DependsOn = append(step.DependsOn, stepID)
}
}
}
steps = append(steps, step)
}
// Build variable definitions
vars := make(map[string]*formula.VarDef)
for _, varName := range replacements {
vars[varName] = &formula.VarDef{
Description: fmt.Sprintf("Value for %s", varName),
Required: true,
}
}
return &formula.Formula{
Formula: name,
Description: applyReplacements(subgraph.Root.Description),
Version: 1,
Type: formula.TypeWorkflow,
Vars: vars,
Steps: steps,
}
} }
func init() { func init() {
molDistillCmd.Flags().String("as", "", "Custom title for the new proto") molDistillCmd.Flags().StringSlice("var", []string{}, "Replace value with {{variable}} placeholder (variable=value)")
molDistillCmd.Flags().StringSlice("var", []string{}, "Replace value with {{variable}} placeholder (value=variable)")
molDistillCmd.Flags().Bool("dry-run", false, "Preview what would be created") molDistillCmd.Flags().Bool("dry-run", false, "Preview what would be created")
molDistillCmd.Flags().String("output", "", "Output directory for formula file")
molCmd.AddCommand(molDistillCmd) molCmd.AddCommand(molDistillCmd)
} }

View File

@@ -17,7 +17,7 @@ import (
// - Proto (solid) -> pour -> Mol (liquid) // - Proto (solid) -> pour -> Mol (liquid)
// - Pour creates persistent, auditable work in .beads/ // - Pour creates persistent, auditable work in .beads/
var pourCmd = &cobra.Command{ var pourCmd = &cobra.Command{
Use: "pour <proto-id-or-formula>", Use: "pour <proto-id>",
Short: "Instantiate a proto as a persistent mol (solid -> liquid)", Short: "Instantiate a proto as a persistent mol (solid -> liquid)",
Long: `Pour a proto into a persistent mol - like pouring molten metal into a mold. Long: `Pour a proto into a persistent mol - like pouring molten metal into a mold.
@@ -26,20 +26,13 @@ The resulting mol lives in .beads/ (permanent storage) and is synced with git.
Phase transition: Proto (solid) -> pour -> Mol (liquid) Phase transition: Proto (solid) -> pour -> Mol (liquid)
The argument can be:
- A proto ID (existing proto in database): bd pour mol-feature
- A formula name (cooked inline): bd pour mol-feature --var name=auth
When given a formula name, pour cooks it inline as an ephemeral proto,
spawns the mol, then cleans up the temporary proto (bd-rciw).
Use pour for: Use pour for:
- Feature work that spans sessions - Feature work that spans sessions
- Important work needing audit trail - Important work needing audit trail
- Anything you might need to reference later - Anything you might need to reference later
Examples: Examples:
bd pour mol-feature --var name=auth # Formula cooked inline bd pour mol-feature --var name=auth # Create persistent mol from proto
bd pour mol-release --var version=1.0 # Release workflow bd pour mol-release --var version=1.0 # Release workflow
bd pour mol-review --var pr=123 # Code review workflow`, bd pour mol-review --var pr=123 # Code review workflow`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
@@ -51,7 +44,7 @@ func runPour(cmd *cobra.Command, args []string) {
ctx := rootCtx ctx := rootCtx
// Pour requires direct store access for subgraph loading and cloning // Pour requires direct store access for cloning
if store == nil { if store == nil {
if daemonClient != nil { if daemonClient != nil {
fmt.Fprintf(os.Stderr, "Error: pour requires direct database access\n") fmt.Fprintf(os.Stderr, "Error: pour requires direct database access\n")
@@ -79,68 +72,75 @@ func runPour(cmd *cobra.Command, args []string) {
vars[parts[0]] = parts[1] vars[parts[0]] = parts[1]
} }
// Resolve proto ID or cook formula inline (bd-rciw) // Try to load as formula first (ephemeral proto - gt-4v1eo)
// This accepts either: // If that fails, fall back to loading from DB (legacy proto beads)
// - An existing proto ID: bd pour mol-feature var subgraph *TemplateSubgraph
// - A formula name: bd pour mol-feature (cooked inline as ephemeral proto) var protoID string
protoIssue, cookedProto, err := resolveOrCookFormula(ctx, store, args[0], actor) isFormula := false
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) // Try to cook formula inline (gt-4v1eo: ephemeral protos)
os.Exit(1) // This works for any valid formula name, not just "mol-" prefixed ones
sg, err := resolveAndCookFormula(args[0], nil)
if err == nil {
subgraph = sg
protoID = sg.Root.ID
isFormula = true
} }
// Track cooked formula for cleanup if subgraph == nil {
cleanupCooked := func() { // Try to load as existing proto bead (legacy path)
if cookedProto { resolvedID, err := utils.ResolvePartialID(ctx, store, args[0])
_ = deleteProtoSubgraph(ctx, store, protoIssue.ID) if err != nil {
fmt.Fprintf(os.Stderr, "Error: %s not found as formula or proto ID\n", args[0])
os.Exit(1)
}
protoID = resolvedID
// Verify it's a proto
protoIssue, err := store.GetIssue(ctx, protoID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading proto %s: %v\n", protoID, err)
os.Exit(1)
}
if !isProto(protoIssue) {
fmt.Fprintf(os.Stderr, "Error: %s is not a proto (missing '%s' label)\n", protoID, MoleculeLabel)
os.Exit(1)
}
// Load the proto subgraph from DB
subgraph, err = loadTemplateSubgraph(ctx, store, protoID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading proto: %v\n", err)
os.Exit(1)
} }
} }
protoID := protoIssue.ID _ = isFormula // For future use (e.g., logging)
// Verify it's a proto
if !isProto(protoIssue) {
cleanupCooked()
fmt.Fprintf(os.Stderr, "Error: %s is not a proto (missing '%s' label)\n", protoID, MoleculeLabel)
os.Exit(1)
}
// Load the proto subgraph
subgraph, err := loadTemplateSubgraph(ctx, store, protoID)
if err != nil {
cleanupCooked()
fmt.Fprintf(os.Stderr, "Error loading proto: %v\n", err)
os.Exit(1)
}
// Resolve and load attached protos // Resolve and load attached protos
type attachmentInfo struct { type attachmentInfo struct {
id string id string
issue *types.Issue issue *types.Issue
subgraph *MoleculeSubgraph subgraph *TemplateSubgraph
} }
var attachments []attachmentInfo var attachments []attachmentInfo
for _, attachArg := range attachFlags { for _, attachArg := range attachFlags {
attachID, err := utils.ResolvePartialID(ctx, store, attachArg) attachID, err := utils.ResolvePartialID(ctx, store, attachArg)
if err != nil { if err != nil {
cleanupCooked()
fmt.Fprintf(os.Stderr, "Error resolving attachment ID %s: %v\n", attachArg, err) fmt.Fprintf(os.Stderr, "Error resolving attachment ID %s: %v\n", attachArg, err)
os.Exit(1) os.Exit(1)
} }
attachIssue, err := store.GetIssue(ctx, attachID) attachIssue, err := store.GetIssue(ctx, attachID)
if err != nil { if err != nil {
cleanupCooked()
fmt.Fprintf(os.Stderr, "Error loading attachment %s: %v\n", attachID, err) fmt.Fprintf(os.Stderr, "Error loading attachment %s: %v\n", attachID, err)
os.Exit(1) os.Exit(1)
} }
if !isProto(attachIssue) { if !isProto(attachIssue) {
cleanupCooked()
fmt.Fprintf(os.Stderr, "Error: %s is not a proto (missing '%s' label)\n", attachID, MoleculeLabel) fmt.Fprintf(os.Stderr, "Error: %s is not a proto (missing '%s' label)\n", attachID, MoleculeLabel)
os.Exit(1) os.Exit(1)
} }
attachSubgraph, err := loadTemplateSubgraph(ctx, store, attachID) attachSubgraph, err := loadTemplateSubgraph(ctx, store, attachID)
if err != nil { if err != nil {
cleanupCooked()
fmt.Fprintf(os.Stderr, "Error loading attachment subgraph %s: %v\n", attachID, err) fmt.Fprintf(os.Stderr, "Error loading attachment subgraph %s: %v\n", attachID, err)
os.Exit(1) os.Exit(1)
} }
@@ -151,10 +151,13 @@ func runPour(cmd *cobra.Command, args []string) {
}) })
} }
// Check for missing variables // Apply variable defaults from formula (gt-4v1eo)
requiredVars := extractAllVariables(subgraph) vars = applyVariableDefaults(vars, subgraph)
// Check for missing required variables (those without defaults)
requiredVars := extractRequiredVariables(subgraph)
for _, attach := range attachments { for _, attach := range attachments {
attachVars := extractAllVariables(attach.subgraph) attachVars := extractRequiredVariables(attach.subgraph)
for _, v := range attachVars { for _, v := range attachVars {
found := false found := false
for _, rv := range requiredVars { for _, rv := range requiredVars {
@@ -175,7 +178,6 @@ func runPour(cmd *cobra.Command, args []string) {
} }
} }
if len(missingVars) > 0 { if len(missingVars) > 0 {
cleanupCooked()
fmt.Fprintf(os.Stderr, "Error: missing required variables: %s\n", strings.Join(missingVars, ", ")) fmt.Fprintf(os.Stderr, "Error: missing required variables: %s\n", strings.Join(missingVars, ", "))
fmt.Fprintf(os.Stderr, "Provide them with: --var %s=<value>\n", missingVars[0]) fmt.Fprintf(os.Stderr, "Provide them with: --var %s=<value>\n", missingVars[0])
os.Exit(1) os.Exit(1)
@@ -198,10 +200,6 @@ func runPour(cmd *cobra.Command, args []string) {
fmt.Printf(" + %s (%d issues)\n", attach.issue.Title, len(attach.subgraph.Issues)) fmt.Printf(" + %s (%d issues)\n", attach.issue.Title, len(attach.subgraph.Issues))
} }
} }
if cookedProto {
fmt.Printf("\n Note: Formula cooked inline as ephemeral proto.\n")
}
cleanupCooked()
return return
} }
@@ -209,7 +207,6 @@ func runPour(cmd *cobra.Command, args []string) {
// bd-hobo: Use "mol" prefix for distinct visual recognition // bd-hobo: Use "mol" prefix for distinct visual recognition
result, err := spawnMolecule(ctx, store, subgraph, vars, assignee, actor, false, "mol") result, err := spawnMolecule(ctx, store, subgraph, vars, assignee, actor, false, "mol")
if err != nil { if err != nil {
cleanupCooked()
fmt.Fprintf(os.Stderr, "Error pouring proto: %v\n", err) fmt.Fprintf(os.Stderr, "Error pouring proto: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -219,7 +216,6 @@ func runPour(cmd *cobra.Command, args []string) {
if len(attachments) > 0 { if len(attachments) > 0 {
spawnedMol, err := store.GetIssue(ctx, result.NewEpicID) spawnedMol, err := store.GetIssue(ctx, result.NewEpicID)
if err != nil { if err != nil {
cleanupCooked()
fmt.Fprintf(os.Stderr, "Error loading spawned mol: %v\n", err) fmt.Fprintf(os.Stderr, "Error loading spawned mol: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -228,7 +224,6 @@ func runPour(cmd *cobra.Command, args []string) {
// pour command always creates persistent (Wisp=false) issues // pour command always creates persistent (Wisp=false) issues
bondResult, err := bondProtoMol(ctx, store, attach.issue, spawnedMol, attachType, vars, "", actor, false, true) bondResult, err := bondProtoMol(ctx, store, attach.issue, spawnedMol, attachType, vars, "", actor, false, true)
if err != nil { if err != nil {
cleanupCooked()
fmt.Fprintf(os.Stderr, "Error attaching %s: %v\n", attach.id, err) fmt.Fprintf(os.Stderr, "Error attaching %s: %v\n", attach.id, err)
os.Exit(1) os.Exit(1)
} }
@@ -236,20 +231,16 @@ func runPour(cmd *cobra.Command, args []string) {
} }
} }
// Clean up ephemeral proto after successful spawn (bd-rciw)
cleanupCooked()
// Schedule auto-flush // Schedule auto-flush
markDirtyAndScheduleFlush() markDirtyAndScheduleFlush()
if jsonOutput { if jsonOutput {
type pourResult struct { type pourResult struct {
*InstantiateResult *InstantiateResult
Attached int `json:"attached"` Attached int `json:"attached"`
Phase string `json:"phase"` Phase string `json:"phase"`
CookedInline bool `json:"cooked_inline,omitempty"`
} }
outputJSON(pourResult{result, totalAttached, "liquid", cookedProto}) outputJSON(pourResult{result, totalAttached, "liquid"})
return return
} }
@@ -259,9 +250,6 @@ func runPour(cmd *cobra.Command, args []string) {
if totalAttached > 0 { if totalAttached > 0 {
fmt.Printf(" Attached: %d issues from %d protos\n", totalAttached, len(attachments)) fmt.Printf(" Attached: %d issues from %d protos\n", totalAttached, len(attachments))
} }
if cookedProto {
fmt.Printf(" Ephemeral proto cleaned up after use.\n")
}
} }
func init() { func init() {

View File

@@ -10,6 +10,7 @@ import (
"time" "time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/formula"
"github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
@@ -25,10 +26,11 @@ var variablePattern = regexp.MustCompile(`\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}`)
// TemplateSubgraph holds a template epic and all its descendants // TemplateSubgraph holds a template epic and all its descendants
type TemplateSubgraph struct { type TemplateSubgraph struct {
Root *types.Issue // The template epic Root *types.Issue // The template epic
Issues []*types.Issue // All issues in the subgraph (including root) Issues []*types.Issue // All issues in the subgraph (including root)
Dependencies []*types.Dependency // All dependencies within the subgraph Dependencies []*types.Dependency // All dependencies within the subgraph
IssueMap map[string]*types.Issue // ID -> Issue for quick lookup IssueMap map[string]*types.Issue // ID -> Issue for quick lookup
VarDefs map[string]formula.VarDef // Variable definitions from formula (for defaults)
} }
// InstantiateResult holds the result of template instantiation // InstantiateResult holds the result of template instantiation
@@ -787,6 +789,57 @@ func extractAllVariables(subgraph *TemplateSubgraph) []string {
return extractVariables(allText) return extractVariables(allText)
} }
// extractRequiredVariables returns only variables that don't have defaults.
// If VarDefs is available (from a cooked formula), uses it to filter out defaulted vars.
// Otherwise, falls back to returning all variables.
func extractRequiredVariables(subgraph *TemplateSubgraph) []string {
allVars := extractAllVariables(subgraph)
// If no VarDefs, assume all variables are required
if subgraph.VarDefs == nil || len(subgraph.VarDefs) == 0 {
return allVars
}
// Filter to only required variables (no default and marked as required, or not defined in VarDefs)
var required []string
for _, v := range allVars {
def, exists := subgraph.VarDefs[v]
// A variable is required if:
// 1. It's not defined in VarDefs at all, OR
// 2. It's defined with Required=true and no Default, OR
// 3. It's defined with no Default (even if Required is false)
if !exists {
required = append(required, v)
} else if def.Default == "" {
required = append(required, v)
}
// If exists and has default, it's not required
}
return required
}
// applyVariableDefaults merges formula default values with provided variables.
// Returns a new map with defaults applied for any missing variables.
func applyVariableDefaults(vars map[string]string, subgraph *TemplateSubgraph) map[string]string {
if subgraph.VarDefs == nil {
return vars
}
result := make(map[string]string)
for k, v := range vars {
result[k] = v
}
// Apply defaults for missing variables
for name, def := range subgraph.VarDefs {
if _, exists := result[name]; !exists && def.Default != "" {
result[name] = def.Default
}
}
return result
}
// substituteVariables replaces {{variable}} with values // substituteVariables replaces {{variable}} with values
func substituteVariables(text string, vars map[string]string) string { func substituteVariables(text string, vars map[string]string) string {
return variablePattern.ReplaceAllStringFunc(text, func(match string) string { return variablePattern.ReplaceAllStringFunc(text, func(match string) string {

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
@@ -69,7 +70,7 @@ const OldThreshold = 24 * time.Hour
// wispCreateCmd instantiates a proto as an ephemeral wisp // wispCreateCmd instantiates a proto as an ephemeral wisp
var wispCreateCmd = &cobra.Command{ var wispCreateCmd = &cobra.Command{
Use: "create <proto-id-or-formula>", Use: "create <proto-id>",
Short: "Instantiate a proto as an ephemeral wisp (solid -> vapor)", Short: "Instantiate a proto as an ephemeral wisp (solid -> vapor)",
Long: `Create a wisp from a proto - sublimation from solid to vapor. Long: `Create a wisp from a proto - sublimation from solid to vapor.
@@ -78,13 +79,6 @@ The resulting wisp is stored in the main database with Wisp=true and NOT exporte
Phase transition: Proto (solid) -> Wisp (vapor) Phase transition: Proto (solid) -> Wisp (vapor)
The argument can be:
- A proto ID (existing proto in database): bd wisp create mol-patrol
- A formula name (cooked inline): bd wisp create mol-patrol --var name=ace
When given a formula name, wisp cooks it inline as an ephemeral proto,
creates the wisp, then cleans up the temporary proto (bd-rciw).
Use wisp create for: Use wisp create for:
- Patrol cycles (deacon, witness) - Patrol cycles (deacon, witness)
- Health checks and monitoring - Health checks and monitoring
@@ -97,8 +91,8 @@ The wisp will:
- Either evaporate (burn) or condense to digest (squash) - Either evaporate (burn) or condense to digest (squash)
Examples: Examples:
bd wisp create mol-patrol # Formula cooked inline bd wisp create mol-patrol # Ephemeral patrol cycle
bd wisp create mol-health-check # One-time health check bd wisp create mol-health-check # One-time health check
bd wisp create mol-diagnostics --var target=db # Diagnostic run`, bd wisp create mol-diagnostics --var target=db # Diagnostic run`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
Run: runWispCreate, Run: runWispCreate,
@@ -134,42 +128,79 @@ func runWispCreate(cmd *cobra.Command, args []string) {
vars[parts[0]] = parts[1] vars[parts[0]] = parts[1]
} }
// Resolve proto ID or cook formula inline (bd-rciw) // Try to load as formula first (ephemeral proto - gt-4v1eo)
// This accepts either: // If that fails, fall back to loading from DB (legacy proto beads)
// - An existing proto ID: bd wisp create mol-patrol var subgraph *TemplateSubgraph
// - A formula name: bd wisp create mol-patrol (cooked inline as ephemeral proto) var protoID string
protoIssue, cookedProto, err := resolveOrCookFormula(ctx, store, args[0], actor)
if err != nil { // Try to cook formula inline (gt-4v1eo: ephemeral protos)
fmt.Fprintf(os.Stderr, "Error: %v\n", err) // This works for any valid formula name, not just "mol-" prefixed ones
os.Exit(1) sg, err := resolveAndCookFormula(args[0], nil)
if err == nil {
subgraph = sg
protoID = sg.Root.ID
} }
// Track cooked formula for cleanup if subgraph == nil {
cleanupCooked := func() { // Resolve proto ID (legacy path)
if cookedProto { protoID = args[0]
_ = deleteProtoSubgraph(ctx, store, protoIssue.ID) // Try to resolve partial ID if it doesn't look like a full ID
if !strings.HasPrefix(protoID, "bd-") && !strings.HasPrefix(protoID, "gt-") && !strings.HasPrefix(protoID, "mol-") {
// Might be a partial ID, try to resolve
if resolved, err := resolvePartialIDDirect(ctx, protoID); err == nil {
protoID = resolved
}
}
// Check if it's a named molecule (mol-xxx) - look up in catalog
if strings.HasPrefix(protoID, "mol-") {
// Find the proto by name
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{
Labels: []string{MoleculeLabel},
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error searching for proto: %v\n", err)
os.Exit(1)
}
found := false
for _, issue := range issues {
if strings.Contains(issue.Title, protoID) || issue.ID == protoID {
protoID = issue.ID
found = true
break
}
}
if !found {
fmt.Fprintf(os.Stderr, "Error: '%s' not found as formula or proto\n", args[0])
fmt.Fprintf(os.Stderr, "Hint: run 'bd formula list' to see available formulas\n")
os.Exit(1)
}
}
// Load the proto
protoIssue, err := store.GetIssue(ctx, protoID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading proto %s: %v\n", protoID, err)
os.Exit(1)
}
if !isProtoIssue(protoIssue) {
fmt.Fprintf(os.Stderr, "Error: %s is not a proto (missing '%s' label)\n", protoID, MoleculeLabel)
os.Exit(1)
}
// Load the proto subgraph from DB
subgraph, err = loadTemplateSubgraph(ctx, store, protoID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading proto: %v\n", err)
os.Exit(1)
} }
} }
protoID := protoIssue.ID // Apply variable defaults from formula (gt-4v1eo)
vars = applyVariableDefaults(vars, subgraph)
// Verify it's a proto // Check for missing required variables (those without defaults)
if !isProtoIssue(protoIssue) { requiredVars := extractRequiredVariables(subgraph)
cleanupCooked()
fmt.Fprintf(os.Stderr, "Error: %s is not a proto (missing '%s' label)\n", protoID, MoleculeLabel)
os.Exit(1)
}
// Load the proto subgraph
subgraph, err := loadTemplateSubgraph(ctx, store, protoID)
if err != nil {
cleanupCooked()
fmt.Fprintf(os.Stderr, "Error loading proto: %v\n", err)
os.Exit(1)
}
// Check for missing variables
requiredVars := extractAllVariables(subgraph)
var missingVars []string var missingVars []string
for _, v := range requiredVars { for _, v := range requiredVars {
if _, ok := vars[v]; !ok { if _, ok := vars[v]; !ok {
@@ -177,7 +208,6 @@ func runWispCreate(cmd *cobra.Command, args []string) {
} }
} }
if len(missingVars) > 0 { if len(missingVars) > 0 {
cleanupCooked()
fmt.Fprintf(os.Stderr, "Error: missing required variables: %s\n", strings.Join(missingVars, ", ")) fmt.Fprintf(os.Stderr, "Error: missing required variables: %s\n", strings.Join(missingVars, ", "))
fmt.Fprintf(os.Stderr, "Provide them with: --var %s=<value>\n", missingVars[0]) fmt.Fprintf(os.Stderr, "Provide them with: --var %s=<value>\n", missingVars[0])
os.Exit(1) os.Exit(1)
@@ -190,10 +220,6 @@ func runWispCreate(cmd *cobra.Command, args []string) {
newTitle := substituteVariables(issue.Title, vars) newTitle := substituteVariables(issue.Title, vars)
fmt.Printf(" - %s (from %s)\n", newTitle, issue.ID) fmt.Printf(" - %s (from %s)\n", newTitle, issue.ID)
} }
if cookedProto {
fmt.Printf("\n Note: Formula cooked inline as ephemeral proto.\n")
}
cleanupCooked()
return return
} }
@@ -201,32 +227,24 @@ func runWispCreate(cmd *cobra.Command, args []string) {
// bd-hobo: Use "wisp" prefix for distinct visual recognition // bd-hobo: Use "wisp" prefix for distinct visual recognition
result, err := spawnMolecule(ctx, store, subgraph, vars, "", actor, true, "wisp") result, err := spawnMolecule(ctx, store, subgraph, vars, "", actor, true, "wisp")
if err != nil { if err != nil {
cleanupCooked()
fmt.Fprintf(os.Stderr, "Error creating wisp: %v\n", err) fmt.Fprintf(os.Stderr, "Error creating wisp: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Clean up ephemeral proto after successful spawn (bd-rciw)
cleanupCooked()
// Wisps are in main db but don't trigger JSONL export (Wisp flag excludes them) // Wisps are in main db but don't trigger JSONL export (Wisp flag excludes them)
if jsonOutput { if jsonOutput {
type wispCreateResult struct { type wispCreateResult struct {
*InstantiateResult *InstantiateResult
Phase string `json:"phase"` Phase string `json:"phase"`
CookedInline bool `json:"cooked_inline,omitempty"`
} }
outputJSON(wispCreateResult{result, "vapor", cookedProto}) outputJSON(wispCreateResult{result, "vapor"})
return return
} }
fmt.Printf("%s Created wisp: %d issues\n", ui.RenderPass("✓"), result.Created) fmt.Printf("%s Created wisp: %d issues\n", ui.RenderPass("✓"), result.Created)
fmt.Printf(" Root issue: %s\n", result.NewEpicID) fmt.Printf(" Root issue: %s\n", result.NewEpicID)
fmt.Printf(" Phase: vapor (ephemeral, not exported to JSONL)\n") fmt.Printf(" Phase: vapor (ephemeral, not exported to JSONL)\n")
if cookedProto {
fmt.Printf(" Ephemeral proto cleaned up after use.\n")
}
fmt.Printf("\nNext steps:\n") fmt.Printf("\nNext steps:\n")
fmt.Printf(" bd close %s.<step> # Complete steps\n", result.NewEpicID) fmt.Printf(" bd close %s.<step> # Complete steps\n", result.NewEpicID)
fmt.Printf(" bd mol squash %s # Condense to digest (promotes to persistent)\n", result.NewEpicID) fmt.Printf(" bd mol squash %s # Condense to digest (promotes to persistent)\n", result.NewEpicID)
@@ -243,6 +261,28 @@ func isProtoIssue(issue *types.Issue) bool {
return false return false
} }
// resolvePartialIDDirect resolves a partial ID directly from store
func resolvePartialIDDirect(ctx context.Context, partial string) (string, error) {
// Try direct lookup first
if issue, err := store.GetIssue(ctx, partial); err == nil {
return issue.ID, nil
}
// Search by prefix
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{
IDs: []string{partial + "*"},
})
if err != nil {
return "", err
}
if len(issues) == 1 {
return issues[0].ID, nil
}
if len(issues) > 1 {
return "", fmt.Errorf("ambiguous ID: %s matches %d issues", partial, len(issues))
}
return "", fmt.Errorf("not found: %s", partial)
}
var wispListCmd = &cobra.Command{ var wispListCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List all wisps in current context", Short: "List all wisps in current context",