diff --git a/cmd/bd/hook.go b/cmd/bd/hook.go new file mode 100644 index 00000000..9e098fa0 --- /dev/null +++ b/cmd/bd/hook.go @@ -0,0 +1,102 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/rpc" + "github.com/steveyegge/beads/internal/types" +) + +// hookCmd inspects what's on an agent's hook +var hookCmd = &cobra.Command{ + Use: "hook", + Short: "Inspect what's on an agent's hook", + Long: `Show what mol is pinned to an agent's hook. + +The hook is an agent's attachment point for work in the molecular chemistry +metaphor. This command shows what work is currently pinned to the agent. + +Examples: + bd hook # Show what's on my hook + bd hook --agent deacon # Show deacon's hook + bd hook --agent polecat-ace # Show specific polecat's hook`, + Args: cobra.NoArgs, + Run: runHook, +} + +func runHook(cmd *cobra.Command, args []string) { + ctx := rootCtx + agentName, _ := cmd.Flags().GetString("agent") + + if agentName == "" { + agentName = actor + } + + var issues []*types.Issue + + // Query for pinned issues assigned to this agent + if daemonClient != nil { + pinned := true + listArgs := &rpc.ListArgs{ + Pinned: &pinned, + Assignee: agentName, + } + resp, err := daemonClient.List(listArgs) + if err != nil { + fmt.Fprintf(os.Stderr, "Error querying hook: %v\n", err) + os.Exit(1) + } + if err := json.Unmarshal(resp.Data, &issues); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err) + os.Exit(1) + } + } else if store != nil { + var err error + pinned := true + filter := types.IssueFilter{ + Pinned: &pinned, + Assignee: &agentName, + } + issues, err = store.SearchIssues(ctx, "", filter) + if err != nil { + fmt.Fprintf(os.Stderr, "Error querying hook: %v\n", err) + os.Exit(1) + } + } else { + fmt.Fprintf(os.Stderr, "Error: no database connection\n") + os.Exit(1) + } + + if jsonOutput { + type hookResult struct { + Agent string `json:"agent"` + Pinned []*types.Issue `json:"pinned"` + } + outputJSON(hookResult{Agent: agentName, Pinned: issues}) + return + } + + fmt.Printf("Hook: %s\n", agentName) + if len(issues) == 0 { + fmt.Printf(" (empty)\n") + return + } + + for _, issue := range issues { + phase := "mol" + if issue.Wisp { + phase = "wisp" + } + fmt.Printf(" 📌 %s (%s) - %s\n", issue.ID, phase, issue.Status) + fmt.Printf(" %s\n", issue.Title) + } +} + +func init() { + hookCmd.Flags().String("agent", "", "Agent to inspect (default: current agent)") + + rootCmd.AddCommand(hookCmd) +} diff --git a/cmd/bd/mol_bond.go b/cmd/bd/mol_bond.go index 5053c25b..49bfac5a 100644 --- a/cmd/bd/mol_bond.go +++ b/cmd/bd/mol_bond.go @@ -32,18 +32,26 @@ Bond types: parallel - B runs alongside A conditional - B runs only if A fails -Wisp storage (ephemeral molecules): - Use --wisp to create molecules in .beads-wisp/ instead of .beads/. - Wisps are local-only, gitignored, and not synced - the "steam" of Gas Town. - Use bd mol squash to convert a wisp to a digest in permanent storage. - Use bd mol burn to delete a wisp without creating a digest. +Phase control: + By default, spawned protos follow the target's phase: + - Attaching to mol → spawns as mol (liquid) + - Attaching to wisp → spawns as wisp (vapor) + + Override with: + --pour Force spawn as liquid (persistent), even when attaching to wisp + --wisp Force spawn as vapor (ephemeral), even when attaching to mol + +Use cases: + - Found important bug during patrol? Use --pour to persist it + - Need ephemeral diagnostic on persistent feature? Use --wisp 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 - bd mol bond mol-patrol --wisp # Create wisp for patrol cycle`, + bd mol bond mol-critical-bug wisp-patrol --pour # Persist found bug + bd mol bond mol-temp-check bd-feature --wisp # Ephemeral diagnostic`, Args: cobra.ExactArgs(2), Run: runMolBond, } @@ -79,11 +87,19 @@ func runMolBond(cmd *cobra.Command, args []string) { dryRun, _ := cmd.Flags().GetBool("dry-run") varFlags, _ := cmd.Flags().GetStringSlice("var") wisp, _ := cmd.Flags().GetBool("wisp") + pour, _ := cmd.Flags().GetBool("pour") + + // Validate phase flags are not both set + if wisp && pour { + fmt.Fprintf(os.Stderr, "Error: cannot use both --wisp and --pour\n") + os.Exit(1) + } // Determine which store to use for spawning + // Default: follow target's phase. Override with --wisp or --pour. targetStore := store if wisp { - // Open wisp storage for ephemeral molecule creation + // Explicit --wisp: use wisp storage wispStore, err := beads.NewWispStorage(ctx) if err != nil { fmt.Fprintf(os.Stderr, "Error: failed to open wisp storage: %v\n", err) @@ -97,6 +113,7 @@ func runMolBond(cmd *cobra.Command, args []string) { fmt.Fprintf(os.Stderr, "Warning: could not update .gitignore: %v\n", err) } } + // Note: --pour means use permanent storage (which is the default targetStore) // Validate bond type if bondType != types.BondTypeSequential && bondType != types.BondTypeParallel && bondType != types.BondTypeConditional { @@ -149,18 +166,23 @@ func runMolBond(cmd *cobra.Command, args []string) { fmt.Printf(" B: %s (%s)\n", issueB.Title, operandType(bIsProto)) fmt.Printf(" Bond type: %s\n", bondType) if wisp { - fmt.Printf(" Storage: wisp (.beads-wisp/)\n") + fmt.Printf(" Phase override: vapor (--wisp)\n") + } else if pour { + fmt.Printf(" Phase override: liquid (--pour)\n") } if aIsProto && bIsProto { fmt.Printf(" Result: compound proto\n") if customTitle != "" { fmt.Printf(" Custom title: %s\n", customTitle) } - if wisp { - fmt.Printf(" Note: --wisp ignored for proto+proto (templates stay in permanent storage)\n") + if wisp || pour { + fmt.Printf(" Note: phase flags ignored for proto+proto (templates stay in permanent storage)\n") } } else if aIsProto || bIsProto { fmt.Printf(" Result: spawn proto, attach to molecule\n") + if !wisp && !pour { + fmt.Printf(" Phase: follows target's phase\n") + } } else { fmt.Printf(" Result: compound molecule\n") } @@ -203,7 +225,9 @@ func runMolBond(cmd *cobra.Command, args []string) { fmt.Printf(" Spawned: %d issues\n", result.Spawned) } if wisp { - fmt.Printf(" Storage: wisp (.beads-wisp/)\n") + fmt.Printf(" Phase: vapor (ephemeral in .beads-wisp/)\n") + } else if pour { + fmt.Printf(" Phase: liquid (persistent in .beads/)\n") } } @@ -418,7 +442,8 @@ func init() { 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)") - molBondCmd.Flags().Bool("wisp", false, "Create molecule in wisp storage (.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/)") molCmd.AddCommand(molBondCmd) } diff --git a/cmd/bd/mol_spawn.go b/cmd/bd/mol_spawn.go index f1c533f2..b15567e6 100644 --- a/cmd/bd/mol_spawn.go +++ b/cmd/bd/mol_spawn.go @@ -19,15 +19,26 @@ var molSpawnCmd = &cobra.Command{ Variables are specified with --var key=value flags. The proto's {{key}} placeholders will be replaced with the corresponding values. +Phase behavior: + - By default, spawned molecules are WISPS (ephemeral, in .beads-wisp/) + - Use --pour to create a persistent MOL (in .beads/) + - Wisps are local-only, gitignored, and not synced + - Mols are permanent, synced, and auditable + +Chemistry shortcuts: + bd pour # Equivalent to: bd mol spawn --pour + bd wisp # Equivalent to: bd mol spawn + 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`, + bd mol spawn mol-patrol # Creates wisp (default) + bd mol spawn mol-feature --pour --var name=auth # Creates persistent mol + bd mol spawn bd-abc123 --pour --var version=1.2.0 # Persistent with vars + bd mol spawn mol-feature --attach mol-testing --var name=auth`, Args: cobra.ExactArgs(1), Run: runMolSpawn, } @@ -53,8 +64,15 @@ func runMolSpawn(cmd *cobra.Command, args []string) { assignee, _ := cmd.Flags().GetString("assignee") attachFlags, _ := cmd.Flags().GetStringSlice("attach") attachType, _ := cmd.Flags().GetString("attach-type") + pour, _ := cmd.Flags().GetBool("pour") persistent, _ := cmd.Flags().GetBool("persistent") + // Handle deprecated --persistent flag + if persistent { + fmt.Fprintf(os.Stderr, "Warning: --persistent is deprecated, use --pour instead\n") + pour = true + } + // Parse variables vars := make(map[string]string) for _, v := range varFlags { @@ -182,8 +200,8 @@ func runMolSpawn(cmd *cobra.Command, args []string) { } // Clone the subgraph (spawn the molecule) - // Spawned molecules are wisps by default (bd-2vh3) - use --persistent to opt out - wisp := !persistent + // Spawned molecules are wisps by default (vapor phase) - use --pour for persistent mol (liquid phase) + wisp := !pour result, err := spawnMolecule(ctx, store, subgraph, vars, assignee, actor, wisp) if err != nil { fmt.Fprintf(os.Stderr, "Error spawning molecule: %v\n", err) @@ -236,7 +254,9 @@ func init() { 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") - molSpawnCmd.Flags().Bool("persistent", false, "Create non-wisp issues (default: wisp for cleanup)") + molSpawnCmd.Flags().Bool("pour", false, "Create persistent mol in .beads/ (default: wisp in .beads-wisp/)") + molSpawnCmd.Flags().Bool("persistent", false, "Deprecated: use --pour instead") + _ = molSpawnCmd.Flags().MarkDeprecated("persistent", "use --pour instead") molCmd.AddCommand(molSpawnCmd) } diff --git a/cmd/bd/pin.go b/cmd/bd/pin.go index 715f410b..27723280 100644 --- a/cmd/bd/pin.go +++ b/cmd/bd/pin.go @@ -12,23 +12,50 @@ import ( "github.com/steveyegge/beads/internal/utils" ) +// pinCmd attaches a mol to an agent's hook (work assignment) +// +// In the molecular chemistry metaphor: +// - Hook: Agent's attachment point for work +// - Pin: Action of attaching a mol to an agent's hook var pinCmd = &cobra.Command{ - Use: "pin [id...]", + Use: "pin ", GroupID: "issues", - Short: "Pin one or more issues as persistent context markers", - Long: `Pin issues to mark them as persistent context markers. + Short: "Attach a mol to an agent's hook (work assignment)", + Long: `Pin a mol to an agent's hook - the action of assigning work. -Pinned issues are not work items - they are context beads that should -remain visible and not be cleaned up or closed automatically. +This is the chemistry-inspired command for work assignment. Pinning a mol +to an agent's hook marks it as their current work focus. + +What happens when you pin: + 1. The mol's pinned flag is set to true + 2. The mol's assignee is set to the target agent (with --for) + 3. The mol's status is set to in_progress (with --start) + +Use cases: + - Witness assigning work to polecat: bd pin bd-abc123 --for polecat-ace + - Self-assigning work: bd pin bd-abc123 + - Reviewing what's on your hook: bd hook + +Legacy behavior: + - Multiple IDs can be pinned at once (original pin command) + - Without --for, just sets the pinned flag Examples: - bd pin bd-abc # Pin a single issue - bd pin bd-abc bd-def # Pin multiple issues`, + bd pin bd-abc123 # Pin (set pinned flag) + bd pin bd-abc123 --for polecat-ace # Pin and assign to agent + bd pin bd-abc123 --for me --start # Pin, assign to self, start work`, Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { CheckReadonly("pin") ctx := rootCtx + forAgent, _ := cmd.Flags().GetString("for") + startWork, _ := cmd.Flags().GetBool("start") + + // Handle "me" as alias for current actor + if forAgent == "me" { + forAgent = actor + } // Resolve partial IDs first var resolvedIDs []string @@ -67,6 +94,17 @@ Examples: Pinned: &pinned, } + // Set assignee if --for was provided + if forAgent != "" { + updateArgs.Assignee = &forAgent + } + + // Set status to in_progress if --start was provided + if startWork { + status := string(types.StatusInProgress) + updateArgs.Status = &status + } + resp, err := daemonClient.Update(updateArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error pinning %s: %v\n", id, err) @@ -79,8 +117,11 @@ Examples: pinnedIssues = append(pinnedIssues, &issue) } } else { - - fmt.Printf("%s Pinned %s\n", ui.RenderPass("📌"), id) + msg := fmt.Sprintf("Pinned %s", id) + if forAgent != "" { + msg += fmt.Sprintf(" to %s's hook", forAgent) + } + fmt.Printf("%s %s\n", ui.RenderPass("📌"), msg) } } @@ -107,6 +148,16 @@ Examples: "pinned": true, } + // Set assignee if --for was provided + if forAgent != "" { + updates["assignee"] = forAgent + } + + // Set status to in_progress if --start was provided + if startWork { + updates["status"] = string(types.StatusInProgress) + } + if err := store.UpdateIssue(ctx, fullID, updates, actor); err != nil { fmt.Fprintf(os.Stderr, "Error pinning %s: %v\n", fullID, err) continue @@ -118,8 +169,11 @@ Examples: pinnedIssues = append(pinnedIssues, issue) } } else { - - fmt.Printf("%s Pinned %s\n", ui.RenderPass("📌"), fullID) + msg := fmt.Sprintf("Pinned %s", fullID) + if forAgent != "" { + msg += fmt.Sprintf(" to %s's hook", forAgent) + } + fmt.Printf("%s %s\n", ui.RenderPass("📌"), msg) } } @@ -134,6 +188,11 @@ Examples: }, } + func init() { + // Pin command flags + pinCmd.Flags().String("for", "", "Agent to pin work for (use 'me' for self)") + pinCmd.Flags().Bool("start", false, "Also set status to in_progress") + rootCmd.AddCommand(pinCmd) } diff --git a/cmd/bd/pour.go b/cmd/bd/pour.go new file mode 100644 index 00000000..3d3272f1 --- /dev/null +++ b/cmd/bd/pour.go @@ -0,0 +1,242 @@ +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" +) + +// pourCmd is a top-level command for instantiating protos as persistent mols. +// It's the "chemistry" alias for: bd mol spawn --pour +// +// In the molecular chemistry metaphor: +// - Proto (solid) -> pour -> Mol (liquid) +// - Pour creates persistent, auditable work in .beads/ +var pourCmd = &cobra.Command{ + Use: "pour ", + Short: "Instantiate a proto as a persistent mol (solid -> liquid)", + Long: `Pour a proto into a persistent mol - like pouring molten metal into a mold. + +This is the chemistry-inspired command for creating persistent work from templates. +The resulting mol lives in .beads/ (permanent storage) and is synced with git. + +Phase transition: Proto (solid) -> pour -> Mol (liquid) + +Use pour for: + - Feature work that spans sessions + - Important work needing audit trail + - Anything you might need to reference later + +Equivalent to: bd mol spawn --pour + +Examples: + bd pour mol-feature --var name=auth # Create persistent mol from proto + bd pour mol-release --var version=1.0 # Release workflow + bd pour mol-review --var pr=123 # Code review workflow`, + Args: cobra.ExactArgs(1), + Run: runPour, +} + +func runPour(cmd *cobra.Command, args []string) { + CheckReadonly("pour") + + ctx := rootCtx + + // Pour requires direct store access for subgraph loading and cloning + if store == nil { + if daemonClient != nil { + fmt.Fprintf(os.Stderr, "Error: pour requires direct database access\n") + fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon pour %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 proto ID + protoID, err := utils.ResolvePartialID(ctx, store, args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving proto ID %s: %v\n", args[0], err) + os.Exit(1) + } + + // Verify it's a proto + protoIssue, err := store.GetIssue(ctx, protoID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading proto %s: %v\n", protoID, err) + os.Exit(1) + } + if !isProto(protoIssue) { + fmt.Fprintf(os.Stderr, "Error: %s is not a proto (missing '%s' label)\n", protoID, MoleculeLabel) + os.Exit(1) + } + + // Load the proto subgraph + subgraph, err := loadTemplateSubgraph(ctx, store, protoID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading proto: %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) + } + if !isProto(attachIssue) { + 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 + requiredVars := extractAllVariables(subgraph) + for _, attach := range attachments { + attachVars := extractAllVariables(attach.subgraph) + for _, v := range attachVars { + 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 pour %d issues from proto %s\n\n", len(subgraph.Issues), protoID) + fmt.Printf("Storage: permanent (.beads/)\n\n") + 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)) + } + } + return + } + + // Spawn as persistent mol (ephemeral=false) + result, err := spawnMolecule(ctx, store, subgraph, vars, assignee, actor, false) + if err != nil { + fmt.Fprintf(os.Stderr, "Error pouring proto: %v\n", err) + os.Exit(1) + } + + // Attach bonded protos + totalAttached := 0 + if len(attachments) > 0 { + spawnedMol, err := store.GetIssue(ctx, result.NewEpicID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading spawned mol: %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 { + type pourResult struct { + *InstantiateResult + Attached int `json:"attached"` + Phase string `json:"phase"` + } + outputJSON(pourResult{result, totalAttached, "liquid"}) + return + } + + fmt.Printf("%s Poured mol: created %d issues\n", ui.RenderPass("✓"), result.Created) + fmt.Printf(" Root issue: %s\n", result.NewEpicID) + fmt.Printf(" Phase: liquid (persistent in .beads/)\n") + if totalAttached > 0 { + fmt.Printf(" Attached: %d issues from %d protos\n", totalAttached, len(attachments)) + } +} + +func init() { + // Pour command flags + pourCmd.Flags().StringSlice("var", []string{}, "Variable substitution (key=value)") + pourCmd.Flags().Bool("dry-run", false, "Preview what would be created") + pourCmd.Flags().String("assignee", "", "Assign the root issue to this agent/user") + pourCmd.Flags().StringSlice("attach", []string{}, "Proto to attach after spawning (repeatable)") + pourCmd.Flags().String("attach-type", types.BondTypeSequential, "Bond type for attachments: sequential, parallel, or conditional") + + rootCmd.AddCommand(pourCmd) +} diff --git a/cmd/bd/wisp.go b/cmd/bd/wisp.go index 4ccd8670..9de0c816 100644 --- a/cmd/bd/wisp.go +++ b/cmd/bd/wisp.go @@ -71,6 +71,217 @@ type WispListResult struct { // StaleThreshold is how old a wisp must be to be considered stale const StaleThreshold = 24 * time.Hour +// wispCreateCmd instantiates a proto as an ephemeral wisp +var wispCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Instantiate a proto as an ephemeral wisp (solid -> vapor)", + Long: `Create a wisp from a proto - sublimation from solid to vapor. + +This is the chemistry-inspired command for creating ephemeral work from templates. +The resulting wisp lives in .beads-wisp/ (ephemeral storage) and is NOT synced. + +Phase transition: Proto (solid) -> wisp -> Wisp (vapor) + +Use wisp create for: + - Patrol cycles (deacon, witness) + - Health checks and monitoring + - One-shot orchestration runs + - Routine operations with no audit value + +The wisp will: + - Be stored in .beads-wisp/ (gitignored) + - NOT sync to remote + - Either evaporate (burn) or condense to digest (squash) + +Equivalent to: bd mol spawn + +Examples: + bd wisp create mol-patrol # Ephemeral patrol cycle + bd wisp create mol-health-check # One-time health check + bd wisp create mol-diagnostics --var target=db # Diagnostic run`, + Args: cobra.ExactArgs(1), + Run: runWispCreate, +} + +func runWispCreate(cmd *cobra.Command, args []string) { + CheckReadonly("wisp create") + + ctx := rootCtx + + // Wisp create requires direct store access + if store == nil { + if daemonClient != nil { + fmt.Fprintf(os.Stderr, "Error: wisp create requires direct database access\n") + fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon wisp create %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") + + // 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 proto ID + protoID := args[0] + // Try to resolve partial ID if it doesn't look like a full ID + if !strings.HasPrefix(protoID, "bd-") && !strings.HasPrefix(protoID, "gt-") && !strings.HasPrefix(protoID, "mol-") { + // Might be a partial ID, try to resolve + if resolved, err := resolvePartialIDDirect(ctx, protoID); err == nil { + protoID = resolved + } + } + + // Check if it's a named molecule (mol-xxx) - look up in catalog + if strings.HasPrefix(protoID, "mol-") { + // Find the proto by name + issues, err := store.SearchIssues(ctx, "", types.IssueFilter{ + Labels: []string{MoleculeLabel}, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Error searching for proto: %v\n", err) + os.Exit(1) + } + found := false + for _, issue := range issues { + if strings.Contains(issue.Title, protoID) || issue.ID == protoID { + protoID = issue.ID + found = true + break + } + } + if !found { + fmt.Fprintf(os.Stderr, "Error: proto '%s' not found in catalog\n", args[0]) + fmt.Fprintf(os.Stderr, "Hint: run 'bd mol catalog' to see available protos\n") + os.Exit(1) + } + } + + // Load the proto + protoIssue, err := store.GetIssue(ctx, protoID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading proto %s: %v\n", protoID, err) + os.Exit(1) + } + if !isProtoIssue(protoIssue) { + fmt.Fprintf(os.Stderr, "Error: %s is not a proto (missing '%s' label)\n", protoID, MoleculeLabel) + os.Exit(1) + } + + // Load the proto subgraph + subgraph, err := loadTemplateSubgraph(ctx, store, protoID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading proto: %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) + } + + if dryRun { + fmt.Printf("\nDry run: would create wisp with %d issues from proto %s\n\n", len(subgraph.Issues), protoID) + fmt.Printf("Storage: wisp (.beads-wisp/)\n\n") + for _, issue := range subgraph.Issues { + newTitle := substituteVariables(issue.Title, vars) + fmt.Printf(" - %s (from %s)\n", newTitle, issue.ID) + } + return + } + + // Open wisp storage + wispStore, err := beads.NewWispStorage(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to open wisp storage: %v\n", err) + os.Exit(1) + } + defer wispStore.Close() + + // Ensure wisp directory is gitignored + if err := beads.EnsureWispGitignore(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not update .gitignore: %v\n", err) + } + + // Spawn as wisp (ephemeral=true) + result, err := spawnMolecule(ctx, wispStore, subgraph, vars, "", actor, true) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating wisp: %v\n", err) + os.Exit(1) + } + + // Don't schedule flush - wisps are not synced + + if jsonOutput { + type wispCreateResult struct { + *InstantiateResult + Phase string `json:"phase"` + } + outputJSON(wispCreateResult{result, "vapor"}) + return + } + + fmt.Printf("%s Created wisp: %d issues\n", ui.RenderPass("✓"), result.Created) + fmt.Printf(" Root issue: %s\n", result.NewEpicID) + fmt.Printf(" Phase: vapor (ephemeral in .beads-wisp/)\n") + fmt.Printf("\nNext steps:\n") + fmt.Printf(" bd close %s. # Complete steps\n", result.NewEpicID) + fmt.Printf(" bd mol squash %s # Condense to digest\n", result.NewEpicID) + fmt.Printf(" bd mol burn %s # Discard without digest\n", result.NewEpicID) +} + +// isProtoIssue checks if an issue is a proto (has the template label) +func isProtoIssue(issue *types.Issue) bool { + for _, label := range issue.Labels { + if label == MoleculeLabel { + return true + } + } + return false +} + +// resolvePartialIDDirect resolves a partial ID directly from store +func resolvePartialIDDirect(ctx context.Context, partial string) (string, error) { + // Try direct lookup first + if issue, err := store.GetIssue(ctx, partial); err == nil { + return issue.ID, nil + } + // Search by prefix + issues, err := store.SearchIssues(ctx, "", types.IssueFilter{ + IDs: []string{partial + "*"}, + }) + if err != nil { + return "", err + } + if len(issues) == 1 { + return issues[0].ID, nil + } + if len(issues) > 1 { + return "", fmt.Errorf("ambiguous ID: %s matches %d issues", partial, len(issues)) + } + return "", fmt.Errorf("not found: %s", partial) +} + var wispListCmd = &cobra.Command{ Use: "list", Short: "List all wisps in current context", @@ -483,12 +694,17 @@ func runWispGC(cmd *cobra.Command, args []string) { } func init() { + // Wisp create command flags + wispCreateCmd.Flags().StringSlice("var", []string{}, "Variable substitution (key=value)") + wispCreateCmd.Flags().Bool("dry-run", false, "Preview what would be created") + wispListCmd.Flags().Bool("all", false, "Include closed wisps") wispGCCmd.Flags().Bool("dry-run", false, "Preview what would be cleaned") wispGCCmd.Flags().String("age", "1h", "Age threshold for orphan detection") wispGCCmd.Flags().Bool("all", false, "Also clean closed wisps older than threshold") + wispCmd.AddCommand(wispCreateCmd) wispCmd.AddCommand(wispListCmd) wispCmd.AddCommand(wispGCCmd) rootCmd.AddCommand(wispCmd)