Updated all documentation and help text to reflect: - bd pour → bd mol pour - bd ephemeral → bd mol wisp - bd ephemeral list → bd mol wisp list - bd ephemeral gc → bd mol wisp gc Files updated: - docs/MOLECULES.md - docs/CLI_REFERENCE.md - docs/ARCHITECTURE.md - docs/DELETIONS.md - skills/beads/references/MOLECULES.md - cmd/bd/mol_catalog.go - cmd/bd/mol_current.go - cmd/bd/mol_distill.go - cmd/bd/cook.go
372 lines
11 KiB
Go
372 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"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> [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 pour: instead of formula → molecule, it's molecule → formula.
|
|
|
|
The distill command:
|
|
1. Loads the existing epic and all its children
|
|
2. Converts the structure to a .formula.json file
|
|
3. Replaces concrete values with {{variable}} placeholders (via --var flags)
|
|
|
|
Use cases:
|
|
- Team develops good workflow organically, wants to reuse it
|
|
- Capture tribal knowledge as executable templates
|
|
- Create starting point for similar future work
|
|
|
|
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 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 {
|
|
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
|
|
func collectSubgraphText(subgraph *MoleculeSubgraph) string {
|
|
var parts []string
|
|
for _, issue := range subgraph.Issues {
|
|
parts = append(parts, issue.Title)
|
|
parts = append(parts, issue.Description)
|
|
parts = append(parts, issue.Design)
|
|
parts = append(parts, issue.AcceptanceCriteria)
|
|
parts = append(parts, issue.Notes)
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// parseDistillVar parses a --var flag with smart detection of syntax.
|
|
// Accepts both spawn-style (variable=value) and substitution-style (value=variable).
|
|
// Returns (findText, varName, error).
|
|
func parseDistillVar(varFlag, searchableText string) (string, string, error) {
|
|
parts := strings.SplitN(varFlag, "=", 2)
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
return "", "", fmt.Errorf("invalid format '%s', expected 'variable=value' or 'value=variable'", varFlag)
|
|
}
|
|
|
|
left, right := parts[0], parts[1]
|
|
leftFound := strings.Contains(searchableText, left)
|
|
rightFound := strings.Contains(searchableText, right)
|
|
|
|
switch {
|
|
case rightFound && !leftFound:
|
|
// spawn-style: --var branch=feature-auth
|
|
// left is variable name, right is the value to find
|
|
return right, left, nil
|
|
case leftFound && !rightFound:
|
|
// substitution-style: --var feature-auth=branch
|
|
// left is value to find, right is variable name
|
|
return left, right, nil
|
|
case leftFound && rightFound:
|
|
// Both found - prefer spawn-style (more natural guess)
|
|
// Agent likely typed: --var varname=concrete_value
|
|
return right, left, nil
|
|
default:
|
|
return "", "", fmt.Errorf("neither '%s' nor '%s' found in epic text", left, right)
|
|
}
|
|
}
|
|
|
|
// runMolDistill implements the distill command
|
|
func runMolDistill(cmd *cobra.Command, args []string) {
|
|
ctx := rootCtx
|
|
|
|
// 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")
|
|
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol distill %s ...\n", args[0])
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
|
|
}
|
|
os.Exit(1)
|
|
}
|
|
|
|
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])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: '%s' not found\n", args[0])
|
|
os.Exit(1)
|
|
}
|
|
|
|
// 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
|
|
replacements := make(map[string]string)
|
|
if len(varFlags) > 0 {
|
|
searchableText := collectSubgraphText(subgraph)
|
|
for _, v := range varFlags {
|
|
findText, varName, err := parseDistillVar(v, searchableText)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
replacements[findText] = varName
|
|
}
|
|
}
|
|
|
|
// 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("\nVariables:\n")
|
|
for value, varName := range replacements {
|
|
fmt.Printf(" %s: \"%s\" → {{%s}}\n", varName, value, varName)
|
|
}
|
|
}
|
|
fmt.Printf("\nStructure:\n")
|
|
printFormulaStepsTree(f.Steps, "")
|
|
return
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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 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:\n")
|
|
fmt.Printf(" bd mol pour %s", result.FormulaName)
|
|
for _, v := range result.Variables {
|
|
fmt.Printf(" --var %s=<value>", v)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 {
|
|
names = append(names, varName)
|
|
}
|
|
return names
|
|
}
|
|
|
|
// 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
|
|
for value, varName := range replacements {
|
|
result = strings.ReplaceAll(result, value, "{{"+varName+"}}")
|
|
}
|
|
return result
|
|
}
|
|
|
|
// 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
|
|
}
|
|
idToStepID[issue.ID] = stepID
|
|
}
|
|
|
|
// 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().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)
|
|
}
|