From 56deb02d2af0bd1cff209d764188d65f0bef7d56 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 25 Dec 2025 17:04:08 -0800 Subject: [PATCH] feat: Support ephemeral protos: cook inline for pour/wisp/bond (bd-rciw) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - bd cook: outputs proto JSON to stdout by default, add --persist flag for legacy behavior (write to database) - bd pour: accepts formula names, cooks inline as ephemeral proto, spawns mol, then cleans up temporary proto - bd wisp create: accepts formula names, cooks inline as ephemeral proto, creates wisp, then cleans up temporary proto - bd mol bond: already supported ephemeral protos (gt-8tmz.25) The ephemeral proto pattern avoids persisting templates in the database. Protos are only needed temporarily during spawn operations - the spawned mol/wisp is what gets persisted. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/cook.go | 102 ++++++++++++++++++++++++++++++------------------- cmd/bd/pour.go | 61 ++++++++++++++++++++++------- cmd/bd/wisp.go | 87 ++++++++++++++++++++--------------------- 3 files changed, 154 insertions(+), 96 deletions(-) diff --git a/cmd/bd/cook.go b/cmd/bd/cook.go index d3935201..7d3abf9b 100644 --- a/cmd/bd/cook.go +++ b/cmd/bd/cook.go @@ -18,8 +18,11 @@ import ( // cookCmd compiles a formula JSON into a proto bead. var cookCmd = &cobra.Command{ Use: "cook ", - Short: "Compile a formula into a proto bead", - Long: `Cook transforms a .formula.json file into a proto bead. + Short: "Compile a formula into a proto (ephemeral by default)", + Long: `Cook transforms a .formula.json file into a proto. + +By default, cook outputs the resolved formula as JSON to stdout for +ephemeral use. The output can be inspected, piped, or saved to a file. Formulas are high-level workflow templates that support: - Variable definitions with defaults and validation @@ -27,16 +30,24 @@ Formulas are high-level workflow templates that support: - Composition rules for bonding formulas together - Inheritance via extends -The cook command parses the formula, resolves inheritance, and -creates a proto bead in the database that can be poured or spawned. +The --persist flag enables the legacy behavior of writing the proto +to the database. This is useful when you want to reuse the same +proto multiple times without re-cooking. + +For most workflows, prefer ephemeral protos: pour and wisp commands +accept formula names directly and cook inline (bd-rciw). Examples: - bd cook mol-feature.formula.json - bd cook .beads/formulas/mol-release.formula.json --force - bd cook mol-patrol.formula.json --search-path .beads/formulas + bd cook mol-feature.formula.json # Output JSON to stdout + bd cook mol-feature --dry-run # Preview steps + bd cook mol-release.formula.json --persist # Write to database + bd cook mol-release.formula.json --persist --force # Replace existing -Output: - Creates a proto bead with: +Output (default): + JSON representation of the resolved formula with all steps. + +Output (--persist): + Creates a proto bead in the database with: - ID matching the formula name (e.g., mol-feature) - The "template" label for proto identification - Child issues for each step @@ -55,26 +66,29 @@ type cookResult struct { } func runCook(cmd *cobra.Command, args []string) { - CheckReadonly("cook") - - ctx := rootCtx - - // Cook requires direct store access for creating protos - if store == nil { - if daemonClient != nil { - fmt.Fprintf(os.Stderr, "Error: cook requires direct database access\n") - fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon cook %s ...\n", args[0]) - } else { - fmt.Fprintf(os.Stderr, "Error: no database connection\n") - } - os.Exit(1) - } - dryRun, _ := cmd.Flags().GetBool("dry-run") + persist, _ := cmd.Flags().GetBool("persist") force, _ := cmd.Flags().GetBool("force") searchPaths, _ := cmd.Flags().GetStringSlice("search-path") prefix, _ := cmd.Flags().GetString("prefix") + // Only need store access if persisting + if persist { + CheckReadonly("cook --persist") + + if store == nil { + if daemonClient != nil { + fmt.Fprintf(os.Stderr, "Error: cook --persist requires direct database access\n") + fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon cook %s --persist ...\n", args[0]) + } else { + fmt.Fprintf(os.Stderr, "Error: no database connection\n") + } + os.Exit(1) + } + } + + ctx := rootCtx + // Create parser with search paths parser := formula.NewParser(searchPaths...) @@ -141,21 +155,6 @@ func runCook(cmd *cobra.Command, args []string) { protoID = prefix + resolved.Formula } - // Check if proto already exists - existingProto, err := store.GetIssue(ctx, protoID) - if err == nil && existingProto != nil { - if !force { - fmt.Fprintf(os.Stderr, "Error: proto %s already exists\n", protoID) - fmt.Fprintf(os.Stderr, "Hint: use --force to replace it\n") - os.Exit(1) - } - // Delete existing proto and its children - if err := deleteProtoSubgraph(ctx, store, protoID); err != nil { - fmt.Fprintf(os.Stderr, "Error deleting existing proto: %v\n", err) - os.Exit(1) - } - } - // Extract variables used in the formula vars := formula.ExtractVariables(resolved) @@ -203,6 +202,28 @@ func runCook(cmd *cobra.Command, args []string) { return } + // Ephemeral mode (default): output resolved formula as JSON to stdout (bd-rciw) + if !persist { + outputJSON(resolved) + return + } + + // Persist mode: create proto bead in database (legacy behavior) + // Check if proto already exists + existingProto, err := store.GetIssue(ctx, protoID) + if err == nil && existingProto != nil { + if !force { + fmt.Fprintf(os.Stderr, "Error: proto %s already exists\n", protoID) + fmt.Fprintf(os.Stderr, "Hint: use --force to replace it\n") + os.Exit(1) + } + // Delete existing proto and its children + if err := deleteProtoSubgraph(ctx, store, protoID); err != nil { + fmt.Fprintf(os.Stderr, "Error deleting existing proto: %v\n", err) + os.Exit(1) + } + } + // Create the proto bead from the formula result, err := cookFormula(ctx, store, resolved, protoID) if err != nil { @@ -526,7 +547,8 @@ func printFormulaSteps(steps []*formula.Step, indent string) { func init() { cookCmd.Flags().Bool("dry-run", false, "Preview what would be created") - cookCmd.Flags().Bool("force", false, "Replace existing proto if it exists") + cookCmd.Flags().Bool("persist", false, "Persist proto to database (legacy behavior)") + cookCmd.Flags().Bool("force", false, "Replace existing proto if it exists (requires --persist)") cookCmd.Flags().StringSlice("search-path", []string{}, "Additional paths to search for formula inheritance") cookCmd.Flags().String("prefix", "", "Prefix to prepend to proto ID (e.g., 'gt-' creates 'gt-mol-feature')") diff --git a/cmd/bd/pour.go b/cmd/bd/pour.go index 6ca6d3d9..5a348215 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,13 +26,20 @@ 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 # Create persistent mol from proto + bd pour mol-feature --var name=auth # Formula cooked inline bd pour mol-release --var version=1.0 # Release workflow bd pour mol-review --var pr=123 # Code review workflow`, Args: cobra.ExactArgs(1), @@ -72,20 +79,28 @@ func runPour(cmd *cobra.Command, args []string) { vars[parts[0]] = parts[1] } - // Resolve proto ID - protoID, err := utils.ResolvePartialID(ctx, store, args[0]) + // 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 resolving proto ID %s: %v\n", args[0], err) + fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - // 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) + // Track cooked formula for cleanup + cleanupCooked := func() { + if cookedProto { + _ = deleteProtoSubgraph(ctx, store, protoIssue.ID) + } } + + 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) } @@ -93,6 +108,7 @@ func runPour(cmd *cobra.Command, args []string) { // 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) } @@ -107,20 +123,24 @@ func runPour(cmd *cobra.Command, args []string) { 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) } @@ -155,6 +175,7 @@ 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) @@ -177,6 +198,10 @@ 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 } @@ -184,6 +209,7 @@ 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) } @@ -193,6 +219,7 @@ 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) } @@ -201,6 +228,7 @@ 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) } @@ -208,16 +236,20 @@ 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"` + Attached int `json:"attached"` + Phase string `json:"phase"` + CookedInline bool `json:"cooked_inline,omitempty"` } - outputJSON(pourResult{result, totalAttached, "liquid"}) + outputJSON(pourResult{result, totalAttached, "liquid", cookedProto}) return } @@ -227,6 +259,9 @@ 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/wisp.go b/cmd/bd/wisp.go index 6f1d92c6..270d6e44 100644 --- a/cmd/bd/wisp.go +++ b/cmd/bd/wisp.go @@ -70,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. @@ -79,6 +79,13 @@ 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 @@ -91,8 +98,8 @@ The wisp will: - Either evaporate (burn) or condense to digest (squash) Examples: - bd wisp create mol-patrol # Ephemeral patrol cycle - bd wisp create mol-health-check # One-time health check + bd wisp create mol-patrol # Formula cooked inline + 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, @@ -128,48 +135,28 @@ func runWispCreate(cmd *cobra.Command, args []string) { vars[parts[0]] = parts[1] } - // Resolve proto ID - 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: proto '%s' not found in catalog\n", args[0]) - fmt.Fprintf(os.Stderr, "Hint: run 'bd mol catalog' to see available protos\n") - os.Exit(1) - } - } - - // Load the proto - protoIssue, err := store.GetIssue(ctx, protoID) + // 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 loading proto %s: %v\n", protoID, err) + fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } + + // Track cooked formula for cleanup + cleanupCooked := func() { + if cookedProto { + _ = deleteProtoSubgraph(ctx, store, protoIssue.ID) + } + } + + protoID := protoIssue.ID + + // 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) } @@ -177,6 +164,7 @@ func runWispCreate(cmd *cobra.Command, args []string) { // 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) } @@ -190,6 +178,7 @@ 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) @@ -202,6 +191,10 @@ 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 } @@ -209,24 +202,32 @@ 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"` + Phase string `json:"phase"` + CookedInline bool `json:"cooked_inline,omitempty"` } - outputJSON(wispCreateResult{result, "vapor"}) + outputJSON(wispCreateResult{result, "vapor", cookedProto}) 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)