feat(mol): add distill command to extract protos from epics
Implements bd mol distill to reverse the spawn operation: - molecule → proto (spawn is proto → molecule) Features: - --as flag for custom proto title - --var value=variable to replace concrete values with placeholders - --dry-run to preview the distilled structure - Preserves full subgraph structure and dependencies - Adds template label to all cloned issues Also updates AGENTS.md with landing checklist. Closes: bd-iq19 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
26
AGENTS.md
26
AGENTS.md
@@ -3,3 +3,29 @@
|
||||
See [CLAUDE.md](CLAUDE.md) for full instructions.
|
||||
|
||||
This file exists for compatibility with tools that look for AGENTS.md.
|
||||
|
||||
## Landing the Plane (Session Completion)
|
||||
|
||||
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
|
||||
|
||||
**MANDATORY WORKFLOW:**
|
||||
|
||||
1. **File issues for remaining work** - Create issues for anything that needs follow-up
|
||||
2. **Run quality gates** (if code changed) - Tests, linters, builds
|
||||
3. **Update issue status** - Close finished work, update in-progress items
|
||||
4. **PUSH TO REMOTE** - This is MANDATORY:
|
||||
```bash
|
||||
git pull --rebase
|
||||
bd sync
|
||||
git push
|
||||
git status # MUST show "up to date with origin"
|
||||
```
|
||||
5. **Clean up** - Clear stashes, prune remote branches
|
||||
6. **Verify** - All changes committed AND pushed
|
||||
7. **Hand off** - Provide context for next session
|
||||
|
||||
**CRITICAL RULES:**
|
||||
- Work is NOT complete until `git push` succeeds
|
||||
- NEVER stop before pushing - that leaves work stranded locally
|
||||
- NEVER say "ready to push when you are" - YOU must push
|
||||
- If push fails, resolve and retry until it succeeds
|
||||
|
||||
247
cmd/bd/mol.go
247
cmd/bd/mol.go
@@ -59,7 +59,8 @@ Commands:
|
||||
show Show proto/molecule structure and variables
|
||||
spawn Instantiate a proto → molecule
|
||||
bond Polymorphic combine: proto+proto, proto+mol, mol+mol
|
||||
run Spawn + assign + pin for durable execution`,
|
||||
run Spawn + assign + pin for durable execution
|
||||
distill Extract proto from ad-hoc epic (reverse of spawn)`,
|
||||
}
|
||||
|
||||
var molCatalogCmd = &cobra.Command{
|
||||
@@ -326,6 +327,30 @@ Examples:
|
||||
Run: runMolBond,
|
||||
}
|
||||
|
||||
var molDistillCmd = &cobra.Command{
|
||||
Use: "distill <epic-id>",
|
||||
Short: "Extract a reusable proto from an existing epic",
|
||||
Long: `Distill a molecule by extracting a reusable proto from an existing epic.
|
||||
|
||||
This is the reverse of spawn: instead of proto → molecule, it's molecule → proto.
|
||||
|
||||
The distill command:
|
||||
1. Loads the existing epic and all its children
|
||||
2. Clones the structure as a new proto (adds "template" label)
|
||||
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
|
||||
|
||||
Examples:
|
||||
bd mol distill bd-o5xe --as release-workflow
|
||||
bd mol distill bd-abc --var title=feature_name --var version=1.0.0`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: runMolDistill,
|
||||
}
|
||||
|
||||
var molRunCmd = &cobra.Command{
|
||||
Use: "run <proto-id>",
|
||||
Short: "Spawn proto and start execution (spawn + assign + pin)",
|
||||
@@ -451,15 +476,20 @@ func init() {
|
||||
molRunCmd.Flags().StringSlice("var", []string{}, "Variable substitution (key=value)")
|
||||
|
||||
molBondCmd.Flags().String("type", types.BondTypeSequential, "Bond type: sequential, parallel, or conditional")
|
||||
molBondCmd.Flags().String("as", "", "Custom ID for compound proto (proto+proto only)")
|
||||
molBondCmd.Flags().String("as", "", "Custom title for compound proto (proto+proto only)")
|
||||
molBondCmd.Flags().Bool("dry-run", false, "Preview what would be created")
|
||||
molBondCmd.Flags().StringSlice("var", []string{}, "Variable substitution for spawned protos (key=value)")
|
||||
|
||||
molDistillCmd.Flags().String("as", "", "Custom title for the new proto")
|
||||
molDistillCmd.Flags().StringSlice("var", []string{}, "Replace value with {{variable}} placeholder (value=variable)")
|
||||
molDistillCmd.Flags().Bool("dry-run", false, "Preview what would be created")
|
||||
|
||||
molCmd.AddCommand(molCatalogCmd)
|
||||
molCmd.AddCommand(molShowCmd)
|
||||
molCmd.AddCommand(molSpawnCmd)
|
||||
molCmd.AddCommand(molRunCmd)
|
||||
molCmd.AddCommand(molBondCmd)
|
||||
molCmd.AddCommand(molDistillCmd)
|
||||
rootCmd.AddCommand(molCmd)
|
||||
}
|
||||
|
||||
@@ -848,3 +878,216 @@ func minPriority(a, b int) int {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Distill Command Implementation
|
||||
// =============================================================================
|
||||
|
||||
// DistillResult holds the result of a distill operation
|
||||
type DistillResult struct {
|
||||
ProtoID string `json:"proto_id"`
|
||||
IDMapping map[string]string `json:"id_mapping"` // old ID -> new ID
|
||||
Created int `json:"created"` // number of issues created
|
||||
Variables []string `json:"variables"` // variables introduced
|
||||
}
|
||||
|
||||
// runMolDistill implements the distill command
|
||||
func runMolDistill(cmd *cobra.Command, args []string) {
|
||||
CheckReadonly("mol distill")
|
||||
|
||||
ctx := rootCtx
|
||||
|
||||
// mol distill requires direct store access
|
||||
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)
|
||||
}
|
||||
|
||||
customTitle, _ := cmd.Flags().GetString("as")
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
if customTitle != "" {
|
||||
fmt.Printf("Proto title: %s\n", customTitle)
|
||||
}
|
||||
if len(replacements) > 0 {
|
||||
fmt.Printf("\nVariable substitutions:\n")
|
||||
for value, varName := range replacements {
|
||||
fmt.Printf(" \"%s\" → {{%s}}\n", value, varName)
|
||||
}
|
||||
}
|
||||
fmt.Printf("\nStructure:\n")
|
||||
for _, issue := range subgraph.Issues {
|
||||
title := issue.Title
|
||||
for value, varName := range replacements {
|
||||
title = strings.ReplaceAll(title, value, "{{"+varName+"}}")
|
||||
}
|
||||
prefix := " "
|
||||
if issue.ID == subgraph.Root.ID {
|
||||
prefix = "→ "
|
||||
}
|
||||
fmt.Printf("%s%s\n", prefix, title)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Distill the molecule into a proto
|
||||
result, err := distillMolecule(ctx, store, subgraph, customTitle, replacements, actor)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error distilling molecule: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Schedule auto-flush
|
||||
markDirtyAndScheduleFlush()
|
||||
|
||||
if jsonOutput {
|
||||
outputJSON(result)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("%s Distilled proto: created %d issues\n", ui.RenderPass("✓"), result.Created)
|
||||
fmt.Printf(" Proto ID: %s\n", result.ProtoID)
|
||||
if len(result.Variables) > 0 {
|
||||
fmt.Printf(" Variables: %s\n", strings.Join(result.Variables, ", "))
|
||||
}
|
||||
fmt.Printf("\nTo spawn this proto:\n")
|
||||
fmt.Printf(" bd mol spawn %s", result.ProtoID[:8])
|
||||
for _, v := range result.Variables {
|
||||
fmt.Printf(" --var %s=<value>", v)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// distillMolecule creates a new proto from an existing epic
|
||||
func distillMolecule(ctx context.Context, s storage.Storage, subgraph *MoleculeSubgraph, customTitle string, replacements map[string]string, actorName string) (*DistillResult, error) {
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("no database connection")
|
||||
}
|
||||
|
||||
// Build the reverse mapping for tracking variables introduced
|
||||
var variables []string
|
||||
for _, varName := range replacements {
|
||||
variables = append(variables, varName)
|
||||
}
|
||||
|
||||
// Generate new IDs and create mapping
|
||||
idMapping := make(map[string]string)
|
||||
|
||||
// Helper to apply replacements
|
||||
applyReplacements := func(text string) string {
|
||||
result := text
|
||||
for value, varName := range replacements {
|
||||
result = strings.ReplaceAll(result, value, "{{"+varName+"}}")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Use transaction for atomicity
|
||||
err := s.RunInTransaction(ctx, func(tx storage.Transaction) error {
|
||||
// First pass: create all issues with new IDs
|
||||
for _, oldIssue := range subgraph.Issues {
|
||||
// Determine title
|
||||
title := applyReplacements(oldIssue.Title)
|
||||
if oldIssue.ID == subgraph.Root.ID && customTitle != "" {
|
||||
title = customTitle
|
||||
}
|
||||
|
||||
// Add template label to all issues
|
||||
labels := append([]string{}, oldIssue.Labels...)
|
||||
hasTemplateLabel := false
|
||||
for _, l := range labels {
|
||||
if l == MoleculeLabel {
|
||||
hasTemplateLabel = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasTemplateLabel {
|
||||
labels = append(labels, MoleculeLabel)
|
||||
}
|
||||
|
||||
newIssue := &types.Issue{
|
||||
Title: title,
|
||||
Description: applyReplacements(oldIssue.Description),
|
||||
Design: applyReplacements(oldIssue.Design),
|
||||
AcceptanceCriteria: applyReplacements(oldIssue.AcceptanceCriteria),
|
||||
Notes: applyReplacements(oldIssue.Notes),
|
||||
Status: types.StatusOpen, // Protos start fresh
|
||||
Priority: oldIssue.Priority,
|
||||
IssueType: oldIssue.IssueType,
|
||||
Labels: labels,
|
||||
EstimatedMinutes: oldIssue.EstimatedMinutes,
|
||||
}
|
||||
|
||||
if err := tx.CreateIssue(ctx, newIssue, actorName); err != nil {
|
||||
return fmt.Errorf("failed to create proto issue from %s: %w", oldIssue.ID, err)
|
||||
}
|
||||
|
||||
idMapping[oldIssue.ID] = newIssue.ID
|
||||
}
|
||||
|
||||
// Second pass: recreate dependencies with new IDs
|
||||
for _, dep := range subgraph.Dependencies {
|
||||
newFromID, ok1 := idMapping[dep.IssueID]
|
||||
newToID, ok2 := idMapping[dep.DependsOnID]
|
||||
if !ok1 || !ok2 {
|
||||
continue // Skip if either end is outside the subgraph
|
||||
}
|
||||
|
||||
newDep := &types.Dependency{
|
||||
IssueID: newFromID,
|
||||
DependsOnID: newToID,
|
||||
Type: dep.Type,
|
||||
}
|
||||
if err := tx.AddDependency(ctx, newDep, actorName); err != nil {
|
||||
return fmt.Errorf("failed to create dependency: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DistillResult{
|
||||
ProtoID: idMapping[subgraph.Root.ID],
|
||||
IDMapping: idMapping,
|
||||
Created: len(subgraph.Issues),
|
||||
Variables: variables,
|
||||
}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user