Fix comments display: move outside dependents block, merge main

This commit is contained in:
Steve Yegge
2025-12-27 00:11:35 -08:00
47 changed files with 597 additions and 285 deletions
+14 -5
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -671,7 +671,7 @@ func flushToJSONLWithState(state flushState) {
issues := make([]*types.Issue, 0, len(issueMap)) issues := make([]*types.Issue, 0, len(issueMap))
wispsSkipped := 0 wispsSkipped := 0
for _, issue := range issueMap { for _, issue := range issueMap {
if issue.Wisp { if issue.Ephemeral {
wispsSkipped++ wispsSkipped++
continue continue
} }
+6 -6
View File
@@ -15,7 +15,7 @@ type CleanupEmptyResponse struct {
DeletedCount int `json:"deleted_count"` DeletedCount int `json:"deleted_count"`
Message string `json:"message"` Message string `json:"message"`
Filter string `json:"filter,omitempty"` Filter string `json:"filter,omitempty"`
Wisp bool `json:"wisp,omitempty"` Ephemeral bool `json:"ephemeral,omitempty"`
} }
// Hard delete mode: bypass tombstone TTL safety, use --older-than days directly // Hard delete mode: bypass tombstone TTL safety, use --older-than days directly
@@ -56,7 +56,7 @@ Delete issues closed more than 30 days ago:
bd cleanup --older-than 30 --force bd cleanup --older-than 30 --force
Delete only closed wisps (transient molecules): Delete only closed wisps (transient molecules):
bd cleanup --wisp --force bd cleanup --ephemeral --force
Preview what would be deleted/pruned: Preview what would be deleted/pruned:
bd cleanup --dry-run bd cleanup --dry-run
@@ -80,7 +80,7 @@ SEE ALSO:
cascade, _ := cmd.Flags().GetBool("cascade") cascade, _ := cmd.Flags().GetBool("cascade")
olderThanDays, _ := cmd.Flags().GetInt("older-than") olderThanDays, _ := cmd.Flags().GetInt("older-than")
hardDelete, _ := cmd.Flags().GetBool("hard") hardDelete, _ := cmd.Flags().GetBool("hard")
wispOnly, _ := cmd.Flags().GetBool("wisp") wispOnly, _ := cmd.Flags().GetBool("ephemeral")
// Calculate custom TTL for --hard mode // Calculate custom TTL for --hard mode
// When --hard is set, use --older-than days as the tombstone TTL cutoff // When --hard is set, use --older-than days as the tombstone TTL cutoff
@@ -129,7 +129,7 @@ SEE ALSO:
// Add wisp filter if specified (bd-kwro.9) // Add wisp filter if specified (bd-kwro.9)
if wispOnly { if wispOnly {
wispTrue := true wispTrue := true
filter.Wisp = &wispTrue filter.Ephemeral = &wispTrue
} }
// Get all closed issues matching filter // Get all closed issues matching filter
@@ -165,7 +165,7 @@ SEE ALSO:
result.Filter = fmt.Sprintf("older than %d days", olderThanDays) result.Filter = fmt.Sprintf("older than %d days", olderThanDays)
} }
if wispOnly { if wispOnly {
result.Wisp = true result.Ephemeral = true
} }
outputJSON(result) outputJSON(result)
} else { } else {
@@ -270,6 +270,6 @@ func init() {
cleanupCmd.Flags().Bool("cascade", false, "Recursively delete all dependent issues") cleanupCmd.Flags().Bool("cascade", false, "Recursively delete all dependent issues")
cleanupCmd.Flags().Int("older-than", 0, "Only delete issues closed more than N days ago (0 = all closed issues)") cleanupCmd.Flags().Int("older-than", 0, "Only delete issues closed more than N days ago (0 = all closed issues)")
cleanupCmd.Flags().Bool("hard", false, "Bypass tombstone TTL safety; use --older-than days as cutoff") cleanupCmd.Flags().Bool("hard", false, "Bypass tombstone TTL safety; use --older-than days as cutoff")
cleanupCmd.Flags().Bool("wisp", false, "Only delete closed wisps (transient molecules)") cleanupCmd.Flags().Bool("ephemeral", false, "Only delete closed wisps (transient molecules)")
rootCmd.AddCommand(cleanupCmd) rootCmd.AddCommand(cleanupCmd)
} }
+1 -1
View File
@@ -353,7 +353,7 @@ func runCook(cmd *cobra.Command, args []string) {
if len(bondPoints) > 0 { if len(bondPoints) > 0 {
fmt.Printf(" Bond points: %s\n", strings.Join(bondPoints, ", ")) fmt.Printf(" Bond points: %s\n", strings.Join(bondPoints, ", "))
} }
fmt.Printf("\nTo use: bd pour %s --var <name>=<value>\n", result.ProtoID) fmt.Printf("\nTo use: bd mol pour %s --var <name>=<value>\n", result.ProtoID)
} }
// cookFormulaResult holds the result of cooking // cookFormulaResult holds the result of cooking
+4 -4
View File
@@ -107,7 +107,7 @@ var createCmd = &cobra.Command{
waitsForGate, _ := cmd.Flags().GetString("waits-for-gate") waitsForGate, _ := cmd.Flags().GetString("waits-for-gate")
forceCreate, _ := cmd.Flags().GetBool("force") forceCreate, _ := cmd.Flags().GetBool("force")
repoOverride, _ := cmd.Flags().GetString("repo") repoOverride, _ := cmd.Flags().GetString("repo")
wisp, _ := cmd.Flags().GetBool("wisp") wisp, _ := cmd.Flags().GetBool("ephemeral")
// Get estimate if provided // Get estimate if provided
var estimatedMinutes *int var estimatedMinutes *int
@@ -222,7 +222,7 @@ var createCmd = &cobra.Command{
Dependencies: deps, Dependencies: deps,
WaitsFor: waitsFor, WaitsFor: waitsFor,
WaitsForGate: waitsForGate, WaitsForGate: waitsForGate,
Wisp: wisp, Ephemeral: wisp,
CreatedBy: getActorWithGit(), CreatedBy: getActorWithGit(),
} }
@@ -268,7 +268,7 @@ var createCmd = &cobra.Command{
Assignee: assignee, Assignee: assignee,
ExternalRef: externalRefPtr, ExternalRef: externalRefPtr,
EstimatedMinutes: estimatedMinutes, EstimatedMinutes: estimatedMinutes,
Wisp: wisp, Ephemeral: wisp,
CreatedBy: getActorWithGit(), // GH#748: track who created the issue CreatedBy: getActorWithGit(), // GH#748: track who created the issue
} }
@@ -448,7 +448,7 @@ func init() {
createCmd.Flags().Bool("force", false, "Force creation even if prefix doesn't match database prefix") createCmd.Flags().Bool("force", false, "Force creation even if prefix doesn't match database prefix")
createCmd.Flags().String("repo", "", "Target repository for issue (overrides auto-routing)") createCmd.Flags().String("repo", "", "Target repository for issue (overrides auto-routing)")
createCmd.Flags().IntP("estimate", "e", 0, "Time estimate in minutes (e.g., 60 for 1 hour)") createCmd.Flags().IntP("estimate", "e", 0, "Time estimate in minutes (e.g., 60 for 1 hour)")
createCmd.Flags().Bool("wisp", false, "Create as wisp (ephemeral, not exported to JSONL)") createCmd.Flags().Bool("ephemeral", false, "Create as ephemeral (ephemeral, not exported to JSONL)")
// Note: --json flag is defined as a persistent flag in main.go, not here // Note: --json flag is defined as a persistent flag in main.go, not here
rootCmd.AddCommand(createCmd) rootCmd.AddCommand(createCmd)
} }
+27 -4
View File
@@ -5,9 +5,11 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/routing"
"github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
@@ -15,6 +17,14 @@ import (
"github.com/steveyegge/beads/internal/utils" "github.com/steveyegge/beads/internal/utils"
) )
// getBeadsDir returns the .beads directory path, derived from the global dbPath.
func getBeadsDir() string {
if dbPath != "" {
return filepath.Dir(dbPath)
}
return ""
}
// isChildOf returns true if childID is a hierarchical child of parentID. // isChildOf returns true if childID is a hierarchical child of parentID.
// For example, "bd-abc.1" is a child of "bd-abc", and "bd-abc.1.2" is a child of "bd-abc.1". // For example, "bd-abc.1" is a child of "bd-abc", and "bd-abc.1.2" is a child of "bd-abc.1".
func isChildOf(childID, parentID string) bool { func isChildOf(childID, parentID string) bool {
@@ -88,9 +98,15 @@ Examples:
resolveArgs = &rpc.ResolveIDArgs{ID: args[1]} resolveArgs = &rpc.ResolveIDArgs{ID: args[1]}
resp, err = daemonClient.ResolveID(resolveArgs) resp, err = daemonClient.ResolveID(resolveArgs)
if err != nil { if err != nil {
FatalErrorRespectJSON("resolving dependency ID %s: %v", args[1], err) // Resolution failed - try auto-converting to external ref (bd-lfiu)
} beadsDir := getBeadsDir()
if err := json.Unmarshal(resp.Data, &toID); err != nil { if extRef := routing.ResolveToExternalRef(args[1], beadsDir); extRef != "" {
toID = extRef
isExternalRef = true
} else {
FatalErrorRespectJSON("resolving dependency ID %s: %v", args[1], err)
}
} else if err := json.Unmarshal(resp.Data, &toID); err != nil {
FatalErrorRespectJSON("unmarshaling resolved ID: %v", err) FatalErrorRespectJSON("unmarshaling resolved ID: %v", err)
} }
} }
@@ -111,7 +127,14 @@ Examples:
} else { } else {
toID, err = utils.ResolvePartialID(ctx, store, args[1]) toID, err = utils.ResolvePartialID(ctx, store, args[1])
if err != nil { if err != nil {
FatalErrorRespectJSON("resolving dependency ID %s: %v", args[1], err) // Resolution failed - try auto-converting to external ref (bd-lfiu)
beadsDir := getBeadsDir()
if extRef := routing.ResolveToExternalRef(args[1], beadsDir); extRef != "" {
toID = extRef
isExternalRef = true
} else {
FatalErrorRespectJSON("resolving dependency ID %s: %v", args[1], err)
}
} }
} }
} }
+1 -1
View File
@@ -362,7 +362,7 @@ Examples:
// Wisps exist only in SQLite and are shared via .beads/redirect, not JSONL. // Wisps exist only in SQLite and are shared via .beads/redirect, not JSONL.
filtered := make([]*types.Issue, 0, len(issues)) filtered := make([]*types.Issue, 0, len(issues))
for _, issue := range issues { for _, issue := range issues {
if !issue.Wisp { if !issue.Ephemeral {
filtered = append(filtered, issue) filtered = append(filtered, issue)
} }
} }
+1 -1
View File
@@ -157,7 +157,7 @@ Examples:
Status: types.StatusOpen, Status: types.StatusOpen,
Priority: 1, // Gates are typically high priority Priority: 1, // Gates are typically high priority
// Assignee left empty - orchestrator decides who processes gates // Assignee left empty - orchestrator decides who processes gates
Wisp: true, // Gates are wisps (ephemeral) Ephemeral: true, // Gates are wisps (ephemeral)
AwaitType: awaitType, AwaitType: awaitType,
AwaitID: awaitID, AwaitID: awaitID,
Timeout: timeout, Timeout: timeout,
+2 -2
View File
@@ -87,8 +87,8 @@ func runHook(cmd *cobra.Command, args []string) {
for _, issue := range issues { for _, issue := range issues {
phase := "mol" phase := "mol"
if issue.Wisp { if issue.Ephemeral {
phase = "wisp" phase = "ephemeral"
} }
fmt.Printf(" 📌 %s (%s) - %s\n", issue.ID, phase, issue.Status) fmt.Printf(" 📌 %s (%s) - %s\n", issue.ID, phase, issue.Status)
fmt.Printf(" %s\n", issue.Title) fmt.Printf(" %s\n", issue.Title)
+1
View File
@@ -292,6 +292,7 @@ var versionChanges = []VersionChange{
Version: "0.37.0", Version: "0.37.0",
Date: "2025-12-26", Date: "2025-12-26",
Changes: []string{ Changes: []string{
"BREAKING: Ephemeral API rename (bd-o18s) - Wisp→Ephemeral: JSON 'wisp'→'ephemeral', bd wisp→bd ephemeral",
"NEW: bd gate create/show/list/close/wait (bd-udsi) - Async coordination primitives for agent workflows", "NEW: bd gate create/show/list/close/wait (bd-udsi) - Async coordination primitives for agent workflows",
"NEW: bd gate eval (gt-twjr5.2) - Evaluate timer gates and GitHub gates (gh:run, gh:pr, mail)", "NEW: bd gate eval (gt-twjr5.2) - Evaluate timer gates and GitHub gates (gh:run, gh:pr, mail)",
"NEW: bd gate approve (gt-twjr5.4) - Human gate approval command", "NEW: bd gate approve (gt-twjr5.4) - Human gate approval command",
+11 -11
View File
@@ -20,8 +20,8 @@ import (
// Usage: // Usage:
// bd mol catalog # List available protos // bd mol catalog # List available protos
// bd mol show <id> # Show proto/molecule structure // bd mol show <id> # Show proto/molecule structure
// bd pour <id> --var key=value # Instantiate proto → persistent mol // bd mol pour <id> --var key=value # Instantiate proto → persistent mol
// bd wisp create <id> --var key=value # Instantiate proto → ephemeral wisp // bd mol wisp <id> --var key=value # Instantiate proto → ephemeral wisp
// MoleculeLabel is the label used to identify molecules (templates) // MoleculeLabel is the label used to identify molecules (templates)
// Molecules use the same label as templates - they ARE templates with workflow semantics // Molecules use the same label as templates - they ARE templates with workflow semantics
@@ -48,14 +48,14 @@ The molecule metaphor:
- Distilling extracts a proto from an ad-hoc epic - Distilling extracts a proto from an ad-hoc epic
Commands: Commands:
catalog List available protos catalog List available protos
show Show proto/molecule structure and variables show Show proto/molecule structure and variables
bond Polymorphic combine: proto+proto, proto+mol, mol+mol pour Instantiate proto as persistent mol (liquid phase)
distill Extract proto from ad-hoc epic wisp Instantiate proto as ephemeral wisp (vapor phase)
bond Polymorphic combine: proto+proto, proto+mol, mol+mol
See also: squash Condense molecule to digest
bd pour <proto> # Instantiate as persistent mol (liquid phase) burn Discard wisp
bd wisp create <proto> # Instantiate as ephemeral wisp (vapor phase)`, distill Extract proto from ad-hoc epic`,
} }
// ============================================================================= // =============================================================================
@@ -72,7 +72,7 @@ func spawnMolecule(ctx context.Context, s storage.Storage, subgraph *MoleculeSub
Vars: vars, Vars: vars,
Assignee: assignee, Assignee: assignee,
Actor: actorName, Actor: actorName,
Wisp: ephemeral, Ephemeral: ephemeral,
Prefix: prefix, Prefix: prefix,
} }
return cloneSubgraph(ctx, s, subgraph, opts) return cloneSubgraph(ctx, s, subgraph, opts)
+36 -36
View File
@@ -40,12 +40,12 @@ Bond types:
Phase control: Phase control:
By default, spawned protos follow the target's phase: By default, spawned protos follow the target's phase:
- Attaching to mol (Wisp=false) spawns as persistent (Wisp=false) - Attaching to mol (Ephemeral=false) spawns as persistent (Ephemeral=false)
- Attaching to wisp (Wisp=true) spawns as ephemeral (Wisp=true) - Attaching to ephemeral issue (Ephemeral=true) spawns as ephemeral (Ephemeral=true)
Override with: Override with:
--pour Force spawn as liquid (persistent, Wisp=false) --pour Force spawn as liquid (persistent, Ephemeral=false)
--wisp Force spawn as vapor (ephemeral, Wisp=true, excluded from JSONL export) --ephemeral Force spawn as vapor (ephemeral, Ephemeral=true, excluded from JSONL export)
Dynamic bonding (Christmas Ornament pattern): Dynamic bonding (Christmas Ornament pattern):
Use --ref to specify a custom child reference with variable substitution. Use --ref to specify a custom child reference with variable substitution.
@@ -57,7 +57,7 @@ Dynamic bonding (Christmas Ornament pattern):
Use cases: Use cases:
- Found important bug during patrol? Use --pour to persist it - Found important bug during patrol? Use --pour to persist it
- Need ephemeral diagnostic on persistent feature? Use --wisp - Need ephemeral diagnostic on persistent feature? Use --ephemeral
- Spawning per-worker arms on a patrol? Use --ref for readable IDs - Spawning per-worker arms on a patrol? Use --ref for readable IDs
Examples: Examples:
@@ -66,7 +66,7 @@ Examples:
bd mol bond mol-feature bd-abc123 # Attach proto to molecule bd mol bond mol-feature bd-abc123 # Attach proto to molecule
bd mol bond bd-abc123 bd-def456 # Join two molecules bd mol bond bd-abc123 bd-def456 # Join two molecules
bd mol bond mol-critical-bug wisp-patrol --pour # Persist found bug bd mol bond mol-critical-bug wisp-patrol --pour # Persist found bug
bd mol bond mol-temp-check bd-feature --wisp # Ephemeral diagnostic bd mol bond mol-temp-check bd-feature --ephemeral # Ephemeral diagnostic
bd mol bond mol-arm bd-patrol --ref arm-{{name}} --var name=ace # Dynamic child ID`, bd mol bond mol-arm bd-patrol --ref arm-{{name}} --var name=ace # Dynamic child ID`,
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
Run: runMolBond, Run: runMolBond,
@@ -102,20 +102,20 @@ func runMolBond(cmd *cobra.Command, args []string) {
customTitle, _ := cmd.Flags().GetString("as") customTitle, _ := cmd.Flags().GetString("as")
dryRun, _ := cmd.Flags().GetBool("dry-run") dryRun, _ := cmd.Flags().GetBool("dry-run")
varFlags, _ := cmd.Flags().GetStringSlice("var") varFlags, _ := cmd.Flags().GetStringSlice("var")
wisp, _ := cmd.Flags().GetBool("wisp") ephemeral, _ := cmd.Flags().GetBool("ephemeral")
pour, _ := cmd.Flags().GetBool("pour") pour, _ := cmd.Flags().GetBool("pour")
childRef, _ := cmd.Flags().GetString("ref") childRef, _ := cmd.Flags().GetString("ref")
// Validate phase flags are not both set // Validate phase flags are not both set
if wisp && pour { if ephemeral && pour {
fmt.Fprintf(os.Stderr, "Error: cannot use both --wisp and --pour\n") fmt.Fprintf(os.Stderr, "Error: cannot use both --ephemeral and --pour\n")
os.Exit(1) os.Exit(1)
} }
// All issues go in the main store; wisp vs pour determines the Wisp flag // All issues go in the main store; ephemeral vs pour determines the Wisp flag
// --wisp: create with Wisp=true (ephemeral, excluded from JSONL export) // --ephemeral: create with Ephemeral=true (ephemeral, excluded from JSONL export)
// --pour: create with Wisp=false (persistent, exported to JSONL) // --pour: create with Ephemeral=false (persistent, exported to JSONL)
// Default: follow target's phase (wisp if target is wisp, otherwise persistent) // Default: follow target's phase (ephemeral if target is ephemeral, otherwise persistent)
// Validate bond type // Validate bond type
if bondType != types.BondTypeSequential && bondType != types.BondTypeParallel && bondType != types.BondTypeConditional { if bondType != types.BondTypeSequential && bondType != types.BondTypeParallel && bondType != types.BondTypeConditional {
@@ -181,8 +181,8 @@ func runMolBond(cmd *cobra.Command, args []string) {
fmt.Printf(" B: %s (%s)\n", issueB.Title, operandType(bIsProto)) fmt.Printf(" B: %s (%s)\n", issueB.Title, operandType(bIsProto))
} }
fmt.Printf(" Bond type: %s\n", bondType) fmt.Printf(" Bond type: %s\n", bondType)
if wisp { if ephemeral {
fmt.Printf(" Phase override: vapor (--wisp)\n") fmt.Printf(" Phase override: vapor (--ephemeral)\n")
} else if pour { } else if pour {
fmt.Printf(" Phase override: liquid (--pour)\n") fmt.Printf(" Phase override: liquid (--pour)\n")
} }
@@ -240,16 +240,16 @@ func runMolBond(cmd *cobra.Command, args []string) {
case aIsProto && !bIsProto: case aIsProto && !bIsProto:
// Pass subgraph directly if cooked from formula // Pass subgraph directly if cooked from formula
if cookedA { if cookedA {
result, err = bondProtoMolWithSubgraph(ctx, store, subgraphA, issueA, issueB, bondType, vars, childRef, actor, wisp, pour) result, err = bondProtoMolWithSubgraph(ctx, store, subgraphA, issueA, issueB, bondType, vars, childRef, actor, ephemeral, pour)
} else { } else {
result, err = bondProtoMol(ctx, store, issueA, issueB, bondType, vars, childRef, actor, wisp, pour) result, err = bondProtoMol(ctx, store, issueA, issueB, bondType, vars, childRef, actor, ephemeral, pour)
} }
case !aIsProto && bIsProto: case !aIsProto && bIsProto:
// Pass subgraph directly if cooked from formula // Pass subgraph directly if cooked from formula
if cookedB { if cookedB {
result, err = bondProtoMolWithSubgraph(ctx, store, subgraphB, issueB, issueA, bondType, vars, childRef, actor, wisp, pour) result, err = bondProtoMolWithSubgraph(ctx, store, subgraphB, issueB, issueA, bondType, vars, childRef, actor, ephemeral, pour)
} else { } else {
result, err = bondMolProto(ctx, store, issueA, issueB, bondType, vars, childRef, actor, wisp, pour) result, err = bondMolProto(ctx, store, issueA, issueB, bondType, vars, childRef, actor, ephemeral, pour)
} }
default: default:
result, err = bondMolMol(ctx, store, issueA, issueB, bondType, actor) result, err = bondMolMol(ctx, store, issueA, issueB, bondType, actor)
@@ -273,10 +273,10 @@ func runMolBond(cmd *cobra.Command, args []string) {
if result.Spawned > 0 { if result.Spawned > 0 {
fmt.Printf(" Spawned: %d issues\n", result.Spawned) fmt.Printf(" Spawned: %d issues\n", result.Spawned)
} }
if wisp { if ephemeral {
fmt.Printf(" Phase: vapor (ephemeral, Wisp=true)\n") fmt.Printf(" Phase: vapor (ephemeral, Ephemeral=true)\n")
} else if pour { } else if pour {
fmt.Printf(" Phase: liquid (persistent, Wisp=false)\n") fmt.Printf(" Phase: liquid (persistent, Ephemeral=false)\n")
} }
} }
@@ -386,12 +386,12 @@ func bondProtoProto(ctx context.Context, s storage.Storage, protoA, protoB *type
// bondProtoMol bonds a proto to an existing molecule by spawning the proto. // bondProtoMol bonds a proto to an existing molecule by spawning the proto.
// If childRef is provided, generates custom IDs like "parent.childref" (dynamic bonding). // If childRef is provided, generates custom IDs like "parent.childref" (dynamic bonding).
// protoSubgraph can be nil if proto is from DB (will be loaded), or pre-loaded for formulas. // protoSubgraph can be nil if proto is from DB (will be loaded), or pre-loaded for formulas.
func bondProtoMol(ctx context.Context, s storage.Storage, proto, mol *types.Issue, bondType string, vars map[string]string, childRef string, actorName string, wispFlag, pourFlag bool) (*BondResult, error) { func bondProtoMol(ctx context.Context, s storage.Storage, proto, mol *types.Issue, bondType string, vars map[string]string, childRef string, actorName string, ephemeralFlag, pourFlag bool) (*BondResult, error) {
return bondProtoMolWithSubgraph(ctx, s, nil, proto, mol, bondType, vars, childRef, actorName, wispFlag, pourFlag) return bondProtoMolWithSubgraph(ctx, s, nil, proto, mol, bondType, vars, childRef, actorName, ephemeralFlag, pourFlag)
} }
// bondProtoMolWithSubgraph is the internal implementation that accepts a pre-loaded subgraph. // bondProtoMolWithSubgraph is the internal implementation that accepts a pre-loaded subgraph.
func bondProtoMolWithSubgraph(ctx context.Context, s storage.Storage, protoSubgraph *TemplateSubgraph, proto, mol *types.Issue, bondType string, vars map[string]string, childRef string, actorName string, wispFlag, pourFlag bool) (*BondResult, error) { func bondProtoMolWithSubgraph(ctx context.Context, s storage.Storage, protoSubgraph *TemplateSubgraph, proto, mol *types.Issue, bondType string, vars map[string]string, childRef string, actorName string, ephemeralFlag, pourFlag bool) (*BondResult, error) {
// Use provided subgraph or load from DB // Use provided subgraph or load from DB
subgraph := protoSubgraph subgraph := protoSubgraph
if subgraph == nil { if subgraph == nil {
@@ -414,20 +414,20 @@ func bondProtoMolWithSubgraph(ctx context.Context, s storage.Storage, protoSubgr
return nil, fmt.Errorf("missing required variables: %s (use --var)", strings.Join(missingVars, ", ")) return nil, fmt.Errorf("missing required variables: %s (use --var)", strings.Join(missingVars, ", "))
} }
// Determine wisp flag based on explicit flags or target's phase // Determine ephemeral flag based on explicit flags or target's phase
// --wisp: force wisp=true, --pour: force wisp=false, neither: follow target // --ephemeral: force ephemeral=true, --pour: force ephemeral=false, neither: follow target
makeWisp := mol.Wisp // Default: follow target's phase makeEphemeral := mol.Ephemeral // Default: follow target's phase
if wispFlag { if ephemeralFlag {
makeWisp = true makeEphemeral = true
} else if pourFlag { } else if pourFlag {
makeWisp = false makeEphemeral = false
} }
// Build CloneOptions for spawning // Build CloneOptions for spawning
opts := CloneOptions{ opts := CloneOptions{
Vars: vars, Vars: vars,
Actor: actorName, Actor: actorName,
Wisp: makeWisp, Ephemeral: makeEphemeral,
} }
// Dynamic bonding: use custom IDs if childRef is provided // Dynamic bonding: use custom IDs if childRef is provided
@@ -482,9 +482,9 @@ func bondProtoMolWithSubgraph(ctx context.Context, s storage.Storage, protoSubgr
} }
// bondMolProto bonds a molecule to a proto (symmetric with bondProtoMol) // bondMolProto bonds a molecule to a proto (symmetric with bondProtoMol)
func bondMolProto(ctx context.Context, s storage.Storage, mol, proto *types.Issue, bondType string, vars map[string]string, childRef string, actorName string, wispFlag, pourFlag bool) (*BondResult, error) { func bondMolProto(ctx context.Context, s storage.Storage, mol, proto *types.Issue, bondType string, vars map[string]string, childRef string, actorName string, ephemeralFlag, pourFlag bool) (*BondResult, error) {
// Same as bondProtoMol but with arguments swapped // Same as bondProtoMol but with arguments swapped
return bondProtoMol(ctx, s, proto, mol, bondType, vars, childRef, actorName, wispFlag, pourFlag) return bondProtoMol(ctx, s, proto, mol, bondType, vars, childRef, actorName, ephemeralFlag, pourFlag)
} }
// bondMolMol bonds two molecules together // bondMolMol bonds two molecules together
@@ -630,8 +630,8 @@ func init() {
molBondCmd.Flags().String("as", "", "Custom title for compound proto (proto+proto only)") 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().Bool("dry-run", false, "Preview what would be created")
molBondCmd.Flags().StringSlice("var", []string{}, "Variable substitution for spawned protos (key=value)") molBondCmd.Flags().StringSlice("var", []string{}, "Variable substitution for spawned protos (key=value)")
molBondCmd.Flags().Bool("wisp", false, "Force spawn as vapor (ephemeral, Wisp=true)") molBondCmd.Flags().Bool("ephemeral", false, "Force spawn as vapor (ephemeral, Ephemeral=true)")
molBondCmd.Flags().Bool("pour", false, "Force spawn as liquid (persistent, Wisp=false)") molBondCmd.Flags().Bool("pour", false, "Force spawn as liquid (persistent, Ephemeral=false)")
molBondCmd.Flags().String("ref", "", "Custom child reference with {{var}} substitution (e.g., arm-{{polecat_name}})") molBondCmd.Flags().String("ref", "", "Custom child reference with {{var}} substitution (e.g., arm-{{polecat_name}})")
molCmd.AddCommand(molBondCmd) molCmd.AddCommand(molBondCmd)
+7 -7
View File
@@ -23,8 +23,8 @@ completely removes the wisp with no trace. Use this for:
- Test/debug wisps you don't want to preserve - Test/debug wisps you don't want to preserve
The burn operation: The burn operation:
1. Verifies the molecule has Wisp=true (is ephemeral) 1. Verifies the molecule has Ephemeral=true (is ephemeral)
2. Deletes the molecule and all its wisp children 2. Deletes the molecule and all its ephemeral children
3. No digest is created (use 'bd mol squash' if you want a digest) 3. No digest is created (use 'bd mol squash' if you want a digest)
CAUTION: This is a destructive operation. The wisp's data will be CAUTION: This is a destructive operation. The wisp's data will be
@@ -81,8 +81,8 @@ func runMolBurn(cmd *cobra.Command, args []string) {
} }
// Verify it's a wisp // Verify it's a wisp
if !rootIssue.Wisp { if !rootIssue.Ephemeral {
fmt.Fprintf(os.Stderr, "Error: molecule %s is not a wisp (Wisp=false)\n", resolvedID) fmt.Fprintf(os.Stderr, "Error: molecule %s is not a wisp (Ephemeral=false)\n", resolvedID)
fmt.Fprintf(os.Stderr, "Hint: mol burn only works with wisp molecules\n") fmt.Fprintf(os.Stderr, "Hint: mol burn only works with wisp molecules\n")
fmt.Fprintf(os.Stderr, " Use 'bd delete' to remove non-wisp issues\n") fmt.Fprintf(os.Stderr, " Use 'bd delete' to remove non-wisp issues\n")
os.Exit(1) os.Exit(1)
@@ -98,7 +98,7 @@ func runMolBurn(cmd *cobra.Command, args []string) {
// Collect wisp issue IDs to delete (only delete wisps, not regular children) // Collect wisp issue IDs to delete (only delete wisps, not regular children)
var wispIDs []string var wispIDs []string
for _, issue := range subgraph.Issues { for _, issue := range subgraph.Issues {
if issue.Wisp { if issue.Ephemeral {
wispIDs = append(wispIDs, issue.ID) wispIDs = append(wispIDs, issue.ID)
} }
} }
@@ -120,7 +120,7 @@ func runMolBurn(cmd *cobra.Command, args []string) {
fmt.Printf("Root: %s\n", subgraph.Root.Title) fmt.Printf("Root: %s\n", subgraph.Root.Title)
fmt.Printf("\nWisp issues to delete (%d total):\n", len(wispIDs)) fmt.Printf("\nWisp issues to delete (%d total):\n", len(wispIDs))
for _, issue := range subgraph.Issues { for _, issue := range subgraph.Issues {
if !issue.Wisp { if !issue.Ephemeral {
continue continue
} }
status := string(issue.Status) status := string(issue.Status)
@@ -166,7 +166,7 @@ func runMolBurn(cmd *cobra.Command, args []string) {
} }
fmt.Printf("%s Burned wisp: %d issues deleted\n", ui.RenderPass("✓"), result.DeletedCount) fmt.Printf("%s Burned wisp: %d issues deleted\n", ui.RenderPass("✓"), result.DeletedCount)
fmt.Printf(" Wisp: %s\n", resolvedID) fmt.Printf(" Ephemeral: %s\n", resolvedID)
fmt.Printf(" No digest created.\n") fmt.Printf(" No digest created.\n")
} }
+4 -4
View File
@@ -23,7 +23,7 @@ var molCatalogCmd = &cobra.Command{
Use: "catalog", Use: "catalog",
Aliases: []string{"list", "ls"}, Aliases: []string{"list", "ls"},
Short: "List available molecule formulas", Short: "List available molecule formulas",
Long: `List formulas available for bd pour / bd wisp create. Long: `List formulas available for bd mol pour / bd mol wisp.
Formulas are ephemeral proto definitions stored as .formula.json files. Formulas are ephemeral proto definitions stored as .formula.json files.
They are cooked inline when pouring, never stored as database beads. They are cooked inline when pouring, never stored as database beads.
@@ -92,12 +92,12 @@ Search paths (in priority order):
fmt.Println("\nOr distill from existing work:") fmt.Println("\nOr distill from existing work:")
fmt.Println(" bd mol distill <epic-id> my-workflow") fmt.Println(" bd mol distill <epic-id> my-workflow")
fmt.Println("\nTo instantiate from formula:") fmt.Println("\nTo instantiate from formula:")
fmt.Println(" bd pour <formula-name> --var key=value # persistent mol") fmt.Println(" bd mol pour <formula-name> --var key=value # persistent mol")
fmt.Println(" bd wisp create <formula-name> --var key=value # ephemeral wisp") fmt.Println(" bd mol wisp <formula-name> --var key=value # ephemeral wisp")
return return
} }
fmt.Printf("%s\n\n", ui.RenderPass("Formulas (for bd pour / bd wisp create):")) fmt.Printf("%s\n\n", ui.RenderPass("Formulas (for bd mol pour / bd mol wisp):"))
// Group by type for display // Group by type for display
byType := make(map[string][]CatalogEntry) byType := make(map[string][]CatalogEntry)
+1 -1
View File
@@ -100,7 +100,7 @@ The output shows all steps with status indicators:
} }
fmt.Println(".") fmt.Println(".")
fmt.Println("\nTo start work on a molecule:") fmt.Println("\nTo start work on a molecule:")
fmt.Println(" bd pour <proto-id> # Instantiate a molecule from template") fmt.Println(" bd mol pour <proto-id> # Instantiate a molecule from template")
fmt.Println(" bd update <step-id> --status in_progress # Claim a step") fmt.Println(" bd update <step-id> --status in_progress # Claim a step")
return return
} }
+1 -1
View File
@@ -225,7 +225,7 @@ func runMolDistill(cmd *cobra.Command, args []string) {
fmt.Printf(" Variables: %s\n", strings.Join(result.Variables, ", ")) fmt.Printf(" Variables: %s\n", strings.Join(result.Variables, ", "))
} }
fmt.Printf("\nTo instantiate:\n") fmt.Printf("\nTo instantiate:\n")
fmt.Printf(" bd pour %s", result.FormulaName) fmt.Printf(" bd mol pour %s", result.FormulaName)
for _, v := range result.Variables { for _, v := range result.Variables {
fmt.Printf(" --var %s=<value>", v) fmt.Printf(" --var %s=<value>", v)
} }
+11 -11
View File
@@ -18,17 +18,17 @@ import (
var molSquashCmd = &cobra.Command{ var molSquashCmd = &cobra.Command{
Use: "squash <molecule-id>", Use: "squash <molecule-id>",
Short: "Compress molecule execution into a digest", Short: "Compress molecule execution into a digest",
Long: `Squash a molecule's wisp children into a single digest issue. Long: `Squash a molecule's ephemeral children into a single digest issue.
This command collects all wisp child issues of a molecule (Wisp=true), This command collects all ephemeral child issues of a molecule (Ephemeral=true),
generates a summary digest, and promotes the wisps to persistent by generates a summary digest, and promotes the wisps to persistent by
clearing their Wisp flag (or optionally deletes them). clearing their Wisp flag (or optionally deletes them).
The squash operation: The squash operation:
1. Loads the molecule and all its children 1. Loads the molecule and all its children
2. Filters to only wisps (ephemeral issues with Wisp=true) 2. Filters to only wisps (ephemeral issues with Ephemeral=true)
3. Generates a digest (summary of work done) 3. Generates a digest (summary of work done)
4. Creates a permanent digest issue (Wisp=false) 4. Creates a permanent digest issue (Ephemeral=false)
5. Clears Wisp flag on children (promotes to persistent) 5. Clears Wisp flag on children (promotes to persistent)
OR deletes them with --delete-children OR deletes them with --delete-children
@@ -95,13 +95,13 @@ func runMolSquash(cmd *cobra.Command, args []string) {
os.Exit(1) os.Exit(1)
} }
// Filter to only wisp children (exclude root) // Filter to only ephemeral children (exclude root)
var wispChildren []*types.Issue var wispChildren []*types.Issue
for _, issue := range subgraph.Issues { for _, issue := range subgraph.Issues {
if issue.ID == subgraph.Root.ID { if issue.ID == subgraph.Root.ID {
continue // Skip root continue // Skip root
} }
if issue.Wisp { if issue.Ephemeral {
wispChildren = append(wispChildren, issue) wispChildren = append(wispChildren, issue)
} }
} }
@@ -113,13 +113,13 @@ func runMolSquash(cmd *cobra.Command, args []string) {
SquashedCount: 0, SquashedCount: 0,
}) })
} else { } else {
fmt.Printf("No wisp children found for molecule %s\n", moleculeID) fmt.Printf("No ephemeral children found for molecule %s\n", moleculeID)
} }
return return
} }
if dryRun { if dryRun {
fmt.Printf("\nDry run: would squash %d wisp children of %s\n\n", len(wispChildren), moleculeID) fmt.Printf("\nDry run: would squash %d ephemeral children of %s\n\n", len(wispChildren), moleculeID)
fmt.Printf("Root: %s\n", subgraph.Root.Title) fmt.Printf("Root: %s\n", subgraph.Root.Title)
fmt.Printf("\nWisp children to squash:\n") fmt.Printf("\nWisp children to squash:\n")
for _, issue := range wispChildren { for _, issue := range wispChildren {
@@ -247,7 +247,7 @@ func squashMolecule(ctx context.Context, s storage.Storage, root *types.Issue, c
CloseReason: fmt.Sprintf("Squashed from %d wisps", len(children)), CloseReason: fmt.Sprintf("Squashed from %d wisps", len(children)),
Priority: root.Priority, Priority: root.Priority,
IssueType: types.TypeTask, IssueType: types.TypeTask,
Wisp: false, // Digest is permanent, not a wisp Ephemeral: false, // Digest is permanent, not a wisp
ClosedAt: &now, ClosedAt: &now,
} }
@@ -283,7 +283,7 @@ func squashMolecule(ctx context.Context, s storage.Storage, root *types.Issue, c
return nil, err return nil, err
} }
// Delete wisp children (outside transaction for better error handling) // Delete ephemeral children (outside transaction for better error handling)
if !keepChildren { if !keepChildren {
deleted, err := deleteWispChildren(ctx, s, childIDs) deleted, err := deleteWispChildren(ctx, s, childIDs)
if err != nil { if err != nil {
@@ -319,7 +319,7 @@ func deleteWispChildren(ctx context.Context, s storage.Storage, ids []string) (i
func init() { func init() {
molSquashCmd.Flags().Bool("dry-run", false, "Preview what would be squashed") molSquashCmd.Flags().Bool("dry-run", false, "Preview what would be squashed")
molSquashCmd.Flags().Bool("keep-children", false, "Don't delete wisp children after squash") molSquashCmd.Flags().Bool("keep-children", false, "Don't delete ephemeral children after squash")
molSquashCmd.Flags().String("summary", "", "Agent-provided summary (bypasses auto-generation)") molSquashCmd.Flags().String("summary", "", "Agent-provided summary (bypasses auto-generation)")
molCmd.AddCommand(molSquashCmd) molCmd.AddCommand(molSquashCmd)
+8 -8
View File
@@ -489,7 +489,7 @@ func TestSquashMolecule(t *testing.T) {
Status: types.StatusClosed, Status: types.StatusClosed,
Priority: 2, Priority: 2,
IssueType: types.TypeTask, IssueType: types.TypeTask,
Wisp: true, Ephemeral: true,
CloseReason: "Completed design", CloseReason: "Completed design",
} }
child2 := &types.Issue{ child2 := &types.Issue{
@@ -498,7 +498,7 @@ func TestSquashMolecule(t *testing.T) {
Status: types.StatusClosed, Status: types.StatusClosed,
Priority: 2, Priority: 2,
IssueType: types.TypeTask, IssueType: types.TypeTask,
Wisp: true, Ephemeral: true,
CloseReason: "Code merged", CloseReason: "Code merged",
} }
@@ -547,7 +547,7 @@ func TestSquashMolecule(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Failed to get digest: %v", err) t.Fatalf("Failed to get digest: %v", err)
} }
if digest.Wisp { if digest.Ephemeral {
t.Error("Digest should NOT be ephemeral") t.Error("Digest should NOT be ephemeral")
} }
if digest.Status != types.StatusClosed { if digest.Status != types.StatusClosed {
@@ -595,7 +595,7 @@ func TestSquashMoleculeWithDelete(t *testing.T) {
Status: types.StatusClosed, Status: types.StatusClosed,
Priority: 2, Priority: 2,
IssueType: types.TypeTask, IssueType: types.TypeTask,
Wisp: true, Ephemeral: true,
} }
if err := s.CreateIssue(ctx, child, "test"); err != nil { if err := s.CreateIssue(ctx, child, "test"); err != nil {
t.Fatalf("Failed to create child: %v", err) t.Fatalf("Failed to create child: %v", err)
@@ -705,7 +705,7 @@ func TestSquashMoleculeWithAgentSummary(t *testing.T) {
Status: types.StatusClosed, Status: types.StatusClosed,
Priority: 2, Priority: 2,
IssueType: types.TypeTask, IssueType: types.TypeTask,
Wisp: true, Ephemeral: true,
CloseReason: "Done", CloseReason: "Done",
} }
if err := s.CreateIssue(ctx, child, "test"); err != nil { if err := s.CreateIssue(ctx, child, "test"); err != nil {
@@ -1304,14 +1304,14 @@ func TestWispFilteringFromExport(t *testing.T) {
Status: types.StatusOpen, Status: types.StatusOpen,
Priority: 1, Priority: 1,
IssueType: types.TypeTask, IssueType: types.TypeTask,
Wisp: false, Ephemeral: false,
} }
wispIssue := &types.Issue{ wispIssue := &types.Issue{
Title: "Wisp Issue", Title: "Wisp Issue",
Status: types.StatusOpen, Status: types.StatusOpen,
Priority: 2, Priority: 2,
IssueType: types.TypeTask, IssueType: types.TypeTask,
Wisp: true, Ephemeral: true,
} }
if err := s.CreateIssue(ctx, normalIssue, "test"); err != nil { if err := s.CreateIssue(ctx, normalIssue, "test"); err != nil {
@@ -1333,7 +1333,7 @@ func TestWispFilteringFromExport(t *testing.T) {
// Filter wisp issues (simulating export behavior) // Filter wisp issues (simulating export behavior)
exportableIssues := make([]*types.Issue, 0) exportableIssues := make([]*types.Issue, 0)
for _, issue := range allIssues { for _, issue := range allIssues {
if !issue.Wisp { if !issue.Ephemeral {
exportableIssues = append(exportableIssues, issue) exportableIssues = append(exportableIssues, issue)
} }
} }
+5 -2
View File
@@ -72,8 +72,11 @@ func initializeNoDbMode() error {
debug.Logf("using prefix '%s'", prefix) debug.Logf("using prefix '%s'", prefix)
// Set global store // Set global store and mark as active (fixes bd comment --no-db)
storeMutex.Lock()
store = memStore store = memStore
storeActive = true
storeMutex.Unlock()
return nil return nil
} }
@@ -218,7 +221,7 @@ func writeIssuesToJSONL(memStore *memory.MemoryStorage, beadsDir string) error {
// Wisps exist only in SQLite and are shared via .beads/redirect, not JSONL. // Wisps exist only in SQLite and are shared via .beads/redirect, not JSONL.
filtered := make([]*types.Issue, 0, len(issues)) filtered := make([]*types.Issue, 0, len(issues))
for _, issue := range issues { for _, issue := range issues {
if !issue.Wisp { if !issue.Ephemeral {
filtered = append(filtered, issue) filtered = append(filtered, issue)
} }
} }
+84
View File
@@ -158,6 +158,90 @@ func TestDetectPrefix(t *testing.T) {
}) })
} }
func TestInitializeNoDbMode_SetsStoreActive(t *testing.T) {
// This test verifies the fix for bd comment --no-db not working.
// The bug was that initializeNoDbMode() set `store` but not `storeActive`,
// so ensureStoreActive() would try to find a SQLite database.
tempDir := t.TempDir()
beadsDir := filepath.Join(tempDir, ".beads")
if err := os.MkdirAll(beadsDir, 0o755); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
// Create a minimal JSONL file with one issue
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
content := `{"id":"bd-1","title":"Test Issue","status":"open"}
`
if err := os.WriteFile(jsonlPath, []byte(content), 0o600); err != nil {
t.Fatalf("Failed to write JSONL: %v", err)
}
// Save and restore global state
oldStore := store
oldStoreActive := storeActive
oldCwd, _ := os.Getwd()
defer func() {
storeMutex.Lock()
store = oldStore
storeActive = oldStoreActive
storeMutex.Unlock()
_ = os.Chdir(oldCwd)
}()
// Change to temp dir so initializeNoDbMode finds .beads
if err := os.Chdir(tempDir); err != nil {
t.Fatalf("Failed to chdir: %v", err)
}
// Reset global state
storeMutex.Lock()
store = nil
storeActive = false
storeMutex.Unlock()
// Initialize no-db mode
if err := initializeNoDbMode(); err != nil {
t.Fatalf("initializeNoDbMode failed: %v", err)
}
// Verify storeActive is now true
storeMutex.Lock()
active := storeActive
s := store
storeMutex.Unlock()
if !active {
t.Error("storeActive should be true after initializeNoDbMode")
}
if s == nil {
t.Fatal("store should not be nil after initializeNoDbMode")
}
// ensureStoreActive should now return immediately without error
if err := ensureStoreActive(); err != nil {
t.Errorf("ensureStoreActive should succeed after initializeNoDbMode: %v", err)
}
// Verify comments work (this was the failing case)
ctx := rootCtx
comment, err := s.AddIssueComment(ctx, "bd-1", "testuser", "Test comment")
if err != nil {
t.Fatalf("AddIssueComment failed: %v", err)
}
if comment.Text != "Test comment" {
t.Errorf("Expected 'Test comment', got %s", comment.Text)
}
comments, err := s.GetIssueComments(ctx, "bd-1")
if err != nil {
t.Fatalf("GetIssueComments failed: %v", err)
}
if len(comments) != 1 {
t.Errorf("Expected 1 comment, got %d", len(comments))
}
}
func TestWriteIssuesToJSONL(t *testing.T) { func TestWriteIssuesToJSONL(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
beadsDir := filepath.Join(tempDir, ".beads") beadsDir := filepath.Join(tempDir, ".beads")
+4 -4
View File
@@ -32,9 +32,9 @@ Use pour for:
- Anything you might need to reference later - Anything you might need to reference later
Examples: Examples:
bd pour mol-feature --var name=auth # Create persistent mol from proto bd mol pour mol-feature --var name=auth # Create persistent mol from proto
bd pour mol-release --var version=1.0 # Release workflow bd mol pour mol-release --var version=1.0 # Release workflow
bd pour mol-review --var pr=123 # Code review workflow`, bd mol pour mol-review --var pr=123 # Code review workflow`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
Run: runPour, Run: runPour,
} }
@@ -260,5 +260,5 @@ func init() {
pourCmd.Flags().StringSlice("attach", []string{}, "Proto to attach after spawning (repeatable)") 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") pourCmd.Flags().String("attach-type", types.BondTypeSequential, "Bond type for attachments: sequential, parallel, or conditional")
rootCmd.AddCommand(pourCmd) molCmd.AddCommand(pourCmd)
} }
+10 -10
View File
@@ -305,20 +305,20 @@ var showCmd = &cobra.Command{
fmt.Printf(" ◊ %s: %s [P%d - %s]\n", dep.ID, dep.Title, dep.Priority, dep.Status) fmt.Printf(" ◊ %s: %s [P%d - %s]\n", dep.ID, dep.Title, dep.Priority, dep.Status)
} }
} }
}
if len(details.Comments) > 0 { if len(details.Comments) > 0 {
fmt.Printf("\nComments (%d):\n", len(details.Comments)) fmt.Printf("\nComments (%d):\n", len(details.Comments))
for _, comment := range details.Comments { for _, comment := range details.Comments {
fmt.Printf(" [%s] %s\n", comment.Author, comment.CreatedAt.Format("2006-01-02 15:04")) fmt.Printf(" [%s] %s\n", comment.Author, comment.CreatedAt.Format("2006-01-02 15:04"))
commentLines := strings.Split(comment.Text, "\n") commentLines := strings.Split(comment.Text, "\n")
for _, line := range commentLines { for _, line := range commentLines {
fmt.Printf(" %s\n", line) fmt.Printf(" %s\n", line)
}
} }
} }
} }
fmt.Println() fmt.Println()
} }
} }
+1 -1
View File
@@ -65,7 +65,7 @@ func exportToJSONL(ctx context.Context, jsonlPath string) error {
// This prevents "zombie" issues that resurrect after mol squash deletes them. // This prevents "zombie" issues that resurrect after mol squash deletes them.
filteredIssues := make([]*types.Issue, 0, len(issues)) filteredIssues := make([]*types.Issue, 0, len(issues))
for _, issue := range issues { for _, issue := range issues {
if issue.Wisp { if issue.Ephemeral {
continue continue
} }
filteredIssues = append(filteredIssues, issue) filteredIssues = append(filteredIssues, issue)
+7 -7
View File
@@ -42,10 +42,10 @@ type InstantiateResult struct {
// CloneOptions controls how the subgraph is cloned during spawn/bond // CloneOptions controls how the subgraph is cloned during spawn/bond
type CloneOptions struct { type CloneOptions struct {
Vars map[string]string // Variable substitutions for {{key}} placeholders Vars map[string]string // Variable substitutions for {{key}} placeholders
Assignee string // Assign the root epic to this agent/user Assignee string // Assign the root epic to this agent/user
Actor string // Actor performing the operation Actor string // Actor performing the operation
Wisp bool // If true, spawned issues are marked for bulk deletion Ephemeral bool // If true, spawned issues are marked for bulk deletion
Prefix string // Override prefix for ID generation (bd-hobo: distinct prefixes) Prefix string // Override prefix for ID generation (bd-hobo: distinct prefixes)
// Dynamic bonding fields (for Christmas Ornament pattern) // Dynamic bonding fields (for Christmas Ornament pattern)
@@ -327,7 +327,7 @@ Example:
Vars: vars, Vars: vars,
Assignee: assignee, Assignee: assignee,
Actor: actor, Actor: actor,
Wisp: false, Ephemeral: false,
} }
var result *InstantiateResult var result *InstantiateResult
if daemonClient != nil { if daemonClient != nil {
@@ -713,7 +713,7 @@ func cloneSubgraphViaDaemon(client *rpc.Client, subgraph *TemplateSubgraph, opts
AcceptanceCriteria: substituteVariables(oldIssue.AcceptanceCriteria, opts.Vars), AcceptanceCriteria: substituteVariables(oldIssue.AcceptanceCriteria, opts.Vars),
Assignee: issueAssignee, Assignee: issueAssignee,
EstimatedMinutes: oldIssue.EstimatedMinutes, EstimatedMinutes: oldIssue.EstimatedMinutes,
Wisp: opts.Wisp, Ephemeral: opts.Ephemeral,
IDPrefix: opts.Prefix, // bd-hobo: distinct prefixes for mols/wisps IDPrefix: opts.Prefix, // bd-hobo: distinct prefixes for mols/wisps
} }
@@ -960,7 +960,7 @@ func cloneSubgraph(ctx context.Context, s storage.Storage, subgraph *TemplateSub
IssueType: oldIssue.IssueType, IssueType: oldIssue.IssueType,
Assignee: issueAssignee, Assignee: issueAssignee,
EstimatedMinutes: oldIssue.EstimatedMinutes, EstimatedMinutes: oldIssue.EstimatedMinutes,
Wisp: opts.Wisp, // bd-2vh3: mark for cleanup when closed Ephemeral: opts.Ephemeral, // bd-2vh3: mark for cleanup when closed
IDPrefix: opts.Prefix, // bd-hobo: distinct prefixes for mols/wisps IDPrefix: opts.Prefix, // bd-hobo: distinct prefixes for mols/wisps
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
+9 -9
View File
@@ -27,7 +27,7 @@ func TestThreadTraversal(t *testing.T) {
IssueType: types.TypeMessage, IssueType: types.TypeMessage,
Assignee: "worker", Assignee: "worker",
Sender: "manager", Sender: "manager",
Wisp: true, Ephemeral: true,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
@@ -43,7 +43,7 @@ func TestThreadTraversal(t *testing.T) {
IssueType: types.TypeMessage, IssueType: types.TypeMessage,
Assignee: "manager", Assignee: "manager",
Sender: "worker", Sender: "worker",
Wisp: true, Ephemeral: true,
CreatedAt: now.Add(time.Minute), CreatedAt: now.Add(time.Minute),
UpdatedAt: now.Add(time.Minute), UpdatedAt: now.Add(time.Minute),
} }
@@ -59,7 +59,7 @@ func TestThreadTraversal(t *testing.T) {
IssueType: types.TypeMessage, IssueType: types.TypeMessage,
Assignee: "worker", Assignee: "worker",
Sender: "manager", Sender: "manager",
Wisp: true, Ephemeral: true,
CreatedAt: now.Add(2 * time.Minute), CreatedAt: now.Add(2 * time.Minute),
UpdatedAt: now.Add(2 * time.Minute), UpdatedAt: now.Add(2 * time.Minute),
} }
@@ -190,7 +190,7 @@ func TestThreadTraversalEmptyThread(t *testing.T) {
IssueType: types.TypeMessage, IssueType: types.TypeMessage,
Assignee: "user", Assignee: "user",
Sender: "sender", Sender: "sender",
Wisp: true, Ephemeral: true,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
@@ -228,7 +228,7 @@ func TestThreadTraversalBranching(t *testing.T) {
IssueType: types.TypeMessage, IssueType: types.TypeMessage,
Assignee: "user", Assignee: "user",
Sender: "sender", Sender: "sender",
Wisp: true, Ephemeral: true,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
@@ -245,7 +245,7 @@ func TestThreadTraversalBranching(t *testing.T) {
IssueType: types.TypeMessage, IssueType: types.TypeMessage,
Assignee: "sender", Assignee: "sender",
Sender: "user", Sender: "user",
Wisp: true, Ephemeral: true,
CreatedAt: now.Add(time.Minute), CreatedAt: now.Add(time.Minute),
UpdatedAt: now.Add(time.Minute), UpdatedAt: now.Add(time.Minute),
} }
@@ -261,7 +261,7 @@ func TestThreadTraversalBranching(t *testing.T) {
IssueType: types.TypeMessage, IssueType: types.TypeMessage,
Assignee: "sender", Assignee: "sender",
Sender: "another-user", Sender: "another-user",
Wisp: true, Ephemeral: true,
CreatedAt: now.Add(2 * time.Minute), CreatedAt: now.Add(2 * time.Minute),
UpdatedAt: now.Add(2 * time.Minute), UpdatedAt: now.Add(2 * time.Minute),
} }
@@ -364,7 +364,7 @@ func TestThreadTraversalOnlyRepliesTo(t *testing.T) {
IssueType: types.TypeMessage, IssueType: types.TypeMessage,
Assignee: "user", Assignee: "user",
Sender: "sender", Sender: "sender",
Wisp: true, Ephemeral: true,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
@@ -380,7 +380,7 @@ func TestThreadTraversalOnlyRepliesTo(t *testing.T) {
IssueType: types.TypeMessage, IssueType: types.TypeMessage,
Assignee: "user", Assignee: "user",
Sender: "sender", Sender: "sender",
Wisp: true, Ephemeral: true,
CreatedAt: now.Add(time.Minute), CreatedAt: now.Add(time.Minute),
UpdatedAt: now.Add(time.Minute), UpdatedAt: now.Add(time.Minute),
} }
+73 -47
View File
@@ -18,33 +18,43 @@ import (
// Wisp commands - manage ephemeral molecules // Wisp commands - manage ephemeral molecules
// //
// Wisps are ephemeral issues with Wisp=true in the main database. // Wisps are ephemeral issues with Ephemeral=true in the main database.
// They're used for patrol cycles and operational loops that shouldn't // They're used for patrol cycles and operational loops that shouldn't
// be exported to JSONL (and thus not synced via git). // be exported to JSONL (and thus not synced via git).
// //
// Commands: // Commands:
// bd wisp list - List all wisps in current context // bd mol wisp list - List all wisps in current context
// bd wisp gc - Garbage collect orphaned wisps // bd mol wisp gc - Garbage collect orphaned wisps
var wispCmd = &cobra.Command{ var wispCmd = &cobra.Command{
Use: "wisp", Use: "wisp [proto-id]",
Short: "Manage ephemeral molecules (wisps)", Short: "Create or manage wisps (ephemeral molecules)",
Long: `Manage wisps - ephemeral molecules for operational workflows. Long: `Create or manage wisps - ephemeral molecules for operational workflows.
Wisps are issues with Wisp=true in the main database. They're stored When called with a proto-id argument, creates a wisp from that proto.
When called with a subcommand (list, gc), manages existing wisps.
Wisps are issues with Ephemeral=true in the main database. They're stored
locally but NOT exported to JSONL (and thus not synced via git). locally but NOT exported to JSONL (and thus not synced via git).
They're used for patrol cycles, operational loops, and other workflows They're used for patrol cycles, operational loops, and other workflows
that shouldn't accumulate in the shared issue database. that shouldn't accumulate in the shared issue database.
The wisp lifecycle: The wisp lifecycle:
1. Create: bd wisp create <proto> or bd create --wisp 1. Create: bd mol wisp <proto> or bd create --ephemeral
2. Execute: Normal bd operations work on wisps 2. Execute: Normal bd operations work on wisp issues
3. Squash: bd mol squash <id> (clears Wisp flag, promotes to persistent) 3. Squash: bd mol squash <id> (clears Ephemeral flag, promotes to persistent)
4. Or burn: bd mol burn <id> (deletes wisp without creating digest) 4. Or burn: bd mol burn <id> (deletes without creating digest)
Commands: Examples:
bd mol wisp mol-patrol # Create wisp from proto
bd mol wisp list # List all wisps
bd mol wisp gc # Garbage collect old wisps
Subcommands:
list List all wisps in current context list List all wisps in current context
gc Garbage collect orphaned wisps`, gc Garbage collect orphaned wisps`,
Args: cobra.MaximumNArgs(1),
Run: runWisp,
} }
// WispListItem represents a wisp in list output // WispListItem represents a wisp in list output
@@ -68,32 +78,44 @@ type WispListResult struct {
// OldThreshold is how old a wisp must be to be flagged as old (time-based, for ephemeral cleanup) // OldThreshold is how old a wisp must be to be flagged as old (time-based, for ephemeral cleanup)
const OldThreshold = 24 * time.Hour const OldThreshold = 24 * time.Hour
// wispCreateCmd instantiates a proto as an ephemeral wisp // runWisp handles the wisp command when called directly with a proto-id
// It delegates to runWispCreate for the actual work
func runWisp(cmd *cobra.Command, args []string) {
if len(args) == 0 {
// No proto-id provided, show help
cmd.Help()
return
}
// Delegate to the create logic
runWispCreate(cmd, args)
}
// wispCreateCmd instantiates a proto as an ephemeral wisp (kept for backwards compat)
var wispCreateCmd = &cobra.Command{ var wispCreateCmd = &cobra.Command{
Use: "create <proto-id>", Use: "create <proto-id>",
Short: "Instantiate a proto as an ephemeral wisp (solid -> vapor)", Short: "Instantiate a proto as a wisp (solid -> vapor)",
Long: `Create a wisp from a proto - sublimation from solid to 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. This is the chemistry-inspired command for creating ephemeral work from templates.
The resulting wisp is stored in the main database with Wisp=true and NOT exported to JSONL. The resulting wisp is stored in the main database with Ephemeral=true and NOT exported to JSONL.
Phase transition: Proto (solid) -> Wisp (vapor) Phase transition: Proto (solid) -> Wisp (vapor)
Use wisp create for: Use wisp for:
- Patrol cycles (deacon, witness) - Patrol cycles (deacon, witness)
- Health checks and monitoring - Health checks and monitoring
- One-shot orchestration runs - One-shot orchestration runs
- Routine operations with no audit value - Routine operations with no audit value
The wisp will: The wisp will:
- Be stored in main database with Wisp=true flag - Be stored in main database with Ephemeral=true flag
- NOT be exported to JSONL (and thus not synced via git) - NOT be exported to JSONL (and thus not synced via git)
- Either evaporate (burn) or condense to digest (squash) - Either evaporate (burn) or condense to digest (squash)
Examples: Examples:
bd wisp create mol-patrol # Ephemeral patrol cycle bd mol wisp create mol-patrol # Ephemeral patrol cycle
bd wisp create mol-health-check # One-time health check bd mol wisp create mol-health-check # One-time health check
bd wisp create mol-diagnostics --var target=db # Diagnostic run`, bd mol wisp create mol-diagnostics --var target=db # Diagnostic run`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
Run: runWispCreate, Run: runWispCreate,
} }
@@ -107,7 +129,7 @@ func runWispCreate(cmd *cobra.Command, args []string) {
if store == nil { if store == nil {
if daemonClient != nil { if daemonClient != nil {
fmt.Fprintf(os.Stderr, "Error: wisp create requires direct database access\n") 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]) fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol wisp %s ...\n", args[0])
} else { } else {
fmt.Fprintf(os.Stderr, "Error: no database connection\n") fmt.Fprintf(os.Stderr, "Error: no database connection\n")
} }
@@ -215,7 +237,7 @@ func runWispCreate(cmd *cobra.Command, args []string) {
if dryRun { if dryRun {
fmt.Printf("\nDry run: would create wisp with %d issues from proto %s\n\n", len(subgraph.Issues), protoID) fmt.Printf("\nDry run: would create wisp with %d issues from proto %s\n\n", len(subgraph.Issues), protoID)
fmt.Printf("Storage: main database (wisp=true, not exported to JSONL)\n\n") fmt.Printf("Storage: main database (ephemeral=true, not exported to JSONL)\n\n")
for _, issue := range subgraph.Issues { for _, issue := range subgraph.Issues {
newTitle := substituteVariables(issue.Title, vars) newTitle := substituteVariables(issue.Title, vars)
fmt.Printf(" - %s (from %s)\n", newTitle, issue.ID) fmt.Printf(" - %s (from %s)\n", newTitle, issue.ID)
@@ -223,15 +245,15 @@ func runWispCreate(cmd *cobra.Command, args []string) {
return return
} }
// Spawn as wisp in main database (ephemeral=true sets Wisp flag, skips JSONL export) // Spawn as ephemeral in main database (Ephemeral=true, skips JSONL export)
// bd-hobo: Use "wisp" prefix for distinct visual recognition // bd-hobo: Use "eph" prefix for distinct visual recognition
result, err := spawnMolecule(ctx, store, subgraph, vars, "", actor, true, "wisp") result, err := spawnMolecule(ctx, store, subgraph, vars, "", actor, true, "eph")
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error creating wisp: %v\n", err) fmt.Fprintf(os.Stderr, "Error creating wisp: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Wisps are in main db but don't trigger JSONL export (Wisp flag excludes them) // Wisp issues are in main db but don't trigger JSONL export (Ephemeral flag excludes them)
if jsonOutput { if jsonOutput {
type wispCreateResult struct { type wispCreateResult struct {
@@ -286,9 +308,9 @@ func resolvePartialIDDirect(ctx context.Context, partial string) (string, error)
var wispListCmd = &cobra.Command{ var wispListCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List all wisps in current context", Short: "List all wisps in current context",
Long: `List all ephemeral molecules (wisps) in the current context. Long: `List all wisps (ephemeral molecules) in the current context.
Wisps are issues with Wisp=true in the main database. They are stored Wisps are issues with Ephemeral=true in the main database. They are stored
locally but not exported to JSONL (and thus not synced via git). locally but not exported to JSONL (and thus not synced via git).
The list shows: The list shows:
@@ -300,12 +322,12 @@ The list shows:
Old wisp detection: Old wisp detection:
- Old wisps haven't been updated in 24+ hours - Old wisps haven't been updated in 24+ hours
- Use 'bd wisp gc' to clean up old/abandoned wisps - Use 'bd mol wisp gc' to clean up old/abandoned wisps
Examples: Examples:
bd wisp list # List all wisps bd mol wisp list # List all wisps
bd wisp list --json # JSON output for programmatic use bd mol wisp list --json # JSON output for programmatic use
bd wisp list --all # Include closed wisps`, bd mol wisp list --all # Include closed wisps`,
Run: runWispList, Run: runWispList,
} }
@@ -327,15 +349,15 @@ func runWispList(cmd *cobra.Command, args []string) {
return return
} }
// Query wisps from main database using Wisp filter // Query wisps from main database using Ephemeral filter
wispFlag := true ephemeralFlag := true
var issues []*types.Issue var issues []*types.Issue
var err error var err error
if daemonClient != nil { if daemonClient != nil {
// Use daemon RPC // Use daemon RPC
resp, rpcErr := daemonClient.List(&rpc.ListArgs{ resp, rpcErr := daemonClient.List(&rpc.ListArgs{
Wisp: &wispFlag, Ephemeral: &ephemeralFlag,
}) })
if rpcErr != nil { if rpcErr != nil {
err = rpcErr err = rpcErr
@@ -347,7 +369,7 @@ func runWispList(cmd *cobra.Command, args []string) {
} else { } else {
// Direct database access // Direct database access
filter := types.IssueFilter{ filter := types.IssueFilter{
Wisp: &wispFlag, Ephemeral: &ephemeralFlag,
} }
issues, err = store.SearchIssues(ctx, "", filter) issues, err = store.SearchIssues(ctx, "", filter)
} }
@@ -444,7 +466,7 @@ func runWispList(cmd *cobra.Command, args []string) {
if oldCount > 0 { if oldCount > 0 {
fmt.Printf("\n%s %d old wisp(s) (not updated in 24+ hours)\n", fmt.Printf("\n%s %d old wisp(s) (not updated in 24+ hours)\n",
ui.RenderWarn("⚠"), oldCount) ui.RenderWarn("⚠"), oldCount)
fmt.Println(" Hint: Use 'bd wisp gc' to clean up old wisps") fmt.Println(" Hint: Use 'bd mol wisp gc' to clean up old wisps")
} }
} }
@@ -493,10 +515,10 @@ Note: This uses time-based cleanup, appropriate for ephemeral wisps.
For graph-pressure staleness detection (blocking other work), see 'bd mol stale'. For graph-pressure staleness detection (blocking other work), see 'bd mol stale'.
Examples: Examples:
bd wisp gc # Clean abandoned wisps (default: 1h threshold) bd mol wisp gc # Clean abandoned wisps (default: 1h threshold)
bd wisp gc --dry-run # Preview what would be cleaned bd mol wisp gc --dry-run # Preview what would be cleaned
bd wisp gc --age 24h # Custom age threshold bd mol wisp gc --age 24h # Custom age threshold
bd wisp gc --all # Also clean closed wisps older than threshold`, bd mol wisp gc --all # Also clean closed wisps older than threshold`,
Run: runWispGC, Run: runWispGC,
} }
@@ -532,17 +554,17 @@ func runWispGC(cmd *cobra.Command, args []string) {
if store == nil { if store == nil {
if daemonClient != nil { if daemonClient != nil {
fmt.Fprintf(os.Stderr, "Error: wisp gc requires direct database access\n") fmt.Fprintf(os.Stderr, "Error: wisp gc requires direct database access\n")
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon wisp gc\n") fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol wisp gc\n")
} else { } else {
fmt.Fprintf(os.Stderr, "Error: no database connection\n") fmt.Fprintf(os.Stderr, "Error: no database connection\n")
} }
os.Exit(1) os.Exit(1)
} }
// Query wisps from main database using Wisp filter // Query wisps from main database using Ephemeral filter
wispFlag := true ephemeralFlag := true
filter := types.IssueFilter{ filter := types.IssueFilter{
Wisp: &wispFlag, Ephemeral: &ephemeralFlag,
} }
issues, err := store.SearchIssues(ctx, "", filter) issues, err := store.SearchIssues(ctx, "", filter)
if err != nil { if err != nil {
@@ -634,7 +656,11 @@ func runWispGC(cmd *cobra.Command, args []string) {
} }
func init() { func init() {
// Wisp create command flags // Wisp command flags (for direct create: bd mol wisp <proto>)
wispCmd.Flags().StringSlice("var", []string{}, "Variable substitution (key=value)")
wispCmd.Flags().Bool("dry-run", false, "Preview what would be created")
// Wisp create command flags (kept for backwards compat: bd mol wisp create <proto>)
wispCreateCmd.Flags().StringSlice("var", []string{}, "Variable substitution (key=value)") wispCreateCmd.Flags().StringSlice("var", []string{}, "Variable substitution (key=value)")
wispCreateCmd.Flags().Bool("dry-run", false, "Preview what would be created") wispCreateCmd.Flags().Bool("dry-run", false, "Preview what would be created")
@@ -647,5 +673,5 @@ func init() {
wispCmd.AddCommand(wispCreateCmd) wispCmd.AddCommand(wispCreateCmd)
wispCmd.AddCommand(wispListCmd) wispCmd.AddCommand(wispListCmd)
wispCmd.AddCommand(wispGCCmd) wispCmd.AddCommand(wispGCCmd)
rootCmd.AddCommand(wispCmd) molCmd.AddCommand(wispCmd)
} }
+1 -1
View File
@@ -275,7 +275,7 @@ open ──▶ in_progress ──▶ closed
``` ```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ bd wisp create │───▶│ Wisp Issues │───▶│ bd mol squash │ bd mol wisp │───▶│ Wisp Issues │───▶│ bd mol squash │
│ (from template) │ │ (local-only) │ │ (→ digest) │ │ (from template) │ │ (local-only) │ │ (→ digest) │
└─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘
``` ```
+19 -19
View File
@@ -350,8 +350,8 @@ Beads uses a chemistry metaphor for template-based workflows. See [MOLECULES.md]
| Phase | State | Storage | Command | | Phase | State | Storage | Command |
|-------|-------|---------|---------| |-------|-------|---------|---------|
| Solid | Proto | `.beads/` | `bd mol catalog` | | Solid | Proto | `.beads/` | `bd mol catalog` |
| Liquid | Mol | `.beads/` | `bd pour` | | Liquid | Mol | `.beads/` | `bd mol pour` |
| Vapor | Wisp | `.beads/` (Wisp=true, not exported) | `bd wisp create` | | Vapor | Wisp | `.beads/` (Ephemeral=true, not exported) | `bd mol wisp` |
### Proto/Template Commands ### Proto/Template Commands
@@ -370,32 +370,32 @@ bd mol distill <epic-id> --json
```bash ```bash
# Instantiate proto as persistent mol (solid → liquid) # Instantiate proto as persistent mol (solid → liquid)
bd pour <proto-id> --var key=value --json bd mol pour <proto-id> --var key=value --json
# Preview what would be created # Preview what would be created
bd pour <proto-id> --var key=value --dry-run bd mol pour <proto-id> --var key=value --dry-run
# Assign root issue # Assign root issue
bd pour <proto-id> --var key=value --assignee alice --json bd mol pour <proto-id> --var key=value --assignee alice --json
# Attach additional protos during pour # Attach additional protos during pour
bd pour <proto-id> --attach <other-proto> --json bd mol pour <proto-id> --attach <other-proto> --json
``` ```
### Wisp Commands ### Wisp Commands
```bash ```bash
# Instantiate proto as ephemeral wisp (solid → vapor) # Instantiate proto as ephemeral wisp (solid → vapor)
bd wisp create <proto-id> --var key=value --json bd mol wisp <proto-id> --var key=value --json
# List all wisps # List all wisps
bd wisp list --json bd mol wisp list --json
bd wisp list --all --json # Include closed bd mol wisp list --all --json # Include closed
# Garbage collect orphaned wisps # Garbage collect orphaned wisps
bd wisp gc --json bd mol wisp gc --json
bd wisp gc --age 24h --json # Custom age threshold bd mol wisp gc --age 24h --json # Custom age threshold
bd wisp gc --dry-run # Preview what would be cleaned bd mol wisp gc --dry-run # Preview what would be cleaned
``` ```
### Bonding (Combining Work) ### Bonding (Combining Work)
@@ -424,29 +424,29 @@ bd mol bond <A> <B> --dry-run
```bash ```bash
# Compress wisp to permanent digest # Compress wisp to permanent digest
bd mol squash <wisp-id> --json bd mol squash <ephemeral-id> --json
# With agent-provided summary # With agent-provided summary
bd mol squash <wisp-id> --summary "Work completed" --json bd mol squash <ephemeral-id> --summary "Work completed" --json
# Preview # Preview
bd mol squash <wisp-id> --dry-run bd mol squash <ephemeral-id> --dry-run
# Keep wisp children after squash # Keep wisp children after squash
bd mol squash <wisp-id> --keep-children --json bd mol squash <ephemeral-id> --keep-children --json
``` ```
### Burn (Discard Wisp) ### Burn (Discard Wisp)
```bash ```bash
# Delete wisp without digest (destructive) # Delete wisp without digest (destructive)
bd mol burn <wisp-id> --json bd mol burn <ephemeral-id> --json
# Preview # Preview
bd mol burn <wisp-id> --dry-run bd mol burn <ephemeral-id> --dry-run
# Skip confirmation # Skip confirmation
bd mol burn <wisp-id> --force --json bd mol burn <ephemeral-id> --force --json
``` ```
**Note:** Most mol commands require `--no-daemon` flag when daemon is running. **Note:** Most mol commands require `--no-daemon` flag when daemon is running.
+1 -1
View File
@@ -202,7 +202,7 @@ The 1-hour grace period ensures tombstones propagate even with minor clock drift
## Wisps: Intentional Tombstone Bypass ## Wisps: Intentional Tombstone Bypass
**Wisps** (ephemeral issues created by `bd wisp create`) are intentionally excluded from tombstone tracking. **Wisps** (ephemeral issues created by `bd mol wisp`) are intentionally excluded from tombstone tracking.
### Why Wisps Don't Need Tombstones ### Why Wisps Don't Need Tombstones
+8 -8
View File
@@ -128,8 +128,8 @@ For reusable workflows, beads uses a chemistry metaphor:
### Phase Commands ### Phase Commands
```bash ```bash
bd pour <proto> # Proto → Mol (persistent instance) bd mol pour <proto> # Proto → Mol (persistent instance)
bd wisp create <proto> # Proto → Wisp (ephemeral instance) bd mol wisp <proto> # Proto → Wisp (ephemeral instance)
bd mol squash <id> # Mol/Wisp → Digest (permanent record) bd mol squash <id> # Mol/Wisp → Digest (permanent record)
bd mol burn <id> # Wisp → nothing (discard) bd mol burn <id> # Wisp → nothing (discard)
``` ```
@@ -227,10 +227,10 @@ bd close <id> --reason "Done"
Wisps accumulate if not squashed/burned: Wisps accumulate if not squashed/burned:
```bash ```bash
bd wisp list # Check for orphans bd mol wisp list # Check for orphans
bd mol squash <id> # Create digest bd mol squash <id> # Create digest
bd mol burn <id> # Or discard bd mol burn <id> # Or discard
bd wisp gc # Garbage collect old wisps bd mol wisp gc # Garbage collect old wisps
``` ```
## Layer Cake Architecture ## Layer Cake Architecture
@@ -272,8 +272,8 @@ bd dep tree <id> # Show dependency tree
### Molecules ### Molecules
```bash ```bash
bd pour <proto> --var k=v # Template → persistent mol bd mol pour <proto> --var k=v # Template → persistent mol
bd wisp create <proto> # Template → ephemeral wisp bd mol wisp <proto> # Template → ephemeral wisp
bd mol bond A B # Connect work graphs bd mol bond A B # Connect work graphs
bd mol squash <id> # Compress to digest bd mol squash <id> # Compress to digest
bd mol burn <id> # Discard without record bd mol burn <id> # Discard without record
+43
View File
@@ -67,6 +67,49 @@ func ExtractPrefix(id string) string {
return id[:idx+1] // Include the hyphen return id[:idx+1] // Include the hyphen
} }
// ExtractProjectFromPath extracts the project name from a route path.
// For "beads/mayor/rig", returns "beads".
// For "gastown/crew/max", returns "gastown".
func ExtractProjectFromPath(path string) string {
// Get the first component of the path
parts := strings.Split(path, "/")
if len(parts) > 0 && parts[0] != "" {
return parts[0]
}
return ""
}
// ResolveToExternalRef attempts to convert a foreign issue ID to an external reference
// using routes.jsonl for prefix-based routing.
//
// If the ID's prefix matches a route, returns "external:<project>:<id>".
// Otherwise, returns empty string (no route found).
//
// Example: If routes.jsonl has {"prefix": "bd-", "path": "beads/mayor/rig"}
// then ResolveToExternalRef("bd-abc", beadsDir) returns "external:beads:bd-abc"
func ResolveToExternalRef(id, beadsDir string) string {
routes, err := LoadRoutes(beadsDir)
if err != nil || len(routes) == 0 {
return ""
}
prefix := ExtractPrefix(id)
if prefix == "" {
return ""
}
for _, route := range routes {
if route.Prefix == prefix {
project := ExtractProjectFromPath(route.Path)
if project != "" {
return fmt.Sprintf("external:%s:%s", project, id)
}
}
}
return ""
}
// ResolveBeadsDirForID determines which beads directory contains the given issue ID. // ResolveBeadsDirForID determines which beads directory contains the given issue ID.
// It first checks the local beads directory, then consults routes.jsonl for prefix-based routing. // It first checks the local beads directory, then consults routes.jsonl for prefix-based routing.
// //
+54
View File
@@ -88,3 +88,57 @@ func TestDetectUserRole_Fallback(t *testing.T) {
t.Errorf("DetectUserRole() = %v, want %v (fallback)", role, Contributor) t.Errorf("DetectUserRole() = %v, want %v (fallback)", role, Contributor)
} }
} }
func TestExtractPrefix(t *testing.T) {
tests := []struct {
id string
want string
}{
{"gt-abc123", "gt-"},
{"bd-xyz", "bd-"},
{"hq-1234", "hq-"},
{"abc123", ""}, // No hyphen
{"", ""}, // Empty string
{"-abc", "-"}, // Starts with hyphen
}
for _, tt := range tests {
t.Run(tt.id, func(t *testing.T) {
got := ExtractPrefix(tt.id)
if got != tt.want {
t.Errorf("ExtractPrefix(%q) = %q, want %q", tt.id, got, tt.want)
}
})
}
}
func TestExtractProjectFromPath(t *testing.T) {
tests := []struct {
path string
want string
}{
{"beads/mayor/rig", "beads"},
{"gastown/crew/max", "gastown"},
{"simple", "simple"},
{"", ""},
{"/absolute/path", ""}, // Starts with /, first component is empty
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
got := ExtractProjectFromPath(tt.path)
if got != tt.want {
t.Errorf("ExtractProjectFromPath(%q) = %q, want %q", tt.path, got, tt.want)
}
})
}
}
func TestResolveToExternalRef(t *testing.T) {
// This test is limited since it requires a routes.jsonl file
// Just test that it returns empty string for nonexistent directory
got := ResolveToExternalRef("bd-abc", "/nonexistent/path")
if got != "" {
t.Errorf("ResolveToExternalRef() = %q, want empty string for nonexistent path", got)
}
}
+7 -7
View File
@@ -89,11 +89,11 @@ type CreateArgs struct {
WaitsFor string `json:"waits_for,omitempty"` // Spawner issue ID to wait for WaitsFor string `json:"waits_for,omitempty"` // Spawner issue ID to wait for
WaitsForGate string `json:"waits_for_gate,omitempty"` // Gate type: all-children or any-children WaitsForGate string `json:"waits_for_gate,omitempty"` // Gate type: all-children or any-children
// Messaging fields (bd-kwro) // Messaging fields (bd-kwro)
Sender string `json:"sender,omitempty"` // Who sent this (for messages) Sender string `json:"sender,omitempty"` // Who sent this (for messages)
Wisp bool `json:"wisp,omitempty"` // Wisp = ephemeral vapor from the Steam Engine; bulk-deleted when closed Ephemeral bool `json:"ephemeral,omitempty"` // If true, not exported to JSONL; bulk-deleted when closed
RepliesTo string `json:"replies_to,omitempty"` // Issue ID for conversation threading RepliesTo string `json:"replies_to,omitempty"` // Issue ID for conversation threading
// ID generation (bd-hobo) // ID generation (bd-hobo)
IDPrefix string `json:"id_prefix,omitempty"` // Override prefix for ID generation (mol, wisp, etc.) IDPrefix string `json:"id_prefix,omitempty"` // Override prefix for ID generation (mol, eph, etc.)
CreatedBy string `json:"created_by,omitempty"` // Who created the issue CreatedBy string `json:"created_by,omitempty"` // Who created the issue
} }
@@ -115,8 +115,8 @@ type UpdateArgs struct {
RemoveLabels []string `json:"remove_labels,omitempty"` RemoveLabels []string `json:"remove_labels,omitempty"`
SetLabels []string `json:"set_labels,omitempty"` SetLabels []string `json:"set_labels,omitempty"`
// Messaging fields (bd-kwro) // Messaging fields (bd-kwro)
Sender *string `json:"sender,omitempty"` // Who sent this (for messages) Sender *string `json:"sender,omitempty"` // Who sent this (for messages)
Wisp *bool `json:"wisp,omitempty"` // Wisp = ephemeral vapor from the Steam Engine; bulk-deleted when closed Ephemeral *bool `json:"ephemeral,omitempty"` // If true, not exported to JSONL; bulk-deleted when closed
RepliesTo *string `json:"replies_to,omitempty"` // Issue ID for conversation threading RepliesTo *string `json:"replies_to,omitempty"` // Issue ID for conversation threading
// Graph link fields (bd-fu83) // Graph link fields (bd-fu83)
RelatesTo *string `json:"relates_to,omitempty"` // JSON array of related issue IDs RelatesTo *string `json:"relates_to,omitempty"` // JSON array of related issue IDs
@@ -193,8 +193,8 @@ type ListArgs struct {
// Parent filtering (bd-yqhh) // Parent filtering (bd-yqhh)
ParentID string `json:"parent_id,omitempty"` ParentID string `json:"parent_id,omitempty"`
// Wisp filtering (bd-bkul) // Ephemeral filtering (bd-bkul)
Wisp *bool `json:"wisp,omitempty"` Ephemeral *bool `json:"ephemeral,omitempty"`
} }
// CountArgs represents arguments for the count operation // CountArgs represents arguments for the count operation
+7 -7
View File
@@ -81,8 +81,8 @@ func updatesFromArgs(a UpdateArgs) map[string]interface{} {
if a.Sender != nil { if a.Sender != nil {
u["sender"] = *a.Sender u["sender"] = *a.Sender
} }
if a.Wisp != nil { if a.Ephemeral != nil {
u["wisp"] = *a.Wisp u["ephemeral"] = *a.Ephemeral
} }
if a.RepliesTo != nil { if a.RepliesTo != nil {
u["replies_to"] = *a.RepliesTo u["replies_to"] = *a.RepliesTo
@@ -176,8 +176,8 @@ func (s *Server) handleCreate(req *Request) Response {
EstimatedMinutes: createArgs.EstimatedMinutes, EstimatedMinutes: createArgs.EstimatedMinutes,
Status: types.StatusOpen, Status: types.StatusOpen,
// Messaging fields (bd-kwro) // Messaging fields (bd-kwro)
Sender: createArgs.Sender, Sender: createArgs.Sender,
Wisp: createArgs.Wisp, Ephemeral: createArgs.Ephemeral,
// NOTE: RepliesTo now handled via replies-to dependency (Decision 004) // NOTE: RepliesTo now handled via replies-to dependency (Decision 004)
// ID generation (bd-hobo) // ID generation (bd-hobo)
IDPrefix: createArgs.IDPrefix, IDPrefix: createArgs.IDPrefix,
@@ -844,8 +844,8 @@ func (s *Server) handleList(req *Request) Response {
filter.ParentID = &listArgs.ParentID filter.ParentID = &listArgs.ParentID
} }
// Wisp filtering (bd-bkul) // Ephemeral filtering (bd-bkul)
filter.Wisp = listArgs.Wisp filter.Ephemeral = listArgs.Ephemeral
// Guard against excessive ID lists to avoid SQLite parameter limits // Guard against excessive ID lists to avoid SQLite parameter limits
const maxIDs = 1000 const maxIDs = 1000
@@ -1480,7 +1480,7 @@ func (s *Server) handleGateCreate(req *Request) Response {
Status: types.StatusOpen, Status: types.StatusOpen,
Priority: 1, // Gates are typically high priority Priority: 1, // Gates are typically high priority
Assignee: "deacon/", Assignee: "deacon/",
Wisp: true, // Gates are wisps (ephemeral) Ephemeral: true, // Gates are wisps (ephemeral)
AwaitType: args.AwaitType, AwaitType: args.AwaitType,
AwaitID: args.AwaitID, AwaitID: args.AwaitID,
Timeout: args.Timeout, Timeout: args.Timeout,
+2 -2
View File
@@ -885,7 +885,7 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type
issue.Sender = sender.String issue.Sender = sender.String
} }
if wisp.Valid && wisp.Int64 != 0 { if wisp.Valid && wisp.Int64 != 0 {
issue.Wisp = true issue.Ephemeral = true
} }
// Pinned field (bd-7h5) // Pinned field (bd-7h5)
if pinned.Valid && pinned.Int64 != 0 { if pinned.Valid && pinned.Int64 != 0 {
@@ -1006,7 +1006,7 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *
issue.Sender = sender.String issue.Sender = sender.String
} }
if wisp.Valid && wisp.Int64 != 0 { if wisp.Valid && wisp.Int64 != 0 {
issue.Wisp = true issue.Ephemeral = true
} }
// Pinned field (bd-7h5) // Pinned field (bd-7h5)
if pinned.Valid && pinned.Int64 != 0 { if pinned.Valid && pinned.Int64 != 0 {
+11 -11
View File
@@ -295,7 +295,7 @@ func TestRepliesTo(t *testing.T) {
IssueType: types.TypeMessage, IssueType: types.TypeMessage,
Sender: "alice", Sender: "alice",
Assignee: "bob", Assignee: "bob",
Wisp: true, Ephemeral: true,
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
@@ -307,7 +307,7 @@ func TestRepliesTo(t *testing.T) {
IssueType: types.TypeMessage, IssueType: types.TypeMessage,
Sender: "bob", Sender: "bob",
Assignee: "alice", Assignee: "alice",
Wisp: true, Ephemeral: true,
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
@@ -363,7 +363,7 @@ func TestRepliesTo_Chain(t *testing.T) {
IssueType: types.TypeMessage, IssueType: types.TypeMessage,
Sender: "user", Sender: "user",
Assignee: "inbox", Assignee: "inbox",
Wisp: true, Ephemeral: true,
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
@@ -415,7 +415,7 @@ func TestWispField(t *testing.T) {
Status: types.StatusOpen, Status: types.StatusOpen,
Priority: 2, Priority: 2,
IssueType: types.TypeMessage, IssueType: types.TypeMessage,
Wisp: true, Ephemeral: true,
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
@@ -426,7 +426,7 @@ func TestWispField(t *testing.T) {
Status: types.StatusOpen, Status: types.StatusOpen,
Priority: 2, Priority: 2,
IssueType: types.TypeTask, IssueType: types.TypeTask,
Wisp: false, Ephemeral: false,
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
@@ -443,7 +443,7 @@ func TestWispField(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetIssue failed: %v", err) t.Fatalf("GetIssue failed: %v", err)
} }
if !savedWisp.Wisp { if !savedWisp.Ephemeral {
t.Error("Wisp issue should have Wisp=true") t.Error("Wisp issue should have Wisp=true")
} }
@@ -451,7 +451,7 @@ func TestWispField(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetIssue failed: %v", err) t.Fatalf("GetIssue failed: %v", err)
} }
if savedPermanent.Wisp { if savedPermanent.Ephemeral {
t.Error("Permanent issue should have Wisp=false") t.Error("Permanent issue should have Wisp=false")
} }
} }
@@ -468,7 +468,7 @@ func TestWispFilter(t *testing.T) {
Status: types.StatusClosed, // Closed for cleanup test Status: types.StatusClosed, // Closed for cleanup test
Priority: 2, Priority: 2,
IssueType: types.TypeMessage, IssueType: types.TypeMessage,
Wisp: true, Ephemeral: true,
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
@@ -483,7 +483,7 @@ func TestWispFilter(t *testing.T) {
Status: types.StatusClosed, Status: types.StatusClosed,
Priority: 2, Priority: 2,
IssueType: types.TypeTask, IssueType: types.TypeTask,
Wisp: false, Ephemeral: false,
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
@@ -497,7 +497,7 @@ func TestWispFilter(t *testing.T) {
closedStatus := types.StatusClosed closedStatus := types.StatusClosed
wispFilter := types.IssueFilter{ wispFilter := types.IssueFilter{
Status: &closedStatus, Status: &closedStatus,
Wisp: &wispTrue, Ephemeral: &wispTrue,
} }
wispIssues, err := store.SearchIssues(ctx, "", wispFilter) wispIssues, err := store.SearchIssues(ctx, "", wispFilter)
@@ -512,7 +512,7 @@ func TestWispFilter(t *testing.T) {
wispFalse := false wispFalse := false
nonWispFilter := types.IssueFilter{ nonWispFilter := types.IssueFilter{
Status: &closedStatus, Status: &closedStatus,
Wisp: &wispFalse, Ephemeral: &wispFalse,
} }
permanentIssues, err := store.SearchIssues(ctx, "", nonWispFilter) permanentIssues, err := store.SearchIssues(ctx, "", nonWispFilter)
+2 -2
View File
@@ -28,7 +28,7 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
} }
wisp := 0 wisp := 0
if issue.Wisp { if issue.Ephemeral {
wisp = 1 wisp = 1
} }
pinned := 0 pinned := 0
@@ -94,7 +94,7 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
} }
wisp := 0 wisp := 0
if issue.Wisp { if issue.Ephemeral {
wisp = 1 wisp = 1
} }
pinned := 0 pinned := 0
@@ -3,6 +3,7 @@ package migrations
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"strings"
) )
// MigrateTombstoneClosedAt updates the closed_at constraint to allow tombstones // MigrateTombstoneClosedAt updates the closed_at constraint to allow tombstones
@@ -22,8 +23,20 @@ func MigrateTombstoneClosedAt(db *sql.DB) error {
// SQLite doesn't support ALTER TABLE to modify CHECK constraints // SQLite doesn't support ALTER TABLE to modify CHECK constraints
// We must recreate the table with the new constraint // We must recreate the table with the new constraint
// Idempotency check: see if the new CHECK constraint already exists
// The new constraint contains "status = 'tombstone'" which the old one didn't
var tableSql string
err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='issues'`).Scan(&tableSql)
if err != nil {
return fmt.Errorf("failed to get issues table schema: %w", err)
}
// If the schema already has the tombstone clause, migration is already applied
if strings.Contains(tableSql, "status = 'tombstone'") || strings.Contains(tableSql, `status = "tombstone"`) {
return nil
}
// Step 0: Drop views that depend on the issues table // Step 0: Drop views that depend on the issues table
_, err := db.Exec(`DROP VIEW IF EXISTS ready_issues`) _, err = db.Exec(`DROP VIEW IF EXISTS ready_issues`)
if err != nil { if err != nil {
return fmt.Errorf("failed to drop ready_issues view: %w", err) return fmt.Errorf("failed to drop ready_issues view: %w", err)
} }
@@ -48,6 +61,7 @@ func MigrateTombstoneClosedAt(db *sql.DB) error {
assignee TEXT, assignee TEXT,
estimated_minutes INTEGER, estimated_minutes INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by TEXT DEFAULT '',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
closed_at DATETIME, closed_at DATETIME,
external_ref TEXT, external_ref TEXT,
@@ -81,20 +95,73 @@ func MigrateTombstoneClosedAt(db *sql.DB) error {
} }
// Step 2: Copy data from old table to new table // Step 2: Copy data from old table to new table
// List all columns explicitly to handle cases where old table has fewer columns // We need to check if created_by column exists in the old table
// Note: created_by is added in migration 029, so don't reference it here // If not, we insert a default empty string for it
_, err = db.Exec(` var hasCreatedBy bool
INSERT INTO issues_new rows, err := db.Query(`PRAGMA table_info(issues)`)
SELECT if err != nil {
id, content_hash, title, description, design, acceptance_criteria, notes, return fmt.Errorf("failed to get table info: %w", err)
status, priority, issue_type, assignee, estimated_minutes, }
created_at, updated_at, closed_at, external_ref, for rows.Next() {
source_repo, compaction_level, compacted_at, compacted_at_commit, original_size, var cid int
deleted_at, deleted_by, delete_reason, original_type, var name, ctype string
sender, ephemeral, close_reason, pinned, is_template, var notnull, pk int
await_type, await_id, timeout_ns, waiters var dflt interface{}
FROM issues if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
`) rows.Close()
return fmt.Errorf("failed to scan table info: %w", err)
}
if name == "created_by" {
hasCreatedBy = true
break
}
}
rows.Close()
var insertSQL string
if hasCreatedBy {
// Old table has created_by, copy all columns directly
insertSQL = `
INSERT INTO issues_new (
id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes, created_at,
created_by, updated_at, closed_at, external_ref, source_repo, compaction_level,
compacted_at, compacted_at_commit, original_size, deleted_at, deleted_by,
delete_reason, original_type, sender, ephemeral, close_reason, pinned,
is_template, await_type, await_id, timeout_ns, waiters
)
SELECT
id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes, created_at,
created_by, updated_at, closed_at, external_ref, source_repo, compaction_level,
compacted_at, compacted_at_commit, original_size, deleted_at, deleted_by,
delete_reason, original_type, sender, ephemeral, close_reason, pinned,
is_template, await_type, await_id, timeout_ns, waiters
FROM issues
`
} else {
// Old table doesn't have created_by, use empty string default
insertSQL = `
INSERT INTO issues_new (
id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes, created_at,
created_by, updated_at, closed_at, external_ref, source_repo, compaction_level,
compacted_at, compacted_at_commit, original_size, deleted_at, deleted_by,
delete_reason, original_type, sender, ephemeral, close_reason, pinned,
is_template, await_type, await_id, timeout_ns, waiters
)
SELECT
id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes, created_at,
'', updated_at, closed_at, external_ref, source_repo, compaction_level,
compacted_at, compacted_at_commit, original_size, deleted_at, deleted_by,
delete_reason, original_type, sender, ephemeral, close_reason, pinned,
is_template, await_type, await_id, timeout_ns, waiters
FROM issues
`
}
_, err = db.Exec(insertSQL)
if err != nil { if err != nil {
return fmt.Errorf("failed to copy issues data: %w", err) return fmt.Errorf("failed to copy issues data: %w", err)
} }
+1 -1
View File
@@ -282,7 +282,7 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
err := tx.QueryRowContext(ctx, `SELECT id FROM issues WHERE id = ?`, issue.ID).Scan(&existingID) err := tx.QueryRowContext(ctx, `SELECT id FROM issues WHERE id = ?`, issue.ID).Scan(&existingID)
wisp := 0 wisp := 0
if issue.Wisp { if issue.Ephemeral {
wisp = 1 wisp = 1
} }
pinned := 0 pinned := 0
+1 -1
View File
@@ -54,7 +54,7 @@ func (s *SQLiteStorage) ExportToMultiRepo(ctx context.Context) (map[string]int,
// Wisps exist only in SQLite and are shared via .beads/redirect, not JSONL. // Wisps exist only in SQLite and are shared via .beads/redirect, not JSONL.
filtered := make([]*types.Issue, 0, len(allIssues)) filtered := make([]*types.Issue, 0, len(allIssues))
for _, issue := range allIssues { for _, issue := range allIssues {
if !issue.Wisp { if !issue.Ephemeral {
filtered = append(filtered, issue) filtered = append(filtered, issue)
} }
} }
+1 -1
View File
@@ -909,7 +909,7 @@ func TestUpsertPreservesGateFields(t *testing.T) {
Status: types.StatusOpen, Status: types.StatusOpen,
Priority: 1, Priority: 1,
IssueType: types.TypeGate, IssueType: types.TypeGate,
Wisp: true, Ephemeral: true,
AwaitType: "gh:run", AwaitType: "gh:run",
AwaitID: "123456789", AwaitID: "123456789",
Timeout: 30 * 60 * 1000000000, // 30 minutes in nanoseconds Timeout: 30 * 60 * 1000000000, // 30 minutes in nanoseconds
+4 -4
View File
@@ -349,7 +349,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
issue.Sender = sender.String issue.Sender = sender.String
} }
if wisp.Valid && wisp.Int64 != 0 { if wisp.Valid && wisp.Int64 != 0 {
issue.Wisp = true issue.Ephemeral = true
} }
// Pinned field (bd-7h5) // Pinned field (bd-7h5)
if pinned.Valid && pinned.Int64 != 0 { if pinned.Valid && pinned.Int64 != 0 {
@@ -562,7 +562,7 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
issue.Sender = sender.String issue.Sender = sender.String
} }
if wisp.Valid && wisp.Int64 != 0 { if wisp.Valid && wisp.Int64 != 0 {
issue.Wisp = true issue.Ephemeral = true
} }
// Pinned field (bd-7h5) // Pinned field (bd-7h5)
if pinned.Valid && pinned.Int64 != 0 { if pinned.Valid && pinned.Int64 != 0 {
@@ -1652,8 +1652,8 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
} }
// Wisp filtering (bd-kwro.9) // Wisp filtering (bd-kwro.9)
if filter.Wisp != nil { if filter.Ephemeral != nil {
if *filter.Wisp { if *filter.Ephemeral {
whereClauses = append(whereClauses, "ephemeral = 1") // SQL column is still 'ephemeral' whereClauses = append(whereClauses, "ephemeral = 1") // SQL column is still 'ephemeral'
} else { } else {
whereClauses = append(whereClauses, "(ephemeral = 0 OR ephemeral IS NULL)") whereClauses = append(whereClauses, "(ephemeral = 0 OR ephemeral IS NULL)")
+3 -2
View File
@@ -17,7 +17,8 @@ import (
// Excludes pinned issues which are persistent anchors, not actionable work (bd-92u) // Excludes pinned issues which are persistent anchors, not actionable work (bd-92u)
func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilter) ([]*types.Issue, error) { func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilter) ([]*types.Issue, error) {
whereClauses := []string{ whereClauses := []string{
"i.pinned = 0", // Exclude pinned issues (bd-92u) "i.pinned = 0", // Exclude pinned issues (bd-92u)
"(i.ephemeral = 0 OR i.ephemeral IS NULL)", // Exclude wisps (hq-t15s)
} }
args := []interface{}{} args := []interface{}{}
@@ -399,7 +400,7 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
issue.Sender = sender.String issue.Sender = sender.String
} }
if ephemeral.Valid && ephemeral.Int64 != 0 { if ephemeral.Valid && ephemeral.Int64 != 0 {
issue.Wisp = true issue.Ephemeral = true
} }
// Pinned field (bd-7h5) // Pinned field (bd-7h5)
if pinned.Valid && pinned.Int64 != 0 { if pinned.Valid && pinned.Int64 != 0 {
+1
View File
@@ -230,6 +230,7 @@ WITH RECURSIVE
SELECT i.* SELECT i.*
FROM issues i FROM issues i
WHERE i.status = 'open' WHERE i.status = 'open'
AND (i.ephemeral = 0 OR i.ephemeral IS NULL)
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM blocked_transitively WHERE issue_id = i.id SELECT 1 FROM blocked_transitively WHERE issue_id = i.id
); );
+3 -3
View File
@@ -1089,8 +1089,8 @@ func (t *sqliteTxStorage) SearchIssues(ctx context.Context, query string, filter
} }
// Wisp filtering (bd-kwro.9) // Wisp filtering (bd-kwro.9)
if filter.Wisp != nil { if filter.Ephemeral != nil {
if *filter.Wisp { if *filter.Ephemeral {
whereClauses = append(whereClauses, "ephemeral = 1") // SQL column is still 'ephemeral' whereClauses = append(whereClauses, "ephemeral = 1") // SQL column is still 'ephemeral'
} else { } else {
whereClauses = append(whereClauses, "(ephemeral = 0 OR ephemeral IS NULL)") whereClauses = append(whereClauses, "(ephemeral = 0 OR ephemeral IS NULL)")
@@ -1244,7 +1244,7 @@ func scanIssueRow(row scanner) (*types.Issue, error) {
issue.Sender = sender.String issue.Sender = sender.String
} }
if wisp.Valid && wisp.Int64 != 0 { if wisp.Valid && wisp.Int64 != 0 {
issue.Wisp = true issue.Ephemeral = true
} }
// Pinned field (bd-7h5) // Pinned field (bd-7h5)
if pinned.Valid && pinned.Int64 != 0 { if pinned.Valid && pinned.Int64 != 0 {
+4 -4
View File
@@ -44,8 +44,8 @@ type Issue struct {
OriginalType string `json:"original_type,omitempty"` // Issue type before deletion (for tombstones) OriginalType string `json:"original_type,omitempty"` // Issue type before deletion (for tombstones)
// Messaging fields (bd-kwro): inter-agent communication support // Messaging fields (bd-kwro): inter-agent communication support
Sender string `json:"sender,omitempty"` // Who sent this (for messages) Sender string `json:"sender,omitempty"` // Who sent this (for messages)
Wisp bool `json:"wisp,omitempty"` // Wisp = ephemeral vapor from the Steam Engine; bulk-deleted when closed Ephemeral bool `json:"ephemeral,omitempty"` // If true, not exported to JSONL; bulk-deleted when closed
// NOTE: RepliesTo, RelatesTo, DuplicateOf, SupersededBy moved to dependencies table // NOTE: RepliesTo, RelatesTo, DuplicateOf, SupersededBy moved to dependencies table
// per Decision 004 (Edge Schema Consolidation). Use dependency API instead. // per Decision 004 (Edge Schema Consolidation). Use dependency API instead.
@@ -598,8 +598,8 @@ type IssueFilter struct {
// Tombstone filtering (bd-1bu) // Tombstone filtering (bd-1bu)
IncludeTombstones bool // If false (default), exclude tombstones from results IncludeTombstones bool // If false (default), exclude tombstones from results
// Wisp filtering (bd-kwro.9) // Ephemeral filtering (bd-kwro.9)
Wisp *bool // Filter by wisp flag (nil = any, true = only wisps, false = only non-wisps) Ephemeral *bool // Filter by ephemeral flag (nil = any, true = only ephemeral, false = only persistent)
// Pinned filtering (bd-7h5) // Pinned filtering (bd-7h5)
Pinned *bool // Filter by pinned flag (nil = any, true = only pinned, false = only non-pinned) Pinned *bool // Filter by pinned flag (nil = any, true = only pinned, false = only non-pinned)
+12 -12
View File
@@ -83,8 +83,8 @@ bd mol spawn mol-release --var version=2.0 # With variable substitution
**Chemistry shortcuts:** **Chemistry shortcuts:**
```bash ```bash
bd pour mol-feature # Shortcut for spawn --pour bd mol pour mol-feature # Shortcut for spawn --pour
bd wisp create mol-patrol # Explicit wisp creation bd mol wisp mol-patrol # Explicit wisp creation
``` ```
### Spawn with Immediate Execution ### Spawn with Immediate Execution
@@ -164,7 +164,7 @@ bd mol bond mol-feature mol-deploy --as "Feature with Deploy"
### Creating Wisps ### Creating Wisps
```bash ```bash
bd wisp create mol-patrol # From proto bd mol wisp mol-patrol # From proto
bd mol spawn mol-patrol # Same (spawn defaults to wisp) bd mol spawn mol-patrol # Same (spawn defaults to wisp)
bd mol spawn mol-check --var target=db # With variables bd mol spawn mol-check --var target=db # With variables
``` ```
@@ -172,8 +172,8 @@ bd mol spawn mol-check --var target=db # With variables
### Listing Wisps ### Listing Wisps
```bash ```bash
bd wisp list # List all wisps bd mol wisp list # List all wisps
bd wisp list --json # Machine-readable bd mol wisp list --json # Machine-readable
``` ```
### Ending Wisps ### Ending Wisps
@@ -198,7 +198,7 @@ Use burn for routine work with no archival value.
### Garbage Collection ### Garbage Collection
```bash ```bash
bd wisp gc # Clean up orphaned wisps bd mol wisp gc # Clean up orphaned wisps
``` ```
--- ---
@@ -289,7 +289,7 @@ bd mol spawn mol-weekly-review --pour
```bash ```bash
# Patrol proto exists # Patrol proto exists
bd wisp create mol-patrol bd mol wisp mol-patrol
# Execute patrol work... # Execute patrol work...
@@ -327,10 +327,10 @@ bd mol distill bd-release-epic --as "Release Process" --var version=X.Y.Z
| `bd mol distill <epic>` | Extract proto from ad-hoc work | | `bd mol distill <epic>` | Extract proto from ad-hoc work |
| `bd mol squash <mol>` | Compress wisp children to digest | | `bd mol squash <mol>` | Compress wisp children to digest |
| `bd mol burn <wisp>` | Delete wisp without trace | | `bd mol burn <wisp>` | Delete wisp without trace |
| `bd pour <proto>` | Shortcut for `spawn --pour` | | `bd mol pour <proto>` | Shortcut for `spawn --pour` |
| `bd wisp create <proto>` | Create ephemeral wisp | | `bd mol wisp <proto>` | Create ephemeral wisp |
| `bd wisp list` | List all wisps | | `bd mol wisp list` | List all wisps |
| `bd wisp gc` | Garbage collect orphaned wisps | | `bd mol wisp gc` | Garbage collect orphaned wisps |
| `bd ship <capability>` | Publish capability for cross-project deps | | `bd ship <capability>` | Publish capability for cross-project deps |
--- ---
@@ -347,7 +347,7 @@ bd mol distill bd-release-epic --as "Release Process" --var version=X.Y.Z
**"Wisp commands fail"** **"Wisp commands fail"**
- Wisps stored in `.beads-wisp/` (separate from `.beads/`) - Wisps stored in `.beads-wisp/` (separate from `.beads/`)
- Check `bd wisp list` for active wisps - Check `bd mol wisp list` for active wisps
**"External dependency not satisfied"** **"External dependency not satisfied"**
- Target project must have closed issue with `provides:<capability>` label - Target project must have closed issue with `provides:<capability>` label