From 031a27c0629964eecd68ec2a447a4a5ef78a688c Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 23 Dec 2025 21:44:08 -0800 Subject: [PATCH] feat: add WaitsFor parsing and mol bond command (gt-odfr, gt-isje) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WaitsFor parsing: - Add WaitsFor []string field to MoleculeStep struct - Parse WaitsFor lines in molecule descriptions - Enables fanout gate semantics (e.g., WaitsFor: all-children) - Case-insensitive parsing like Needs/Tier mol bond command: - Add gt mol bond for dynamic child molecule creation - Supports --parent, --ref, and --var flags - Enables Christmas Ornament pattern for parallel child execution - Creates child issue with expanded template variables - Instantiates proto steps under the bonded child 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/beads/builtin_molecules_test.go | 5 + internal/beads/molecule.go | 17 +++ internal/beads/molecule_test.go | 49 ++++++++- internal/cmd/molecule.go | 39 +++++++ internal/cmd/molecule_lifecycle.go | 126 +++++++++++++++++++++++ 5 files changed, 235 insertions(+), 1 deletion(-) diff --git a/internal/beads/builtin_molecules_test.go b/internal/beads/builtin_molecules_test.go index 97611abe..71d49c45 100644 --- a/internal/beads/builtin_molecules_test.go +++ b/internal/beads/builtin_molecules_test.go @@ -343,6 +343,11 @@ func TestWitnessPatrolMolecule(t *testing.T) { t.Errorf("aggregate should need survey-workers, got %v", steps[4].Needs) } + // aggregate should have WaitsFor: all-children + if len(steps[4].WaitsFor) != 1 || steps[4].WaitsFor[0] != "all-children" { + t.Errorf("aggregate should WaitsFor all-children, got %v", steps[4].WaitsFor) + } + // burn-or-loop needs context-check if len(steps[8].Needs) != 1 || steps[8].Needs[0] != "context-check" { t.Errorf("burn-or-loop should need context-check, got %v", steps[8].Needs) diff --git a/internal/beads/molecule.go b/internal/beads/molecule.go index 030a8f44..fb443fa1 100644 --- a/internal/beads/molecule.go +++ b/internal/beads/molecule.go @@ -13,6 +13,7 @@ type MoleculeStep struct { Title string // Step title (first non-empty line or ref) Instructions string // Prose instructions for this step Needs []string // Step refs this step depends on + WaitsFor []string // Dynamic wait conditions (e.g., "all-children") Tier string // Optional tier hint: haiku, sonnet, opus } @@ -25,6 +26,10 @@ var needsLineRegex = regexp.MustCompile(`(?i)^Needs:\s*(.+)$`) // tierLineRegex matches "Tier: haiku|sonnet|opus" lines. var tierLineRegex = regexp.MustCompile(`(?i)^Tier:\s*(haiku|sonnet|opus)\s*$`) +// waitsForLineRegex matches "WaitsFor: condition1, condition2, ..." lines. +// Common conditions: "all-children" (fanout gate for dynamically bonded children) +var waitsForLineRegex = regexp.MustCompile(`(?i)^WaitsFor:\s*(.+)$`) + // templateVarRegex matches {{variable}} placeholders. var templateVarRegex = regexp.MustCompile(`\{\{(\w+)\}\}`) @@ -77,6 +82,18 @@ func ParseMoleculeSteps(description string) ([]MoleculeStep, error) { continue } + // Check for WaitsFor: line + if matches := waitsForLineRegex.FindStringSubmatch(trimmed); matches != nil { + conditions := strings.Split(matches[1], ",") + for _, cond := range conditions { + cond = strings.TrimSpace(cond) + if cond != "" { + currentStep.WaitsFor = append(currentStep.WaitsFor, cond) + } + } + continue + } + // Regular instruction line instructionLines = append(instructionLines, line) } diff --git a/internal/beads/molecule_test.go b/internal/beads/molecule_test.go index 004307c2..33e38667 100644 --- a/internal/beads/molecule_test.go +++ b/internal/beads/molecule_test.go @@ -143,16 +143,56 @@ Tier: opus` } } +func TestParseMoleculeSteps_WithWaitsFor(t *testing.T) { + desc := `## Step: survey +Discover work items. + +## Step: aggregate +Collect results from dynamically bonded children. +WaitsFor: all-children +Needs: survey + +## Step: finish +Wrap up. +WaitsFor: all-children, external-signal +Needs: aggregate` + + steps, err := ParseMoleculeSteps(desc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(steps) != 3 { + t.Fatalf("expected 3 steps, got %d", len(steps)) + } + + // survey has no WaitsFor + if len(steps[0].WaitsFor) != 0 { + t.Errorf("step[0].WaitsFor = %v, want empty", steps[0].WaitsFor) + } + + // aggregate waits for all-children + if !reflect.DeepEqual(steps[1].WaitsFor, []string{"all-children"}) { + t.Errorf("step[1].WaitsFor = %v, want [all-children]", steps[1].WaitsFor) + } + + // finish waits for multiple conditions + if !reflect.DeepEqual(steps[2].WaitsFor, []string{"all-children", "external-signal"}) { + t.Errorf("step[2].WaitsFor = %v, want [all-children, external-signal]", steps[2].WaitsFor) + } +} + func TestParseMoleculeSteps_CaseInsensitive(t *testing.T) { desc := `## STEP: Design Plan the work. NEEDS: nothing TIER: SONNET +WAITSFOR: All-Children ## step: implement Write code. needs: Design -tier: Haiku` +tier: Haiku +waitsfor: some-condition` steps, err := ParseMoleculeSteps(desc) if err != nil { @@ -169,6 +209,10 @@ tier: Haiku` if steps[0].Tier != "sonnet" { t.Errorf("step[0].Tier = %q, want sonnet", steps[0].Tier) } + // WaitsFor values preserve case + if !reflect.DeepEqual(steps[0].WaitsFor, []string{"All-Children"}) { + t.Errorf("step[0].WaitsFor = %v, want [All-Children]", steps[0].WaitsFor) + } if steps[1].Ref != "implement" { t.Errorf("step[1].Ref = %q, want implement", steps[1].Ref) @@ -176,6 +220,9 @@ tier: Haiku` if steps[1].Tier != "haiku" { t.Errorf("step[1].Tier = %q, want haiku", steps[1].Tier) } + if !reflect.DeepEqual(steps[1].WaitsFor, []string{"some-condition"}) { + t.Errorf("step[1].WaitsFor = %v, want [some-condition]", steps[1].WaitsFor) + } } func TestParseMoleculeSteps_EngineerInBox(t *testing.T) { diff --git a/internal/cmd/molecule.go b/internal/cmd/molecule.go index 20c1a45b..9ab5ad02 100644 --- a/internal/cmd/molecule.go +++ b/internal/cmd/molecule.go @@ -15,6 +15,9 @@ var ( moleculeInstContext []string moleculeCatalogOnly bool // List only catalog templates moleculeDBOnly bool // List only database molecules + moleculeBondParent string + moleculeBondRef string + moleculeBondVars []string ) var moleculeCmd = &cobra.Command{ @@ -317,6 +320,34 @@ a permanent (but compact) record.`, RunE: runMoleculeSquash, } +var moleculeBondCmd = &cobra.Command{ + Use: "bond ", + Short: "Dynamically bond a child molecule to a running parent", + Long: `Bond a child molecule to a running parent molecule/wisp. + +This creates a new child molecule instance under the specified parent, +enabling the Christmas Ornament pattern where a step can dynamically +spawn children for parallel execution. + +Examples: + # Bond a polecat inspection arm to current patrol wisp + gt mol bond mol-polecat-arm --parent=patrol-x7k --ref=arm-toast \ + --var polecat_name=toast --var rig=gastown + + # The child will have ID: patrol-x7k.arm-toast + # And template variables {{polecat_name}} and {{rig}} expanded + +Usage in mol-witness-patrol's survey-workers step: + for polecat in $(gt polecat list --names); do + gt mol bond mol-polecat-arm --parent=$PATROL_WISP_ID \ + --ref=arm-$polecat \ + --var polecat_name=$polecat \ + --var rig= + done`, + Args: cobra.ExactArgs(1), + RunE: runMoleculeBond, +} + func init() { // List flags moleculeListCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") @@ -358,6 +389,13 @@ func init() { // Squash flags moleculeSquashCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") + // Bond flags + moleculeBondCmd.Flags().StringVar(&moleculeBondParent, "parent", "", "Parent molecule/wisp ID (required)") + moleculeBondCmd.Flags().StringVar(&moleculeBondRef, "ref", "", "Child reference suffix (e.g., arm-toast)") + moleculeBondCmd.Flags().StringArrayVar(&moleculeBondVars, "var", nil, "Template variable (key=value)") + moleculeBondCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") + moleculeBondCmd.MarkFlagRequired("parent") + // Add subcommands moleculeCmd.AddCommand(moleculeStatusCmd) moleculeCmd.AddCommand(moleculeCurrentCmd) @@ -375,6 +413,7 @@ func init() { moleculeCmd.AddCommand(moleculeDetachCmd) moleculeCmd.AddCommand(moleculeAttachmentCmd) moleculeCmd.AddCommand(moleculeAttachFromMailCmd) + moleculeCmd.AddCommand(moleculeBondCmd) rootCmd.AddCommand(moleculeCmd) } diff --git a/internal/cmd/molecule_lifecycle.go b/internal/cmd/molecule_lifecycle.go index 79978544..2e5fe0a4 100644 --- a/internal/cmd/molecule_lifecycle.go +++ b/internal/cmd/molecule_lifecycle.go @@ -83,6 +83,132 @@ func runMoleculeInstantiate(cmd *cobra.Command, args []string) error { return nil } +// runMoleculeBond dynamically bonds a child molecule to a running parent. +// This enables the Christmas Ornament pattern for parallel child execution. +func runMoleculeBond(cmd *cobra.Command, args []string) error { + protoID := args[0] + + workDir, err := findLocalBeadsDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + b := beads.New(workDir) + + // Load the molecule proto from catalog + catalog, err := loadMoleculeCatalog(workDir) + if err != nil { + return fmt.Errorf("loading catalog: %w", err) + } + + var proto *beads.Issue + + if catalogMol := catalog.Get(protoID); catalogMol != nil { + proto = catalogMol.ToIssue() + } else { + // Fall back to database + proto, err = b.Show(protoID) + if err != nil { + return fmt.Errorf("getting molecule proto: %w", err) + } + } + + if proto.Type != "molecule" { + return fmt.Errorf("%s is not a molecule (type: %s)", protoID, proto.Type) + } + + // Validate molecule + if err := beads.ValidateMolecule(proto); err != nil { + return fmt.Errorf("invalid molecule: %w", err) + } + + // Get the parent issue (the running molecule/wisp) + parent, err := b.Show(moleculeBondParent) + if err != nil { + return fmt.Errorf("getting parent: %w", err) + } + + // Parse template variables from --var flags + ctx := make(map[string]string) + for _, kv := range moleculeBondVars { + parts := strings.SplitN(kv, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid var format %q (expected key=value)", kv) + } + ctx[parts[0]] = parts[1] + } + + // Create the bonded child as an issue under the parent + // First, create a container issue for the bonded molecule + childTitle := proto.Title + if moleculeBondRef != "" { + childTitle = fmt.Sprintf("%s (%s)", proto.Title, moleculeBondRef) + } + + // Expand template variables in the proto description + expandedDesc := beads.ExpandTemplateVars(proto.Description, ctx) + + // Add bonding metadata + bondingMeta := fmt.Sprintf(` +--- +bonded_from: %s +bonded_to: %s +bonded_ref: %s +bonded_at: %s +`, protoID, moleculeBondParent, moleculeBondRef, time.Now().UTC().Format(time.RFC3339)) + + childDesc := expandedDesc + bondingMeta + + // Create the child molecule container + childOpts := beads.CreateOptions{ + Title: childTitle, + Description: childDesc, + Type: "task", // Bonded children are tasks, not molecules + Priority: parent.Priority, + Parent: moleculeBondParent, + } + + child, err := b.Create(childOpts) + if err != nil { + return fmt.Errorf("creating bonded child: %w", err) + } + + // Now instantiate the proto's steps under this child + opts := beads.InstantiateOptions{Context: ctx} + steps, err := b.InstantiateMolecule(proto, child, opts) + if err != nil { + // Clean up the child container on failure + _ = b.Close(child.ID) + return fmt.Errorf("instantiating bonded molecule: %w", err) + } + + if moleculeJSON { + result := map[string]interface{}{ + "proto": protoID, + "parent": moleculeBondParent, + "ref": moleculeBondRef, + "child_id": child.ID, + "steps": len(steps), + "variables": ctx, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) + } + + fmt.Printf("%s Bonded %s to %s\n", + style.Bold.Render("🔗"), protoID, moleculeBondParent) + fmt.Printf(" Child: %s (%d steps)\n", child.ID, len(steps)) + if moleculeBondRef != "" { + fmt.Printf(" Ref: %s\n", moleculeBondRef) + } + if len(ctx) > 0 { + fmt.Printf(" Variables: %v\n", ctx) + } + + return nil +} + // runMoleculeCatalog lists available molecule protos. func runMoleculeCatalog(cmd *cobra.Command, args []string) error { workDir, err := findLocalBeadsDir()