From 54a051aba3e1e4abe1f82303f5e23940cee0e6f8 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 25 Dec 2025 17:20:10 -0800 Subject: [PATCH] gt-4v1eo: Implement ephemeral protos - cook formulas inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/bd/cook.go | 251 ++++++++++++++++++++++++++++++++ cmd/bd/mol_bond.go | 159 ++++++++------------ cmd/bd/mol_catalog.go | 148 ++++++++++++------- cmd/bd/mol_distill.go | 329 +++++++++++++++++++++++++----------------- cmd/bd/pour.go | 116 +++++++-------- cmd/bd/template.go | 61 +++++++- cmd/bd/wisp.go | 150 ++++++++++++------- 7 files changed, 807 insertions(+), 407 deletions(-) diff --git a/cmd/bd/cook.go b/cmd/bd/cook.go index 7d3abf9b..093f6f95 100644 --- a/cmd/bd/cook.go +++ b/cmd/bd/cook.go @@ -262,6 +262,257 @@ type cookFormulaResult struct { 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. // 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) { diff --git a/cmd/bd/mol_bond.go b/cmd/bd/mol_bond.go index b0bbac3b..00776f06 100644 --- a/cmd/bd/mol_bond.go +++ b/cmd/bd/mol_bond.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "strings" - "time" "github.com/spf13/cobra" "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 - // Formula names are cooked inline to ephemeral protos (gt-8tmz.25) - issueA, cookedA, err := resolveOrCookFormula(ctx, store, args[0], actor) + // Formula names are cooked inline to in-memory subgraphs (gt-4v1eo) + subgraphA, cookedA, err := resolveOrCookToSubgraph(ctx, store, args[0]) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - issueB, cookedB, err := resolveOrCookFormula(ctx, store, args[1], actor) + subgraphB, cookedB, err := resolveOrCookToSubgraph(ctx, store, args[1]) 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 cooked formulas for cleanup (ephemeral protos deleted after use) - cleanupCooked := func() { - if cookedA { - _ = deleteProtoSubgraph(ctx, store, issueA.ID) - } - if cookedB { - _ = deleteProtoSubgraph(ctx, store, issueB.ID) - } - } - + // No cleanup needed - in-memory subgraphs don't pollute the DB + issueA := subgraphA.Root + issueB := subgraphB.Root idA := issueA.ID idB := issueB.ID // Determine operand types - aIsProto := isProto(issueA) - bIsProto := isProto(issueB) + aIsProto := issueA.IsTemplate || cookedA + bIsProto := issueB.IsTemplate || cookedB // Dispatch based on operand types // All operations use the main store; wisp flag determines ephemeral vs persistent @@ -247,17 +235,27 @@ func runMolBond(cmd *cobra.Command, args []string) { switch { case aIsProto && bIsProto: // 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) 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: - 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: 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) } @@ -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 markDirtyAndScheduleFlush() - // Clean up ephemeral protos after successful bond - // These were only needed to get the proto structure; the spawned issues persist - cleanupCooked() - if jsonOutput { outputJSON(result) return @@ -284,9 +278,6 @@ func runMolBond(cmd *cobra.Command, args []string) { } else if pour { 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) @@ -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. // 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) { - // Load proto subgraph - subgraph, err := loadTemplateSubgraph(ctx, s, proto.ID) - if err != nil { - return nil, fmt.Errorf("loading proto: %w", err) + return bondProtoMolWithSubgraph(ctx, s, nil, proto, mol, bondType, vars, childRef, actorName, wispFlag, pourFlag) +} + +// 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 @@ -564,18 +565,31 @@ func resolveOrDescribe(ctx context.Context, s storage.Storage, operand string) ( return nil, f.Formula, nil } -// 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. +// resolveOrCookToSubgraph tries to resolve an operand as an issue ID or formula. +// If it's an issue, loads the subgraph from DB. If it's a formula, cooks inline to subgraph. +// 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. -func resolveOrCookFormula(ctx context.Context, s storage.Storage, operand string, actorName string) (*types.Issue, bool, error) { +// This implements gt-4v1eo: formulas are cooked to in-memory subgraphs (no DB storage). +func resolveOrCookToSubgraph(ctx context.Context, s storage.Storage, operand string) (*TemplateSubgraph, 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 + // 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) } - // Try to load and cook the formula - parser := formula.NewParser() - f, err := parser.LoadByName(operand) + // Try to cook formula inline to in-memory subgraph (gt-4v1eo) + subgraph, err := resolveAndCookFormula(operand, nil) 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 a unique ID to avoid collision with existing protos - // Format: _ephemeral-- (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 + return subgraph, true, nil } // looksLikeFormulaName checks if an operand looks like a formula name. diff --git a/cmd/bd/mol_catalog.go b/cmd/bd/mol_catalog.go index 8044b0d5..e5e1bf14 100644 --- a/cmd/bd/mol_catalog.go +++ b/cmd/bd/mol_catalog.go @@ -1,84 +1,134 @@ package main import ( - "encoding/json" "fmt" - "os" + "sort" "strings" "github.com/spf13/cobra" - "github.com/steveyegge/beads/internal/rpc" - "github.com/steveyegge/beads/internal/types" "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{ Use: "catalog", Aliases: []string{"list", "ls"}, - Short: "List available molecules", - Run: func(cmd *cobra.Command, args []string) { - ctx := rootCtx - var molecules []*types.Issue + Short: "List available molecule formulas", + Long: `List formulas available for bd pour / bd wisp create. - if daemonClient != nil { - resp, err := daemonClient.List(&rpc.ListArgs{}) +Formulas are ephemeral proto definitions stored as .formula.json files. +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 { - fmt.Fprintf(os.Stderr, "Error loading molecules: %v\n", err) - os.Exit(1) + continue // Skip inaccessible directories } - var allIssues []*types.Issue - if err := json.Unmarshal(resp.Data, &allIssues); err == nil { - for _, issue := range allIssues { - for _, label := range issue.Labels { - if label == MoleculeLabel { - molecules = append(molecules, issue) - break - } - } + + for _, f := range formulas { + if seen[f.Formula] { + continue // Skip shadowed formulas } + 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 { - outputJSON(molecules) + outputJSON(entries) return } - if len(molecules) == 0 { - fmt.Println("No protos available.") - fmt.Println("\nTo create a proto:") - fmt.Println(" 1. Create an epic with child issues") - fmt.Println(" 2. Add the 'template' label: bd label add template") - fmt.Println(" 3. Use {{variable}} placeholders in titles/descriptions") - fmt.Println("\nTo instantiate a molecule from a proto:") - fmt.Println(" bd pour --var key=value # persistent mol") - fmt.Println(" bd wisp create --var key=value # ephemeral wisp") + if len(entries) == 0 { + fmt.Println("No formulas found.") + fmt.Println("\nTo create a formula, write a .formula.json file:") + fmt.Println(" .beads/formulas/my-workflow.formula.json") + fmt.Println("\nOr distill from existing work:") + fmt.Println(" bd mol distill my-workflow") + fmt.Println("\nTo instantiate from formula:") + fmt.Println(" bd pour --var key=value # persistent mol") + fmt.Println(" bd wisp create --var key=value # ephemeral wisp") return } - fmt.Printf("%s\n", ui.RenderPass("Protos (for bd pour / bd wisp create):")) - for _, mol := range molecules { - vars := extractVariables(mol.Title + " " + mol.Description) - varStr := "" - if len(vars) > 0 { - varStr = fmt.Sprintf(" (vars: %s)", strings.Join(vars, ", ")) + fmt.Printf("%s\n\n", ui.RenderPass("Formulas (for bd pour / bd wisp create):")) + + // Group by type for display + byType := make(map[string][]CatalogEntry) + for _, e := range entries { + 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() { + molCatalogCmd.Flags().String("type", "", "Filter by formula type (workflow, expansion, aspect)") molCmd.AddCommand(molCatalogCmd) } diff --git a/cmd/bd/mol_distill.go b/cmd/bd/mol_distill.go index acd9146d..49e5f834 100644 --- a/cmd/bd/mol_distill.go +++ b/cmd/bd/mol_distill.go @@ -1,28 +1,29 @@ package main import ( - "context" + "encoding/json" "fmt" "os" + "path/filepath" + "regexp" "strings" "github.com/spf13/cobra" - "github.com/steveyegge/beads/internal/storage" - "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/formula" "github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/utils" ) var molDistillCmd = &cobra.Command{ - Use: "distill ", - Short: "Extract a reusable proto from an existing epic", - Long: `Distill a molecule by extracting a reusable proto from an existing epic. + Use: "distill [formula-name]", + Short: "Extract a formula 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: 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) 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 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: - bd mol distill bd-o5xe --as "Release Workflow" - bd mol distill bd-abc --var feature_name=auth-refactor --var version=1.0.0`, - Args: cobra.ExactArgs(1), + bd mol distill bd-o5xe my-workflow + bd mol distill bd-abc release-workflow --var feature_name=auth-refactor`, + Args: cobra.RangeArgs(1, 2), Run: runMolDistill, } // DistillResult holds the result of a distill operation type DistillResult struct { - ProtoID string `json:"proto_id"` - IDMapping map[string]string `json:"id_mapping"` // old ID -> new ID - Created int `json:"created"` // number of issues created - Variables []string `json:"variables"` // variables introduced + FormulaName string `json:"formula_name"` + FormulaPath string `json:"formula_path"` + Steps int `json:"steps"` // number of steps in formula + Variables []string `json:"variables"` // variables introduced } // 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 func runMolDistill(cmd *cobra.Command, args []string) { - CheckReadonly("mol distill") - ctx := rootCtx - // mol distill requires direct store access + // mol distill requires direct store access for reading the epic if store == nil { if daemonClient != nil { 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) } - customTitle, _ := cmd.Flags().GetString("as") varFlags, _ := cmd.Flags().GetStringSlice("var") dryRun, _ := cmd.Flags().GetBool("dry-run") + outputDir, _ := cmd.Flags().GetString("output") // Resolve epic ID epicID, err := utils.ResolvePartialID(ctx, store, args[0]) @@ -121,15 +124,23 @@ func runMolDistill(cmd *cobra.Command, args []string) { os.Exit(1) } - // Load the epic subgraph (needed for smart var detection) + // Load the epic subgraph subgraph, err := loadTemplateSubgraph(ctx, store, epicID) if err != nil { fmt.Fprintf(os.Stderr, "Error loading epic: %v\n", err) 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 - // Accepts both spawn-style (variable=value) and substitution-style (value=variable) replacements := make(map[string]string) if len(varFlags) > 0 { searchableText := collectSubgraphText(subgraph) @@ -143,76 +154,127 @@ func runMolDistill(cmd *cobra.Command, args []string) { } } - if dryRun { - fmt.Printf("\nDry run: would distill %d issues from %s into a proto\n\n", len(subgraph.Issues), epicID) - fmt.Printf("Source: %s\n", subgraph.Root.Title) - if customTitle != "" { - fmt.Printf("Proto title: %s\n", customTitle) + // Convert to formula + f := subgraphToFormula(subgraph, formulaName, replacements) + + // Determine output path + 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 { - fmt.Printf("\nVariable substitutions:\n") + fmt.Printf("\nVariables:\n") 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") - for _, issue := range subgraph.Issues { - 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) - } + printFormulaStepsTree(f.Steps, "") return } - // Distill the molecule into a proto - result, err := distillMolecule(ctx, store, subgraph, customTitle, replacements, actor) - if err != nil { - fmt.Fprintf(os.Stderr, "Error distilling molecule: %v\n", err) + // Ensure output directory exists + dir := filepath.Dir(outputPath) + if err := os.MkdirAll(dir, 0755); err != nil { + fmt.Fprintf(os.Stderr, "Error creating directory %s: %v\n", dir, err) os.Exit(1) } - // Schedule auto-flush - markDirtyAndScheduleFlush() + // Write formula + 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 { outputJSON(result) return } - fmt.Printf("%s Distilled proto: created %d issues\n", ui.RenderPass("✓"), result.Created) - fmt.Printf(" Proto ID: %s\n", result.ProtoID) + fmt.Printf("%s Distilled formula: %d steps\n", ui.RenderPass("✓"), result.Steps) + fmt.Printf(" Formula: %s\n", result.FormulaName) + fmt.Printf(" Path: %s\n", result.FormulaPath) if len(result.Variables) > 0 { fmt.Printf(" Variables: %s\n", strings.Join(result.Variables, ", ")) } - fmt.Printf("\nTo instantiate this proto:\n") - fmt.Printf(" bd pour %s", result.ProtoID[:8]) + fmt.Printf("\nTo instantiate:\n") + fmt.Printf(" bd pour %s", result.FormulaName) for _, v := range result.Variables { fmt.Printf(" --var %s=", v) } fmt.Println() } -// distillMolecule creates a new proto from an existing epic -func distillMolecule(ctx context.Context, s storage.Storage, subgraph *MoleculeSubgraph, customTitle string, replacements map[string]string, actorName string) (*DistillResult, error) { - if s == nil { - return nil, fmt.Errorf("no database connection") +// sanitizeFormulaName converts a title to a valid formula name +func sanitizeFormulaName(title string) string { + // Convert to lowercase and replace spaces/special chars with hyphens + 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 - var variables []string +// findWritableFormulaDir finds the first writable formula directory +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 { - variables = append(variables, varName) + names = append(names, varName) } + return names +} - // Generate new IDs and create mapping - idMapping := make(map[string]string) - +// subgraphToFormula converts a molecule subgraph to a formula +func subgraphToFormula(subgraph *TemplateSubgraph, name string, replacements map[string]string) *formula.Formula { // Helper to apply replacements applyReplacements := func(text string) string { result := text @@ -222,87 +284,88 @@ func distillMolecule(ctx context.Context, s storage.Storage, subgraph *MoleculeS return result } - // Use transaction for atomicity - err := s.RunInTransaction(ctx, func(tx storage.Transaction) error { - // First pass: create all issues with new IDs - for _, oldIssue := range subgraph.Issues { - // Determine title - title := applyReplacements(oldIssue.Title) - if oldIssue.ID == subgraph.Root.ID && customTitle != "" { - 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 + // Build ID mapping for step references + idToStepID := make(map[string]string) + for _, issue := range subgraph.Issues { + // Create a sanitized step ID from the issue ID + stepID := sanitizeFormulaName(issue.Title) + if stepID == "" { + stepID = issue.ID } - - // 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 + idToStepID[issue.ID] = stepID } - return &DistillResult{ - ProtoID: idMapping[subgraph.Root.ID], - IDMapping: idMapping, - Created: len(subgraph.Issues), - Variables: variables, - }, nil + // Build dependency map (issue ID -> list of depends-on IDs) + depsByIssue := make(map[string][]string) + for _, dep := range subgraph.Dependencies { + depsByIssue[dep.IssueID] = append(depsByIssue[dep.IssueID], dep.DependsOnID) + } + + // 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() { - molDistillCmd.Flags().String("as", "", "Custom title for the new proto") - molDistillCmd.Flags().StringSlice("var", []string{}, "Replace value with {{variable}} placeholder (value=variable)") + molDistillCmd.Flags().StringSlice("var", []string{}, "Replace value with {{variable}} placeholder (variable=value)") molDistillCmd.Flags().Bool("dry-run", false, "Preview what would be created") + molDistillCmd.Flags().String("output", "", "Output directory for formula file") molCmd.AddCommand(molDistillCmd) } diff --git a/cmd/bd/pour.go b/cmd/bd/pour.go index 5a348215..bcd05968 100644 --- a/cmd/bd/pour.go +++ b/cmd/bd/pour.go @@ -17,7 +17,7 @@ import ( // - Proto (solid) -> pour -> Mol (liquid) // - Pour creates persistent, auditable work in .beads/ var pourCmd = &cobra.Command{ - Use: "pour ", + Use: "pour ", 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. @@ -26,20 +26,13 @@ The resulting mol lives in .beads/ (permanent storage) and is synced with git. 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: - Feature work that spans sessions - Important work needing audit trail - Anything you might need to reference later 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-review --var pr=123 # Code review workflow`, Args: cobra.ExactArgs(1), @@ -51,7 +44,7 @@ func runPour(cmd *cobra.Command, args []string) { ctx := rootCtx - // Pour requires direct store access for subgraph loading and cloning + // Pour requires direct store access for cloning if store == nil { if daemonClient != nil { 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] } - // Resolve proto ID or cook formula inline (bd-rciw) - // This accepts either: - // - An existing proto ID: bd pour mol-feature - // - A formula name: bd pour mol-feature (cooked inline as ephemeral proto) - protoIssue, cookedProto, err := resolveOrCookFormula(ctx, store, args[0], actor) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + // Try to load as formula first (ephemeral proto - gt-4v1eo) + // If that fails, fall back to loading from DB (legacy proto beads) + var subgraph *TemplateSubgraph + var protoID string + isFormula := false + + // Try to cook formula inline (gt-4v1eo: ephemeral protos) + // 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 - cleanupCooked := func() { - if cookedProto { - _ = deleteProtoSubgraph(ctx, store, protoIssue.ID) + if subgraph == nil { + // Try to load as existing proto bead (legacy path) + resolvedID, err := utils.ResolvePartialID(ctx, store, args[0]) + 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 - - // 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) - } + _ = isFormula // For future use (e.g., logging) // Resolve and load attached protos type attachmentInfo struct { id string issue *types.Issue - subgraph *MoleculeSubgraph + subgraph *TemplateSubgraph } var attachments []attachmentInfo for _, attachArg := range attachFlags { attachID, err := utils.ResolvePartialID(ctx, store, attachArg) if err != nil { - cleanupCooked() fmt.Fprintf(os.Stderr, "Error resolving attachment ID %s: %v\n", attachArg, err) os.Exit(1) } attachIssue, err := store.GetIssue(ctx, attachID) if err != nil { - cleanupCooked() fmt.Fprintf(os.Stderr, "Error loading attachment %s: %v\n", attachID, err) os.Exit(1) } if !isProto(attachIssue) { - cleanupCooked() fmt.Fprintf(os.Stderr, "Error: %s is not a proto (missing '%s' label)\n", attachID, MoleculeLabel) os.Exit(1) } attachSubgraph, err := loadTemplateSubgraph(ctx, store, attachID) if err != nil { - cleanupCooked() fmt.Fprintf(os.Stderr, "Error loading attachment subgraph %s: %v\n", attachID, err) os.Exit(1) } @@ -151,10 +151,13 @@ func runPour(cmd *cobra.Command, args []string) { }) } - // Check for missing variables - requiredVars := extractAllVariables(subgraph) + // Apply variable defaults from formula (gt-4v1eo) + vars = applyVariableDefaults(vars, subgraph) + + // Check for missing required variables (those without defaults) + requiredVars := extractRequiredVariables(subgraph) for _, attach := range attachments { - attachVars := extractAllVariables(attach.subgraph) + attachVars := extractRequiredVariables(attach.subgraph) for _, v := range attachVars { found := false for _, rv := range requiredVars { @@ -175,7 +178,6 @@ func runPour(cmd *cobra.Command, args []string) { } } if len(missingVars) > 0 { - cleanupCooked() fmt.Fprintf(os.Stderr, "Error: missing required variables: %s\n", strings.Join(missingVars, ", ")) fmt.Fprintf(os.Stderr, "Provide them with: --var %s=\n", missingVars[0]) 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)) } } - if cookedProto { - fmt.Printf("\n Note: Formula cooked inline as ephemeral proto.\n") - } - cleanupCooked() return } @@ -209,7 +207,6 @@ func runPour(cmd *cobra.Command, args []string) { // bd-hobo: Use "mol" prefix for distinct visual recognition result, err := spawnMolecule(ctx, store, subgraph, vars, assignee, actor, false, "mol") if err != nil { - cleanupCooked() fmt.Fprintf(os.Stderr, "Error pouring proto: %v\n", err) os.Exit(1) } @@ -219,7 +216,6 @@ func runPour(cmd *cobra.Command, args []string) { if len(attachments) > 0 { spawnedMol, err := store.GetIssue(ctx, result.NewEpicID) if err != nil { - cleanupCooked() fmt.Fprintf(os.Stderr, "Error loading spawned mol: %v\n", err) os.Exit(1) } @@ -228,7 +224,6 @@ func runPour(cmd *cobra.Command, args []string) { // pour command always creates persistent (Wisp=false) issues bondResult, err := bondProtoMol(ctx, store, attach.issue, spawnedMol, attachType, vars, "", actor, false, true) if err != nil { - cleanupCooked() fmt.Fprintf(os.Stderr, "Error attaching %s: %v\n", attach.id, err) 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 markDirtyAndScheduleFlush() if jsonOutput { type pourResult struct { *InstantiateResult - Attached int `json:"attached"` - Phase string `json:"phase"` - CookedInline bool `json:"cooked_inline,omitempty"` + Attached int `json:"attached"` + Phase string `json:"phase"` } - outputJSON(pourResult{result, totalAttached, "liquid", cookedProto}) + outputJSON(pourResult{result, totalAttached, "liquid"}) return } @@ -259,9 +250,6 @@ func runPour(cmd *cobra.Command, args []string) { if totalAttached > 0 { 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() { diff --git a/cmd/bd/template.go b/cmd/bd/template.go index cd06290c..24e7c2f9 100644 --- a/cmd/bd/template.go +++ b/cmd/bd/template.go @@ -10,6 +10,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/formula" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage" "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 type TemplateSubgraph struct { - Root *types.Issue // The template epic - Issues []*types.Issue // All issues in the subgraph (including root) - Dependencies []*types.Dependency // All dependencies within the subgraph - IssueMap map[string]*types.Issue // ID -> Issue for quick lookup + Root *types.Issue // The template epic + Issues []*types.Issue // All issues in the subgraph (including root) + Dependencies []*types.Dependency // All dependencies within the subgraph + 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 @@ -787,6 +789,57 @@ func extractAllVariables(subgraph *TemplateSubgraph) []string { 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 func substituteVariables(text string, vars map[string]string) string { return variablePattern.ReplaceAllStringFunc(text, func(match string) string { diff --git a/cmd/bd/wisp.go b/cmd/bd/wisp.go index c6c8d61c..2ce28e14 100644 --- a/cmd/bd/wisp.go +++ b/cmd/bd/wisp.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "fmt" "os" @@ -69,7 +70,7 @@ const OldThreshold = 24 * time.Hour // wispCreateCmd instantiates a proto as an ephemeral wisp var wispCreateCmd = &cobra.Command{ - Use: "create ", + Use: "create ", Short: "Instantiate a proto as an ephemeral wisp (solid -> 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) -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: - Patrol cycles (deacon, witness) - Health checks and monitoring @@ -97,8 +91,8 @@ The wisp will: - Either evaporate (burn) or condense to digest (squash) Examples: - bd wisp create mol-patrol # Formula cooked inline - bd wisp create mol-health-check # One-time health check + bd wisp create mol-patrol # Ephemeral patrol cycle + bd wisp create mol-health-check # One-time health check bd wisp create mol-diagnostics --var target=db # Diagnostic run`, Args: cobra.ExactArgs(1), Run: runWispCreate, @@ -134,42 +128,79 @@ func runWispCreate(cmd *cobra.Command, args []string) { vars[parts[0]] = parts[1] } - // Resolve proto ID or cook formula inline (bd-rciw) - // This accepts either: - // - An existing proto ID: bd wisp create mol-patrol - // - A formula name: bd wisp create mol-patrol (cooked inline as ephemeral proto) - protoIssue, cookedProto, err := resolveOrCookFormula(ctx, store, args[0], actor) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + // Try to load as formula first (ephemeral proto - gt-4v1eo) + // If that fails, fall back to loading from DB (legacy proto beads) + var subgraph *TemplateSubgraph + var protoID string + + // Try to cook formula inline (gt-4v1eo: ephemeral protos) + // 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 } - // Track cooked formula for cleanup - cleanupCooked := func() { - if cookedProto { - _ = deleteProtoSubgraph(ctx, store, protoIssue.ID) + if subgraph == nil { + // Resolve proto ID (legacy path) + protoID = args[0] + // 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 - if !isProtoIssue(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) - } - - // Check for missing variables - requiredVars := extractAllVariables(subgraph) + // Check for missing required variables (those without defaults) + requiredVars := extractRequiredVariables(subgraph) var missingVars []string for _, v := range requiredVars { if _, ok := vars[v]; !ok { @@ -177,7 +208,6 @@ func runWispCreate(cmd *cobra.Command, args []string) { } } if len(missingVars) > 0 { - cleanupCooked() fmt.Fprintf(os.Stderr, "Error: missing required variables: %s\n", strings.Join(missingVars, ", ")) fmt.Fprintf(os.Stderr, "Provide them with: --var %s=\n", missingVars[0]) os.Exit(1) @@ -190,10 +220,6 @@ func runWispCreate(cmd *cobra.Command, args []string) { newTitle := substituteVariables(issue.Title, vars) fmt.Printf(" - %s (from %s)\n", newTitle, issue.ID) } - if cookedProto { - fmt.Printf("\n Note: Formula cooked inline as ephemeral proto.\n") - } - cleanupCooked() return } @@ -201,32 +227,24 @@ func runWispCreate(cmd *cobra.Command, args []string) { // bd-hobo: Use "wisp" prefix for distinct visual recognition result, err := spawnMolecule(ctx, store, subgraph, vars, "", actor, true, "wisp") if err != nil { - cleanupCooked() fmt.Fprintf(os.Stderr, "Error creating wisp: %v\n", err) 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) if jsonOutput { type wispCreateResult struct { *InstantiateResult - Phase string `json:"phase"` - CookedInline bool `json:"cooked_inline,omitempty"` + Phase string `json:"phase"` } - outputJSON(wispCreateResult{result, "vapor", cookedProto}) + outputJSON(wispCreateResult{result, "vapor"}) return } fmt.Printf("%s Created wisp: %d issues\n", ui.RenderPass("✓"), result.Created) fmt.Printf(" Root issue: %s\n", result.NewEpicID) 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(" bd close %s. # Complete steps\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 } +// 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{ Use: "list", Short: "List all wisps in current context",