feat: add WaitsFor parsing and mol bond command (gt-odfr, gt-isje)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -343,6 +343,11 @@ func TestWitnessPatrolMolecule(t *testing.T) {
|
|||||||
t.Errorf("aggregate should need survey-workers, got %v", steps[4].Needs)
|
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
|
// burn-or-loop needs context-check
|
||||||
if len(steps[8].Needs) != 1 || steps[8].Needs[0] != "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)
|
t.Errorf("burn-or-loop should need context-check, got %v", steps[8].Needs)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type MoleculeStep struct {
|
|||||||
Title string // Step title (first non-empty line or ref)
|
Title string // Step title (first non-empty line or ref)
|
||||||
Instructions string // Prose instructions for this step
|
Instructions string // Prose instructions for this step
|
||||||
Needs []string // Step refs this step depends on
|
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
|
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.
|
// tierLineRegex matches "Tier: haiku|sonnet|opus" lines.
|
||||||
var tierLineRegex = regexp.MustCompile(`(?i)^Tier:\s*(haiku|sonnet|opus)\s*$`)
|
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.
|
// templateVarRegex matches {{variable}} placeholders.
|
||||||
var templateVarRegex = regexp.MustCompile(`\{\{(\w+)\}\}`)
|
var templateVarRegex = regexp.MustCompile(`\{\{(\w+)\}\}`)
|
||||||
|
|
||||||
@@ -77,6 +82,18 @@ func ParseMoleculeSteps(description string) ([]MoleculeStep, error) {
|
|||||||
continue
|
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
|
// Regular instruction line
|
||||||
instructionLines = append(instructionLines, line)
|
instructionLines = append(instructionLines, line)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestParseMoleculeSteps_CaseInsensitive(t *testing.T) {
|
||||||
desc := `## STEP: Design
|
desc := `## STEP: Design
|
||||||
Plan the work.
|
Plan the work.
|
||||||
NEEDS: nothing
|
NEEDS: nothing
|
||||||
TIER: SONNET
|
TIER: SONNET
|
||||||
|
WAITSFOR: All-Children
|
||||||
|
|
||||||
## step: implement
|
## step: implement
|
||||||
Write code.
|
Write code.
|
||||||
needs: Design
|
needs: Design
|
||||||
tier: Haiku`
|
tier: Haiku
|
||||||
|
waitsfor: some-condition`
|
||||||
|
|
||||||
steps, err := ParseMoleculeSteps(desc)
|
steps, err := ParseMoleculeSteps(desc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -169,6 +209,10 @@ tier: Haiku`
|
|||||||
if steps[0].Tier != "sonnet" {
|
if steps[0].Tier != "sonnet" {
|
||||||
t.Errorf("step[0].Tier = %q, want sonnet", steps[0].Tier)
|
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" {
|
if steps[1].Ref != "implement" {
|
||||||
t.Errorf("step[1].Ref = %q, want implement", steps[1].Ref)
|
t.Errorf("step[1].Ref = %q, want implement", steps[1].Ref)
|
||||||
@@ -176,6 +220,9 @@ tier: Haiku`
|
|||||||
if steps[1].Tier != "haiku" {
|
if steps[1].Tier != "haiku" {
|
||||||
t.Errorf("step[1].Tier = %q, want haiku", steps[1].Tier)
|
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) {
|
func TestParseMoleculeSteps_EngineerInBox(t *testing.T) {
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ var (
|
|||||||
moleculeInstContext []string
|
moleculeInstContext []string
|
||||||
moleculeCatalogOnly bool // List only catalog templates
|
moleculeCatalogOnly bool // List only catalog templates
|
||||||
moleculeDBOnly bool // List only database molecules
|
moleculeDBOnly bool // List only database molecules
|
||||||
|
moleculeBondParent string
|
||||||
|
moleculeBondRef string
|
||||||
|
moleculeBondVars []string
|
||||||
)
|
)
|
||||||
|
|
||||||
var moleculeCmd = &cobra.Command{
|
var moleculeCmd = &cobra.Command{
|
||||||
@@ -317,6 +320,34 @@ a permanent (but compact) record.`,
|
|||||||
RunE: runMoleculeSquash,
|
RunE: runMoleculeSquash,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var moleculeBondCmd = &cobra.Command{
|
||||||
|
Use: "bond <proto-id>",
|
||||||
|
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 <rig> --names); do
|
||||||
|
gt mol bond mol-polecat-arm --parent=$PATROL_WISP_ID \
|
||||||
|
--ref=arm-$polecat \
|
||||||
|
--var polecat_name=$polecat \
|
||||||
|
--var rig=<rig>
|
||||||
|
done`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: runMoleculeBond,
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
// List flags
|
// List flags
|
||||||
moleculeListCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
|
moleculeListCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
|
||||||
@@ -358,6 +389,13 @@ func init() {
|
|||||||
// Squash flags
|
// Squash flags
|
||||||
moleculeSquashCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
|
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
|
// Add subcommands
|
||||||
moleculeCmd.AddCommand(moleculeStatusCmd)
|
moleculeCmd.AddCommand(moleculeStatusCmd)
|
||||||
moleculeCmd.AddCommand(moleculeCurrentCmd)
|
moleculeCmd.AddCommand(moleculeCurrentCmd)
|
||||||
@@ -375,6 +413,7 @@ func init() {
|
|||||||
moleculeCmd.AddCommand(moleculeDetachCmd)
|
moleculeCmd.AddCommand(moleculeDetachCmd)
|
||||||
moleculeCmd.AddCommand(moleculeAttachmentCmd)
|
moleculeCmd.AddCommand(moleculeAttachmentCmd)
|
||||||
moleculeCmd.AddCommand(moleculeAttachFromMailCmd)
|
moleculeCmd.AddCommand(moleculeAttachFromMailCmd)
|
||||||
|
moleculeCmd.AddCommand(moleculeBondCmd)
|
||||||
|
|
||||||
rootCmd.AddCommand(moleculeCmd)
|
rootCmd.AddCommand(moleculeCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,132 @@ func runMoleculeInstantiate(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
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.
|
// runMoleculeCatalog lists available molecule protos.
|
||||||
func runMoleculeCatalog(cmd *cobra.Command, args []string) error {
|
func runMoleculeCatalog(cmd *cobra.Command, args []string) error {
|
||||||
workDir, err := findLocalBeadsDir()
|
workDir, err := findLocalBeadsDir()
|
||||||
|
|||||||
Reference in New Issue
Block a user