From 9a551534506e5c6793559ba8c46a7fb48bf1ef7c Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Fri, 19 Dec 2025 14:42:10 -0800 Subject: [PATCH] feat(molecule): add gt molecule seed command (gt-dd8s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the gt molecule seed subcommand that creates built-in molecule definitions (engineer-in-box, quick-fix, research) in the beads database. - Brings molecule.go from main (with list, show, parse, instantiate, instances) - Adds builtin_molecules.go with 3 built-in workflow templates - SeedBuiltinMolecules() writes directly to JSONL to bypass bd CLI type validation - Molecules use well-known IDs (mol-engineer-in-box, mol-quick-fix, mol-research) - Command is idempotent - skips molecules that already exist Note: bd CLI does not yet support molecule as a valid issue type. Filed beads-1 to add molecule type support. Until then, use bd --no-db. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/beads/builtin_molecules.go | 224 ++++++++++++ internal/cmd/molecule.go | 533 ++++++++++++++++++++++++++++ 2 files changed, 757 insertions(+) create mode 100644 internal/beads/builtin_molecules.go create mode 100644 internal/cmd/molecule.go diff --git a/internal/beads/builtin_molecules.go b/internal/beads/builtin_molecules.go new file mode 100644 index 00000000..21a9cb7c --- /dev/null +++ b/internal/beads/builtin_molecules.go @@ -0,0 +1,224 @@ +// Package beads provides a wrapper for the bd (beads) CLI. +package beads + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// BuiltinMolecule defines a built-in molecule template. +type BuiltinMolecule struct { + ID string // Well-known ID (e.g., "mol-engineer-in-box") + Title string + Description string +} + +// BuiltinMolecules returns all built-in molecule definitions. +func BuiltinMolecules() []BuiltinMolecule { + return []BuiltinMolecule{ + EngineerInBoxMolecule(), + QuickFixMolecule(), + ResearchMolecule(), + } +} + +// EngineerInBoxMolecule returns the engineer-in-box molecule definition. +// This is a full workflow from design to merge. +func EngineerInBoxMolecule() BuiltinMolecule { + return BuiltinMolecule{ + ID: "mol-engineer-in-box", + Title: "Engineer in a Box", + Description: `Full workflow from design to merge. + +## Step: design +Think carefully about architecture. Consider: +- Existing patterns in the codebase +- Trade-offs between approaches +- Testability and maintainability + +Write a brief design summary before proceeding. + +## Step: implement +Write the code. Follow codebase conventions. +Needs: design + +## Step: review +Self-review the changes. Look for: +- Bugs and edge cases +- Style issues +- Missing error handling +Needs: implement + +## Step: test +Write and run tests. Cover happy path and edge cases. +Fix any failures before proceeding. +Needs: implement + +## Step: submit +Submit for merge via refinery. +Needs: review, test`, + } +} + +// QuickFixMolecule returns the quick-fix molecule definition. +// This is a fast path for small changes. +func QuickFixMolecule() BuiltinMolecule { + return BuiltinMolecule{ + ID: "mol-quick-fix", + Title: "Quick Fix", + Description: `Fast path for small changes. + +## Step: implement +Make the fix. Keep it focused. + +## Step: test +Run relevant tests. Fix any regressions. +Needs: implement + +## Step: submit +Submit for merge. +Needs: test`, + } +} + +// ResearchMolecule returns the research molecule definition. +// This is an investigation workflow. +func ResearchMolecule() BuiltinMolecule { + return BuiltinMolecule{ + ID: "mol-research", + Title: "Research", + Description: `Investigation workflow. + +## Step: investigate +Explore the question. Search code, read docs, +understand context. Take notes. + +## Step: document +Write up findings. Include: +- What you learned +- Recommendations +- Open questions +Needs: investigate`, + } +} + +// jsonlIssue represents an issue in the JSONL format. +// This struct matches the beads JSONL schema for direct file writes. +type jsonlIssue struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + Priority int `json:"priority"` + IssueType string `json:"issue_type"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// SeedBuiltinMolecules creates all built-in molecules in the beads database. +// It skips molecules that already exist (by ID match). +// Returns the number of molecules created. +// +// Note: Since the bd CLI doesn't support the "molecule" type, this function +// writes directly to the JSONL file to create molecules with the proper type. +func (b *Beads) SeedBuiltinMolecules() (int, error) { + molecules := BuiltinMolecules() + created := 0 + + // Find the JSONL file + jsonlPath := filepath.Join(b.workDir, ".beads", "issues.jsonl") + if _, err := os.Stat(jsonlPath); os.IsNotExist(err) { + return 0, fmt.Errorf("beads JSONL not found: %s", jsonlPath) + } + + // Read existing issues to check for duplicates + existingIDs, err := readExistingIDs(jsonlPath) + if err != nil { + return 0, fmt.Errorf("reading existing issues: %w", err) + } + + // Prepare new molecules to add + var newMolecules []jsonlIssue + now := time.Now().Format(time.RFC3339Nano) + + for _, mol := range molecules { + if existingIDs[mol.ID] { + continue // Already exists + } + + newMolecules = append(newMolecules, jsonlIssue{ + ID: mol.ID, + Title: mol.Title, + Description: mol.Description, + Status: "open", + Priority: 2, // Medium priority + IssueType: "molecule", + CreatedAt: now, + UpdatedAt: now, + }) + created++ + } + + if len(newMolecules) == 0 { + return 0, nil + } + + // Append new molecules to the JSONL file + f, err := os.OpenFile(jsonlPath, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return 0, fmt.Errorf("opening JSONL for append: %w", err) + } + defer f.Close() + + for _, mol := range newMolecules { + line, err := json.Marshal(mol) + if err != nil { + return created, fmt.Errorf("marshaling molecule %s: %w", mol.ID, err) + } + if _, err := f.Write(append(line, '\n')); err != nil { + return created, fmt.Errorf("writing molecule %s: %w", mol.ID, err) + } + } + + return created, nil +} + +// readExistingIDs reads the JSONL file and returns a set of existing issue IDs. +func readExistingIDs(jsonlPath string) (map[string]bool, error) { + ids := make(map[string]bool) + + f, err := os.Open(jsonlPath) + if err != nil { + return nil, err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + // Increase buffer size for long lines + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + // Just extract the ID field - we don't need to parse the full issue + var partial struct { + ID string `json:"id"` + } + if err := json.Unmarshal(line, &partial); err != nil { + continue // Skip malformed lines + } + if partial.ID != "" { + ids[partial.ID] = true + } + } + + return ids, scanner.Err() +} diff --git a/internal/cmd/molecule.go b/internal/cmd/molecule.go new file mode 100644 index 00000000..ab1beada --- /dev/null +++ b/internal/cmd/molecule.go @@ -0,0 +1,533 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/style" +) + +// Molecule command flags +var ( + moleculeJSON bool + moleculeInstParent string + moleculeInstContext []string +) + +var moleculeCmd = &cobra.Command{ + Use: "molecule", + Short: "Molecule workflow commands", + Long: `Manage molecule workflow templates. + +Molecules are composable workflow patterns stored as beads issues. +When instantiated on a parent issue, they create child beads forming a DAG.`, +} + +var moleculeListCmd = &cobra.Command{ + Use: "list", + Short: "List molecules", + Long: `List all molecule definitions. + +Molecules are issues with type=molecule.`, + RunE: runMoleculeList, +} + +var moleculeShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show molecule with parsed steps", + Long: `Show a molecule definition with its parsed steps. + +Displays the molecule's title, description structure, and all defined steps +with their dependencies.`, + Args: cobra.ExactArgs(1), + RunE: runMoleculeShow, +} + +var moleculeParseCmd = &cobra.Command{ + Use: "parse ", + Short: "Validate and show parsed structure", + Long: `Parse and validate a molecule definition. + +This command parses the molecule's step definitions and reports any errors. +Useful for debugging molecule definitions before instantiation.`, + Args: cobra.ExactArgs(1), + RunE: runMoleculeParse, +} + +var moleculeInstantiateCmd = &cobra.Command{ + Use: "instantiate ", + Short: "Create steps from molecule template", + Long: `Instantiate a molecule on a parent issue. + +Creates child issues for each step defined in the molecule, wiring up +dependencies according to the Needs: declarations. + +Template variables ({{variable}}) can be substituted using --context flags. + +Examples: + gt molecule instantiate mol-xyz --parent=gt-abc + gt molecule instantiate mol-xyz --parent=gt-abc --context feature=auth --context file=login.go`, + Args: cobra.ExactArgs(1), + RunE: runMoleculeInstantiate, +} + +var moleculeInstancesCmd = &cobra.Command{ + Use: "instances ", + Short: "Show all instantiations of a molecule", + Long: `Show all parent issues that have instantiated this molecule. + +Lists each instantiation with its status and progress.`, + Args: cobra.ExactArgs(1), + RunE: runMoleculeInstances, +} + +var moleculeSeedCmd = &cobra.Command{ + Use: "seed", + Short: "Create built-in molecules", + Long: `Seed the beads database with built-in molecule definitions. + +Creates the following molecules if they don't already exist: + - Engineer in a Box: Full workflow from design to merge + - Quick Fix: Fast path for small changes + - Research: Investigation workflow + +This command is idempotent - running it multiple times is safe.`, + RunE: runMoleculeSeed, +} + +func init() { + // List flags + moleculeListCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") + + // Show flags + moleculeShowCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") + + // Parse flags + moleculeParseCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") + + // Instantiate flags + moleculeInstantiateCmd.Flags().StringVar(&moleculeInstParent, "parent", "", "Parent issue ID (required)") + moleculeInstantiateCmd.Flags().StringArrayVar(&moleculeInstContext, "context", nil, "Context variable (key=value)") + moleculeInstantiateCmd.MarkFlagRequired("parent") + + // Instances flags + moleculeInstancesCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") + + // Add subcommands + moleculeCmd.AddCommand(moleculeListCmd) + moleculeCmd.AddCommand(moleculeShowCmd) + moleculeCmd.AddCommand(moleculeParseCmd) + moleculeCmd.AddCommand(moleculeInstantiateCmd) + moleculeCmd.AddCommand(moleculeInstancesCmd) + moleculeCmd.AddCommand(moleculeSeedCmd) + + rootCmd.AddCommand(moleculeCmd) +} + +func runMoleculeList(cmd *cobra.Command, args []string) error { + workDir, err := findBeadsWorkDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + b := beads.New(workDir) + issues, err := b.List(beads.ListOptions{ + Type: "molecule", + Status: "all", + Priority: -1, + }) + if err != nil { + return fmt.Errorf("listing molecules: %w", err) + } + + if moleculeJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(issues) + } + + // Human-readable output + fmt.Printf("%s Molecules (%d)\n\n", style.Bold.Render("🧬"), len(issues)) + + if len(issues) == 0 { + fmt.Printf(" %s\n", style.Dim.Render("(no molecules defined)")) + return nil + } + + for _, mol := range issues { + statusMarker := "" + if mol.Status == "closed" { + statusMarker = " " + style.Dim.Render("[closed]") + } + + // Parse steps to show count + steps, _ := beads.ParseMoleculeSteps(mol.Description) + stepCount := "" + if len(steps) > 0 { + stepCount = fmt.Sprintf(" (%d steps)", len(steps)) + } + + fmt.Printf(" %s: %s%s%s\n", style.Bold.Render(mol.ID), mol.Title, stepCount, statusMarker) + } + + return nil +} + +func runMoleculeShow(cmd *cobra.Command, args []string) error { + molID := args[0] + + workDir, err := findBeadsWorkDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + b := beads.New(workDir) + mol, err := b.Show(molID) + if err != nil { + return fmt.Errorf("getting molecule: %w", err) + } + + if mol.Type != "molecule" { + return fmt.Errorf("%s is not a molecule (type: %s)", molID, mol.Type) + } + + // Parse steps + steps, parseErr := beads.ParseMoleculeSteps(mol.Description) + + // For JSON, include parsed steps + if moleculeJSON { + type moleculeOutput struct { + *beads.Issue + Steps []beads.MoleculeStep `json:"steps,omitempty"` + ParseError string `json:"parse_error,omitempty"` + } + out := moleculeOutput{Issue: mol, Steps: steps} + if parseErr != nil { + out.ParseError = parseErr.Error() + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(out) + } + + // Human-readable output + fmt.Printf("\n%s: %s\n", style.Bold.Render(mol.ID), mol.Title) + fmt.Printf("Type: %s\n", mol.Type) + + if parseErr != nil { + fmt.Printf("\n%s Parse error: %s\n", style.Bold.Render("⚠"), parseErr) + } + + // Show steps + fmt.Printf("\nSteps (%d):\n", len(steps)) + if len(steps) == 0 { + fmt.Printf(" %s\n", style.Dim.Render("(no steps defined)")) + } else { + // Find which steps are ready (no dependencies) + for _, step := range steps { + needsStr := "" + if len(step.Needs) == 0 { + needsStr = style.Dim.Render("(ready first)") + } else { + needsStr = fmt.Sprintf("Needs: %s", strings.Join(step.Needs, ", ")) + } + + tierStr := "" + if step.Tier != "" { + tierStr = fmt.Sprintf(" [%s]", step.Tier) + } + + fmt.Printf(" %-12s → %s%s\n", step.Ref, needsStr, tierStr) + } + } + + // Count instances + instances, _ := findMoleculeInstances(b, molID) + fmt.Printf("\nInstances: %d\n", len(instances)) + + return nil +} + +func runMoleculeParse(cmd *cobra.Command, args []string) error { + molID := args[0] + + workDir, err := findBeadsWorkDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + b := beads.New(workDir) + mol, err := b.Show(molID) + if err != nil { + return fmt.Errorf("getting molecule: %w", err) + } + + // Validate the molecule + validationErr := beads.ValidateMolecule(mol) + + // Parse steps regardless of validation + steps, parseErr := beads.ParseMoleculeSteps(mol.Description) + + if moleculeJSON { + type parseOutput struct { + Valid bool `json:"valid"` + ValidationError string `json:"validation_error,omitempty"` + ParseError string `json:"parse_error,omitempty"` + Steps []beads.MoleculeStep `json:"steps"` + } + out := parseOutput{ + Valid: validationErr == nil, + Steps: steps, + } + if validationErr != nil { + out.ValidationError = validationErr.Error() + } + if parseErr != nil { + out.ParseError = parseErr.Error() + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(out) + } + + // Human-readable output + fmt.Printf("\n%s: %s\n\n", style.Bold.Render(mol.ID), mol.Title) + + if validationErr != nil { + fmt.Printf("%s Validation failed: %s\n\n", style.Bold.Render("✗"), validationErr) + } else { + fmt.Printf("%s Valid molecule\n\n", style.Bold.Render("✓")) + } + + if parseErr != nil { + fmt.Printf("Parse error: %s\n\n", parseErr) + } + + fmt.Printf("Parsed Steps (%d):\n", len(steps)) + for i, step := range steps { + fmt.Printf("\n [%d] %s\n", i+1, style.Bold.Render(step.Ref)) + if step.Title != step.Ref { + fmt.Printf(" Title: %s\n", step.Title) + } + if len(step.Needs) > 0 { + fmt.Printf(" Needs: %s\n", strings.Join(step.Needs, ", ")) + } + if step.Tier != "" { + fmt.Printf(" Tier: %s\n", step.Tier) + } + if step.Instructions != "" { + // Show first line of instructions + firstLine := strings.SplitN(step.Instructions, "\n", 2)[0] + if len(firstLine) > 60 { + firstLine = firstLine[:57] + "..." + } + fmt.Printf(" Instructions: %s\n", style.Dim.Render(firstLine)) + } + } + + return nil +} + +func runMoleculeInstantiate(cmd *cobra.Command, args []string) error { + molID := args[0] + + workDir, err := findBeadsWorkDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + b := beads.New(workDir) + + // Get the molecule + mol, err := b.Show(molID) + if err != nil { + return fmt.Errorf("getting molecule: %w", err) + } + + if mol.Type != "molecule" { + return fmt.Errorf("%s is not a molecule (type: %s)", molID, mol.Type) + } + + // Validate molecule + if err := beads.ValidateMolecule(mol); err != nil { + return fmt.Errorf("invalid molecule: %w", err) + } + + // Get the parent issue + parent, err := b.Show(moleculeInstParent) + if err != nil { + return fmt.Errorf("getting parent issue: %w", err) + } + + // Parse context variables + ctx := make(map[string]string) + for _, kv := range moleculeInstContext { + parts := strings.SplitN(kv, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid context format %q (expected key=value)", kv) + } + ctx[parts[0]] = parts[1] + } + + // Instantiate the molecule + opts := beads.InstantiateOptions{Context: ctx} + steps, err := b.InstantiateMolecule(mol, parent, opts) + if err != nil { + return fmt.Errorf("instantiating molecule: %w", err) + } + + fmt.Printf("%s Created %d steps from %s on %s\n\n", + style.Bold.Render("✓"), len(steps), molID, moleculeInstParent) + + for _, step := range steps { + fmt.Printf(" %s: %s\n", style.Dim.Render(step.ID), step.Title) + } + + return nil +} + +func runMoleculeInstances(cmd *cobra.Command, args []string) error { + molID := args[0] + + workDir, err := findBeadsWorkDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + b := beads.New(workDir) + + // Verify the molecule exists + mol, err := b.Show(molID) + if err != nil { + return fmt.Errorf("getting molecule: %w", err) + } + + if mol.Type != "molecule" { + return fmt.Errorf("%s is not a molecule (type: %s)", molID, mol.Type) + } + + // Find all instances + instances, err := findMoleculeInstances(b, molID) + if err != nil { + return fmt.Errorf("finding instances: %w", err) + } + + if moleculeJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(instances) + } + + // Human-readable output + fmt.Printf("\n%s Instances of %s (%d)\n\n", + style.Bold.Render("📋"), molID, len(instances)) + + if len(instances) == 0 { + fmt.Printf(" %s\n", style.Dim.Render("(no instantiations found)")) + return nil + } + + fmt.Printf("%-16s %-12s %s\n", + style.Bold.Render("Parent"), + style.Bold.Render("Status"), + style.Bold.Render("Created")) + fmt.Println(strings.Repeat("-", 50)) + + for _, inst := range instances { + // Calculate progress from children + progress := "" + if len(inst.Children) > 0 { + closed := 0 + for _, childID := range inst.Children { + child, err := b.Show(childID) + if err == nil && child.Status == "closed" { + closed++ + } + } + progress = fmt.Sprintf(" (%d/%d complete)", closed, len(inst.Children)) + } + + statusStr := inst.Status + if inst.Status == "closed" { + statusStr = style.Dim.Render("done") + } else if inst.Status == "in_progress" { + statusStr = "active" + } + + created := "" + if inst.CreatedAt != "" { + // Parse and format date + created = inst.CreatedAt[:10] // Just the date portion + } + + fmt.Printf("%-16s %-12s %s%s\n", inst.ID, statusStr, created, progress) + } + + return nil +} + +func runMoleculeSeed(cmd *cobra.Command, args []string) error { + workDir, err := findBeadsWorkDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + b := beads.New(workDir) + created, err := b.SeedBuiltinMolecules() + if err != nil { + return fmt.Errorf("seeding molecules: %w", err) + } + + if created == 0 { + fmt.Printf("%s All built-in molecules already exist\n", style.Dim.Render("✓")) + } else { + fmt.Printf("%s Seeded %d built-in molecule(s)\n", style.Bold.Render("✓"), created) + fmt.Printf("\n%s Molecules added to JSONL. Use 'bd --no-db' until beads-cli supports molecule type.\n", + style.Dim.Render("Note:")) + } + + return nil +} + +// moleculeInstance represents an instantiation of a molecule. +type moleculeInstance struct { + *beads.Issue +} + +// findMoleculeInstances finds all parent issues that have steps instantiated from the given molecule. +func findMoleculeInstances(b *beads.Beads, molID string) ([]*beads.Issue, error) { + // Get all issues and look for ones with children that have instantiated_from metadata + // This is a brute-force approach - could be optimized with better queries + + // Strategy: search for issues whose descriptions contain "instantiated_from: " + allIssues, err := b.List(beads.ListOptions{Status: "all", Priority: -1}) + if err != nil { + return nil, err + } + + // Find issues that reference this molecule + parentIDs := make(map[string]bool) + for _, issue := range allIssues { + if strings.Contains(issue.Description, fmt.Sprintf("instantiated_from: %s", molID)) { + // This is a step - find its parent + if issue.Parent != "" { + parentIDs[issue.Parent] = true + } + } + } + + // Fetch the parent issues + var parents []*beads.Issue + for parentID := range parentIDs { + parent, err := b.Show(parentID) + if err == nil { + parents = append(parents, parent) + } + } + + return parents, nil +}