From 4c064aff5dd68993c356e218978742c71225fc82 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Fri, 19 Dec 2025 12:00:30 -0800 Subject: [PATCH] feat(molecule): add molecule CLI commands and spawn integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `gt molecule` subcommands for managing workflow templates: - list: Show all molecules (type=molecule issues) - show: Display molecule with parsed step structure - parse: Validate molecule and show parsed details - instantiate: Create child beads from molecule template - instances: Show all instantiations of a molecule Also add `--molecule` flag to `gt spawn` for molecule-based workflows. When specified, the molecule is instantiated on the parent issue first, then the polecat is spawned on the first ready step. 🤝 Co-authored-by: Claude --- internal/cmd/molecule.go | 495 +++++++++++++++++++++++++++++++++++++++ internal/cmd/spawn.go | 83 ++++++- 2 files changed, 571 insertions(+), 7 deletions(-) create mode 100644 internal/cmd/molecule.go diff --git a/internal/cmd/molecule.go b/internal/cmd/molecule.go new file mode 100644 index 00000000..3fc92f6e --- /dev/null +++ b/internal/cmd/molecule.go @@ -0,0 +1,495 @@ +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, +} + +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) + + 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 +} + +// 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 +} diff --git a/internal/cmd/spawn.go b/internal/cmd/spawn.go index 1b0084e6..0df63e81 100644 --- a/internal/cmd/spawn.go +++ b/internal/cmd/spawn.go @@ -11,6 +11,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/polecat" @@ -30,12 +31,13 @@ var polecatNames = []string{ // Spawn command flags var ( - spawnIssue string - spawnMessage string - spawnCreate bool - spawnNoStart bool - spawnPolecat string - spawnRig string + spawnIssue string + spawnMessage string + spawnCreate bool + spawnNoStart bool + spawnPolecat string + spawnRig string + spawnMolecule string ) var spawnCmd = &cobra.Command{ @@ -47,6 +49,9 @@ var spawnCmd = &cobra.Command{ Assigns an issue or task to a polecat and starts a session. If no polecat is specified, auto-selects an idle polecat in the rig. +When --molecule is specified, the molecule is first instantiated on the parent +issue (creating child steps), then the polecat is spawned on the first ready step. + Examples: gt spawn gastown/Toast --issue gt-abc gt spawn gastown --issue gt-def # auto-select polecat @@ -55,7 +60,10 @@ Examples: # Flag-based selection (rig inferred from current directory): gt spawn --issue gt-xyz --polecat Angharad - gt spawn --issue gt-abc --rig gastown --polecat Toast`, + gt spawn --issue gt-abc --rig gastown --polecat Toast + + # With molecule workflow: + gt spawn --issue gt-abc --molecule mol-engineer-box`, Args: cobra.MaximumNArgs(1), RunE: runSpawn, } @@ -67,6 +75,7 @@ func init() { spawnCmd.Flags().BoolVar(&spawnNoStart, "no-start", false, "Assign work but don't start session") spawnCmd.Flags().StringVar(&spawnPolecat, "polecat", "", "Polecat name (alternative to positional arg)") spawnCmd.Flags().StringVar(&spawnRig, "rig", "", "Rig name (defaults to current directory's rig)") + spawnCmd.Flags().StringVar(&spawnMolecule, "molecule", "", "Molecule ID to instantiate on the issue") rootCmd.AddCommand(spawnCmd) } @@ -86,6 +95,11 @@ func runSpawn(cmd *cobra.Command, args []string) error { return fmt.Errorf("must specify --issue or -m/--message") } + // --molecule requires --issue + if spawnMolecule != "" && spawnIssue == "" { + return fmt.Errorf("--molecule requires --issue to be specified") + } + // Find workspace first (needed for rig inference) townRoot, err := workspace.FindFromCwdOrError() if err != nil { @@ -170,6 +184,61 @@ func runSpawn(cmd *cobra.Command, args []string) error { return fmt.Errorf("polecat '%s' is already working on %s", polecatName, pc.Issue) } + // Handle molecule instantiation if specified + if spawnMolecule != "" { + b := beads.New(r.Path) + + // Get the molecule + mol, err := b.Show(spawnMolecule) + if err != nil { + return fmt.Errorf("getting molecule %s: %w", spawnMolecule, err) + } + + if mol.Type != "molecule" { + return fmt.Errorf("%s is not a molecule (type: %s)", spawnMolecule, mol.Type) + } + + // Validate the molecule + if err := beads.ValidateMolecule(mol); err != nil { + return fmt.Errorf("invalid molecule: %w", err) + } + + // Get the parent issue + parent, err := b.Show(spawnIssue) + if err != nil { + return fmt.Errorf("getting parent issue %s: %w", spawnIssue, err) + } + + // Instantiate the molecule + fmt.Printf("Instantiating molecule %s on %s...\n", spawnMolecule, spawnIssue) + steps, err := b.InstantiateMolecule(mol, parent, beads.InstantiateOptions{}) + if err != nil { + return fmt.Errorf("instantiating molecule: %w", err) + } + + fmt.Printf("%s Created %d steps\n", style.Bold.Render("✓"), len(steps)) + for _, step := range steps { + fmt.Printf(" %s: %s\n", style.Dim.Render(step.ID), step.Title) + } + + // Find the first ready step (one with no dependencies) + var firstReadyStep *beads.Issue + for _, step := range steps { + if len(step.DependsOn) == 0 { + firstReadyStep = step + break + } + } + + if firstReadyStep == nil { + return fmt.Errorf("no ready step found in molecule (all steps have dependencies)") + } + + // Switch to spawning on the first ready step + fmt.Printf("\nSpawning on first ready step: %s\n", firstReadyStep.ID) + spawnIssue = firstReadyStep.ID + } + // Get issue details if specified var issue *BeadsIssue if spawnIssue != "" {