feat(mol): smart --var detection for distill accepts both syntaxes
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 <noreply@anthropic.com>
This commit is contained in:
+66
-14
@@ -448,9 +448,13 @@ Use cases:
|
|||||||
- Capture tribal knowledge as executable templates
|
- Capture tribal knowledge as executable templates
|
||||||
- Create starting point for similar future work
|
- 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:
|
Examples:
|
||||||
bd mol distill bd-o5xe --as release-workflow
|
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-abc --var feature_name=auth-refactor --var version=1.0.0`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
Run: runMolDistill,
|
Run: runMolDistill,
|
||||||
}
|
}
|
||||||
@@ -960,6 +964,50 @@ type DistillResult struct {
|
|||||||
Variables []string `json:"variables"` // variables introduced
|
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
|
// runMolDistill implements the distill command
|
||||||
func runMolDistill(cmd *cobra.Command, args []string) {
|
func runMolDistill(cmd *cobra.Command, args []string) {
|
||||||
CheckReadonly("mol distill")
|
CheckReadonly("mol distill")
|
||||||
@@ -981,17 +1029,6 @@ func runMolDistill(cmd *cobra.Command, args []string) {
|
|||||||
varFlags, _ := cmd.Flags().GetStringSlice("var")
|
varFlags, _ := cmd.Flags().GetStringSlice("var")
|
||||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
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
|
// Resolve epic ID
|
||||||
epicID, err := utils.ResolvePartialID(ctx, store, args[0])
|
epicID, err := utils.ResolvePartialID(ctx, store, args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -999,13 +1036,28 @@ func runMolDistill(cmd *cobra.Command, args []string) {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the epic subgraph
|
// Load the epic subgraph (needed for smart var detection)
|
||||||
subgraph, err := loadTemplateSubgraph(ctx, store, epicID)
|
subgraph, err := loadTemplateSubgraph(ctx, store, epicID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error loading epic: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error loading epic: %v\n", err)
|
||||||
os.Exit(1)
|
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 {
|
if dryRun {
|
||||||
fmt.Printf("\nDry run: would distill %d issues from %s into a proto\n\n", len(subgraph.Issues), epicID)
|
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)
|
fmt.Printf("Source: %s\n", subgraph.Root.Title)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user