From 65801782268858ddc2783e3d57bacd30e82aba4b Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 21 Dec 2025 11:09:48 -0800 Subject: [PATCH] feat(mol): smart --var detection for distill accepts both syntaxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents naturally use spawn-style (variable=value) for both spawn and distill commands. Now distill accepts both: - --var branch=feature-auth (spawn-style) - --var feature-auth=branch (substitution-style) Smart detection checks which side appears in the epic text: - Right side found → spawn-style (left is varname) - Left side found → substitution-style (left is value) - Both found → prefers spawn-style (more common guess) Embodies the Beads philosophy: watch what agents do, make their guess correct. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/mol.go | 80 +++++++++++++++++++---- cmd/bd/mol_test.go | 159 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 225 insertions(+), 14 deletions(-) create mode 100644 cmd/bd/mol_test.go diff --git a/cmd/bd/mol.go b/cmd/bd/mol.go index 2596aa5a..826875d4 100644 --- a/cmd/bd/mol.go +++ b/cmd/bd/mol.go @@ -448,9 +448,13 @@ Use cases: - 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 + Examples: - bd mol distill bd-o5xe --as release-workflow - bd mol distill bd-abc --var title=feature_name --var version=1.0.0`, + 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), Run: runMolDistill, } @@ -960,6 +964,50 @@ type DistillResult struct { 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) { CheckReadonly("mol distill") @@ -981,17 +1029,6 @@ func runMolDistill(cmd *cobra.Command, args []string) { varFlags, _ := cmd.Flags().GetStringSlice("var") dryRun, _ := cmd.Flags().GetBool("dry-run") - // Parse variable substitutions: value=variable means replace "value" with "{{variable}}" - replacements := make(map[string]string) - for _, v := range varFlags { - parts := strings.SplitN(v, "=", 2) - if len(parts) != 2 { - fmt.Fprintf(os.Stderr, "Error: invalid variable format '%s', expected 'value=variable'\n", v) - os.Exit(1) - } - replacements[parts[0]] = parts[1] - } - // Resolve epic ID epicID, err := utils.ResolvePartialID(ctx, store, args[0]) if err != nil { @@ -999,13 +1036,28 @@ func runMolDistill(cmd *cobra.Command, args []string) { os.Exit(1) } - // Load the epic subgraph + // Load the epic subgraph (needed for smart var detection) subgraph, err := loadTemplateSubgraph(ctx, store, epicID) if err != nil { fmt.Fprintf(os.Stderr, "Error loading epic: %v\n", err) os.Exit(1) } + // 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) + 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 + } + } + 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) diff --git a/cmd/bd/mol_test.go b/cmd/bd/mol_test.go new file mode 100644 index 00000000..9a54b1db --- /dev/null +++ b/cmd/bd/mol_test.go @@ -0,0 +1,159 @@ +package main + +import ( + "strings" + "testing" + + "github.com/steveyegge/beads/internal/types" +) + +func TestParseDistillVar(t *testing.T) { + tests := []struct { + name string + varFlag string + searchableText string + wantFind string + wantVar string + wantErr bool + }{ + { + name: "spawn-style: variable=value", + varFlag: "branch=feature-auth", + searchableText: "Implement feature-auth login flow", + wantFind: "feature-auth", + wantVar: "branch", + wantErr: false, + }, + { + name: "substitution-style: value=variable", + varFlag: "feature-auth=branch", + searchableText: "Implement feature-auth login flow", + wantFind: "feature-auth", + wantVar: "branch", + wantErr: false, + }, + { + name: "spawn-style with version number", + varFlag: "version=1.2.3", + searchableText: "Release version 1.2.3 to production", + wantFind: "1.2.3", + wantVar: "version", + wantErr: false, + }, + { + name: "both found - prefers spawn-style", + varFlag: "api=api", + searchableText: "The api endpoint uses api keys", + wantFind: "api", + wantVar: "api", + wantErr: false, + }, + { + name: "neither found - error", + varFlag: "foo=bar", + searchableText: "Nothing matches here", + wantFind: "", + wantVar: "", + wantErr: true, + }, + { + name: "empty left side - error", + varFlag: "=value", + searchableText: "Some text with value", + wantFind: "", + wantVar: "", + wantErr: true, + }, + { + name: "empty right side - error", + varFlag: "value=", + searchableText: "Some text with value", + wantFind: "", + wantVar: "", + wantErr: true, + }, + { + name: "no equals sign - error", + varFlag: "noequals", + searchableText: "Some text", + wantFind: "", + wantVar: "", + wantErr: true, + }, + { + name: "value with equals sign", + varFlag: "env=KEY=VALUE", + searchableText: "Set KEY=VALUE in config", + wantFind: "KEY=VALUE", + wantVar: "env", + wantErr: false, + }, + { + name: "partial match in longer word - finds it", + varFlag: "name=auth", + searchableText: "authentication module", + wantFind: "auth", + wantVar: "name", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotFind, gotVar, err := parseDistillVar(tt.varFlag, tt.searchableText) + + if tt.wantErr { + if err == nil { + t.Errorf("parseDistillVar() expected error, got none") + } + return + } + + if err != nil { + t.Errorf("parseDistillVar() unexpected error: %v", err) + return + } + + if gotFind != tt.wantFind { + t.Errorf("parseDistillVar() find = %q, want %q", gotFind, tt.wantFind) + } + if gotVar != tt.wantVar { + t.Errorf("parseDistillVar() var = %q, want %q", gotVar, tt.wantVar) + } + }) + } +} + +func TestCollectSubgraphText(t *testing.T) { + // Create a simple subgraph for testing + subgraph := &MoleculeSubgraph{ + Issues: []*types.Issue{ + { + Title: "Epic: Feature Auth", + Description: "Implement authentication", + Design: "Use OAuth2", + }, + { + Title: "Add login endpoint", + Notes: "See RFC 6749", + }, + }, + } + + text := collectSubgraphText(subgraph) + + // Verify all fields are included + expected := []string{ + "Epic: Feature Auth", + "Implement authentication", + "Use OAuth2", + "Add login endpoint", + "See RFC 6749", + } + + for _, exp := range expected { + if !strings.Contains(text, exp) { + t.Errorf("collectSubgraphText() missing %q", exp) + } + } +}