diff --git a/internal/beads/molecule.go b/internal/beads/molecule.go new file mode 100644 index 00000000..79b6250b --- /dev/null +++ b/internal/beads/molecule.go @@ -0,0 +1,305 @@ +// Package beads molecule support - composable workflow templates. +package beads + +import ( + "fmt" + "regexp" + "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 + Tier string // Optional tier hint: haiku, sonnet, opus +} + +// 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*$`) + +// 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 +// +// 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 + } + + // 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 +} + +// 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. +// +// For each step in the molecule, 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 Needs: declarations +// +// 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") + } + + // 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 + 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. +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) + } + } + } + + // TODO: Detect cycles in dependency graph + + return nil +} diff --git a/internal/beads/molecule_test.go b/internal/beads/molecule_test.go new file mode 100644 index 00000000..09b5fe14 --- /dev/null +++ b/internal/beads/molecule_test.go @@ -0,0 +1,491 @@ +package beads + +import ( + "reflect" + "testing" +) + +func TestParseMoleculeSteps_EmptyDescription(t *testing.T) { + steps, err := ParseMoleculeSteps("") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if len(steps) != 0 { + t.Errorf("expected 0 steps, got %d", len(steps)) + } +} + +func TestParseMoleculeSteps_NoSteps(t *testing.T) { + desc := `This is a molecule description without any steps. +Just some prose text.` + + steps, err := ParseMoleculeSteps(desc) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if len(steps) != 0 { + t.Errorf("expected 0 steps, got %d", len(steps)) + } +} + +func TestParseMoleculeSteps_SingleStep(t *testing.T) { + desc := `## Step: implement +Write the code carefully. +Follow existing patterns.` + + steps, err := ParseMoleculeSteps(desc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(steps) != 1 { + t.Fatalf("expected 1 step, got %d", len(steps)) + } + + step := steps[0] + if step.Ref != "implement" { + t.Errorf("Ref = %q, want implement", step.Ref) + } + if step.Title != "Write the code carefully." { + t.Errorf("Title = %q, want 'Write the code carefully.'", step.Title) + } + if step.Instructions != "Write the code carefully.\nFollow existing patterns." { + t.Errorf("Instructions = %q", step.Instructions) + } + if len(step.Needs) != 0 { + t.Errorf("Needs = %v, want empty", step.Needs) + } +} + +func TestParseMoleculeSteps_MultipleSteps(t *testing.T) { + desc := `This workflow takes a task through multiple stages. + +## Step: design +Think about architecture and patterns. +Consider edge cases. + +## Step: implement +Write the implementation. +Needs: design + +## Step: test +Write comprehensive tests. +Needs: implement + +## Step: submit +Submit for review. +Needs: implement, test` + + steps, err := ParseMoleculeSteps(desc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(steps) != 4 { + t.Fatalf("expected 4 steps, got %d", len(steps)) + } + + // Check design step + if steps[0].Ref != "design" { + t.Errorf("step[0].Ref = %q, want design", steps[0].Ref) + } + if len(steps[0].Needs) != 0 { + t.Errorf("step[0].Needs = %v, want empty", steps[0].Needs) + } + + // Check implement step + if steps[1].Ref != "implement" { + t.Errorf("step[1].Ref = %q, want implement", steps[1].Ref) + } + if !reflect.DeepEqual(steps[1].Needs, []string{"design"}) { + t.Errorf("step[1].Needs = %v, want [design]", steps[1].Needs) + } + + // Check test step + if steps[2].Ref != "test" { + t.Errorf("step[2].Ref = %q, want test", steps[2].Ref) + } + if !reflect.DeepEqual(steps[2].Needs, []string{"implement"}) { + t.Errorf("step[2].Needs = %v, want [implement]", steps[2].Needs) + } + + // Check submit step with multiple dependencies + if steps[3].Ref != "submit" { + t.Errorf("step[3].Ref = %q, want submit", steps[3].Ref) + } + if !reflect.DeepEqual(steps[3].Needs, []string{"implement", "test"}) { + t.Errorf("step[3].Needs = %v, want [implement, test]", steps[3].Needs) + } +} + +func TestParseMoleculeSteps_WithTier(t *testing.T) { + desc := `## Step: quick-task +Do something simple. +Tier: haiku + +## Step: complex-task +Do something complex. +Needs: quick-task +Tier: opus` + + steps, err := ParseMoleculeSteps(desc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(steps) != 2 { + t.Fatalf("expected 2 steps, got %d", len(steps)) + } + + if steps[0].Tier != "haiku" { + t.Errorf("step[0].Tier = %q, want haiku", steps[0].Tier) + } + if steps[1].Tier != "opus" { + t.Errorf("step[1].Tier = %q, want opus", steps[1].Tier) + } +} + +func TestParseMoleculeSteps_CaseInsensitive(t *testing.T) { + desc := `## STEP: Design +Plan the work. +NEEDS: nothing +TIER: SONNET + +## step: implement +Write code. +needs: Design +tier: Haiku` + + steps, err := ParseMoleculeSteps(desc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(steps) != 2 { + t.Fatalf("expected 2 steps, got %d", len(steps)) + } + + // Note: refs preserve original case + if steps[0].Ref != "Design" { + t.Errorf("step[0].Ref = %q, want Design", steps[0].Ref) + } + if steps[0].Tier != "sonnet" { + t.Errorf("step[0].Tier = %q, want sonnet", steps[0].Tier) + } + + if steps[1].Ref != "implement" { + t.Errorf("step[1].Ref = %q, want implement", steps[1].Ref) + } + if steps[1].Tier != "haiku" { + t.Errorf("step[1].Tier = %q, want haiku", steps[1].Tier) + } +} + +func TestParseMoleculeSteps_EngineerInBox(t *testing.T) { + // The canonical example from the design doc + desc := `This workflow takes a task from design to merge. + +## Step: design +Think carefully about architecture. Consider existing patterns, +trade-offs, testability. + +## Step: implement +Write clean code. Follow codebase conventions. +Needs: design + +## Step: review +Review for bugs, edge cases, style issues. +Needs: implement + +## Step: test +Write and run tests. Cover happy path and edge cases. +Needs: implement + +## Step: submit +Submit for merge via refinery. +Needs: review, test` + + steps, err := ParseMoleculeSteps(desc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(steps) != 5 { + t.Fatalf("expected 5 steps, got %d", len(steps)) + } + + expected := []struct { + ref string + needs []string + }{ + {"design", nil}, + {"implement", []string{"design"}}, + {"review", []string{"implement"}}, + {"test", []string{"implement"}}, + {"submit", []string{"review", "test"}}, + } + + for i, exp := range expected { + if steps[i].Ref != exp.ref { + t.Errorf("step[%d].Ref = %q, want %q", i, steps[i].Ref, exp.ref) + } + if exp.needs == nil { + if len(steps[i].Needs) != 0 { + t.Errorf("step[%d].Needs = %v, want empty", i, steps[i].Needs) + } + } else if !reflect.DeepEqual(steps[i].Needs, exp.needs) { + t.Errorf("step[%d].Needs = %v, want %v", i, steps[i].Needs, exp.needs) + } + } +} + +func TestExpandTemplateVars(t *testing.T) { + tests := []struct { + name string + text string + ctx map[string]string + want string + }{ + { + name: "no variables", + text: "Just plain text", + ctx: map[string]string{"foo": "bar"}, + want: "Just plain text", + }, + { + name: "single variable", + text: "Implement {{feature_name}} feature", + ctx: map[string]string{"feature_name": "authentication"}, + want: "Implement authentication feature", + }, + { + name: "multiple variables", + text: "Implement {{feature}} in {{file}}", + ctx: map[string]string{"feature": "login", "file": "auth.go"}, + want: "Implement login in auth.go", + }, + { + name: "unknown variable left as-is", + text: "Value is {{unknown}}", + ctx: map[string]string{"known": "value"}, + want: "Value is {{unknown}}", + }, + { + name: "nil context", + text: "Value is {{var}}", + ctx: nil, + want: "Value is {{var}}", + }, + { + name: "empty context", + text: "Value is {{var}}", + ctx: map[string]string{}, + want: "Value is {{var}}", + }, + { + name: "repeated variable", + text: "{{x}} and {{x}} again", + ctx: map[string]string{"x": "foo"}, + want: "foo and foo again", + }, + { + name: "multiline", + text: "First line with {{a}}.\nSecond line with {{b}}.", + ctx: map[string]string{"a": "alpha", "b": "beta"}, + want: "First line with alpha.\nSecond line with beta.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExpandTemplateVars(tt.text, tt.ctx) + if got != tt.want { + t.Errorf("ExpandTemplateVars() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestParseMoleculeSteps_WithTemplateVars(t *testing.T) { + desc := `## Step: implement +Implement {{feature_name}} in {{target_file}}. +Follow the existing patterns.` + + steps, err := ParseMoleculeSteps(desc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(steps) != 1 { + t.Fatalf("expected 1 step, got %d", len(steps)) + } + + // Template vars should be preserved in parsed instructions + if steps[0].Instructions != "Implement {{feature_name}} in {{target_file}}.\nFollow the existing patterns." { + t.Errorf("Instructions = %q", steps[0].Instructions) + } + + // Now expand them + expanded := ExpandTemplateVars(steps[0].Instructions, map[string]string{ + "feature_name": "user auth", + "target_file": "auth.go", + }) + + if expanded != "Implement user auth in auth.go.\nFollow the existing patterns." { + t.Errorf("expanded = %q", expanded) + } +} + +func TestValidateMolecule_Valid(t *testing.T) { + mol := &Issue{ + ID: "mol-xyz", + Type: "molecule", + Description: `## Step: design +Plan the work. + +## Step: implement +Write code. +Needs: design`, + } + + err := ValidateMolecule(mol) + if err != nil { + t.Errorf("ValidateMolecule() = %v, want nil", err) + } +} + +func TestValidateMolecule_WrongType(t *testing.T) { + mol := &Issue{ + ID: "task-xyz", + Type: "task", + Description: `## Step: design\nPlan.`, + } + + err := ValidateMolecule(mol) + if err == nil { + t.Error("ValidateMolecule() = nil, want error for wrong type") + } +} + +func TestValidateMolecule_NoSteps(t *testing.T) { + mol := &Issue{ + ID: "mol-xyz", + Type: "molecule", + Description: "Just some description without steps.", + } + + err := ValidateMolecule(mol) + if err == nil { + t.Error("ValidateMolecule() = nil, want error for no steps") + } +} + +func TestValidateMolecule_DuplicateRef(t *testing.T) { + mol := &Issue{ + ID: "mol-xyz", + Type: "molecule", + Description: `## Step: design +Plan the work. + +## Step: design +Plan again.`, + } + + err := ValidateMolecule(mol) + if err == nil { + t.Error("ValidateMolecule() = nil, want error for duplicate ref") + } +} + +func TestValidateMolecule_UnknownDependency(t *testing.T) { + mol := &Issue{ + ID: "mol-xyz", + Type: "molecule", + Description: `## Step: implement +Write code. +Needs: nonexistent`, + } + + err := ValidateMolecule(mol) + if err == nil { + t.Error("ValidateMolecule() = nil, want error for unknown dependency") + } +} + +func TestValidateMolecule_SelfDependency(t *testing.T) { + mol := &Issue{ + ID: "mol-xyz", + Type: "molecule", + Description: `## Step: implement +Write code. +Needs: implement`, + } + + err := ValidateMolecule(mol) + if err == nil { + t.Error("ValidateMolecule() = nil, want error for self-dependency") + } +} + +func TestValidateMolecule_Nil(t *testing.T) { + err := ValidateMolecule(nil) + if err == nil { + t.Error("ValidateMolecule(nil) = nil, want error") + } +} + +func TestParseMoleculeSteps_WhitespaceHandling(t *testing.T) { + desc := `## Step: spaced + Indented instructions. + + More indented content. + +Needs: dep1 , dep2 ,dep3 +Tier: opus ` + + steps, err := ParseMoleculeSteps(desc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(steps) != 1 { + t.Fatalf("expected 1 step, got %d", len(steps)) + } + + // Ref preserves original (though trimmed) + if steps[0].Ref != "spaced" { + t.Errorf("Ref = %q, want spaced", steps[0].Ref) + } + + // Dependencies should be trimmed + expectedDeps := []string{"dep1", "dep2", "dep3"} + if !reflect.DeepEqual(steps[0].Needs, expectedDeps) { + t.Errorf("Needs = %v, want %v", steps[0].Needs, expectedDeps) + } + + // Tier should be lowercase and trimmed + if steps[0].Tier != "opus" { + t.Errorf("Tier = %q, want opus", steps[0].Tier) + } +} + +func TestParseMoleculeSteps_EmptyInstructions(t *testing.T) { + desc := `## Step: empty + +## Step: next +Has content.` + + steps, err := ParseMoleculeSteps(desc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(steps) != 2 { + t.Fatalf("expected 2 steps, got %d", len(steps)) + } + + // First step has empty instructions, title defaults to ref + if steps[0].Instructions != "" { + t.Errorf("step[0].Instructions = %q, want empty", steps[0].Instructions) + } + if steps[0].Title != "empty" { + t.Errorf("step[0].Title = %q, want empty", steps[0].Title) + } + + // Second step has content + if steps[1].Instructions != "Has content." { + t.Errorf("step[1].Instructions = %q", steps[1].Instructions) + } +}