// Package beads molecule support - composable workflow templates. package beads import ( "fmt" "regexp" "strconv" "strings" ) // MoleculeStep represents a parsed step from a molecule definition. type MoleculeStep struct { Ref string // Step reference (from "## Step: ") Title string // Step title (first non-empty line or ref) Instructions string // Prose instructions for this step Needs []string // Step refs this step depends on WaitsFor []string // Dynamic wait conditions (e.g., "all-children") Tier string // Optional tier hint: haiku, sonnet, opus Type string // Step type: "task" (default), "wait", etc. Backoff *BackoffConfig // Backoff configuration for wait-type steps } // BackoffConfig defines exponential backoff parameters for wait-type steps. // Used by patrol agents to implement cost-saving await-signal patterns. type BackoffConfig struct { Base string // Base interval (e.g., "30s") Multiplier int // Multiplier for exponential growth (default: 2) Max string // Maximum interval cap (e.g., "10m") } // stepHeaderRegex matches "## Step: " with optional whitespace. var stepHeaderRegex = regexp.MustCompile(`(?i)^##\s*Step:\s*(\S+)\s*$`) // needsLineRegex matches "Needs: step1, step2, ..." lines. var needsLineRegex = regexp.MustCompile(`(?i)^Needs:\s*(.+)$`) // tierLineRegex matches "Tier: haiku|sonnet|opus" lines. var tierLineRegex = regexp.MustCompile(`(?i)^Tier:\s*(haiku|sonnet|opus)\s*$`) // waitsForLineRegex matches "WaitsFor: condition1, condition2, ..." lines. // Common conditions: "all-children" (fanout gate for dynamically bonded children) var waitsForLineRegex = regexp.MustCompile(`(?i)^WaitsFor:\s*(.+)$`) // typeLineRegex matches "Type: task|wait|..." lines. // Common types: "task" (default), "wait" (await-signal with backoff) var typeLineRegex = regexp.MustCompile(`(?i)^Type:\s*(\w+)\s*$`) // backoffLineRegex matches "Backoff: base=30s, multiplier=2, max=10m" lines. // Parses backoff configuration for wait-type steps. var backoffLineRegex = regexp.MustCompile(`(?i)^Backoff:\s*(.+)$`) // templateVarRegex matches {{variable}} placeholders. var templateVarRegex = regexp.MustCompile(`\{\{(\w+)\}\}`) // ParseMoleculeSteps extracts step definitions from a molecule's description. // // The expected format is: // // ## Step: // // Needs: , # optional // Tier: haiku|sonnet|opus # optional // Type: task|wait # optional, default is "task" // Backoff: base=30s, multiplier=2, max=10m # optional, for wait-type steps // // Returns an empty slice if no steps are found. func ParseMoleculeSteps(description string) ([]MoleculeStep, error) { if description == "" { return nil, nil } lines := strings.Split(description, "\n") var steps []MoleculeStep var currentStep *MoleculeStep var contentLines []string // Helper to finalize current step finalizeStep := func() { if currentStep == nil { return } // Process content lines to extract Needs/Tier and build instructions var instructionLines []string for _, line := range contentLines { trimmed := strings.TrimSpace(line) // Check for Needs: line if matches := needsLineRegex.FindStringSubmatch(trimmed); matches != nil { deps := strings.Split(matches[1], ",") for _, dep := range deps { dep = strings.TrimSpace(dep) if dep != "" { currentStep.Needs = append(currentStep.Needs, dep) } } continue } // Check for Tier: line if matches := tierLineRegex.FindStringSubmatch(trimmed); matches != nil { currentStep.Tier = strings.ToLower(matches[1]) continue } // Check for WaitsFor: line if matches := waitsForLineRegex.FindStringSubmatch(trimmed); matches != nil { conditions := strings.Split(matches[1], ",") for _, cond := range conditions { cond = strings.TrimSpace(cond) if cond != "" { currentStep.WaitsFor = append(currentStep.WaitsFor, cond) } } continue } // Check for Type: line if matches := typeLineRegex.FindStringSubmatch(trimmed); matches != nil { currentStep.Type = strings.ToLower(matches[1]) continue } // Check for Backoff: line if matches := backoffLineRegex.FindStringSubmatch(trimmed); matches != nil { currentStep.Backoff = parseBackoffConfig(matches[1]) continue } // Regular instruction line instructionLines = append(instructionLines, line) } // Build instructions, trimming leading/trailing blank lines currentStep.Instructions = strings.TrimSpace(strings.Join(instructionLines, "\n")) // Set title from first non-empty line of instructions, or use ref if currentStep.Instructions != "" { firstLine := strings.SplitN(currentStep.Instructions, "\n", 2)[0] currentStep.Title = strings.TrimSpace(firstLine) } if currentStep.Title == "" { currentStep.Title = currentStep.Ref } steps = append(steps, *currentStep) currentStep = nil contentLines = nil } for _, line := range lines { // Check for step header if matches := stepHeaderRegex.FindStringSubmatch(line); matches != nil { // Finalize previous step if any finalizeStep() // Start new step currentStep = &MoleculeStep{ Ref: matches[1], } contentLines = nil continue } // Accumulate content lines if we're in a step if currentStep != nil { contentLines = append(contentLines, line) } } // Finalize last step finalizeStep() return steps, nil } // parseBackoffConfig parses a backoff configuration string. // Expected format: "base=30s, multiplier=2, max=10m" // Returns nil if parsing fails. func parseBackoffConfig(configStr string) *BackoffConfig { cfg := &BackoffConfig{ Multiplier: 2, // Default multiplier } // Split by comma and parse key=value pairs parts := strings.Split(configStr, ",") for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } // Split by = to get key and value kv := strings.SplitN(part, "=", 2) if len(kv) != 2 { continue } key := strings.TrimSpace(strings.ToLower(kv[0])) value := strings.TrimSpace(kv[1]) switch key { case "base": cfg.Base = value case "multiplier": if m, err := strconv.Atoi(value); err == nil { cfg.Multiplier = m } case "max": cfg.Max = value } } // Return nil if no base was specified (incomplete config) if cfg.Base == "" { return nil } return cfg } // ExpandTemplateVars replaces {{variable}} placeholders in text using the provided context map. // Unknown variables are left as-is. func ExpandTemplateVars(text string, ctx map[string]string) string { if ctx == nil { return text } return templateVarRegex.ReplaceAllStringFunc(text, func(match string) string { // Extract variable name from {{name}} varName := match[2 : len(match)-2] if value, ok := ctx[varName]; ok { return value } return match // Leave unknown variables as-is }) } // InstantiateOptions configures molecule instantiation behavior. type InstantiateOptions struct { // Context map for {{variable}} substitution Context map[string]string } // InstantiateMolecule creates child issues from a molecule template. // // This function supports two molecule formats (format bridge pattern): // // 1. New format (child issues): If the molecule proto has child issues, // those children are used as templates. Dependencies are copied from // the template children's DependsOn relationships. // // 2. Old format (embedded markdown): If the molecule has no children, // steps are parsed from the Description field using ParseMoleculeSteps(). // Dependencies are extracted from "Needs:" declarations in the markdown. // // For each step, this creates: // - A child issue with ID "{parent.ID}.{step.Ref}" // - Title from step title // - Description from step instructions (with template vars expanded) // - Type: task // - Priority: inherited from parent // - Dependencies wired according to template // // The function is atomic via bd CLI - either all issues are created or none. // Returns the created step issues. func (b *Beads) InstantiateMolecule(mol *Issue, parent *Issue, opts InstantiateOptions) ([]*Issue, error) { if mol == nil { return nil, fmt.Errorf("molecule issue is nil") } if parent == nil { return nil, fmt.Errorf("parent issue is nil") } // FORMAT BRIDGE: Try new format first (child issues), fall back to old format (markdown) templateChildren, err := b.List(ListOptions{ Parent: mol.ID, Status: "all", Priority: -1, }) if err != nil { // Non-fatal - might not have children, continue to old format templateChildren = nil } if len(templateChildren) > 0 { // NEW FORMAT: Use child issues as templates return b.instantiateFromChildren(mol, parent, templateChildren, opts) } // OLD FORMAT: Parse steps from molecule description return b.instantiateFromMarkdown(mol, parent, opts) } // instantiateFromChildren creates steps from template child issues (new format). func (b *Beads) instantiateFromChildren(mol *Issue, parent *Issue, templates []*Issue, opts InstantiateOptions) ([]*Issue, error) { var createdIssues []*Issue templateToNew := make(map[string]string) // template ID -> new issue ID // First pass: create all child issues for _, tmpl := range templates { // Expand template variables in description description := tmpl.Description if opts.Context != nil { description = ExpandTemplateVars(description, opts.Context) } // Add provenance metadata if description != "" { description += "\n\n" } description += fmt.Sprintf("instantiated_from: %s\ntemplate_step: %s", mol.ID, tmpl.ID) // Create the child issue childOpts := CreateOptions{ Title: tmpl.Title, Type: tmpl.Type, Priority: parent.Priority, Description: description, Parent: parent.ID, } if childOpts.Type == "" { childOpts.Type = "task" } child, err := b.Create(childOpts) if err != nil { // Attempt to clean up created issues on failure (best-effort cleanup) for _, created := range createdIssues { _ = b.Close(created.ID) } return nil, fmt.Errorf("creating step from template %q: %w", tmpl.ID, err) } createdIssues = append(createdIssues, child) templateToNew[tmpl.ID] = child.ID } // Second pass: wire dependencies based on template dependencies for _, tmpl := range templates { if len(tmpl.DependsOn) == 0 { continue } newChildID := templateToNew[tmpl.ID] for _, depTemplateID := range tmpl.DependsOn { newDepID, ok := templateToNew[depTemplateID] if !ok { // Dependency points outside the template - skip continue } if err := b.AddDependency(newChildID, newDepID); err != nil { // Log but don't fail - the issues are created return createdIssues, fmt.Errorf("adding dependency %s -> %s: %w", newChildID, newDepID, err) } } } return createdIssues, nil } // instantiateFromMarkdown creates steps from embedded markdown (old format). func (b *Beads) instantiateFromMarkdown(mol *Issue, parent *Issue, opts InstantiateOptions) ([]*Issue, error) { // Parse steps from molecule steps, err := ParseMoleculeSteps(mol.Description) if err != nil { return nil, fmt.Errorf("parsing molecule steps: %w", err) } if len(steps) == 0 { return nil, fmt.Errorf("molecule has no steps defined") } // Build map of step ref -> step for dependency validation stepMap := make(map[string]*MoleculeStep) for i := range steps { stepMap[steps[i].Ref] = &steps[i] } // Validate all Needs references exist for _, step := range steps { for _, need := range step.Needs { if _, ok := stepMap[need]; !ok { return nil, fmt.Errorf("step %q depends on unknown step %q", step.Ref, need) } } } // Create child issues for each step var createdIssues []*Issue stepIssueIDs := make(map[string]string) // step ref -> issue ID for _, step := range steps { // Expand template variables in instructions instructions := step.Instructions if opts.Context != nil { instructions = ExpandTemplateVars(instructions, opts.Context) } // Build description with provenance metadata description := instructions if description != "" { description += "\n\n" } description += fmt.Sprintf("instantiated_from: %s\nstep: %s", mol.ID, step.Ref) if step.Tier != "" { description += fmt.Sprintf("\ntier: %s", step.Tier) } // Create the child issue childOpts := CreateOptions{ Title: step.Title, Type: "task", Priority: parent.Priority, Description: description, Parent: parent.ID, } child, err := b.Create(childOpts) if err != nil { // Attempt to clean up created issues on failure (best-effort cleanup) for _, created := range createdIssues { _ = b.Close(created.ID) } return nil, fmt.Errorf("creating step %q: %w", step.Ref, err) } createdIssues = append(createdIssues, child) stepIssueIDs[step.Ref] = child.ID } // Wire inter-step dependencies based on Needs: declarations for _, step := range steps { if len(step.Needs) == 0 { continue } childID := stepIssueIDs[step.Ref] for _, need := range step.Needs { dependsOnID := stepIssueIDs[need] if err := b.AddDependency(childID, dependsOnID); err != nil { // Log but don't fail - the issues are created // This is non-atomic but bd CLI doesn't support transactions return createdIssues, fmt.Errorf("adding dependency %s -> %s: %w", childID, dependsOnID, err) } } } return createdIssues, nil } // ValidateMolecule checks if an issue is a valid molecule definition. // Returns an error describing the problem, or nil if valid. // // Note: This function only validates the old format (embedded markdown steps). // For new format molecules (with child issues), validation is implicit during // instantiation - if the molecule has children, those are used as templates. // Use InstantiateMolecule directly for new format molecules; this function // will report "no steps defined" for new format molecules since it cannot // access child issues without a Beads client. func ValidateMolecule(mol *Issue) error { if mol == nil { return fmt.Errorf("molecule is nil") } if mol.Type != "molecule" { return fmt.Errorf("issue type is %q, expected molecule", mol.Type) } steps, err := ParseMoleculeSteps(mol.Description) if err != nil { return fmt.Errorf("parsing steps: %w", err) } if len(steps) == 0 { return fmt.Errorf("molecule has no steps defined") } // Build step map for reference validation stepMap := make(map[string]bool) for _, step := range steps { if step.Ref == "" { return fmt.Errorf("step has empty ref") } if stepMap[step.Ref] { return fmt.Errorf("duplicate step ref: %s", step.Ref) } stepMap[step.Ref] = true } // Validate Needs references for _, step := range steps { for _, need := range step.Needs { if !stepMap[need] { return fmt.Errorf("step %q depends on unknown step %q", step.Ref, need) } if need == step.Ref { return fmt.Errorf("step %q has self-dependency", step.Ref) } } } // Detect cycles in dependency graph if err := detectCycles(steps); err != nil { return err } return nil } // detectCycles checks for circular dependencies in the step graph using DFS. // Returns an error describing the cycle if one is found. func detectCycles(steps []MoleculeStep) error { // Build adjacency list: step -> steps it depends on deps := make(map[string][]string) for _, step := range steps { deps[step.Ref] = step.Needs } // Track visit state: 0 = unvisited, 1 = visiting (in stack), 2 = visited state := make(map[string]int) // DFS from each node to find cycles var path []string var dfs func(node string) error dfs = func(node string) error { if state[node] == 2 { return nil // Already fully processed } if state[node] == 1 { // Found a back edge - there's a cycle // Build cycle path for error message cycleStart := -1 for i, n := range path { if n == node { cycleStart = i break } } cycle := append(path[cycleStart:], node) return fmt.Errorf("cycle detected in step dependencies: %s", formatCycle(cycle)) } state[node] = 1 // Mark as visiting path = append(path, node) for _, dep := range deps[node] { if err := dfs(dep); err != nil { return err } } path = path[:len(path)-1] // Pop from path state[node] = 2 // Mark as visited return nil } for _, step := range steps { if state[step.Ref] == 0 { if err := dfs(step.Ref); err != nil { return err } } } return nil } // formatCycle formats a cycle path as "a -> b -> c -> a". func formatCycle(cycle []string) string { return strings.Join(cycle, " -> ") }