feat: implement molecular chemistry UX commands

Add chemistry-inspired commands for the molecular work metaphor:

New commands:
- bd pour <proto>: Instantiate proto as persistent mol (liquid phase)
- bd wisp create <proto>: Instantiate proto as ephemeral wisp (vapor)
- bd hook: Inspect what's pinned to an agent's hook

Enhanced commands:
- bd mol spawn: Add --pour flag, deprecate --persistent
- bd mol bond: Add --pour flag (force liquid on wisp target)
- bd pin: Add --for <agent> and --start flags

Phase transitions:
  Proto (solid) --pour--> Mol (liquid) --squash--> Digest
  Proto (solid) --wisp--> Wisp (vapor) --burn--> (nothing)

Design docs: gastown/mayor/rig/docs/molecular-chemistry.md

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-22 02:21:40 -08:00
parent 8f8e9516df
commit cadf798b23
6 changed files with 693 additions and 29 deletions

102
cmd/bd/hook.go Normal file
View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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 <proto> # Equivalent to: bd mol spawn <proto> --pour
bd wisp <proto> # Equivalent to: bd mol spawn <proto>
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)
}

View File

@@ -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 <mol-id>",
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)
}

242
cmd/bd/pour.go Normal file
View File

@@ -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 <proto> --pour
//
// In the molecular chemistry metaphor:
// - Proto (solid) -> pour -> Mol (liquid)
// - Pour creates persistent, auditable work in .beads/
var pourCmd = &cobra.Command{
Use: "pour <proto-id>",
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 <proto> --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=<value>\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)
}

View File

@@ -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 <proto-id>",
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 <proto>
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=<value>\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.<step> # 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)