diff --git a/cmd/bd/mol.go b/cmd/bd/mol.go index bd0143c6..c7697a8f 100644 --- a/cmd/bd/mol.go +++ b/cmd/bd/mol.go @@ -2,17 +2,9 @@ package main import ( "context" - "encoding/json" - "fmt" - "os" - "strings" "github.com/spf13/cobra" - "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage" - "github.com/steveyegge/beads/internal/types" - "github.com/steveyegge/beads/internal/ui" - "github.com/steveyegge/beads/internal/utils" ) // Molecule commands - work templates for agent workflows @@ -63,553 +55,13 @@ Commands: distill Extract proto from ad-hoc epic (reverse of spawn)`, } -var molCatalogCmd = &cobra.Command{ - Use: "catalog", - Aliases: []string{"list", "ls"}, - Short: "List available molecules", - Run: func(cmd *cobra.Command, args []string) { - ctx := rootCtx - var molecules []*types.Issue - - if daemonClient != nil { - resp, err := daemonClient.List(&rpc.ListArgs{}) - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading molecules: %v\n", err) - os.Exit(1) - } - var allIssues []*types.Issue - if err := json.Unmarshal(resp.Data, &allIssues); err == nil { - for _, issue := range allIssues { - for _, label := range issue.Labels { - if label == MoleculeLabel { - molecules = append(molecules, issue) - break - } - } - } - } - } else if store != nil { - var err error - molecules, err = store.GetIssuesByLabel(ctx, MoleculeLabel) - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading molecules: %v\n", err) - os.Exit(1) - } - } else { - fmt.Fprintf(os.Stderr, "Error: no database connection\n") - os.Exit(1) - } - - if jsonOutput { - outputJSON(molecules) - return - } - - if len(molecules) == 0 { - fmt.Println("No protos available.") - fmt.Println("\nTo create a proto:") - fmt.Println(" 1. Create an epic with child issues") - fmt.Println(" 2. Add the 'template' label: bd label add template") - fmt.Println(" 3. Use {{variable}} placeholders in titles/descriptions") - fmt.Println("\nTo spawn (instantiate) a molecule from a proto:") - fmt.Println(" bd mol spawn --var key=value") - return - } - - fmt.Printf("%s\n", ui.RenderPass("Protos (for bd mol spawn):")) - for _, mol := range molecules { - vars := extractVariables(mol.Title + " " + mol.Description) - varStr := "" - if len(vars) > 0 { - varStr = fmt.Sprintf(" (vars: %s)", strings.Join(vars, ", ")) - } - fmt.Printf(" %s: %s%s\n", ui.RenderAccent(mol.ID), mol.Title, varStr) - } - fmt.Println() - }, -} - -var molShowCmd = &cobra.Command{ - Use: "show ", - Short: "Show molecule details", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - ctx := rootCtx - - // mol show requires direct store access for subgraph loading - if store == nil { - if daemonClient != nil { - fmt.Fprintf(os.Stderr, "Error: mol show requires direct database access\n") - fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol show %s\n", args[0]) - } else { - fmt.Fprintf(os.Stderr, "Error: no database connection\n") - } - os.Exit(1) - } - - moleculeID, err := utils.ResolvePartialID(ctx, store, args[0]) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: molecule '%s' not found\n", args[0]) - os.Exit(1) - } - - subgraph, err := loadTemplateSubgraph(ctx, store, moleculeID) - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading molecule: %v\n", err) - os.Exit(1) - } - - showMolecule(subgraph) - }, -} - -func showMolecule(subgraph *MoleculeSubgraph) { - if jsonOutput { - outputJSON(map[string]interface{}{ - "root": subgraph.Root, - "issues": subgraph.Issues, - "dependencies": subgraph.Dependencies, - "variables": extractAllVariables(subgraph), - }) - return - } - - fmt.Printf("\n%s Molecule: %s\n", ui.RenderAccent("๐Ÿงช"), subgraph.Root.Title) - fmt.Printf(" ID: %s\n", subgraph.Root.ID) - fmt.Printf(" Steps: %d\n", len(subgraph.Issues)) - - vars := extractAllVariables(subgraph) - if len(vars) > 0 { - fmt.Printf("\n%s Variables:\n", ui.RenderWarn("๐Ÿ“")) - for _, v := range vars { - fmt.Printf(" {{%s}}\n", v) - } - } - - fmt.Printf("\n%s Structure:\n", ui.RenderPass("๐ŸŒฒ")) - printMoleculeTree(subgraph, subgraph.Root.ID, 0, true) - fmt.Println() -} - -var molSpawnCmd = &cobra.Command{ - Use: "spawn ", - Short: "Instantiate a proto into a molecule", - Long: `Spawn a molecule by instantiating a proto template into real issues. - -Variables are specified with --var key=value flags. The proto's {{key}} -placeholders will be replaced with the corresponding values. - -Use --attach to bond additional protos to the spawned molecule in a single -command. Each attached proto is spawned and bonded using the --attach-type -(default: sequential). This is equivalent to running spawn + multiple bond -commands, but more convenient for composing workflows. - -Example: - bd mol spawn mol-code-review --var pr=123 --var repo=myproject - bd mol spawn bd-abc123 --var version=1.2.0 --assignee=worker-1 - bd mol spawn mol-feature --attach mol-testing --attach mol-docs --var name=auth`, - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - CheckReadonly("mol spawn") - - ctx := rootCtx - - // mol spawn requires direct store access for subgraph loading and cloning - if store == nil { - if daemonClient != nil { - fmt.Fprintf(os.Stderr, "Error: mol spawn requires direct database access\n") - fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol spawn %s ...\n", args[0]) - } else { - fmt.Fprintf(os.Stderr, "Error: no database connection\n") - } - os.Exit(1) - } - - dryRun, _ := cmd.Flags().GetBool("dry-run") - varFlags, _ := cmd.Flags().GetStringSlice("var") - assignee, _ := cmd.Flags().GetString("assignee") - attachFlags, _ := cmd.Flags().GetStringSlice("attach") - attachType, _ := cmd.Flags().GetString("attach-type") - - // Parse variables - vars := make(map[string]string) - for _, v := range varFlags { - parts := strings.SplitN(v, "=", 2) - if len(parts) != 2 { - fmt.Fprintf(os.Stderr, "Error: invalid variable format '%s', expected 'key=value'\n", v) - os.Exit(1) - } - vars[parts[0]] = parts[1] - } - - // Resolve molecule ID - moleculeID, err := utils.ResolvePartialID(ctx, store, args[0]) - if err != nil { - fmt.Fprintf(os.Stderr, "Error resolving molecule ID %s: %v\n", args[0], err) - os.Exit(1) - } - - // Load the molecule subgraph - subgraph, err := loadTemplateSubgraph(ctx, store, moleculeID) - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading molecule: %v\n", err) - os.Exit(1) - } - - // Resolve and load attached protos - type attachmentInfo struct { - id string - issue *types.Issue - subgraph *MoleculeSubgraph - } - var attachments []attachmentInfo - for _, attachArg := range attachFlags { - attachID, err := utils.ResolvePartialID(ctx, store, attachArg) - if err != nil { - fmt.Fprintf(os.Stderr, "Error resolving attachment ID %s: %v\n", attachArg, err) - os.Exit(1) - } - attachIssue, err := store.GetIssue(ctx, attachID) - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading attachment %s: %v\n", attachID, err) - os.Exit(1) - } - // Verify it's a proto (has template label) - isProto := false - for _, label := range attachIssue.Labels { - if label == MoleculeLabel { - isProto = true - break - } - } - if !isProto { - fmt.Fprintf(os.Stderr, "Error: %s is not a proto (missing '%s' label)\n", attachID, MoleculeLabel) - os.Exit(1) - } - attachSubgraph, err := loadTemplateSubgraph(ctx, store, attachID) - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading attachment subgraph %s: %v\n", attachID, err) - os.Exit(1) - } - attachments = append(attachments, attachmentInfo{ - id: attachID, - issue: attachIssue, - subgraph: attachSubgraph, - }) - } - - // Check for missing variables (primary + all attachments) - requiredVars := extractAllVariables(subgraph) - for _, attach := range attachments { - attachVars := extractAllVariables(attach.subgraph) - for _, v := range attachVars { - // Dedupe: only add if not already in requiredVars - found := false - for _, rv := range requiredVars { - if rv == v { - found = true - break - } - } - if !found { - requiredVars = append(requiredVars, v) - } - } - } - var missingVars []string - for _, v := range requiredVars { - if _, ok := vars[v]; !ok { - missingVars = append(missingVars, v) - } - } - if len(missingVars) > 0 { - fmt.Fprintf(os.Stderr, "Error: missing required variables: %s\n", strings.Join(missingVars, ", ")) - fmt.Fprintf(os.Stderr, "Provide them with: --var %s=\n", missingVars[0]) - os.Exit(1) - } - - if dryRun { - fmt.Printf("\nDry run: would create %d issues from molecule %s\n\n", len(subgraph.Issues), moleculeID) - for _, issue := range subgraph.Issues { - newTitle := substituteVariables(issue.Title, vars) - suffix := "" - if issue.ID == subgraph.Root.ID && assignee != "" { - suffix = fmt.Sprintf(" (assignee: %s)", assignee) - } - fmt.Printf(" - %s (from %s)%s\n", newTitle, issue.ID, suffix) - } - if len(attachments) > 0 { - fmt.Printf("\nAttachments (%s bonding):\n", attachType) - for _, attach := range attachments { - fmt.Printf(" + %s (%d issues)\n", attach.issue.Title, len(attach.subgraph.Issues)) - for _, issue := range attach.subgraph.Issues { - newTitle := substituteVariables(issue.Title, vars) - fmt.Printf(" - %s (from %s)\n", newTitle, issue.ID) - } - } - } - if len(vars) > 0 { - fmt.Printf("\nVariables:\n") - for k, v := range vars { - fmt.Printf(" {{%s}} = %s\n", k, v) - } - } - return - } - - // Clone the subgraph (spawn the molecule) - result, err := spawnMolecule(ctx, store, subgraph, vars, assignee, actor) - if err != nil { - fmt.Fprintf(os.Stderr, "Error spawning molecule: %v\n", err) - os.Exit(1) - } - - // Attach bonded protos to the spawned molecule - totalAttached := 0 - if len(attachments) > 0 { - // Get the spawned molecule issue for bonding - spawnedMol, err := store.GetIssue(ctx, result.NewEpicID) - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading spawned molecule: %v\n", err) - os.Exit(1) - } - - for _, attach := range attachments { - bondResult, err := bondProtoMol(ctx, store, attach.issue, spawnedMol, attachType, vars, actor) - if err != nil { - fmt.Fprintf(os.Stderr, "Error attaching %s: %v\n", attach.id, err) - os.Exit(1) - } - totalAttached += bondResult.Spawned - } - } - - // Schedule auto-flush - markDirtyAndScheduleFlush() - - if jsonOutput { - // Enhance result with attachment info - type spawnWithAttach struct { - *InstantiateResult - Attached int `json:"attached"` - } - outputJSON(spawnWithAttach{result, totalAttached}) - return - } - - fmt.Printf("%s Spawned molecule: created %d issues\n", ui.RenderPass("โœ“"), result.Created) - fmt.Printf(" Root issue: %s\n", result.NewEpicID) - if totalAttached > 0 { - fmt.Printf(" Attached: %d issues from %d protos\n", totalAttached, len(attachments)) - } - }, -} - -var molBondCmd = &cobra.Command{ - Use: "bond ", - Short: "Bond two protos or molecules together", - Long: `Bond two protos or molecules to create a compound. - -The bond command is polymorphic - it handles different operand types: - - proto + proto โ†’ compound proto (reusable template) - proto + mol โ†’ spawn proto, attach to molecule - mol + proto โ†’ spawn proto, attach to molecule - mol + mol โ†’ join into compound molecule - -Bond types: - sequential (default) - B runs after A completes - parallel - B runs alongside A - conditional - B runs only if A fails - -Examples: - bd mol bond mol-feature mol-deploy # Compound proto - bd mol bond mol-feature mol-deploy --type parallel # Run in parallel - bd mol bond mol-feature bd-abc123 # Attach proto to molecule - bd mol bond bd-abc123 bd-def456 # Join two molecules`, - Args: cobra.ExactArgs(2), - Run: runMolBond, -} - -var molDistillCmd = &cobra.Command{ - Use: "distill ", - Short: "Extract a reusable proto from an existing epic", - Long: `Distill a molecule by extracting a reusable proto from an existing epic. - -This is the reverse of spawn: instead of proto โ†’ molecule, it's molecule โ†’ proto. - -The distill command: - 1. Loads the existing epic and all its children - 2. Clones the structure as a new proto (adds "template" label) - 3. Replaces concrete values with {{variable}} placeholders (via --var flags) - -Use cases: - - Team develops good workflow organically, wants to reuse it - - Capture tribal knowledge as executable templates - - Create starting point for similar future work - -Variable syntax (both work - we detect which side is the concrete value): - --var branch=feature-auth Spawn-style: variable=value (recommended) - --var feature-auth=branch Substitution-style: value=variable - -Examples: - bd mol distill bd-o5xe --as "Release Workflow" - bd mol distill bd-abc --var feature_name=auth-refactor --var version=1.0.0`, - Args: cobra.ExactArgs(1), - Run: runMolDistill, -} - -var molRunCmd = &cobra.Command{ - Use: "run ", - Short: "Spawn proto and start execution (spawn + assign + pin)", - Long: `Run a molecule by spawning a proto and setting up for durable execution. - -This command: - 1. Spawns the molecule (creates issues from proto template) - 2. Assigns the root issue to the caller - 3. Sets root status to in_progress - 4. Pins the root issue for session recovery - -After a crash or session reset, the pinned root issue ensures the agent -can resume from where it left off by checking 'bd ready'. - -Example: - bd mol run mol-version-bump --var version=1.2.0 - bd mol run bd-qqc --var version=0.32.0 --var date=2025-01-01`, - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - CheckReadonly("mol run") - - ctx := rootCtx - - // mol run requires direct store access - if store == nil { - if daemonClient != nil { - fmt.Fprintf(os.Stderr, "Error: mol run requires direct database access\n") - fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol run %s ...\n", args[0]) - } else { - fmt.Fprintf(os.Stderr, "Error: no database connection\n") - } - os.Exit(1) - } - - varFlags, _ := cmd.Flags().GetStringSlice("var") - - // Parse variables - vars := make(map[string]string) - for _, v := range varFlags { - parts := strings.SplitN(v, "=", 2) - if len(parts) != 2 { - fmt.Fprintf(os.Stderr, "Error: invalid variable format '%s', expected 'key=value'\n", v) - os.Exit(1) - } - vars[parts[0]] = parts[1] - } - - // Resolve molecule ID - moleculeID, err := utils.ResolvePartialID(ctx, store, args[0]) - if err != nil { - fmt.Fprintf(os.Stderr, "Error resolving molecule ID %s: %v\n", args[0], err) - os.Exit(1) - } - - // Load the molecule subgraph - subgraph, err := loadTemplateSubgraph(ctx, store, moleculeID) - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading molecule: %v\n", err) - os.Exit(1) - } - - // Check for missing variables - requiredVars := extractAllVariables(subgraph) - var missingVars []string - for _, v := range requiredVars { - if _, ok := vars[v]; !ok { - missingVars = append(missingVars, v) - } - } - if len(missingVars) > 0 { - fmt.Fprintf(os.Stderr, "Error: missing required variables: %s\n", strings.Join(missingVars, ", ")) - fmt.Fprintf(os.Stderr, "Provide them with: --var %s=\n", missingVars[0]) - os.Exit(1) - } - - // Spawn the molecule with actor as assignee - result, err := spawnMolecule(ctx, store, subgraph, vars, actor, actor) - if err != nil { - fmt.Fprintf(os.Stderr, "Error spawning molecule: %v\n", err) - os.Exit(1) - } - - // Update root issue: set status=in_progress and pinned=true - rootID := result.NewEpicID - updates := map[string]interface{}{ - "status": string(types.StatusInProgress), - "pinned": true, - } - if err := store.UpdateIssue(ctx, rootID, updates, actor); err != nil { - fmt.Fprintf(os.Stderr, "Error updating root issue: %v\n", err) - os.Exit(1) - } - - // Schedule auto-flush - markDirtyAndScheduleFlush() - - if jsonOutput { - outputJSON(map[string]interface{}{ - "root_id": rootID, - "created": result.Created, - "id_mapping": result.IDMapping, - "pinned": true, - "status": "in_progress", - "assignee": actor, - }) - return - } - - fmt.Printf("%s Molecule running: created %d issues\n", ui.RenderPass("โœ“"), result.Created) - fmt.Printf(" Root issue: %s (pinned, in_progress)\n", rootID) - fmt.Printf(" Assignee: %s\n", actor) - fmt.Println("\nNext steps:") - fmt.Printf(" bd ready # Find unblocked work in this molecule\n") - fmt.Printf(" bd show %s # View molecule status\n", rootID[:8]) - }, -} - -func init() { - molSpawnCmd.Flags().StringSlice("var", []string{}, "Variable substitution (key=value)") - molSpawnCmd.Flags().Bool("dry-run", false, "Preview what would be created") - molSpawnCmd.Flags().String("assignee", "", "Assign the root issue to this agent/user") - molSpawnCmd.Flags().StringSlice("attach", []string{}, "Proto to attach after spawning (repeatable)") - molSpawnCmd.Flags().String("attach-type", types.BondTypeSequential, "Bond type for attachments: sequential, parallel, or conditional") - - molRunCmd.Flags().StringSlice("var", []string{}, "Variable substitution (key=value)") - - molBondCmd.Flags().String("type", types.BondTypeSequential, "Bond type: sequential, parallel, or conditional") - molBondCmd.Flags().String("as", "", "Custom title for compound proto (proto+proto only)") - molBondCmd.Flags().Bool("dry-run", false, "Preview what would be created") - molBondCmd.Flags().StringSlice("var", []string{}, "Variable substitution for spawned protos (key=value)") - - molDistillCmd.Flags().String("as", "", "Custom title for the new proto") - molDistillCmd.Flags().StringSlice("var", []string{}, "Replace value with {{variable}} placeholder (value=variable)") - molDistillCmd.Flags().Bool("dry-run", false, "Preview what would be created") - - molCmd.AddCommand(molCatalogCmd) - molCmd.AddCommand(molShowCmd) - molCmd.AddCommand(molSpawnCmd) - molCmd.AddCommand(molRunCmd) - molCmd.AddCommand(molBondCmd) - molCmd.AddCommand(molDistillCmd) - rootCmd.AddCommand(molCmd) -} - // ============================================================================= // Molecule Helper Functions // ============================================================================= // spawnMolecule creates new issues from the proto with variable substitution. // This instantiates a proto (template) into a molecule (real issues). -// Wraps cloneSubgraph from template.go and returns SpawnResult. +// Wraps cloneSubgraph from template.go and returns InstantiateResult. func spawnMolecule(ctx context.Context, s storage.Storage, subgraph *MoleculeSubgraph, vars map[string]string, assignee string, actorName string) (*InstantiateResult, error) { return cloneSubgraph(ctx, s, subgraph, vars, assignee, actorName) } @@ -619,600 +71,6 @@ func printMoleculeTree(subgraph *MoleculeSubgraph, parentID string, depth int, i printTemplateTree(subgraph, parentID, depth, isRoot) } -// ============================================================================= -// Bond Command Implementation -// ============================================================================= - -// runMolBond implements the polymorphic bond command -func runMolBond(cmd *cobra.Command, args []string) { - CheckReadonly("mol bond") - - ctx := rootCtx - - // mol bond requires direct store access - if store == nil { - if daemonClient != nil { - fmt.Fprintf(os.Stderr, "Error: mol bond requires direct database access\n") - fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol bond %s %s ...\n", args[0], args[1]) - } else { - fmt.Fprintf(os.Stderr, "Error: no database connection\n") - } - os.Exit(1) - } - - bondType, _ := cmd.Flags().GetString("type") - customID, _ := cmd.Flags().GetString("as") - dryRun, _ := cmd.Flags().GetBool("dry-run") - varFlags, _ := cmd.Flags().GetStringSlice("var") - - // Validate bond type - if bondType != types.BondTypeSequential && bondType != types.BondTypeParallel && bondType != types.BondTypeConditional { - fmt.Fprintf(os.Stderr, "Error: invalid bond type '%s', must be: sequential, parallel, or conditional\n", bondType) - os.Exit(1) - } - - // Parse variables - vars := make(map[string]string) - for _, v := range varFlags { - parts := strings.SplitN(v, "=", 2) - if len(parts) != 2 { - fmt.Fprintf(os.Stderr, "Error: invalid variable format '%s', expected 'key=value'\n", v) - os.Exit(1) - } - vars[parts[0]] = parts[1] - } - - // Resolve both IDs - idA, err := utils.ResolvePartialID(ctx, store, args[0]) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: '%s' not found\n", args[0]) - os.Exit(1) - } - idB, err := utils.ResolvePartialID(ctx, store, args[1]) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: '%s' not found\n", args[1]) - os.Exit(1) - } - - // Load both issues - issueA, err := store.GetIssue(ctx, idA) - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading %s: %v\n", idA, err) - os.Exit(1) - } - issueB, err := store.GetIssue(ctx, idB) - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading %s: %v\n", idB, err) - os.Exit(1) - } - - // Determine operand types - aIsProto := isProto(issueA) - bIsProto := isProto(issueB) - - if dryRun { - fmt.Printf("\nDry run: bond %s + %s\n", idA, idB) - fmt.Printf(" A: %s (%s)\n", issueA.Title, operandType(aIsProto)) - fmt.Printf(" B: %s (%s)\n", issueB.Title, operandType(bIsProto)) - fmt.Printf(" Bond type: %s\n", bondType) - if aIsProto && bIsProto { - fmt.Printf(" Result: compound proto\n") - if customID != "" { - fmt.Printf(" Custom ID: %s\n", customID) - } - } else if aIsProto || bIsProto { - fmt.Printf(" Result: spawn proto, attach to molecule\n") - } else { - fmt.Printf(" Result: compound molecule\n") - } - return - } - - // Dispatch based on operand types - var result *BondResult - switch { - case aIsProto && bIsProto: - result, err = bondProtoProto(ctx, store, issueA, issueB, bondType, customID, actor) - case aIsProto && !bIsProto: - result, err = bondProtoMol(ctx, store, issueA, issueB, bondType, vars, actor) - case !aIsProto && bIsProto: - result, err = bondMolProto(ctx, store, issueA, issueB, bondType, vars, actor) - default: - result, err = bondMolMol(ctx, store, issueA, issueB, bondType, actor) - } - - if err != nil { - fmt.Fprintf(os.Stderr, "Error bonding: %v\n", err) - os.Exit(1) - } - - // Schedule auto-flush - markDirtyAndScheduleFlush() - - if jsonOutput { - outputJSON(result) - return - } - - fmt.Printf("%s Bonded: %s + %s\n", ui.RenderPass("โœ“"), idA, idB) - fmt.Printf(" Result: %s (%s)\n", result.ResultID, result.ResultType) - if result.Spawned > 0 { - fmt.Printf(" Spawned: %d issues\n", result.Spawned) - } -} - -// BondResult holds the result of a bond operation -type BondResult struct { - ResultID string `json:"result_id"` - ResultType string `json:"result_type"` // "compound_proto" or "compound_molecule" - BondType string `json:"bond_type"` - Spawned int `json:"spawned,omitempty"` // Number of issues spawned (if proto was involved) - IDMapping map[string]string `json:"id_mapping,omitempty"` // Old ID -> new ID for spawned issues -} - -// isProto checks if an issue is a proto (has the template label) -func isProto(issue *types.Issue) bool { - for _, label := range issue.Labels { - if label == MoleculeLabel { - return true - } - } - return false -} - -// operandType returns a human-readable type string -func operandType(isProto bool) string { - if isProto { - return "proto" - } - return "molecule" -} - -// bondProtoProto bonds two protos to create a compound proto -func bondProtoProto(ctx context.Context, s storage.Storage, protoA, protoB *types.Issue, bondType, customID, actorName string) (*BondResult, error) { - // Create compound proto: a new root that references both protos as children - // The compound root will be a new issue that ties them together - compoundTitle := fmt.Sprintf("Compound: %s + %s", protoA.Title, protoB.Title) - if customID != "" { - compoundTitle = customID - } - - var compoundID string - err := s.RunInTransaction(ctx, func(tx storage.Transaction) error { - // Create compound root issue - compound := &types.Issue{ - Title: compoundTitle, - Description: fmt.Sprintf("Compound proto bonding %s and %s", protoA.ID, protoB.ID), - Status: types.StatusOpen, - Priority: minPriority(protoA.Priority, protoB.Priority), - IssueType: types.TypeEpic, - BondedFrom: []types.BondRef{ - {ProtoID: protoA.ID, BondType: bondType, BondPoint: ""}, - {ProtoID: protoB.ID, BondType: bondType, BondPoint: ""}, - }, - } - if err := tx.CreateIssue(ctx, compound, actorName); err != nil { - return fmt.Errorf("creating compound: %w", err) - } - compoundID = compound.ID - - // Add template label (labels are stored separately, not in issue table) - if err := tx.AddLabel(ctx, compoundID, MoleculeLabel, actorName); err != nil { - return fmt.Errorf("adding template label: %w", err) - } - - // Add parent-child dependencies from compound to both proto roots - depA := &types.Dependency{ - IssueID: protoA.ID, - DependsOnID: compoundID, - Type: types.DepParentChild, - } - if err := tx.AddDependency(ctx, depA, actorName); err != nil { - return fmt.Errorf("linking proto A: %w", err) - } - - depB := &types.Dependency{ - IssueID: protoB.ID, - DependsOnID: compoundID, - Type: types.DepParentChild, - } - if err := tx.AddDependency(ctx, depB, actorName); err != nil { - return fmt.Errorf("linking proto B: %w", err) - } - - // For sequential bonding, add blocking dependency: B blocks on A - if bondType == types.BondTypeSequential { - seqDep := &types.Dependency{ - IssueID: protoB.ID, - DependsOnID: protoA.ID, - Type: types.DepBlocks, - } - if err := tx.AddDependency(ctx, seqDep, actorName); err != nil { - return fmt.Errorf("adding sequence dep: %w", err) - } - } - - return nil - }) - - if err != nil { - return nil, err - } - - return &BondResult{ - ResultID: compoundID, - ResultType: "compound_proto", - BondType: bondType, - Spawned: 0, - }, nil -} - -// 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) { - // Load proto subgraph - subgraph, err := loadTemplateSubgraph(ctx, s, proto.ID) - if err != nil { - return nil, fmt.Errorf("loading proto: %w", err) - } - - // Check for missing variables - requiredVars := extractAllVariables(subgraph) - var missingVars []string - for _, v := range requiredVars { - if _, ok := vars[v]; !ok { - missingVars = append(missingVars, v) - } - } - if len(missingVars) > 0 { - return nil, fmt.Errorf("missing required variables: %s (use --var)", strings.Join(missingVars, ", ")) - } - - // Spawn the proto - spawnResult, err := spawnMolecule(ctx, s, subgraph, vars, "", actorName) - if err != nil { - return nil, fmt.Errorf("spawning proto: %w", err) - } - - // Attach spawned molecule to existing molecule - err = s.RunInTransaction(ctx, func(tx storage.Transaction) error { - // Add dependency from spawned root to molecule - // For sequential: use blocks (captures workflow semantics) - // For parallel/conditional: use parent-child (organizational) - // Note: Schema only allows one dependency per (issue_id, depends_on_id) pair - depType := types.DepParentChild - if bondType == types.BondTypeSequential { - depType = types.DepBlocks - } - dep := &types.Dependency{ - IssueID: spawnResult.NewEpicID, - DependsOnID: mol.ID, - Type: depType, - } - return tx.AddDependency(ctx, dep, actorName) - // Note: bonded_from field tracking is not yet supported by storage layer. - // The dependency relationship captures the bonding semantics. - }) - - if err != nil { - return nil, fmt.Errorf("attaching to molecule: %w", err) - } - - return &BondResult{ - ResultID: mol.ID, - ResultType: "compound_molecule", - BondType: bondType, - Spawned: spawnResult.Created, - IDMapping: spawnResult.IDMapping, - }, nil -} - -// 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) { - // Same as bondProtoMol but with arguments swapped - return bondProtoMol(ctx, s, proto, mol, bondType, vars, actorName) -} - -// bondMolMol bonds two molecules together -func bondMolMol(ctx context.Context, s storage.Storage, molA, molB *types.Issue, bondType, actorName string) (*BondResult, error) { - err := s.RunInTransaction(ctx, func(tx storage.Transaction) error { - // Add dependency: B links to A - // For sequential: use blocks (captures workflow semantics) - // For parallel/conditional: use parent-child (organizational) - // Note: Schema only allows one dependency per (issue_id, depends_on_id) pair - depType := types.DepParentChild - if bondType == types.BondTypeSequential { - depType = types.DepBlocks - } - dep := &types.Dependency{ - IssueID: molB.ID, - DependsOnID: molA.ID, - Type: depType, - } - if err := tx.AddDependency(ctx, dep, actorName); err != nil { - return fmt.Errorf("linking molecules: %w", err) - } - - // Note: bonded_from field tracking is not yet supported by storage layer. - // The dependency relationship captures the bonding semantics. - return nil - }) - - if err != nil { - return nil, fmt.Errorf("linking molecules: %w", err) - } - - return &BondResult{ - ResultID: molA.ID, - ResultType: "compound_molecule", - BondType: bondType, - }, nil -} - -// minPriority returns the higher priority (lower number) -func minPriority(a, b int) int { - if a < b { - return a - } - return b -} - -// ============================================================================= -// Distill Command Implementation -// ============================================================================= - -// DistillResult holds the result of a distill operation -type DistillResult struct { - ProtoID string `json:"proto_id"` - IDMapping map[string]string `json:"id_mapping"` // old ID -> new ID - Created int `json:"created"` // number of issues created - Variables []string `json:"variables"` // variables introduced -} - -// collectSubgraphText gathers all searchable text from a molecule subgraph -func collectSubgraphText(subgraph *MoleculeSubgraph) string { - var parts []string - for _, issue := range subgraph.Issues { - parts = append(parts, issue.Title) - parts = append(parts, issue.Description) - parts = append(parts, issue.Design) - parts = append(parts, issue.AcceptanceCriteria) - parts = append(parts, issue.Notes) - } - return strings.Join(parts, " ") -} - -// parseDistillVar parses a --var flag with smart detection of syntax. -// Accepts both spawn-style (variable=value) and substitution-style (value=variable). -// Returns (findText, varName, error). -func parseDistillVar(varFlag, searchableText string) (string, string, error) { - parts := strings.SplitN(varFlag, "=", 2) - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return "", "", fmt.Errorf("invalid format '%s', expected 'variable=value' or 'value=variable'", varFlag) - } - - left, right := parts[0], parts[1] - leftFound := strings.Contains(searchableText, left) - rightFound := strings.Contains(searchableText, right) - - switch { - case rightFound && !leftFound: - // spawn-style: --var branch=feature-auth - // left is variable name, right is the value to find - return right, left, nil - case leftFound && !rightFound: - // substitution-style: --var feature-auth=branch - // left is value to find, right is variable name - return left, right, nil - case leftFound && rightFound: - // Both found - prefer spawn-style (more natural guess) - // Agent likely typed: --var varname=concrete_value - return right, left, nil - default: - return "", "", fmt.Errorf("neither '%s' nor '%s' found in epic text", left, right) - } -} - -// runMolDistill implements the distill command -func runMolDistill(cmd *cobra.Command, args []string) { - CheckReadonly("mol distill") - - ctx := rootCtx - - // mol distill requires direct store access - if store == nil { - if daemonClient != nil { - fmt.Fprintf(os.Stderr, "Error: mol distill requires direct database access\n") - fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol distill %s ...\n", args[0]) - } else { - fmt.Fprintf(os.Stderr, "Error: no database connection\n") - } - os.Exit(1) - } - - customTitle, _ := cmd.Flags().GetString("as") - varFlags, _ := cmd.Flags().GetStringSlice("var") - dryRun, _ := cmd.Flags().GetBool("dry-run") - - // Resolve epic ID - epicID, err := utils.ResolvePartialID(ctx, store, args[0]) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: '%s' not found\n", args[0]) - os.Exit(1) - } - - // Load the epic subgraph (needed for smart var detection) - subgraph, err := loadTemplateSubgraph(ctx, store, epicID) - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading epic: %v\n", err) - os.Exit(1) - } - - // Parse variable substitutions with smart detection - // Accepts both spawn-style (variable=value) and substitution-style (value=variable) - replacements := make(map[string]string) - if len(varFlags) > 0 { - searchableText := collectSubgraphText(subgraph) - for _, v := range varFlags { - findText, varName, err := parseDistillVar(v, searchableText) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - replacements[findText] = varName - } - } - - if dryRun { - fmt.Printf("\nDry run: would distill %d issues from %s into a proto\n\n", len(subgraph.Issues), epicID) - fmt.Printf("Source: %s\n", subgraph.Root.Title) - if customTitle != "" { - fmt.Printf("Proto title: %s\n", customTitle) - } - if len(replacements) > 0 { - fmt.Printf("\nVariable substitutions:\n") - for value, varName := range replacements { - fmt.Printf(" \"%s\" โ†’ {{%s}}\n", value, varName) - } - } - fmt.Printf("\nStructure:\n") - for _, issue := range subgraph.Issues { - title := issue.Title - for value, varName := range replacements { - title = strings.ReplaceAll(title, value, "{{"+varName+"}}") - } - prefix := " " - if issue.ID == subgraph.Root.ID { - prefix = "โ†’ " - } - fmt.Printf("%s%s\n", prefix, title) - } - return - } - - // Distill the molecule into a proto - result, err := distillMolecule(ctx, store, subgraph, customTitle, replacements, actor) - if err != nil { - fmt.Fprintf(os.Stderr, "Error distilling molecule: %v\n", err) - os.Exit(1) - } - - // Schedule auto-flush - markDirtyAndScheduleFlush() - - if jsonOutput { - outputJSON(result) - return - } - - fmt.Printf("%s Distilled proto: created %d issues\n", ui.RenderPass("โœ“"), result.Created) - fmt.Printf(" Proto ID: %s\n", result.ProtoID) - if len(result.Variables) > 0 { - fmt.Printf(" Variables: %s\n", strings.Join(result.Variables, ", ")) - } - fmt.Printf("\nTo spawn this proto:\n") - fmt.Printf(" bd mol spawn %s", result.ProtoID[:8]) - for _, v := range result.Variables { - fmt.Printf(" --var %s=", v) - } - fmt.Println() -} - -// distillMolecule creates a new proto from an existing epic -func distillMolecule(ctx context.Context, s storage.Storage, subgraph *MoleculeSubgraph, customTitle string, replacements map[string]string, actorName string) (*DistillResult, error) { - if s == nil { - return nil, fmt.Errorf("no database connection") - } - - // Build the reverse mapping for tracking variables introduced - var variables []string - for _, varName := range replacements { - variables = append(variables, varName) - } - - // Generate new IDs and create mapping - idMapping := make(map[string]string) - - // Helper to apply replacements - applyReplacements := func(text string) string { - result := text - for value, varName := range replacements { - result = strings.ReplaceAll(result, value, "{{"+varName+"}}") - } - return result - } - - // Use transaction for atomicity - err := s.RunInTransaction(ctx, func(tx storage.Transaction) error { - // First pass: create all issues with new IDs - for _, oldIssue := range subgraph.Issues { - // Determine title - title := applyReplacements(oldIssue.Title) - if oldIssue.ID == subgraph.Root.ID && customTitle != "" { - title = customTitle - } - - // Add template label to all issues - labels := append([]string{}, oldIssue.Labels...) - hasTemplateLabel := false - for _, l := range labels { - if l == MoleculeLabel { - hasTemplateLabel = true - break - } - } - if !hasTemplateLabel { - labels = append(labels, MoleculeLabel) - } - - newIssue := &types.Issue{ - Title: title, - Description: applyReplacements(oldIssue.Description), - Design: applyReplacements(oldIssue.Design), - AcceptanceCriteria: applyReplacements(oldIssue.AcceptanceCriteria), - Notes: applyReplacements(oldIssue.Notes), - Status: types.StatusOpen, // Protos start fresh - Priority: oldIssue.Priority, - IssueType: oldIssue.IssueType, - Labels: labels, - EstimatedMinutes: oldIssue.EstimatedMinutes, - } - - if err := tx.CreateIssue(ctx, newIssue, actorName); err != nil { - return fmt.Errorf("failed to create proto issue from %s: %w", oldIssue.ID, err) - } - - idMapping[oldIssue.ID] = newIssue.ID - } - - // Second pass: recreate dependencies with new IDs - for _, dep := range subgraph.Dependencies { - newFromID, ok1 := idMapping[dep.IssueID] - newToID, ok2 := idMapping[dep.DependsOnID] - if !ok1 || !ok2 { - continue // Skip if either end is outside the subgraph - } - - newDep := &types.Dependency{ - IssueID: newFromID, - DependsOnID: newToID, - Type: dep.Type, - } - if err := tx.AddDependency(ctx, newDep, actorName); err != nil { - return fmt.Errorf("failed to create dependency: %w", err) - } - } - - return nil - }) - - if err != nil { - return nil, err - } - - return &DistillResult{ - ProtoID: idMapping[subgraph.Root.ID], - IDMapping: idMapping, - Created: len(subgraph.Issues), - Variables: variables, - }, nil +func init() { + rootCmd.AddCommand(molCmd) } diff --git a/cmd/bd/mol_bond.go b/cmd/bd/mol_bond.go new file mode 100644 index 00000000..9b2728e1 --- /dev/null +++ b/cmd/bd/mol_bond.go @@ -0,0 +1,382 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/storage" + "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" + "github.com/steveyegge/beads/internal/utils" +) + +var molBondCmd = &cobra.Command{ + Use: "bond ", + Short: "Bond two protos or molecules together", + Long: `Bond two protos or molecules to create a compound. + +The bond command is polymorphic - it handles different operand types: + + proto + proto โ†’ compound proto (reusable template) + proto + mol โ†’ spawn proto, attach to molecule + mol + proto โ†’ spawn proto, attach to molecule + mol + mol โ†’ join into compound molecule + +Bond types: + sequential (default) - B runs after A completes + parallel - B runs alongside A + conditional - B runs only if A fails + +Examples: + bd mol bond mol-feature mol-deploy # Compound proto + bd mol bond mol-feature mol-deploy --type parallel # Run in parallel + bd mol bond mol-feature bd-abc123 # Attach proto to molecule + bd mol bond bd-abc123 bd-def456 # Join two molecules`, + Args: cobra.ExactArgs(2), + Run: runMolBond, +} + +// BondResult holds the result of a bond operation +type BondResult struct { + ResultID string `json:"result_id"` + ResultType string `json:"result_type"` // "compound_proto" or "compound_molecule" + BondType string `json:"bond_type"` + Spawned int `json:"spawned,omitempty"` // Number of issues spawned (if proto was involved) + IDMapping map[string]string `json:"id_mapping,omitempty"` // Old ID -> new ID for spawned issues +} + +// runMolBond implements the polymorphic bond command +func runMolBond(cmd *cobra.Command, args []string) { + CheckReadonly("mol bond") + + ctx := rootCtx + + // mol bond requires direct store access + if store == nil { + if daemonClient != nil { + fmt.Fprintf(os.Stderr, "Error: mol bond requires direct database access\n") + fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol bond %s %s ...\n", args[0], args[1]) + } else { + fmt.Fprintf(os.Stderr, "Error: no database connection\n") + } + os.Exit(1) + } + + bondType, _ := cmd.Flags().GetString("type") + customID, _ := cmd.Flags().GetString("as") + dryRun, _ := cmd.Flags().GetBool("dry-run") + varFlags, _ := cmd.Flags().GetStringSlice("var") + + // Validate bond type + if bondType != types.BondTypeSequential && bondType != types.BondTypeParallel && bondType != types.BondTypeConditional { + fmt.Fprintf(os.Stderr, "Error: invalid bond type '%s', must be: sequential, parallel, or conditional\n", bondType) + os.Exit(1) + } + + // Parse variables + vars := make(map[string]string) + for _, v := range varFlags { + parts := strings.SplitN(v, "=", 2) + if len(parts) != 2 { + fmt.Fprintf(os.Stderr, "Error: invalid variable format '%s', expected 'key=value'\n", v) + os.Exit(1) + } + vars[parts[0]] = parts[1] + } + + // Resolve both IDs + idA, err := utils.ResolvePartialID(ctx, store, args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: '%s' not found\n", args[0]) + os.Exit(1) + } + idB, err := utils.ResolvePartialID(ctx, store, args[1]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: '%s' not found\n", args[1]) + os.Exit(1) + } + + // Load both issues + issueA, err := store.GetIssue(ctx, idA) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading %s: %v\n", idA, err) + os.Exit(1) + } + issueB, err := store.GetIssue(ctx, idB) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading %s: %v\n", idB, err) + os.Exit(1) + } + + // Determine operand types + aIsProto := isProto(issueA) + bIsProto := isProto(issueB) + + if dryRun { + fmt.Printf("\nDry run: bond %s + %s\n", idA, idB) + fmt.Printf(" A: %s (%s)\n", issueA.Title, operandType(aIsProto)) + fmt.Printf(" B: %s (%s)\n", issueB.Title, operandType(bIsProto)) + fmt.Printf(" Bond type: %s\n", bondType) + if aIsProto && bIsProto { + fmt.Printf(" Result: compound proto\n") + if customID != "" { + fmt.Printf(" Custom ID: %s\n", customID) + } + } else if aIsProto || bIsProto { + fmt.Printf(" Result: spawn proto, attach to molecule\n") + } else { + fmt.Printf(" Result: compound molecule\n") + } + return + } + + // Dispatch based on operand types + var result *BondResult + switch { + case aIsProto && bIsProto: + result, err = bondProtoProto(ctx, store, issueA, issueB, bondType, customID, actor) + case aIsProto && !bIsProto: + result, err = bondProtoMol(ctx, store, issueA, issueB, bondType, vars, actor) + case !aIsProto && bIsProto: + result, err = bondMolProto(ctx, store, issueA, issueB, bondType, vars, actor) + default: + result, err = bondMolMol(ctx, store, issueA, issueB, bondType, actor) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "Error bonding: %v\n", err) + os.Exit(1) + } + + // Schedule auto-flush + markDirtyAndScheduleFlush() + + if jsonOutput { + outputJSON(result) + return + } + + fmt.Printf("%s Bonded: %s + %s\n", ui.RenderPass("โœ“"), idA, idB) + fmt.Printf(" Result: %s (%s)\n", result.ResultID, result.ResultType) + if result.Spawned > 0 { + fmt.Printf(" Spawned: %d issues\n", result.Spawned) + } +} + +// isProto checks if an issue is a proto (has the template label) +func isProto(issue *types.Issue) bool { + for _, label := range issue.Labels { + if label == MoleculeLabel { + return true + } + } + return false +} + +// operandType returns a human-readable type string +func operandType(isProtoIssue bool) string { + if isProtoIssue { + return "proto" + } + return "molecule" +} + +// bondProtoProto bonds two protos to create a compound proto +func bondProtoProto(ctx context.Context, s storage.Storage, protoA, protoB *types.Issue, bondType, customID, actorName string) (*BondResult, error) { + // Create compound proto: a new root that references both protos as children + // The compound root will be a new issue that ties them together + compoundTitle := fmt.Sprintf("Compound: %s + %s", protoA.Title, protoB.Title) + if customID != "" { + compoundTitle = customID + } + + var compoundID string + err := s.RunInTransaction(ctx, func(tx storage.Transaction) error { + // Create compound root issue + compound := &types.Issue{ + Title: compoundTitle, + Description: fmt.Sprintf("Compound proto bonding %s and %s", protoA.ID, protoB.ID), + Status: types.StatusOpen, + Priority: minPriority(protoA.Priority, protoB.Priority), + IssueType: types.TypeEpic, + BondedFrom: []types.BondRef{ + {ProtoID: protoA.ID, BondType: bondType, BondPoint: ""}, + {ProtoID: protoB.ID, BondType: bondType, BondPoint: ""}, + }, + } + if err := tx.CreateIssue(ctx, compound, actorName); err != nil { + return fmt.Errorf("creating compound: %w", err) + } + compoundID = compound.ID + + // Add template label (labels are stored separately, not in issue table) + if err := tx.AddLabel(ctx, compoundID, MoleculeLabel, actorName); err != nil { + return fmt.Errorf("adding template label: %w", err) + } + + // Add parent-child dependencies from compound to both proto roots + depA := &types.Dependency{ + IssueID: protoA.ID, + DependsOnID: compoundID, + Type: types.DepParentChild, + } + if err := tx.AddDependency(ctx, depA, actorName); err != nil { + return fmt.Errorf("linking proto A: %w", err) + } + + depB := &types.Dependency{ + IssueID: protoB.ID, + DependsOnID: compoundID, + Type: types.DepParentChild, + } + if err := tx.AddDependency(ctx, depB, actorName); err != nil { + return fmt.Errorf("linking proto B: %w", err) + } + + // For sequential bonding, add blocking dependency: B blocks on A + if bondType == types.BondTypeSequential { + seqDep := &types.Dependency{ + IssueID: protoB.ID, + DependsOnID: protoA.ID, + Type: types.DepBlocks, + } + if err := tx.AddDependency(ctx, seqDep, actorName); err != nil { + return fmt.Errorf("adding sequence dep: %w", err) + } + } + + return nil + }) + + if err != nil { + return nil, err + } + + return &BondResult{ + ResultID: compoundID, + ResultType: "compound_proto", + BondType: bondType, + Spawned: 0, + }, nil +} + +// 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) { + // Load proto subgraph + subgraph, err := loadTemplateSubgraph(ctx, s, proto.ID) + if err != nil { + return nil, fmt.Errorf("loading proto: %w", err) + } + + // Check for missing variables + requiredVars := extractAllVariables(subgraph) + var missingVars []string + for _, v := range requiredVars { + if _, ok := vars[v]; !ok { + missingVars = append(missingVars, v) + } + } + if len(missingVars) > 0 { + return nil, fmt.Errorf("missing required variables: %s (use --var)", strings.Join(missingVars, ", ")) + } + + // Spawn the proto + spawnResult, err := spawnMolecule(ctx, s, subgraph, vars, "", actorName) + if err != nil { + return nil, fmt.Errorf("spawning proto: %w", err) + } + + // Attach spawned molecule to existing molecule + err = s.RunInTransaction(ctx, func(tx storage.Transaction) error { + // Add dependency from spawned root to molecule + // For sequential: use blocks (captures workflow semantics) + // For parallel/conditional: use parent-child (organizational) + // Note: Schema only allows one dependency per (issue_id, depends_on_id) pair + depType := types.DepParentChild + if bondType == types.BondTypeSequential { + depType = types.DepBlocks + } + dep := &types.Dependency{ + IssueID: spawnResult.NewEpicID, + DependsOnID: mol.ID, + Type: depType, + } + return tx.AddDependency(ctx, dep, actorName) + // Note: bonded_from field tracking is not yet supported by storage layer. + // The dependency relationship captures the bonding semantics. + }) + + if err != nil { + return nil, fmt.Errorf("attaching to molecule: %w", err) + } + + return &BondResult{ + ResultID: mol.ID, + ResultType: "compound_molecule", + BondType: bondType, + Spawned: spawnResult.Created, + IDMapping: spawnResult.IDMapping, + }, nil +} + +// 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) { + // Same as bondProtoMol but with arguments swapped + return bondProtoMol(ctx, s, proto, mol, bondType, vars, actorName) +} + +// bondMolMol bonds two molecules together +func bondMolMol(ctx context.Context, s storage.Storage, molA, molB *types.Issue, bondType, actorName string) (*BondResult, error) { + err := s.RunInTransaction(ctx, func(tx storage.Transaction) error { + // Add dependency: B links to A + // For sequential: use blocks (captures workflow semantics) + // For parallel/conditional: use parent-child (organizational) + // Note: Schema only allows one dependency per (issue_id, depends_on_id) pair + depType := types.DepParentChild + if bondType == types.BondTypeSequential { + depType = types.DepBlocks + } + dep := &types.Dependency{ + IssueID: molB.ID, + DependsOnID: molA.ID, + Type: depType, + } + if err := tx.AddDependency(ctx, dep, actorName); err != nil { + return fmt.Errorf("linking molecules: %w", err) + } + + // Note: bonded_from field tracking is not yet supported by storage layer. + // The dependency relationship captures the bonding semantics. + return nil + }) + + if err != nil { + return nil, fmt.Errorf("linking molecules: %w", err) + } + + return &BondResult{ + ResultID: molA.ID, + ResultType: "compound_molecule", + BondType: bondType, + }, nil +} + +// minPriority returns the higher priority (lower number) +func minPriority(a, b int) int { + if a < b { + return a + } + return b +} + +func init() { + molBondCmd.Flags().String("type", types.BondTypeSequential, "Bond type: sequential, parallel, or conditional") + molBondCmd.Flags().String("as", "", "Custom title for compound proto (proto+proto only)") + molBondCmd.Flags().Bool("dry-run", false, "Preview what would be created") + molBondCmd.Flags().StringSlice("var", []string{}, "Variable substitution for spawned protos (key=value)") + + molCmd.AddCommand(molBondCmd) +} diff --git a/cmd/bd/mol_catalog.go b/cmd/bd/mol_catalog.go new file mode 100644 index 00000000..7b115225 --- /dev/null +++ b/cmd/bd/mol_catalog.go @@ -0,0 +1,83 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/rpc" + "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" +) + +var molCatalogCmd = &cobra.Command{ + Use: "catalog", + Aliases: []string{"list", "ls"}, + Short: "List available molecules", + Run: func(cmd *cobra.Command, args []string) { + ctx := rootCtx + var molecules []*types.Issue + + if daemonClient != nil { + resp, err := daemonClient.List(&rpc.ListArgs{}) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading molecules: %v\n", err) + os.Exit(1) + } + var allIssues []*types.Issue + if err := json.Unmarshal(resp.Data, &allIssues); err == nil { + for _, issue := range allIssues { + for _, label := range issue.Labels { + if label == MoleculeLabel { + molecules = append(molecules, issue) + break + } + } + } + } + } else if store != nil { + var err error + molecules, err = store.GetIssuesByLabel(ctx, MoleculeLabel) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading molecules: %v\n", err) + os.Exit(1) + } + } else { + fmt.Fprintf(os.Stderr, "Error: no database connection\n") + os.Exit(1) + } + + if jsonOutput { + outputJSON(molecules) + return + } + + if len(molecules) == 0 { + fmt.Println("No protos available.") + fmt.Println("\nTo create a proto:") + fmt.Println(" 1. Create an epic with child issues") + fmt.Println(" 2. Add the 'template' label: bd label add template") + fmt.Println(" 3. Use {{variable}} placeholders in titles/descriptions") + fmt.Println("\nTo spawn (instantiate) a molecule from a proto:") + fmt.Println(" bd mol spawn --var key=value") + return + } + + fmt.Printf("%s\n", ui.RenderPass("Protos (for bd mol spawn):")) + for _, mol := range molecules { + vars := extractVariables(mol.Title + " " + mol.Description) + varStr := "" + if len(vars) > 0 { + varStr = fmt.Sprintf(" (vars: %s)", strings.Join(vars, ", ")) + } + fmt.Printf(" %s: %s%s\n", ui.RenderAccent(mol.ID), mol.Title, varStr) + } + fmt.Println() + }, +} + +func init() { + molCmd.AddCommand(molCatalogCmd) +} diff --git a/cmd/bd/mol_distill.go b/cmd/bd/mol_distill.go new file mode 100644 index 00000000..5b6d5ce3 --- /dev/null +++ b/cmd/bd/mol_distill.go @@ -0,0 +1,307 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/storage" + "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" + "github.com/steveyegge/beads/internal/utils" +) + +var molDistillCmd = &cobra.Command{ + Use: "distill ", + Short: "Extract a reusable proto from an existing epic", + Long: `Distill a molecule by extracting a reusable proto from an existing epic. + +This is the reverse of spawn: instead of proto โ†’ molecule, it's molecule โ†’ proto. + +The distill command: + 1. Loads the existing epic and all its children + 2. Clones the structure as a new proto (adds "template" label) + 3. Replaces concrete values with {{variable}} placeholders (via --var flags) + +Use cases: + - Team develops good workflow organically, wants to reuse it + - Capture tribal knowledge as executable templates + - Create starting point for similar future work + +Variable syntax (both work - we detect which side is the concrete value): + --var branch=feature-auth Spawn-style: variable=value (recommended) + --var feature-auth=branch Substitution-style: value=variable + +Examples: + bd mol distill bd-o5xe --as "Release Workflow" + bd mol distill bd-abc --var feature_name=auth-refactor --var version=1.0.0`, + Args: cobra.ExactArgs(1), + Run: runMolDistill, +} + +// DistillResult holds the result of a distill operation +type DistillResult struct { + ProtoID string `json:"proto_id"` + IDMapping map[string]string `json:"id_mapping"` // old ID -> new ID + Created int `json:"created"` // number of issues created + Variables []string `json:"variables"` // variables introduced +} + +// collectSubgraphText gathers all searchable text from a molecule subgraph +func collectSubgraphText(subgraph *MoleculeSubgraph) string { + var parts []string + for _, issue := range subgraph.Issues { + parts = append(parts, issue.Title) + parts = append(parts, issue.Description) + parts = append(parts, issue.Design) + parts = append(parts, issue.AcceptanceCriteria) + parts = append(parts, issue.Notes) + } + return strings.Join(parts, " ") +} + +// parseDistillVar parses a --var flag with smart detection of syntax. +// Accepts both spawn-style (variable=value) and substitution-style (value=variable). +// Returns (findText, varName, error). +func parseDistillVar(varFlag, searchableText string) (string, string, error) { + parts := strings.SplitN(varFlag, "=", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("invalid format '%s', expected 'variable=value' or 'value=variable'", varFlag) + } + + left, right := parts[0], parts[1] + leftFound := strings.Contains(searchableText, left) + rightFound := strings.Contains(searchableText, right) + + switch { + case rightFound && !leftFound: + // spawn-style: --var branch=feature-auth + // left is variable name, right is the value to find + return right, left, nil + case leftFound && !rightFound: + // substitution-style: --var feature-auth=branch + // left is value to find, right is variable name + return left, right, nil + case leftFound && rightFound: + // Both found - prefer spawn-style (more natural guess) + // Agent likely typed: --var varname=concrete_value + return right, left, nil + default: + return "", "", fmt.Errorf("neither '%s' nor '%s' found in epic text", left, right) + } +} + +// runMolDistill implements the distill command +func runMolDistill(cmd *cobra.Command, args []string) { + CheckReadonly("mol distill") + + ctx := rootCtx + + // mol distill requires direct store access + if store == nil { + if daemonClient != nil { + fmt.Fprintf(os.Stderr, "Error: mol distill requires direct database access\n") + fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol distill %s ...\n", args[0]) + } else { + fmt.Fprintf(os.Stderr, "Error: no database connection\n") + } + os.Exit(1) + } + + customTitle, _ := cmd.Flags().GetString("as") + varFlags, _ := cmd.Flags().GetStringSlice("var") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + // Resolve epic ID + epicID, err := utils.ResolvePartialID(ctx, store, args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: '%s' not found\n", args[0]) + os.Exit(1) + } + + // Load the epic subgraph (needed for smart var detection) + subgraph, err := loadTemplateSubgraph(ctx, store, epicID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading epic: %v\n", err) + os.Exit(1) + } + + // Parse variable substitutions with smart detection + // Accepts both spawn-style (variable=value) and substitution-style (value=variable) + replacements := make(map[string]string) + if len(varFlags) > 0 { + searchableText := collectSubgraphText(subgraph) + for _, v := range varFlags { + findText, varName, err := parseDistillVar(v, searchableText) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + replacements[findText] = varName + } + } + + if dryRun { + fmt.Printf("\nDry run: would distill %d issues from %s into a proto\n\n", len(subgraph.Issues), epicID) + fmt.Printf("Source: %s\n", subgraph.Root.Title) + if customTitle != "" { + fmt.Printf("Proto title: %s\n", customTitle) + } + if len(replacements) > 0 { + fmt.Printf("\nVariable substitutions:\n") + for value, varName := range replacements { + fmt.Printf(" \"%s\" โ†’ {{%s}}\n", value, varName) + } + } + fmt.Printf("\nStructure:\n") + for _, issue := range subgraph.Issues { + title := issue.Title + for value, varName := range replacements { + title = strings.ReplaceAll(title, value, "{{"+varName+"}}") + } + prefix := " " + if issue.ID == subgraph.Root.ID { + prefix = "โ†’ " + } + fmt.Printf("%s%s\n", prefix, title) + } + return + } + + // Distill the molecule into a proto + result, err := distillMolecule(ctx, store, subgraph, customTitle, replacements, actor) + if err != nil { + fmt.Fprintf(os.Stderr, "Error distilling molecule: %v\n", err) + os.Exit(1) + } + + // Schedule auto-flush + markDirtyAndScheduleFlush() + + if jsonOutput { + outputJSON(result) + return + } + + fmt.Printf("%s Distilled proto: created %d issues\n", ui.RenderPass("โœ“"), result.Created) + fmt.Printf(" Proto ID: %s\n", result.ProtoID) + if len(result.Variables) > 0 { + fmt.Printf(" Variables: %s\n", strings.Join(result.Variables, ", ")) + } + fmt.Printf("\nTo spawn this proto:\n") + fmt.Printf(" bd mol spawn %s", result.ProtoID[:8]) + for _, v := range result.Variables { + fmt.Printf(" --var %s=", v) + } + fmt.Println() +} + +// distillMolecule creates a new proto from an existing epic +func distillMolecule(ctx context.Context, s storage.Storage, subgraph *MoleculeSubgraph, customTitle string, replacements map[string]string, actorName string) (*DistillResult, error) { + if s == nil { + return nil, fmt.Errorf("no database connection") + } + + // Build the reverse mapping for tracking variables introduced + var variables []string + for _, varName := range replacements { + variables = append(variables, varName) + } + + // Generate new IDs and create mapping + idMapping := make(map[string]string) + + // Helper to apply replacements + applyReplacements := func(text string) string { + result := text + for value, varName := range replacements { + result = strings.ReplaceAll(result, value, "{{"+varName+"}}") + } + return result + } + + // Use transaction for atomicity + err := s.RunInTransaction(ctx, func(tx storage.Transaction) error { + // First pass: create all issues with new IDs + for _, oldIssue := range subgraph.Issues { + // Determine title + title := applyReplacements(oldIssue.Title) + if oldIssue.ID == subgraph.Root.ID && customTitle != "" { + title = customTitle + } + + // Add template label to all issues + labels := append([]string{}, oldIssue.Labels...) + hasTemplateLabel := false + for _, l := range labels { + if l == MoleculeLabel { + hasTemplateLabel = true + break + } + } + if !hasTemplateLabel { + labels = append(labels, MoleculeLabel) + } + + newIssue := &types.Issue{ + Title: title, + Description: applyReplacements(oldIssue.Description), + Design: applyReplacements(oldIssue.Design), + AcceptanceCriteria: applyReplacements(oldIssue.AcceptanceCriteria), + Notes: applyReplacements(oldIssue.Notes), + Status: types.StatusOpen, // Protos start fresh + Priority: oldIssue.Priority, + IssueType: oldIssue.IssueType, + Labels: labels, + EstimatedMinutes: oldIssue.EstimatedMinutes, + } + + if err := tx.CreateIssue(ctx, newIssue, actorName); err != nil { + return fmt.Errorf("failed to create proto issue from %s: %w", oldIssue.ID, err) + } + + idMapping[oldIssue.ID] = newIssue.ID + } + + // Second pass: recreate dependencies with new IDs + for _, dep := range subgraph.Dependencies { + newFromID, ok1 := idMapping[dep.IssueID] + newToID, ok2 := idMapping[dep.DependsOnID] + if !ok1 || !ok2 { + continue // Skip if either end is outside the subgraph + } + + newDep := &types.Dependency{ + IssueID: newFromID, + DependsOnID: newToID, + Type: dep.Type, + } + if err := tx.AddDependency(ctx, newDep, actorName); err != nil { + return fmt.Errorf("failed to create dependency: %w", err) + } + } + + return nil + }) + + if err != nil { + return nil, err + } + + return &DistillResult{ + ProtoID: idMapping[subgraph.Root.ID], + IDMapping: idMapping, + Created: len(subgraph.Issues), + Variables: variables, + }, nil +} + +func init() { + molDistillCmd.Flags().String("as", "", "Custom title for the new proto") + molDistillCmd.Flags().StringSlice("var", []string{}, "Replace value with {{variable}} placeholder (value=variable)") + molDistillCmd.Flags().Bool("dry-run", false, "Preview what would be created") + + molCmd.AddCommand(molDistillCmd) +} diff --git a/cmd/bd/mol_run.go b/cmd/bd/mol_run.go new file mode 100644 index 00000000..438da634 --- /dev/null +++ b/cmd/bd/mol_run.go @@ -0,0 +1,137 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" + "github.com/steveyegge/beads/internal/utils" +) + +var molRunCmd = &cobra.Command{ + Use: "run ", + Short: "Spawn proto and start execution (spawn + assign + pin)", + Long: `Run a molecule by spawning a proto and setting up for durable execution. + +This command: + 1. Spawns the molecule (creates issues from proto template) + 2. Assigns the root issue to the caller + 3. Sets root status to in_progress + 4. Pins the root issue for session recovery + +After a crash or session reset, the pinned root issue ensures the agent +can resume from where it left off by checking 'bd ready'. + +Example: + bd mol run mol-version-bump --var version=1.2.0 + bd mol run bd-qqc --var version=0.32.0 --var date=2025-01-01`, + Args: cobra.ExactArgs(1), + Run: runMolRun, +} + +func runMolRun(cmd *cobra.Command, args []string) { + CheckReadonly("mol run") + + ctx := rootCtx + + // mol run requires direct store access + if store == nil { + if daemonClient != nil { + fmt.Fprintf(os.Stderr, "Error: mol run requires direct database access\n") + fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol run %s ...\n", args[0]) + } else { + fmt.Fprintf(os.Stderr, "Error: no database connection\n") + } + os.Exit(1) + } + + varFlags, _ := cmd.Flags().GetStringSlice("var") + + // Parse variables + vars := make(map[string]string) + for _, v := range varFlags { + parts := strings.SplitN(v, "=", 2) + if len(parts) != 2 { + fmt.Fprintf(os.Stderr, "Error: invalid variable format '%s', expected 'key=value'\n", v) + os.Exit(1) + } + vars[parts[0]] = parts[1] + } + + // Resolve molecule ID + moleculeID, err := utils.ResolvePartialID(ctx, store, args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving molecule ID %s: %v\n", args[0], err) + os.Exit(1) + } + + // Load the molecule subgraph + subgraph, err := loadTemplateSubgraph(ctx, store, moleculeID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading molecule: %v\n", err) + os.Exit(1) + } + + // Check for missing variables + requiredVars := extractAllVariables(subgraph) + var missingVars []string + for _, v := range requiredVars { + if _, ok := vars[v]; !ok { + missingVars = append(missingVars, v) + } + } + if len(missingVars) > 0 { + fmt.Fprintf(os.Stderr, "Error: missing required variables: %s\n", strings.Join(missingVars, ", ")) + fmt.Fprintf(os.Stderr, "Provide them with: --var %s=\n", missingVars[0]) + os.Exit(1) + } + + // Spawn the molecule with actor as assignee + result, err := spawnMolecule(ctx, store, subgraph, vars, actor, actor) + if err != nil { + fmt.Fprintf(os.Stderr, "Error spawning molecule: %v\n", err) + os.Exit(1) + } + + // Update root issue: set status=in_progress and pinned=true + rootID := result.NewEpicID + updates := map[string]interface{}{ + "status": string(types.StatusInProgress), + "pinned": true, + } + if err := store.UpdateIssue(ctx, rootID, updates, actor); err != nil { + fmt.Fprintf(os.Stderr, "Error updating root issue: %v\n", err) + os.Exit(1) + } + + // Schedule auto-flush + markDirtyAndScheduleFlush() + + if jsonOutput { + outputJSON(map[string]interface{}{ + "root_id": rootID, + "created": result.Created, + "id_mapping": result.IDMapping, + "pinned": true, + "status": "in_progress", + "assignee": actor, + }) + return + } + + fmt.Printf("%s Molecule running: created %d issues\n", ui.RenderPass("โœ“"), result.Created) + fmt.Printf(" Root issue: %s (pinned, in_progress)\n", rootID) + fmt.Printf(" Assignee: %s\n", actor) + fmt.Println("\nNext steps:") + fmt.Printf(" bd ready # Find unblocked work in this molecule\n") + fmt.Printf(" bd show %s # View molecule status\n", rootID[:8]) +} + +func init() { + molRunCmd.Flags().StringSlice("var", []string{}, "Variable substitution (key=value)") + + molCmd.AddCommand(molRunCmd) +} diff --git a/cmd/bd/mol_show.go b/cmd/bd/mol_show.go new file mode 100644 index 00000000..9ed325dd --- /dev/null +++ b/cmd/bd/mol_show.go @@ -0,0 +1,76 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/ui" + "github.com/steveyegge/beads/internal/utils" +) + +var molShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show molecule details", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + ctx := rootCtx + + // mol show requires direct store access for subgraph loading + if store == nil { + if daemonClient != nil { + fmt.Fprintf(os.Stderr, "Error: mol show requires direct database access\n") + fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol show %s\n", args[0]) + } else { + fmt.Fprintf(os.Stderr, "Error: no database connection\n") + } + os.Exit(1) + } + + moleculeID, err := utils.ResolvePartialID(ctx, store, args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: molecule '%s' not found\n", args[0]) + os.Exit(1) + } + + subgraph, err := loadTemplateSubgraph(ctx, store, moleculeID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading molecule: %v\n", err) + os.Exit(1) + } + + showMolecule(subgraph) + }, +} + +func showMolecule(subgraph *MoleculeSubgraph) { + if jsonOutput { + outputJSON(map[string]interface{}{ + "root": subgraph.Root, + "issues": subgraph.Issues, + "dependencies": subgraph.Dependencies, + "variables": extractAllVariables(subgraph), + }) + return + } + + fmt.Printf("\n%s Molecule: %s\n", ui.RenderAccent("๐Ÿงช"), subgraph.Root.Title) + fmt.Printf(" ID: %s\n", subgraph.Root.ID) + fmt.Printf(" Steps: %d\n", len(subgraph.Issues)) + + vars := extractAllVariables(subgraph) + if len(vars) > 0 { + fmt.Printf("\n%s Variables:\n", ui.RenderWarn("๐Ÿ“")) + for _, v := range vars { + fmt.Printf(" {{%s}}\n", v) + } + } + + fmt.Printf("\n%s Structure:\n", ui.RenderPass("๐ŸŒฒ")) + printMoleculeTree(subgraph, subgraph.Root.ID, 0, true) + fmt.Println() +} + +func init() { + molCmd.AddCommand(molShowCmd) +} diff --git a/cmd/bd/mol_spawn.go b/cmd/bd/mol_spawn.go new file mode 100644 index 00000000..05909ab3 --- /dev/null +++ b/cmd/bd/mol_spawn.go @@ -0,0 +1,238 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" + "github.com/steveyegge/beads/internal/utils" +) + +var molSpawnCmd = &cobra.Command{ + Use: "spawn ", + Short: "Instantiate a proto into a molecule", + Long: `Spawn a molecule by instantiating a proto template into real issues. + +Variables are specified with --var key=value flags. The proto's {{key}} +placeholders will be replaced with the corresponding values. + +Use --attach to bond additional protos to the spawned molecule in a single +command. Each attached proto is spawned and bonded using the --attach-type +(default: sequential). This is equivalent to running spawn + multiple bond +commands, but more convenient for composing workflows. + +Example: + bd mol spawn mol-code-review --var pr=123 --var repo=myproject + bd mol spawn bd-abc123 --var version=1.2.0 --assignee=worker-1 + bd mol spawn mol-feature --attach mol-testing --attach mol-docs --var name=auth`, + Args: cobra.ExactArgs(1), + Run: runMolSpawn, +} + +func runMolSpawn(cmd *cobra.Command, args []string) { + CheckReadonly("mol spawn") + + ctx := rootCtx + + // mol spawn requires direct store access for subgraph loading and cloning + if store == nil { + if daemonClient != nil { + fmt.Fprintf(os.Stderr, "Error: mol spawn requires direct database access\n") + fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol spawn %s ...\n", args[0]) + } else { + fmt.Fprintf(os.Stderr, "Error: no database connection\n") + } + os.Exit(1) + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + varFlags, _ := cmd.Flags().GetStringSlice("var") + assignee, _ := cmd.Flags().GetString("assignee") + attachFlags, _ := cmd.Flags().GetStringSlice("attach") + attachType, _ := cmd.Flags().GetString("attach-type") + + // Parse variables + vars := make(map[string]string) + for _, v := range varFlags { + parts := strings.SplitN(v, "=", 2) + if len(parts) != 2 { + fmt.Fprintf(os.Stderr, "Error: invalid variable format '%s', expected 'key=value'\n", v) + os.Exit(1) + } + vars[parts[0]] = parts[1] + } + + // Resolve molecule ID + moleculeID, err := utils.ResolvePartialID(ctx, store, args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving molecule ID %s: %v\n", args[0], err) + os.Exit(1) + } + + // Load the molecule subgraph + subgraph, err := loadTemplateSubgraph(ctx, store, moleculeID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading molecule: %v\n", err) + os.Exit(1) + } + + // Resolve and load attached protos + type attachmentInfo struct { + id string + issue *types.Issue + subgraph *MoleculeSubgraph + } + var attachments []attachmentInfo + for _, attachArg := range attachFlags { + attachID, err := utils.ResolvePartialID(ctx, store, attachArg) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving attachment ID %s: %v\n", attachArg, err) + os.Exit(1) + } + attachIssue, err := store.GetIssue(ctx, attachID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading attachment %s: %v\n", attachID, err) + os.Exit(1) + } + // Verify it's a proto (has template label) + isProtoIssue := false + for _, label := range attachIssue.Labels { + if label == MoleculeLabel { + isProtoIssue = true + break + } + } + if !isProtoIssue { + fmt.Fprintf(os.Stderr, "Error: %s is not a proto (missing '%s' label)\n", attachID, MoleculeLabel) + os.Exit(1) + } + attachSubgraph, err := loadTemplateSubgraph(ctx, store, attachID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading attachment subgraph %s: %v\n", attachID, err) + os.Exit(1) + } + attachments = append(attachments, attachmentInfo{ + id: attachID, + issue: attachIssue, + subgraph: attachSubgraph, + }) + } + + // Check for missing variables (primary + all attachments) + requiredVars := extractAllVariables(subgraph) + for _, attach := range attachments { + attachVars := extractAllVariables(attach.subgraph) + for _, v := range attachVars { + // Dedupe: only add if not already in requiredVars + found := false + for _, rv := range requiredVars { + if rv == v { + found = true + break + } + } + if !found { + requiredVars = append(requiredVars, v) + } + } + } + var missingVars []string + for _, v := range requiredVars { + if _, ok := vars[v]; !ok { + missingVars = append(missingVars, v) + } + } + if len(missingVars) > 0 { + fmt.Fprintf(os.Stderr, "Error: missing required variables: %s\n", strings.Join(missingVars, ", ")) + fmt.Fprintf(os.Stderr, "Provide them with: --var %s=\n", missingVars[0]) + os.Exit(1) + } + + if dryRun { + fmt.Printf("\nDry run: would create %d issues from molecule %s\n\n", len(subgraph.Issues), moleculeID) + for _, issue := range subgraph.Issues { + newTitle := substituteVariables(issue.Title, vars) + suffix := "" + if issue.ID == subgraph.Root.ID && assignee != "" { + suffix = fmt.Sprintf(" (assignee: %s)", assignee) + } + fmt.Printf(" - %s (from %s)%s\n", newTitle, issue.ID, suffix) + } + if len(attachments) > 0 { + fmt.Printf("\nAttachments (%s bonding):\n", attachType) + for _, attach := range attachments { + fmt.Printf(" + %s (%d issues)\n", attach.issue.Title, len(attach.subgraph.Issues)) + for _, issue := range attach.subgraph.Issues { + newTitle := substituteVariables(issue.Title, vars) + fmt.Printf(" - %s (from %s)\n", newTitle, issue.ID) + } + } + } + if len(vars) > 0 { + fmt.Printf("\nVariables:\n") + for k, v := range vars { + fmt.Printf(" {{%s}} = %s\n", k, v) + } + } + return + } + + // Clone the subgraph (spawn the molecule) + result, err := spawnMolecule(ctx, store, subgraph, vars, assignee, actor) + if err != nil { + fmt.Fprintf(os.Stderr, "Error spawning molecule: %v\n", err) + os.Exit(1) + } + + // Attach bonded protos to the spawned molecule + totalAttached := 0 + if len(attachments) > 0 { + // Get the spawned molecule issue for bonding + spawnedMol, err := store.GetIssue(ctx, result.NewEpicID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading spawned molecule: %v\n", err) + os.Exit(1) + } + + for _, attach := range attachments { + bondResult, err := bondProtoMol(ctx, store, attach.issue, spawnedMol, attachType, vars, actor) + if err != nil { + fmt.Fprintf(os.Stderr, "Error attaching %s: %v\n", attach.id, err) + os.Exit(1) + } + totalAttached += bondResult.Spawned + } + } + + // Schedule auto-flush + markDirtyAndScheduleFlush() + + if jsonOutput { + // Enhance result with attachment info + type spawnWithAttach struct { + *InstantiateResult + Attached int `json:"attached"` + } + outputJSON(spawnWithAttach{result, totalAttached}) + return + } + + fmt.Printf("%s Spawned molecule: created %d issues\n", ui.RenderPass("โœ“"), result.Created) + fmt.Printf(" Root issue: %s\n", result.NewEpicID) + if totalAttached > 0 { + fmt.Printf(" Attached: %d issues from %d protos\n", totalAttached, len(attachments)) + } +} + +func init() { + molSpawnCmd.Flags().StringSlice("var", []string{}, "Variable substitution (key=value)") + molSpawnCmd.Flags().Bool("dry-run", false, "Preview what would be created") + molSpawnCmd.Flags().String("assignee", "", "Assign the root issue to this agent/user") + molSpawnCmd.Flags().StringSlice("attach", []string{}, "Proto to attach after spawning (repeatable)") + molSpawnCmd.Flags().String("attach-type", types.BondTypeSequential, "Bond type for attachments: sequential, parallel, or conditional") + + molCmd.AddCommand(molSpawnCmd) +}