feat: add dynamic molecule bonding with --ref flag (bd-xo1o.1)

Implements the Christmas Ornament pattern for patrol molecules:
- Add CloneOptions struct with ParentID and ChildRef for dynamic bonding
- Add generateBondedID() to create custom IDs like "patrol-x7k.arm-ace"
- Add --ref flag to `bd mol bond` for custom child references
- Variable substitution in childRef (e.g., "arm-{{polecat_name}}")

This enables:
  bd mol bond mol-polecat-arm bd-patrol --ref arm-{{name}} --var name=ace
  # Creates: bd-patrol.arm-ace, bd-patrol.arm-ace.capture, etc.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-23 03:38:24 -08:00
parent 3c86144d26
commit ee04b1ea96
7 changed files with 525 additions and 38 deletions

View File

@@ -64,7 +64,19 @@ Commands:
// Wraps cloneSubgraph from template.go and returns InstantiateResult. // Wraps cloneSubgraph from template.go and returns InstantiateResult.
// If ephemeral is true, spawned issues are marked for bulk deletion when closed. // If ephemeral is true, spawned issues are marked for bulk deletion when closed.
func spawnMolecule(ctx context.Context, s storage.Storage, subgraph *MoleculeSubgraph, vars map[string]string, assignee string, actorName string, ephemeral bool) (*InstantiateResult, error) { func spawnMolecule(ctx context.Context, s storage.Storage, subgraph *MoleculeSubgraph, vars map[string]string, assignee string, actorName string, ephemeral bool) (*InstantiateResult, error) {
return cloneSubgraph(ctx, s, subgraph, vars, assignee, actorName, ephemeral) opts := CloneOptions{
Vars: vars,
Assignee: assignee,
Actor: actorName,
Wisp: ephemeral,
}
return cloneSubgraph(ctx, s, subgraph, opts)
}
// spawnMoleculeWithOptions creates new issues from the proto using CloneOptions.
// This allows full control over dynamic bonding, variable substitution, and wisp phase.
func spawnMoleculeWithOptions(ctx context.Context, s storage.Storage, subgraph *MoleculeSubgraph, opts CloneOptions) (*InstantiateResult, error) {
return cloneSubgraph(ctx, s, subgraph, opts)
} }
// printMoleculeTree prints the molecule structure as a tree // printMoleculeTree prints the molecule structure as a tree

View File

@@ -41,9 +41,18 @@ Phase control:
--pour Force spawn as liquid (persistent), even when attaching to wisp --pour Force spawn as liquid (persistent), even when attaching to wisp
--wisp Force spawn as vapor (ephemeral), even when attaching to mol --wisp Force spawn as vapor (ephemeral), even when attaching to mol
Dynamic bonding (Christmas Ornament pattern):
Use --ref to specify a custom child reference with variable substitution.
This creates IDs like "parent.child-ref" instead of random hashes.
Example:
bd mol bond mol-polecat-arm bd-patrol --ref arm-{{polecat_name}} --var polecat_name=ace
# Creates: bd-patrol.arm-ace (and children like bd-patrol.arm-ace.capture)
Use cases: Use cases:
- Found important bug during patrol? Use --pour to persist it - Found important bug during patrol? Use --pour to persist it
- Need ephemeral diagnostic on persistent feature? Use --wisp - Need ephemeral diagnostic on persistent feature? Use --wisp
- Spawning per-worker arms on a patrol? Use --ref for readable IDs
Examples: Examples:
bd mol bond mol-feature mol-deploy # Compound proto bd mol bond mol-feature mol-deploy # Compound proto
@@ -51,7 +60,8 @@ Examples:
bd mol bond mol-feature bd-abc123 # Attach proto to molecule bd mol bond mol-feature bd-abc123 # Attach proto to molecule
bd mol bond bd-abc123 bd-def456 # Join two molecules bd mol bond bd-abc123 bd-def456 # Join two molecules
bd mol bond mol-critical-bug wisp-patrol --pour # Persist found bug bd mol bond mol-critical-bug wisp-patrol --pour # Persist found bug
bd mol bond mol-temp-check bd-feature --wisp # Ephemeral diagnostic`, bd mol bond mol-temp-check bd-feature --wisp # Ephemeral diagnostic
bd mol bond mol-arm bd-patrol --ref arm-{{name}} --var name=ace # Dynamic child ID`,
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
Run: runMolBond, Run: runMolBond,
} }
@@ -88,6 +98,7 @@ func runMolBond(cmd *cobra.Command, args []string) {
varFlags, _ := cmd.Flags().GetStringSlice("var") varFlags, _ := cmd.Flags().GetStringSlice("var")
wisp, _ := cmd.Flags().GetBool("wisp") wisp, _ := cmd.Flags().GetBool("wisp")
pour, _ := cmd.Flags().GetBool("pour") pour, _ := cmd.Flags().GetBool("pour")
childRef, _ := cmd.Flags().GetString("ref")
// Validate phase flags are not both set // Validate phase flags are not both set
if wisp && pour { if wisp && pour {
@@ -170,6 +181,10 @@ func runMolBond(cmd *cobra.Command, args []string) {
} else if pour { } else if pour {
fmt.Printf(" Phase override: liquid (--pour)\n") fmt.Printf(" Phase override: liquid (--pour)\n")
} }
if childRef != "" {
resolvedRef := substituteVariables(childRef, vars)
fmt.Printf(" Child ref: %s (resolved: %s)\n", childRef, resolvedRef)
}
if aIsProto && bIsProto { if aIsProto && bIsProto {
fmt.Printf(" Result: compound proto\n") fmt.Printf(" Result: compound proto\n")
if customTitle != "" { if customTitle != "" {
@@ -178,13 +193,28 @@ func runMolBond(cmd *cobra.Command, args []string) {
if wisp || pour { if wisp || pour {
fmt.Printf(" Note: phase flags ignored for proto+proto (templates stay in permanent storage)\n") fmt.Printf(" Note: phase flags ignored for proto+proto (templates stay in permanent storage)\n")
} }
if childRef != "" {
fmt.Printf(" Note: --ref ignored for proto+proto (use when bonding to molecule)\n")
}
} else if aIsProto || bIsProto { } else if aIsProto || bIsProto {
fmt.Printf(" Result: spawn proto, attach to molecule\n") fmt.Printf(" Result: spawn proto, attach to molecule\n")
if !wisp && !pour { if !wisp && !pour {
fmt.Printf(" Phase: follows target's phase\n") fmt.Printf(" Phase: follows target's phase\n")
} }
if childRef != "" {
// Determine parent molecule
parentID := idB
if bIsProto {
parentID = idA
}
resolvedRef := substituteVariables(childRef, vars)
fmt.Printf(" Bonded ID: %s.%s\n", parentID, resolvedRef)
}
} else { } else {
fmt.Printf(" Result: compound molecule\n") fmt.Printf(" Result: compound molecule\n")
if childRef != "" {
fmt.Printf(" Note: --ref ignored for mol+mol (use when bonding proto to molecule)\n")
}
} }
return return
} }
@@ -197,9 +227,9 @@ func runMolBond(cmd *cobra.Command, args []string) {
// Compound protos are templates - always use permanent storage // Compound protos are templates - always use permanent storage
result, err = bondProtoProto(ctx, store, issueA, issueB, bondType, customTitle, actor) result, err = bondProtoProto(ctx, store, issueA, issueB, bondType, customTitle, actor)
case aIsProto && !bIsProto: case aIsProto && !bIsProto:
result, err = bondProtoMol(ctx, targetStore, issueA, issueB, bondType, vars, actor) result, err = bondProtoMol(ctx, targetStore, issueA, issueB, bondType, vars, childRef, actor)
case !aIsProto && bIsProto: case !aIsProto && bIsProto:
result, err = bondMolProto(ctx, targetStore, issueA, issueB, bondType, vars, actor) result, err = bondMolProto(ctx, targetStore, issueA, issueB, bondType, vars, childRef, actor)
default: default:
result, err = bondMolMol(ctx, targetStore, issueA, issueB, bondType, actor) result, err = bondMolMol(ctx, targetStore, issueA, issueB, bondType, actor)
} }
@@ -334,8 +364,9 @@ func bondProtoProto(ctx context.Context, s storage.Storage, protoA, protoB *type
}, nil }, nil
} }
// bondProtoMol bonds a proto to an existing molecule by spawning the proto // bondProtoMol bonds a proto to an existing molecule by spawning the proto.
func bondProtoMol(ctx context.Context, s storage.Storage, proto, mol *types.Issue, bondType string, vars map[string]string, actorName string) (*BondResult, error) { // If childRef is provided, generates custom IDs like "parent.childref" (dynamic bonding).
func bondProtoMol(ctx context.Context, s storage.Storage, proto, mol *types.Issue, bondType string, vars map[string]string, childRef string, actorName string) (*BondResult, error) {
// Load proto subgraph // Load proto subgraph
subgraph, err := loadTemplateSubgraph(ctx, s, proto.ID) subgraph, err := loadTemplateSubgraph(ctx, s, proto.ID)
if err != nil { if err != nil {
@@ -354,8 +385,21 @@ func bondProtoMol(ctx context.Context, s storage.Storage, proto, mol *types.Issu
return nil, fmt.Errorf("missing required variables: %s (use --var)", strings.Join(missingVars, ", ")) return nil, fmt.Errorf("missing required variables: %s (use --var)", strings.Join(missingVars, ", "))
} }
// Spawn the proto (wisp by default for molecule execution - bd-2vh3) // Build CloneOptions for spawning
spawnResult, err := spawnMolecule(ctx, s, subgraph, vars, "", actorName, true) opts := CloneOptions{
Vars: vars,
Actor: actorName,
Wisp: true, // wisp by default for molecule execution - bd-2vh3
}
// Dynamic bonding: use custom IDs if childRef is provided
if childRef != "" {
opts.ParentID = mol.ID
opts.ChildRef = childRef
}
// Spawn the proto with options
spawnResult, err := spawnMoleculeWithOptions(ctx, s, subgraph, opts)
if err != nil { if err != nil {
return nil, fmt.Errorf("spawning proto: %w", err) return nil, fmt.Errorf("spawning proto: %w", err)
} }
@@ -400,9 +444,9 @@ func bondProtoMol(ctx context.Context, s storage.Storage, proto, mol *types.Issu
} }
// bondMolProto bonds a molecule to a proto (symmetric with bondProtoMol) // bondMolProto bonds a molecule to a proto (symmetric with bondProtoMol)
func bondMolProto(ctx context.Context, s storage.Storage, mol, proto *types.Issue, bondType string, vars map[string]string, actorName string) (*BondResult, error) { func bondMolProto(ctx context.Context, s storage.Storage, mol, proto *types.Issue, bondType string, vars map[string]string, childRef string, actorName string) (*BondResult, error) {
// Same as bondProtoMol but with arguments swapped // Same as bondProtoMol but with arguments swapped
return bondProtoMol(ctx, s, proto, mol, bondType, vars, actorName) return bondProtoMol(ctx, s, proto, mol, bondType, vars, childRef, actorName)
} }
// bondMolMol bonds two molecules together // bondMolMol bonds two molecules together
@@ -462,6 +506,7 @@ func init() {
molBondCmd.Flags().StringSlice("var", []string{}, "Variable substitution for spawned protos (key=value)") molBondCmd.Flags().StringSlice("var", []string{}, "Variable substitution for spawned protos (key=value)")
molBondCmd.Flags().Bool("wisp", false, "Force spawn as vapor (ephemeral in .beads-wisp/)") molBondCmd.Flags().Bool("wisp", false, "Force spawn as vapor (ephemeral in .beads-wisp/)")
molBondCmd.Flags().Bool("pour", false, "Force spawn as liquid (persistent in .beads/)") molBondCmd.Flags().Bool("pour", false, "Force spawn as liquid (persistent in .beads/)")
molBondCmd.Flags().String("ref", "", "Custom child reference with {{var}} substitution (e.g., arm-{{polecat_name}})")
molCmd.AddCommand(molBondCmd) molCmd.AddCommand(molBondCmd)
} }

View File

@@ -219,7 +219,7 @@ func runMolSpawn(cmd *cobra.Command, args []string) {
} }
for _, attach := range attachments { for _, attach := range attachments {
bondResult, err := bondProtoMol(ctx, store, attach.issue, spawnedMol, attachType, vars, actor) bondResult, err := bondProtoMol(ctx, store, attach.issue, spawnedMol, attachType, vars, "", actor)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error attaching %s: %v\n", attach.id, err) fmt.Fprintf(os.Stderr, "Error attaching %s: %v\n", attach.id, err)
os.Exit(1) os.Exit(1)

View File

@@ -343,7 +343,7 @@ func TestBondProtoMol(t *testing.T) {
// Bond proto to molecule // Bond proto to molecule
vars := map[string]string{"name": "auth-feature"} vars := map[string]string{"name": "auth-feature"}
result, err := bondProtoMol(ctx, store, proto, mol, types.BondTypeSequential, vars, "test") result, err := bondProtoMol(ctx, store, proto, mol, types.BondTypeSequential, vars, "", "test")
if err != nil { if err != nil {
t.Fatalf("bondProtoMol failed: %v", err) t.Fatalf("bondProtoMol failed: %v", err)
} }
@@ -840,7 +840,7 @@ func TestSpawnWithBasicAttach(t *testing.T) {
} }
// Attach the second proto (simulating --attach flag behavior) // Attach the second proto (simulating --attach flag behavior)
bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, types.BondTypeSequential, vars, "test") bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, types.BondTypeSequential, vars, "", "test")
if err != nil { if err != nil {
t.Fatalf("Failed to bond attachment: %v", err) t.Fatalf("Failed to bond attachment: %v", err)
} }
@@ -945,12 +945,12 @@ func TestSpawnWithMultipleAttachments(t *testing.T) {
} }
// Attach both protos (simulating --attach A --attach B) // Attach both protos (simulating --attach A --attach B)
bondResultA, err := bondProtoMol(ctx, s, attachA, spawnedMol, types.BondTypeSequential, nil, "test") bondResultA, err := bondProtoMol(ctx, s, attachA, spawnedMol, types.BondTypeSequential, nil, "", "test")
if err != nil { if err != nil {
t.Fatalf("Failed to bond attachA: %v", err) t.Fatalf("Failed to bond attachA: %v", err)
} }
bondResultB, err := bondProtoMol(ctx, s, attachB, spawnedMol, types.BondTypeSequential, nil, "test") bondResultB, err := bondProtoMol(ctx, s, attachB, spawnedMol, types.BondTypeSequential, nil, "", "test")
if err != nil { if err != nil {
t.Fatalf("Failed to bond attachB: %v", err) t.Fatalf("Failed to bond attachB: %v", err)
} }
@@ -1063,7 +1063,7 @@ func TestSpawnAttachTypes(t *testing.T) {
} }
// Bond with specified type // Bond with specified type
bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, tt.bondType, nil, "test") bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, tt.bondType, nil, "", "test")
if err != nil { if err != nil {
t.Fatalf("Failed to bond: %v", err) t.Fatalf("Failed to bond: %v", err)
} }
@@ -1228,7 +1228,7 @@ func TestSpawnVariableAggregation(t *testing.T) {
// Bond attachment with same variables // Bond attachment with same variables
spawnedMol, _ := s.GetIssue(ctx, spawnResult.NewEpicID) spawnedMol, _ := s.GetIssue(ctx, spawnResult.NewEpicID)
bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, types.BondTypeSequential, vars, "test") bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, types.BondTypeSequential, vars, "", "test")
if err != nil { if err != nil {
t.Fatalf("Failed to bond: %v", err) t.Fatalf("Failed to bond: %v", err)
} }
@@ -2024,3 +2024,324 @@ func TestAdvanceToNextStepOrphanIssue(t *testing.T) {
t.Error("result should be nil for orphan issue") t.Error("result should be nil for orphan issue")
} }
} }
// =============================================================================
// Dynamic Bonding Tests (bd-xo1o.1)
// =============================================================================
// TestGenerateBondedID tests the custom ID generation for dynamic bonding
func TestGenerateBondedID(t *testing.T) {
tests := []struct {
name string
oldID string
rootID string
opts CloneOptions
wantID string
wantErr bool
errMatch string
}{
{
name: "root issue with simple childRef",
oldID: "mol-arm",
rootID: "mol-arm",
opts: CloneOptions{
ParentID: "patrol-x7k",
ChildRef: "arm-ace",
},
wantID: "patrol-x7k.arm-ace",
},
{
name: "root issue with variable substitution",
oldID: "mol-arm",
rootID: "mol-arm",
opts: CloneOptions{
ParentID: "patrol-x7k",
ChildRef: "arm-{{polecat_name}}",
Vars: map[string]string{"polecat_name": "ace"},
},
wantID: "patrol-x7k.arm-ace",
},
{
name: "child issue with relative ID",
oldID: "mol-arm.capture",
rootID: "mol-arm",
opts: CloneOptions{
ParentID: "patrol-x7k",
ChildRef: "arm-ace",
},
wantID: "patrol-x7k.arm-ace.capture",
},
{
name: "nested child issue",
oldID: "mol-arm.capture.sub",
rootID: "mol-arm",
opts: CloneOptions{
ParentID: "patrol-x7k",
ChildRef: "arm-ace",
},
wantID: "patrol-x7k.arm-ace.capture.sub",
},
{
name: "no parent ID returns empty (not a bonded operation)",
oldID: "mol-arm",
rootID: "mol-arm",
opts: CloneOptions{},
wantID: "",
},
{
name: "empty childRef after substitution is error",
oldID: "mol-arm",
rootID: "mol-arm",
opts: CloneOptions{
ParentID: "patrol-x7k",
ChildRef: "{{missing_var}}",
},
wantErr: true,
errMatch: "invalid childRef",
},
{
name: "childRef with special chars is error",
oldID: "mol-arm",
rootID: "mol-arm",
opts: CloneOptions{
ParentID: "patrol-x7k",
ChildRef: "arm/ace",
},
wantErr: true,
errMatch: "invalid childRef",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotID, err := generateBondedID(tt.oldID, tt.rootID, tt.opts)
if tt.wantErr {
if err == nil {
t.Errorf("generateBondedID() expected error containing %q, got nil", tt.errMatch)
} else if !strings.Contains(err.Error(), tt.errMatch) {
t.Errorf("generateBondedID() error = %q, want error containing %q", err.Error(), tt.errMatch)
}
return
}
if err != nil {
t.Errorf("generateBondedID() unexpected error: %v", err)
return
}
if gotID != tt.wantID {
t.Errorf("generateBondedID() = %q, want %q", gotID, tt.wantID)
}
})
}
}
// TestGetRelativeID tests extracting relative portion from child IDs
func TestGetRelativeID(t *testing.T) {
tests := []struct {
name string
oldID string
rootID string
want string
}{
{
name: "same ID returns empty",
oldID: "mol-arm",
rootID: "mol-arm",
want: "",
},
{
name: "child with single step",
oldID: "mol-arm.capture",
rootID: "mol-arm",
want: "capture",
},
{
name: "child with nested steps",
oldID: "mol-arm.capture.sub.deep",
rootID: "mol-arm",
want: "capture.sub.deep",
},
{
name: "unrelated IDs returns empty",
oldID: "other-123",
rootID: "mol-arm",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := getRelativeID(tt.oldID, tt.rootID)
if got != tt.want {
t.Errorf("getRelativeID() = %q, want %q", got, tt.want)
}
})
}
}
// TestBondProtoMolWithRef tests dynamic bonding with custom child references
func TestBondProtoMolWithRef(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "patrol"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create a proto with child steps (mol-polecat-arm template)
protoRoot := &types.Issue{
Title: "Polecat Arm: {{polecat_name}}",
IssueType: types.TypeEpic,
Status: types.StatusOpen,
Priority: 2,
Labels: []string{MoleculeLabel},
}
if err := s.CreateIssue(ctx, protoRoot, "test"); err != nil {
t.Fatalf("Failed to create proto root: %v", err)
}
// Add proto steps
protoCapture := &types.Issue{
Title: "Capture {{polecat_name}}",
IssueType: types.TypeTask,
Status: types.StatusOpen,
Priority: 2,
}
if err := s.CreateIssue(ctx, protoCapture, "test"); err != nil {
t.Fatalf("Failed to create proto capture: %v", err)
}
if err := s.AddDependency(ctx, &types.Dependency{
IssueID: protoCapture.ID,
DependsOnID: protoRoot.ID,
Type: types.DepParentChild,
}, "test"); err != nil {
t.Fatalf("Failed to add proto dependency: %v", err)
}
// Create target molecule (patrol-xxx)
patrol := &types.Issue{
Title: "Witness Patrol",
IssueType: types.TypeEpic,
Status: types.StatusInProgress,
Priority: 1,
}
if err := s.CreateIssue(ctx, patrol, "test"); err != nil {
t.Fatalf("Failed to create patrol: %v", err)
}
// Bond proto to patrol with custom child ref
vars := map[string]string{"polecat_name": "ace"}
childRef := "arm-{{polecat_name}}"
result, err := bondProtoMol(ctx, s, protoRoot, patrol, types.BondTypeSequential, vars, childRef, "test")
if err != nil {
t.Fatalf("bondProtoMol failed: %v", err)
}
// Verify spawned count
if result.Spawned != 2 {
t.Errorf("Spawned = %d, want 2", result.Spawned)
}
// Verify root ID follows pattern: patrol.arm-ace
expectedRootID := patrol.ID + ".arm-ace"
if result.IDMapping[protoRoot.ID] != expectedRootID {
t.Errorf("Root ID = %q, want %q", result.IDMapping[protoRoot.ID], expectedRootID)
}
// Verify child ID follows pattern: patrol.arm-ace.relative
// The child's ID should be patrol.arm-ace.capture (but relative part depends on proto structure)
childID := result.IDMapping[protoCapture.ID]
if !strings.HasPrefix(childID, expectedRootID+".") {
t.Errorf("Child ID %q should start with %q", childID, expectedRootID+".")
}
// Verify the spawned issues exist and have correct titles
spawnedRoot, err := s.GetIssue(ctx, expectedRootID)
if err != nil {
t.Fatalf("Failed to get spawned root: %v", err)
}
if !strings.Contains(spawnedRoot.Title, "ace") {
t.Errorf("Spawned root title %q should contain 'ace'", spawnedRoot.Title)
}
}
// TestBondProtoMolMultipleArms tests bonding multiple arms to the same parent
func TestBondProtoMolMultipleArms(t *testing.T) {
ctx := context.Background()
dbPath := t.TempDir() + "/test.db"
s, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer s.Close()
if err := s.SetConfig(ctx, "issue_prefix", "patrol"); err != nil {
t.Fatalf("Failed to set config: %v", err)
}
// Create simple proto
proto := &types.Issue{
Title: "Arm: {{name}}",
IssueType: types.TypeTask,
Status: types.StatusOpen,
Priority: 2,
Labels: []string{MoleculeLabel},
}
if err := s.CreateIssue(ctx, proto, "test"); err != nil {
t.Fatalf("Failed to create proto: %v", err)
}
// Create parent patrol
patrol := &types.Issue{
Title: "Patrol",
IssueType: types.TypeEpic,
Status: types.StatusOpen,
Priority: 1,
}
if err := s.CreateIssue(ctx, patrol, "test"); err != nil {
t.Fatalf("Failed to create patrol: %v", err)
}
// Bond arm-ace
varsAce := map[string]string{"name": "ace"}
resultAce, err := bondProtoMol(ctx, s, proto, patrol, types.BondTypeParallel, varsAce, "arm-{{name}}", "test")
if err != nil {
t.Fatalf("bondProtoMol (ace) failed: %v", err)
}
// Bond arm-nux
varsNux := map[string]string{"name": "nux"}
resultNux, err := bondProtoMol(ctx, s, proto, patrol, types.BondTypeParallel, varsNux, "arm-{{name}}", "test")
if err != nil {
t.Fatalf("bondProtoMol (nux) failed: %v", err)
}
// Verify IDs are correct and distinct
aceID := resultAce.IDMapping[proto.ID]
nuxID := resultNux.IDMapping[proto.ID]
expectedAceID := patrol.ID + ".arm-ace"
expectedNuxID := patrol.ID + ".arm-nux"
if aceID != expectedAceID {
t.Errorf("Ace ID = %q, want %q", aceID, expectedAceID)
}
if nuxID != expectedNuxID {
t.Errorf("Nux ID = %q, want %q", nuxID, expectedNuxID)
}
// Verify both exist
aceIssue, err := s.GetIssue(ctx, aceID)
if err != nil || aceIssue == nil {
t.Errorf("Ace issue not found: %v", err)
}
nuxIssue, err := s.GetIssue(ctx, nuxID)
if err != nil || nuxIssue == nil {
t.Errorf("Nux issue not found: %v", err)
}
}

View File

@@ -200,7 +200,7 @@ func runPour(cmd *cobra.Command, args []string) {
} }
for _, attach := range attachments { for _, attach := range attachments {
bondResult, err := bondProtoMol(ctx, store, attach.issue, spawnedMol, attachType, vars, actor) bondResult, err := bondProtoMol(ctx, store, attach.issue, spawnedMol, attachType, vars, "", actor)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error attaching %s: %v\n", attach.id, err) fmt.Fprintf(os.Stderr, "Error attaching %s: %v\n", attach.id, err)
os.Exit(1) os.Exit(1)

View File

@@ -38,6 +38,21 @@ type InstantiateResult struct {
Created int `json:"created"` // number of issues created Created int `json:"created"` // number of issues created
} }
// CloneOptions controls how the subgraph is cloned during spawn/bond
type CloneOptions struct {
Vars map[string]string // Variable substitutions for {{key}} placeholders
Assignee string // Assign the root epic to this agent/user
Actor string // Actor performing the operation
Wisp bool // If true, spawned issues are marked for bulk deletion
// Dynamic bonding fields (for Christmas Ornament pattern)
ParentID string // Parent molecule ID to bond under (e.g., "patrol-x7k")
ChildRef string // Child reference with variables (e.g., "arm-{{polecat_name}}")
}
// bondedIDPattern validates bonded IDs (alphanumeric, dash, underscore, dot)
var bondedIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`)
var templateCmd = &cobra.Command{ var templateCmd = &cobra.Command{
Use: "template", Use: "template",
GroupID: "setup", GroupID: "setup",
@@ -293,7 +308,13 @@ Example:
} }
// Clone the subgraph (deprecated command, non-wisp for backwards compatibility) // Clone the subgraph (deprecated command, non-wisp for backwards compatibility)
result, err := cloneSubgraph(ctx, store, subgraph, vars, assignee, actor, false) opts := CloneOptions{
Vars: vars,
Assignee: assignee,
Actor: actor,
Wisp: false,
}
result, err := cloneSubgraph(ctx, store, subgraph, opts)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error instantiating template: %v\n", err) fmt.Fprintf(os.Stderr, "Error instantiating template: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -451,10 +472,85 @@ func substituteVariables(text string, vars map[string]string) string {
}) })
} }
// cloneSubgraph creates new issues from the template with variable substitution // generateBondedID creates a custom ID for dynamically bonded molecules.
// If assignee is non-empty, it will be set on the root epic // When bonding a proto to a parent molecule, this generates IDs like:
// If wisp is true, spawned issues are marked for bulk deletion when closed (bd-2vh3) // - Root: parent.childref (e.g., "patrol-x7k.arm-ace")
func cloneSubgraph(ctx context.Context, s storage.Storage, subgraph *TemplateSubgraph, vars map[string]string, assignee string, actorName string, wisp bool) (*InstantiateResult, error) { // - Children: parent.childref.step (e.g., "patrol-x7k.arm-ace.capture")
//
// The childRef is variable-substituted before use.
// Returns empty string if not a bonded operation (opts.ParentID empty).
func generateBondedID(oldID string, rootID string, opts CloneOptions) (string, error) {
if opts.ParentID == "" {
return "", nil // Not a bonded operation
}
// Substitute variables in childRef
childRef := substituteVariables(opts.ChildRef, opts.Vars)
// Validate childRef after substitution
if childRef == "" {
return "", fmt.Errorf("childRef is empty after variable substitution")
}
if !bondedIDPattern.MatchString(childRef) {
return "", fmt.Errorf("invalid childRef '%s': must be alphanumeric, dash, underscore, or dot only", childRef)
}
if oldID == rootID {
// Root issue: parent.childref
newID := fmt.Sprintf("%s.%s", opts.ParentID, childRef)
return newID, nil
}
// Child issue: parent.childref.relative
// Extract the relative portion of the old ID (part after root)
relativeID := getRelativeID(oldID, rootID)
if relativeID == "" {
// No hierarchical relationship - use a suffix from the old ID to ensure uniqueness.
// Extract the last part of the old ID (after any prefix or dash)
suffix := extractIDSuffix(oldID)
newID := fmt.Sprintf("%s.%s.%s", opts.ParentID, childRef, suffix)
return newID, nil
}
newID := fmt.Sprintf("%s.%s.%s", opts.ParentID, childRef, relativeID)
return newID, nil
}
// extractIDSuffix extracts a suffix from an ID for use when IDs aren't hierarchical.
// For "patrol-abc123", returns "abc123".
// For "bd-xyz.1", returns "1".
// This ensures child IDs remain unique when bonding.
func extractIDSuffix(id string) string {
// First try to get the part after the last dot (for hierarchical IDs)
if lastDot := strings.LastIndex(id, "."); lastDot >= 0 {
return id[lastDot+1:]
}
// Otherwise, get the part after the last dash (for prefix-hash IDs)
if lastDash := strings.LastIndex(id, "-"); lastDash >= 0 {
return id[lastDash+1:]
}
// Fallback: use the whole ID
return id
}
// getRelativeID extracts the relative portion of a child ID from its parent.
// For example: getRelativeID("bd-abc.step1.sub", "bd-abc") returns "step1.sub"
// Returns empty string if oldID equals rootID or doesn't start with rootID.
func getRelativeID(oldID, rootID string) string {
if oldID == rootID {
return ""
}
// Check if oldID starts with rootID followed by a dot
prefix := rootID + "."
if strings.HasPrefix(oldID, prefix) {
return oldID[len(prefix):]
}
return ""
}
// cloneSubgraph creates new issues from the template with variable substitution.
// Uses CloneOptions to control all spawn/bond behavior including dynamic bonding.
func cloneSubgraph(ctx context.Context, s storage.Storage, subgraph *TemplateSubgraph, opts CloneOptions) (*InstantiateResult, error) {
if s == nil { if s == nil {
return nil, fmt.Errorf("no database connection") return nil, fmt.Errorf("no database connection")
} }
@@ -468,28 +564,37 @@ func cloneSubgraph(ctx context.Context, s storage.Storage, subgraph *TemplateSub
for _, oldIssue := range subgraph.Issues { for _, oldIssue := range subgraph.Issues {
// Determine assignee: use override for root epic, otherwise keep template's // Determine assignee: use override for root epic, otherwise keep template's
issueAssignee := oldIssue.Assignee issueAssignee := oldIssue.Assignee
if oldIssue.ID == subgraph.Root.ID && assignee != "" { if oldIssue.ID == subgraph.Root.ID && opts.Assignee != "" {
issueAssignee = assignee issueAssignee = opts.Assignee
} }
newIssue := &types.Issue{ newIssue := &types.Issue{
// Don't set ID - let the system generate it // ID will be set below based on bonding options
Title: substituteVariables(oldIssue.Title, vars), Title: substituteVariables(oldIssue.Title, opts.Vars),
Description: substituteVariables(oldIssue.Description, vars), Description: substituteVariables(oldIssue.Description, opts.Vars),
Design: substituteVariables(oldIssue.Design, vars), Design: substituteVariables(oldIssue.Design, opts.Vars),
AcceptanceCriteria: substituteVariables(oldIssue.AcceptanceCriteria, vars), AcceptanceCriteria: substituteVariables(oldIssue.AcceptanceCriteria, opts.Vars),
Notes: substituteVariables(oldIssue.Notes, vars), Notes: substituteVariables(oldIssue.Notes, opts.Vars),
Status: types.StatusOpen, // Always start fresh Status: types.StatusOpen, // Always start fresh
Priority: oldIssue.Priority, Priority: oldIssue.Priority,
IssueType: oldIssue.IssueType, IssueType: oldIssue.IssueType,
Assignee: issueAssignee, Assignee: issueAssignee,
EstimatedMinutes: oldIssue.EstimatedMinutes, EstimatedMinutes: oldIssue.EstimatedMinutes,
Wisp: wisp, // bd-2vh3: mark for cleanup when closed Wisp: opts.Wisp, // bd-2vh3: mark for cleanup when closed
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
if err := tx.CreateIssue(ctx, newIssue, actorName); err != nil { // Generate custom ID for dynamic bonding if ParentID is set
if opts.ParentID != "" {
bondedID, err := generateBondedID(oldIssue.ID, subgraph.Root.ID, opts)
if err != nil {
return fmt.Errorf("failed to generate bonded ID for %s: %w", oldIssue.ID, err)
}
newIssue.ID = bondedID
}
if err := tx.CreateIssue(ctx, newIssue, opts.Actor); err != nil {
return fmt.Errorf("failed to create issue from %s: %w", oldIssue.ID, err) return fmt.Errorf("failed to create issue from %s: %w", oldIssue.ID, err)
} }
@@ -509,7 +614,7 @@ func cloneSubgraph(ctx context.Context, s storage.Storage, subgraph *TemplateSub
DependsOnID: newToID, DependsOnID: newToID,
Type: dep.Type, Type: dep.Type,
} }
if err := tx.AddDependency(ctx, newDep, actorName); err != nil { if err := tx.AddDependency(ctx, newDep, opts.Actor); err != nil {
return fmt.Errorf("failed to create dependency: %w", err) return fmt.Errorf("failed to create dependency: %w", err)
} }
} }

View File

@@ -268,7 +268,8 @@ func TestCloneSubgraph(t *testing.T) {
} }
vars := map[string]string{"version": "2.0.0"} vars := map[string]string{"version": "2.0.0"}
result, err := cloneSubgraph(ctx, s, subgraph, vars, "", "test-user", false) opts := CloneOptions{Vars: vars, Actor: "test-user"}
result, err := cloneSubgraph(ctx, s, subgraph, opts)
if err != nil { if err != nil {
t.Fatalf("cloneSubgraph failed: %v", err) t.Fatalf("cloneSubgraph failed: %v", err)
} }
@@ -308,7 +309,8 @@ func TestCloneSubgraph(t *testing.T) {
} }
vars := map[string]string{"service": "api-gateway"} vars := map[string]string{"service": "api-gateway"}
result, err := cloneSubgraph(ctx, s, subgraph, vars, "", "test-user", false) opts := CloneOptions{Vars: vars, Actor: "test-user"}
result, err := cloneSubgraph(ctx, s, subgraph, opts)
if err != nil { if err != nil {
t.Fatalf("cloneSubgraph failed: %v", err) t.Fatalf("cloneSubgraph failed: %v", err)
} }
@@ -367,7 +369,8 @@ func TestCloneSubgraph(t *testing.T) {
t.Fatalf("loadTemplateSubgraph failed: %v", err) t.Fatalf("loadTemplateSubgraph failed: %v", err)
} }
result, err := cloneSubgraph(ctx, s, subgraph, nil, "", "test-user", false) opts := CloneOptions{Actor: "test-user"}
result, err := cloneSubgraph(ctx, s, subgraph, opts)
if err != nil { if err != nil {
t.Fatalf("cloneSubgraph failed: %v", err) t.Fatalf("cloneSubgraph failed: %v", err)
} }
@@ -402,7 +405,8 @@ func TestCloneSubgraph(t *testing.T) {
} }
// Clone with assignee override // Clone with assignee override
result, err := cloneSubgraph(ctx, s, subgraph, nil, "new-assignee", "test-user", false) opts := CloneOptions{Assignee: "new-assignee", Actor: "test-user"}
result, err := cloneSubgraph(ctx, s, subgraph, opts)
if err != nil { if err != nil {
t.Fatalf("cloneSubgraph failed: %v", err) t.Fatalf("cloneSubgraph failed: %v", err)
} }