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:
251
cmd/bd/cook.go
251
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) {
|
||||
|
||||
@@ -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-<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
|
||||
return subgraph, true, nil
|
||||
}
|
||||
|
||||
// looksLikeFormulaName checks if an operand looks like a formula name.
|
||||
|
||||
@@ -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 <epic-id> template")
|
||||
fmt.Println(" 3. Use {{variable}} placeholders in titles/descriptions")
|
||||
fmt.Println("\nTo instantiate a molecule from a proto:")
|
||||
fmt.Println(" bd pour <id> --var key=value # persistent mol")
|
||||
fmt.Println(" bd wisp create <id> --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 <epic-id> my-workflow")
|
||||
fmt.Println("\nTo instantiate from formula:")
|
||||
fmt.Println(" bd pour <formula-name> --var key=value # persistent mol")
|
||||
fmt.Println(" bd wisp create <formula-name> --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)
|
||||
}
|
||||
|
||||
@@ -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 <epic-id>",
|
||||
Short: "Extract a reusable proto from an existing epic",
|
||||
Long: `Distill a molecule by extracting a reusable proto from an existing epic.
|
||||
Use: "distill <epic-id> [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=<value>", 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)
|
||||
}
|
||||
|
||||
116
cmd/bd/pour.go
116
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 <proto-id-or-formula>",
|
||||
Use: "pour <proto-id>",
|
||||
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=<value>\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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
150
cmd/bd/wisp.go
150
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 <proto-id-or-formula>",
|
||||
Use: "create <proto-id>",
|
||||
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=<value>\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.<step> # 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",
|
||||
|
||||
Reference in New Issue
Block a user