refactor: simplify wisp architecture - single DB with Wisp flag (bd-bkul)

- Remove cross-store squash logic from mol_squash.go
  - Delete runWispSquash() and squashWispToPermanent() functions
  - Simplify runMolSquash() to work with main store only

- Update mol_burn.go to work with main database
  - Remove .beads-wisp/ directory references
  - Look for Wisp=true issues in main store instead

- Update mol_bond.go to use Wisp flag instead of separate store
  - --wisp now creates issues with Wisp=true in main store
  - --pour creates issues with Wisp=false (persistent)
  - Update bondProtoMol signature to accept both flags

- Deprecate wisp storage functions in beads.go
  - WispDirName, FindWispDir, FindWispDatabasePath
  - NewWispStorage, EnsureWispGitignore, IsWispDatabase
  - All marked deprecated with reference to bd-bkul

- Remove obsolete cross-store squash tests
  - TestSquashWispToPermanent
  - TestSquashWispToPermanentWithSummary
  - TestSquashWispToPermanentKeepChildren

All tests pass. Build succeeds.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-24 20:42:54 -08:00
parent c0271aedbf
commit f2e6df95c0
6 changed files with 121 additions and 583 deletions

View File

@@ -7,7 +7,6 @@ import (
"strings" "strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/ui"
@@ -34,12 +33,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 → spawns as mol (liquid) - Attaching to mol (Wisp=false) → spawns as persistent (Wisp=false)
- Attaching to wisp → spawns as wisp (vapor) - Attaching to wisp (Wisp=true) → spawns as ephemeral (Wisp=true)
Override with: Override with:
--pour Force spawn as liquid (persistent), even when attaching to wisp --pour Force spawn as liquid (persistent, Wisp=false)
--wisp Force spawn as vapor (ephemeral), even when attaching to mol --wisp Force spawn as vapor (ephemeral, Wisp=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.
@@ -106,25 +105,10 @@ func runMolBond(cmd *cobra.Command, args []string) {
os.Exit(1) os.Exit(1)
} }
// Determine which store to use for spawning // All issues go in the main store; wisp vs pour determines the Wisp flag
// Default: follow target's phase. Override with --wisp or --pour. // --wisp: create with Wisp=true (ephemeral, excluded from JSONL export)
targetStore := store // --pour: create with Wisp=false (persistent, exported to JSONL)
if wisp { // Default: follow target's phase (wisp if target is wisp, otherwise persistent)
// Explicit --wisp: use wisp storage
wispStore, err := beads.NewWispStorage(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to open wisp storage: %v\n", err)
os.Exit(1)
}
defer func() { _ = wispStore.Close() }()
targetStore = wispStore
// Ensure wisp directory is gitignored
if err := beads.EnsureWispGitignore(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not update .gitignore: %v\n", err)
}
}
// Note: --pour means use permanent storage (which is the default targetStore)
// Validate bond type // Validate bond type
if bondType != types.BondTypeSequential && bondType != types.BondTypeParallel && bondType != types.BondTypeConditional { if bondType != types.BondTypeSequential && bondType != types.BondTypeParallel && bondType != types.BondTypeConditional {
@@ -220,18 +204,18 @@ func runMolBond(cmd *cobra.Command, args []string) {
} }
// Dispatch based on operand types // Dispatch based on operand types
// Note: proto+proto creates templates (permanent storage), others use targetStore // All operations use the main store; wisp flag determines ephemeral vs persistent
var result *BondResult var result *BondResult
switch { switch {
case aIsProto && bIsProto: case aIsProto && bIsProto:
// Compound protos are templates - always use permanent storage // Compound protos are templates - always persistent
result, err = bondProtoProto(ctx, store, issueA, issueB, bondType, customTitle, actor) result, err = bondProtoProto(ctx, store, issueA, issueB, bondType, customTitle, actor)
case aIsProto && !bIsProto: case aIsProto && !bIsProto:
result, err = bondProtoMol(ctx, targetStore, issueA, issueB, bondType, vars, childRef, actor, pour) result, err = bondProtoMol(ctx, store, issueA, issueB, bondType, vars, childRef, actor, wisp, pour)
case !aIsProto && bIsProto: case !aIsProto && bIsProto:
result, err = bondMolProto(ctx, targetStore, issueA, issueB, bondType, vars, childRef, actor, pour) result, err = bondMolProto(ctx, store, issueA, issueB, bondType, vars, childRef, actor, wisp, pour)
default: default:
result, err = bondMolMol(ctx, targetStore, issueA, issueB, bondType, actor) result, err = bondMolMol(ctx, store, issueA, issueB, bondType, actor)
} }
if err != nil { if err != nil {
@@ -239,10 +223,8 @@ func runMolBond(cmd *cobra.Command, args []string) {
os.Exit(1) os.Exit(1)
} }
// Schedule auto-flush (only for non-wisp, wisps don't sync) // Schedule auto-flush - wisps are in main DB now, but JSONL export skips them
if !wisp { markDirtyAndScheduleFlush()
markDirtyAndScheduleFlush()
}
if jsonOutput { if jsonOutput {
outputJSON(result) outputJSON(result)
@@ -255,9 +237,9 @@ func runMolBond(cmd *cobra.Command, args []string) {
fmt.Printf(" Spawned: %d issues\n", result.Spawned) fmt.Printf(" Spawned: %d issues\n", result.Spawned)
} }
if wisp { if wisp {
fmt.Printf(" Phase: vapor (ephemeral in .beads-wisp/)\n") fmt.Printf(" Phase: vapor (ephemeral, Wisp=true)\n")
} else if pour { } else if pour {
fmt.Printf(" Phase: liquid (persistent in .beads/)\n") fmt.Printf(" Phase: liquid (persistent, Wisp=false)\n")
} }
} }
@@ -366,7 +348,7 @@ 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).
func bondProtoMol(ctx context.Context, s storage.Storage, proto, mol *types.Issue, bondType string, vars map[string]string, childRef string, actorName string, pour 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, wispFlag, pourFlag bool) (*BondResult, error) {
// Load proto subgraph // Load proto subgraph
subgraph, err := loadTemplateSubgraph(ctx, s, proto.ID) subgraph, err := loadTemplateSubgraph(ctx, s, proto.ID)
if err != nil { if err != nil {
@@ -385,11 +367,20 @@ func bondProtoMol(ctx context.Context, s storage.Storage, proto, mol *types.Issu
return nil, fmt.Errorf("missing required variables: %s (use --var)", strings.Join(missingVars, ", ")) return nil, fmt.Errorf("missing required variables: %s (use --var)", strings.Join(missingVars, ", "))
} }
// Determine wisp flag based on explicit flags or target's phase
// --wisp: force wisp=true, --pour: force wisp=false, neither: follow target
makeWisp := mol.Wisp // Default: follow target's phase
if wispFlag {
makeWisp = true
} else if pourFlag {
makeWisp = false
}
// Build CloneOptions for spawning // Build CloneOptions for spawning
opts := CloneOptions{ opts := CloneOptions{
Vars: vars, Vars: vars,
Actor: actorName, Actor: actorName,
Wisp: !pour, // wisp by default, but --pour makes persistent (bd-l7y3) Wisp: makeWisp,
} }
// Dynamic bonding: use custom IDs if childRef is provided // Dynamic bonding: use custom IDs if childRef is provided
@@ -444,9 +435,9 @@ func bondProtoMol(ctx context.Context, s storage.Storage, proto, mol *types.Issu
} }
// bondMolProto bonds a molecule to a proto (symmetric with bondProtoMol) // bondMolProto bonds a molecule to a proto (symmetric with bondProtoMol)
func bondMolProto(ctx context.Context, s storage.Storage, mol, proto *types.Issue, bondType string, vars map[string]string, childRef string, actorName string, pour 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, wispFlag, 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, pour) return bondProtoMol(ctx, s, proto, mol, bondType, vars, childRef, actorName, wispFlag, pourFlag)
} }
// bondMolMol bonds two molecules together // bondMolMol bonds two molecules together
@@ -504,8 +495,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 in .beads-wisp/)") molBondCmd.Flags().Bool("wisp", false, "Force spawn as vapor (ephemeral, Wisp=true)")
molBondCmd.Flags().Bool("pour", false, "Force spawn as liquid (persistent in .beads/)") molBondCmd.Flags().Bool("pour", false, "Force spawn as liquid (persistent, Wisp=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)

View File

@@ -6,7 +6,6 @@ import (
"os" "os"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/utils" "github.com/steveyegge/beads/internal/utils"
@@ -24,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 is in wisp storage (.beads-wisp/) 1. Verifies the molecule has Wisp=true (is ephemeral)
2. Deletes the molecule and all its children 2. Deletes the molecule and all its wisp 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
@@ -44,7 +43,6 @@ type BurnResult struct {
MoleculeID string `json:"molecule_id"` MoleculeID string `json:"molecule_id"`
DeletedIDs []string `json:"deleted_ids"` DeletedIDs []string `json:"deleted_ids"`
DeletedCount int `json:"deleted_count"` DeletedCount int `json:"deleted_count"`
WispDir string `json:"wisp_dir"`
} }
func runMolBurn(cmd *cobra.Command, args []string) { func runMolBurn(cmd *cobra.Command, args []string) {
@@ -52,75 +50,79 @@ func runMolBurn(cmd *cobra.Command, args []string) {
ctx := rootCtx ctx := rootCtx
// mol burn requires direct store access
if store == nil {
if daemonClient != nil {
fmt.Fprintf(os.Stderr, "Error: mol burn requires direct database access\n")
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol burn %s ...\n", args[0])
} else {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
}
os.Exit(1)
}
dryRun, _ := cmd.Flags().GetBool("dry-run") dryRun, _ := cmd.Flags().GetBool("dry-run")
force, _ := cmd.Flags().GetBool("force") force, _ := cmd.Flags().GetBool("force")
moleculeID := args[0] moleculeID := args[0]
// Find wisp storage // Resolve molecule ID in main store
wispDir := beads.FindWispDir() resolvedID, err := utils.ResolvePartialID(ctx, store, moleculeID)
if wispDir == "" {
if jsonOutput {
outputJSON(BurnResult{
MoleculeID: moleculeID,
DeletedCount: 0,
})
} else {
fmt.Fprintf(os.Stderr, "Error: no .beads directory found\n")
}
os.Exit(1)
}
// Check if wisp directory exists
if _, err := os.Stat(wispDir); os.IsNotExist(err) {
if jsonOutput {
outputJSON(BurnResult{
MoleculeID: moleculeID,
DeletedCount: 0,
WispDir: wispDir,
})
} else {
fmt.Fprintf(os.Stderr, "Error: no wisp storage found at %s\n", wispDir)
}
os.Exit(1)
}
// Open wisp storage
wispStore, err := beads.NewWispStorage(ctx)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error opening wisp storage: %v\n", err) fmt.Fprintf(os.Stderr, "Error resolving molecule ID %s: %v\n", moleculeID, err)
os.Exit(1) os.Exit(1)
} }
defer func() { _ = wispStore.Close() }()
// Resolve molecule ID in wisp storage // Load the molecule
resolvedID, err := utils.ResolvePartialID(ctx, wispStore, moleculeID) rootIssue, err := store.GetIssue(ctx, resolvedID)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: molecule %s not found in wisp storage\n", moleculeID) fmt.Fprintf(os.Stderr, "Error loading molecule: %v\n", err)
fmt.Fprintf(os.Stderr, "Hint: mol burn only works with wisps in .beads-wisp/\n") os.Exit(1)
fmt.Fprintf(os.Stderr, " Use 'bd wisp list' to see available wisps\n") }
// Verify it's a wisp
if !rootIssue.Wisp {
fmt.Fprintf(os.Stderr, "Error: molecule %s is not a wisp (Wisp=false)\n", resolvedID)
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")
os.Exit(1) os.Exit(1)
} }
// Load the molecule subgraph // Load the molecule subgraph
subgraph, err := loadTemplateSubgraph(ctx, wispStore, resolvedID) subgraph, err := loadTemplateSubgraph(ctx, store, resolvedID)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error loading wisp molecule: %v\n", err) fmt.Fprintf(os.Stderr, "Error loading wisp molecule: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Collect all issue IDs to delete // Collect wisp issue IDs to delete (only delete wisps, not regular children)
var allIDs []string var wispIDs []string
for _, issue := range subgraph.Issues { for _, issue := range subgraph.Issues {
allIDs = append(allIDs, issue.ID) if issue.Wisp {
wispIDs = append(wispIDs, issue.ID)
}
}
if len(wispIDs) == 0 {
if jsonOutput {
outputJSON(BurnResult{
MoleculeID: resolvedID,
DeletedCount: 0,
})
} else {
fmt.Printf("No wisp issues found for molecule %s\n", resolvedID)
}
return
} }
if dryRun { if dryRun {
fmt.Printf("\nDry run: would burn wisp %s\n\n", resolvedID) fmt.Printf("\nDry run: would burn wisp %s\n\n", resolvedID)
fmt.Printf("Root: %s\n", subgraph.Root.Title) fmt.Printf("Root: %s\n", subgraph.Root.Title)
fmt.Printf("Storage: .beads-wisp/\n") fmt.Printf("\nWisp issues to delete (%d total):\n", len(wispIDs))
fmt.Printf("\nIssues to delete (%d total):\n", len(allIDs))
for _, issue := range subgraph.Issues { for _, issue := range subgraph.Issues {
if !issue.Wisp {
continue
}
status := string(issue.Status) status := string(issue.Status)
if issue.ID == subgraph.Root.ID { if issue.ID == subgraph.Root.ID {
fmt.Printf(" - [%s] %s (%s) [ROOT]\n", status, issue.Title, issue.ID) fmt.Printf(" - [%s] %s (%s) [ROOT]\n", status, issue.Title, issue.ID)
@@ -134,8 +136,8 @@ func runMolBurn(cmd *cobra.Command, args []string) {
// Confirm unless --force // Confirm unless --force
if !force && !jsonOutput { if !force && !jsonOutput {
fmt.Printf("About to burn wisp %s (%d issues)\n", resolvedID, len(allIDs)) fmt.Printf("About to burn wisp %s (%d issues)\n", resolvedID, len(wispIDs))
fmt.Printf("This will permanently delete all data with no digest.\n") fmt.Printf("This will permanently delete all wisp data with no digest.\n")
fmt.Printf("Use 'bd mol squash' instead if you want to preserve a summary.\n") fmt.Printf("Use 'bd mol squash' instead if you want to preserve a summary.\n")
fmt.Printf("\nContinue? [y/N] ") fmt.Printf("\nContinue? [y/N] ")
@@ -148,13 +150,16 @@ func runMolBurn(cmd *cobra.Command, args []string) {
} }
// Perform the burn // Perform the burn
result, err := burnWisp(ctx, wispStore, allIDs, wispDir) result, err := burnWisps(ctx, store, wispIDs)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error burning wisp: %v\n", err) fmt.Fprintf(os.Stderr, "Error burning wisp: %v\n", err)
os.Exit(1) os.Exit(1)
} }
result.MoleculeID = resolvedID result.MoleculeID = resolvedID
// Schedule auto-flush
markDirtyAndScheduleFlush()
if jsonOutput { if jsonOutput {
outputJSON(result) outputJSON(result)
return return
@@ -165,17 +170,16 @@ func runMolBurn(cmd *cobra.Command, args []string) {
fmt.Printf(" No digest created.\n") fmt.Printf(" No digest created.\n")
} }
// burnWisp deletes all wisp issues without creating a digest // burnWisps deletes all wisp issues without creating a digest
func burnWisp(ctx context.Context, wispStore beads.Storage, ids []string, wispDir string) (*BurnResult, error) { func burnWisps(ctx context.Context, s interface{}, ids []string) (*BurnResult, error) {
// Type assert to SQLite storage for delete access // Type assert to SQLite storage for delete access
sqliteStore, ok := wispStore.(*sqlite.SQLiteStorage) sqliteStore, ok := s.(*sqlite.SQLiteStorage)
if !ok { if !ok {
return nil, fmt.Errorf("burn requires SQLite storage backend") return nil, fmt.Errorf("burn requires SQLite storage backend")
} }
result := &BurnResult{ result := &BurnResult{
DeletedIDs: make([]string, 0, len(ids)), DeletedIDs: make([]string, 0, len(ids)),
WispDir: wispDir,
} }
for _, id := range ids { for _, id := range ids {

View File

@@ -81,54 +81,13 @@ func runMolSquash(cmd *cobra.Command, args []string) {
keepChildren, _ := cmd.Flags().GetBool("keep-children") keepChildren, _ := cmd.Flags().GetBool("keep-children")
summary, _ := cmd.Flags().GetString("summary") summary, _ := cmd.Flags().GetString("summary")
// Try to resolve molecule ID in main store first // Resolve molecule ID in main store
moleculeID, err := utils.ResolvePartialID(ctx, store, args[0]) moleculeID, err := utils.ResolvePartialID(ctx, store, args[0])
// If not found in main store, check wisp storage
if err != nil { if err != nil {
// Try wisp storage fmt.Fprintf(os.Stderr, "Error resolving molecule ID %s: %v\n", args[0], err)
wispStore, wispErr := beads.NewWispStorage(ctx)
if wispErr != nil {
// No wisp storage available, report original error
fmt.Fprintf(os.Stderr, "Error resolving molecule ID %s: %v\n", args[0], err)
os.Exit(1)
}
defer func() { _ = wispStore.Close() }()
wispMolID, wispResolveErr := utils.ResolvePartialID(ctx, wispStore, args[0])
if wispResolveErr != nil {
// Not found in either store
fmt.Fprintf(os.Stderr, "Error resolving molecule ID %s: %v\n", args[0], err)
os.Exit(1)
}
// Found in wisp storage - do cross-store squash
runWispSquash(ctx, cmd, wispStore, store, wispMolID, dryRun, keepChildren, summary)
return
}
// Found in main store - check if it's actually a wisp by looking at root
issue, err := store.GetIssue(ctx, moleculeID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading molecule: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// If the root itself is a wisp, check if it should be in wisp storage
// This handles the case where someone created a wisp in main store by mistake
if issue.Wisp {
// Check if there's a corresponding wisp in wisp storage
wispStore, wispErr := beads.NewWispStorage(ctx)
if wispErr == nil {
defer func() { _ = wispStore.Close() }()
if wispIssue, _ := wispStore.GetIssue(ctx, moleculeID); wispIssue != nil {
// Found in wisp storage - do cross-store squash
runWispSquash(ctx, cmd, wispStore, store, moleculeID, dryRun, keepChildren, summary)
return
}
}
}
// Load the molecule subgraph from main store // Load the molecule subgraph from main store
subgraph, err := loadTemplateSubgraph(ctx, store, moleculeID) subgraph, err := loadTemplateSubgraph(ctx, store, moleculeID)
if err != nil { if err != nil {
@@ -207,80 +166,6 @@ func runMolSquash(cmd *cobra.Command, args []string) {
} }
} }
// runWispSquash handles squashing a wisp from wisp storage into permanent storage.
// This is the cross-store squash operation: load from wisp, create digest in permanent, delete wisp.
func runWispSquash(ctx context.Context, _ *cobra.Command, wispStore, permanentStore storage.Storage, moleculeID string, dryRun, keepChildren bool, summary string) {
// Load the molecule subgraph from wisp storage
subgraph, err := loadTemplateSubgraph(ctx, wispStore, moleculeID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading wisp molecule: %v\n", err)
os.Exit(1)
}
// Collect all issues in the wisp (including root)
allIssues := subgraph.Issues
if dryRun {
fmt.Printf("\nDry run: would squash wisp %s → permanent digest\n\n", moleculeID)
fmt.Printf("Root: %s\n", subgraph.Root.Title)
fmt.Printf("Storage: .beads-wisp/ → .beads/\n")
fmt.Printf("\nIssues to squash (%d total):\n", len(allIssues))
for _, issue := range allIssues {
status := string(issue.Status)
if issue.ID == subgraph.Root.ID {
fmt.Printf(" - [%s] %s (%s) [ROOT]\n", status, issue.Title, issue.ID)
} else {
fmt.Printf(" - [%s] %s (%s)\n", status, issue.Title, issue.ID)
}
}
fmt.Printf("\nDigest preview:\n")
// For wisp squash, we generate a digest from all issues
var children []*types.Issue
for _, issue := range allIssues {
if issue.ID != subgraph.Root.ID {
children = append(children, issue)
}
}
digest := generateDigest(subgraph.Root, children)
if len(digest) > 500 {
fmt.Printf("%s...\n", digest[:500])
} else {
fmt.Printf("%s\n", digest)
}
if keepChildren {
fmt.Printf("\n--keep-children: wisp would NOT be deleted from .beads-wisp/\n")
} else {
fmt.Printf("\nWisp will be deleted from .beads-wisp/ after digest creation.\n")
}
return
}
// Perform the cross-store squash
result, err := squashWispToPermanent(ctx, wispStore, permanentStore, subgraph, keepChildren, summary, actor)
if err != nil {
fmt.Fprintf(os.Stderr, "Error squashing wisp: %v\n", err)
os.Exit(1)
}
// Schedule auto-flush for permanent store
markDirtyAndScheduleFlush()
if jsonOutput {
outputJSON(result)
return
}
fmt.Printf("%s Squashed wisp → permanent digest\n", ui.RenderPass("✓"))
fmt.Printf(" Wisp: %s (.beads-wisp/)\n", moleculeID)
fmt.Printf(" Digest: %s (.beads/)\n", result.DigestID)
fmt.Printf(" Squashed: %d issues\n", result.SquashedCount)
if result.DeletedCount > 0 {
fmt.Printf(" Deleted: %d issues from wisp storage\n", result.DeletedCount)
} else if result.KeptChildren {
fmt.Printf(" Wisp preserved (--keep-children)\n")
}
}
// generateDigest creates a summary from the molecule execution // generateDigest creates a summary from the molecule execution
// Tier 2: Simple concatenation of titles and descriptions // Tier 2: Simple concatenation of titles and descriptions
// Tier 3 (future): AI-powered summarization using Haiku // Tier 3 (future): AI-powered summarization using Haiku
@@ -432,85 +317,6 @@ func deleteWispChildren(ctx context.Context, s storage.Storage, ids []string) (i
return deleted, lastErr return deleted, lastErr
} }
// squashWispToPermanent performs a cross-store squash: wisp → permanent digest.
// This is the key operation for wisp lifecycle management:
// 1. Creates a digest issue in permanent storage summarizing the wisp's work
// 2. Deletes the entire wisp molecule from wisp storage
//
// The digest captures the outcome of the ephemeral work without preserving
// the full execution trace (which would accumulate unbounded over time).
func squashWispToPermanent(ctx context.Context, wispStore, permanentStore storage.Storage, subgraph *MoleculeSubgraph, keepChildren bool, summary string, actorName string) (*SquashResult, error) {
if wispStore == nil || permanentStore == nil {
return nil, fmt.Errorf("both wisp and permanent stores are required")
}
root := subgraph.Root
// Collect all issue IDs (including root)
var allIDs []string
var children []*types.Issue
for _, issue := range subgraph.Issues {
allIDs = append(allIDs, issue.ID)
if issue.ID != root.ID {
children = append(children, issue)
}
}
// Use agent-provided summary if available, otherwise generate basic digest
var digestContent string
if summary != "" {
digestContent = summary
} else {
digestContent = generateDigest(root, children)
}
// Create digest issue in permanent storage (not a wisp)
now := time.Now()
digestIssue := &types.Issue{
Title: fmt.Sprintf("Digest: %s @ %s", root.Title, now.Format("2006-01-02 15:04")),
Description: digestContent,
Status: types.StatusClosed,
CloseReason: fmt.Sprintf("Squashed from wisp %s (%d issues)", root.ID, len(subgraph.Issues)),
Priority: root.Priority,
IssueType: types.TypeTask,
Wisp: false, // Digest is permanent
ClosedAt: &now,
}
result := &SquashResult{
MoleculeID: root.ID,
SquashedIDs: allIDs,
SquashedCount: len(subgraph.Issues),
KeptChildren: keepChildren,
WispSquash: true,
}
// Create digest in permanent storage
err := permanentStore.RunInTransaction(ctx, func(tx storage.Transaction) error {
if err := tx.CreateIssue(ctx, digestIssue, actorName); err != nil {
return fmt.Errorf("failed to create digest issue: %w", err)
}
result.DigestID = digestIssue.ID
return nil
})
if err != nil {
return nil, err
}
// Delete wisp issues from wisp storage (unless --keep-children)
if !keepChildren {
deleted, err := deleteWispChildren(ctx, wispStore, allIDs)
if err != nil {
// Log but don't fail - digest was created successfully
fmt.Fprintf(os.Stderr, "Warning: failed to delete some wisp issues: %v\n", err)
}
result.DeletedCount = deleted
}
return result, nil
}
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 wisp children after squash")

View File

@@ -343,7 +343,7 @@ func TestBondProtoMol(t *testing.T) {
// Bond proto to molecule // Bond proto to molecule
vars := map[string]string{"name": "auth-feature"} vars := map[string]string{"name": "auth-feature"}
result, err := bondProtoMol(ctx, store, proto, mol, types.BondTypeSequential, vars, "", "test", false) result, err := bondProtoMol(ctx, store, proto, mol, types.BondTypeSequential, vars, "", "test", false, false)
if err != nil { if err != nil {
t.Fatalf("bondProtoMol failed: %v", err) t.Fatalf("bondProtoMol failed: %v", err)
} }
@@ -840,7 +840,7 @@ func TestSpawnWithBasicAttach(t *testing.T) {
} }
// Attach the second proto (simulating --attach flag behavior) // Attach the second proto (simulating --attach flag behavior)
bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, types.BondTypeSequential, vars, "", "test", false) bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, types.BondTypeSequential, vars, "", "test", false, false)
if err != nil { if err != nil {
t.Fatalf("Failed to bond attachment: %v", err) t.Fatalf("Failed to bond attachment: %v", err)
} }
@@ -945,12 +945,12 @@ func TestSpawnWithMultipleAttachments(t *testing.T) {
} }
// Attach both protos (simulating --attach A --attach B) // Attach both protos (simulating --attach A --attach B)
bondResultA, err := bondProtoMol(ctx, s, attachA, spawnedMol, types.BondTypeSequential, nil, "", "test", false) bondResultA, err := bondProtoMol(ctx, s, attachA, spawnedMol, types.BondTypeSequential, nil, "", "test", false, false)
if err != nil { if err != nil {
t.Fatalf("Failed to bond attachA: %v", err) t.Fatalf("Failed to bond attachA: %v", err)
} }
bondResultB, err := bondProtoMol(ctx, s, attachB, spawnedMol, types.BondTypeSequential, nil, "", "test", false) bondResultB, err := bondProtoMol(ctx, s, attachB, spawnedMol, types.BondTypeSequential, nil, "", "test", false, false)
if err != nil { if err != nil {
t.Fatalf("Failed to bond attachB: %v", err) t.Fatalf("Failed to bond attachB: %v", err)
} }
@@ -1063,7 +1063,7 @@ func TestSpawnAttachTypes(t *testing.T) {
} }
// Bond with specified type // Bond with specified type
bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, tt.bondType, nil, "", "test", false) bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, tt.bondType, nil, "", "test", false, false)
if err != nil { if err != nil {
t.Fatalf("Failed to bond: %v", err) t.Fatalf("Failed to bond: %v", err)
} }
@@ -1228,7 +1228,7 @@ func TestSpawnVariableAggregation(t *testing.T) {
// Bond attachment with same variables // Bond attachment with same variables
spawnedMol, _ := s.GetIssue(ctx, spawnResult.NewEpicID) spawnedMol, _ := s.GetIssue(ctx, spawnResult.NewEpicID)
bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, types.BondTypeSequential, vars, "", "test", false) bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, types.BondTypeSequential, vars, "", "test", false, false)
if err != nil { if err != nil {
t.Fatalf("Failed to bond: %v", err) t.Fatalf("Failed to bond: %v", err)
} }
@@ -1283,288 +1283,6 @@ func TestSpawnAttachDryRunOutput(t *testing.T) {
} }
} }
// TestSquashWispToPermanent tests cross-store squash: wisp → permanent digest (bd-kwjh.4)
func TestSquashWispToPermanent(t *testing.T) {
ctx := context.Background()
// Create separate wisp and permanent stores
wispPath := t.TempDir() + "/wisp.db"
permPath := t.TempDir() + "/permanent.db"
wispStore, err := sqlite.New(ctx, wispPath)
if err != nil {
t.Fatalf("Failed to create wisp store: %v", err)
}
defer wispStore.Close()
if err := wispStore.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set wisp config: %v", err)
}
permStore, err := sqlite.New(ctx, permPath)
if err != nil {
t.Fatalf("Failed to create permanent store: %v", err)
}
defer permStore.Close()
if err := permStore.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set permanent config: %v", err)
}
// Create a wisp molecule in wisp storage
wispRoot := &types.Issue{
Title: "Deacon Patrol Cycle",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
Wisp: true,
}
if err := wispStore.CreateIssue(ctx, wispRoot, "test"); err != nil {
t.Fatalf("Failed to create wisp root: %v", err)
}
wispChild1 := &types.Issue{
Title: "Check witnesses",
Description: "Verified 3 witnesses healthy",
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
Wisp: true,
CloseReason: "All healthy",
}
wispChild2 := &types.Issue{
Title: "Process mail queue",
Description: "Processed 5 mail items",
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
Wisp: true,
CloseReason: "Mail delivered",
}
if err := wispStore.CreateIssue(ctx, wispChild1, "test"); err != nil {
t.Fatalf("Failed to create wisp child1: %v", err)
}
if err := wispStore.CreateIssue(ctx, wispChild2, "test"); err != nil {
t.Fatalf("Failed to create wisp child2: %v", err)
}
// Add parent-child dependencies
if err := wispStore.AddDependency(ctx, &types.Dependency{
IssueID: wispChild1.ID,
DependsOnID: wispRoot.ID,
Type: types.DepParentChild,
}, "test"); err != nil {
t.Fatalf("Failed to add child1 dependency: %v", err)
}
if err := wispStore.AddDependency(ctx, &types.Dependency{
IssueID: wispChild2.ID,
DependsOnID: wispRoot.ID,
Type: types.DepParentChild,
}, "test"); err != nil {
t.Fatalf("Failed to add child2 dependency: %v", err)
}
// Load the subgraph
subgraph, err := loadTemplateSubgraph(ctx, wispStore, wispRoot.ID)
if err != nil {
t.Fatalf("Failed to load wisp subgraph: %v", err)
}
// Verify subgraph loaded correctly
if len(subgraph.Issues) != 3 {
t.Fatalf("Expected 3 issues in subgraph, got %d", len(subgraph.Issues))
}
// Perform cross-store squash
result, err := squashWispToPermanent(ctx, wispStore, permStore, subgraph, false, "", "test")
if err != nil {
t.Fatalf("squashWispToPermanent failed: %v", err)
}
// Verify result
if result.SquashedCount != 3 {
t.Errorf("SquashedCount = %d, want 3", result.SquashedCount)
}
if !result.WispSquash {
t.Error("WispSquash should be true")
}
if result.DigestID == "" {
t.Error("DigestID should not be empty")
}
if result.DeletedCount != 3 {
t.Errorf("DeletedCount = %d, want 3", result.DeletedCount)
}
// Verify digest was created in permanent storage
digest, err := permStore.GetIssue(ctx, result.DigestID)
if err != nil {
t.Fatalf("Failed to get digest from permanent store: %v", err)
}
if digest.Wisp {
t.Error("Digest should NOT be a wisp")
}
if digest.Status != types.StatusClosed {
t.Errorf("Digest status = %v, want closed", digest.Status)
}
if !strings.Contains(digest.Title, "Deacon Patrol Cycle") {
t.Errorf("Digest title %q should contain original molecule title", digest.Title)
}
if !strings.Contains(digest.Description, "Check witnesses") {
t.Error("Digest description should contain child titles")
}
// Verify wisps were deleted from wisp storage
// Note: GetIssue returns (nil, nil) when issue doesn't exist
rootIssue, err := wispStore.GetIssue(ctx, wispRoot.ID)
if err != nil {
t.Errorf("Unexpected error checking root deletion: %v", err)
}
if rootIssue != nil {
t.Error("Wisp root should have been deleted")
}
child1Issue, err := wispStore.GetIssue(ctx, wispChild1.ID)
if err != nil {
t.Errorf("Unexpected error checking child1 deletion: %v", err)
}
if child1Issue != nil {
t.Error("Wisp child1 should have been deleted")
}
child2Issue, err := wispStore.GetIssue(ctx, wispChild2.ID)
if err != nil {
t.Errorf("Unexpected error checking child2 deletion: %v", err)
}
if child2Issue != nil {
t.Error("Wisp child2 should have been deleted")
}
}
// TestSquashWispToPermanentWithSummary tests that agent summaries override auto-generation
func TestSquashWispToPermanentWithSummary(t *testing.T) {
ctx := context.Background()
wispPath := t.TempDir() + "/wisp.db"
permPath := t.TempDir() + "/permanent.db"
wispStore, err := sqlite.New(ctx, wispPath)
if err != nil {
t.Fatalf("Failed to create wisp store: %v", err)
}
defer wispStore.Close()
if err := wispStore.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set wisp config: %v", err)
}
permStore, err := sqlite.New(ctx, permPath)
if err != nil {
t.Fatalf("Failed to create permanent store: %v", err)
}
defer permStore.Close()
if err := permStore.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set permanent config: %v", err)
}
// Create a simple wisp molecule
wispRoot := &types.Issue{
Title: "Patrol Cycle",
Status: types.StatusClosed,
Priority: 1,
IssueType: types.TypeEpic,
Wisp: true,
}
if err := wispStore.CreateIssue(ctx, wispRoot, "test"); err != nil {
t.Fatalf("Failed to create wisp root: %v", err)
}
subgraph := &MoleculeSubgraph{
Root: wispRoot,
Issues: []*types.Issue{wispRoot},
}
// Squash with agent-provided summary
agentSummary := "## AI-Generated Patrol Summary\n\nAll systems healthy. No issues found."
result, err := squashWispToPermanent(ctx, wispStore, permStore, subgraph, true, agentSummary, "test")
if err != nil {
t.Fatalf("squashWispToPermanent failed: %v", err)
}
// Verify digest uses agent summary
digest, err := permStore.GetIssue(ctx, result.DigestID)
if err != nil {
t.Fatalf("Failed to get digest: %v", err)
}
if digest.Description != agentSummary {
t.Errorf("Digest should use agent summary.\nGot: %s\nWant: %s", digest.Description, agentSummary)
}
}
// TestSquashWispToPermanentKeepChildren tests --keep-children flag
func TestSquashWispToPermanentKeepChildren(t *testing.T) {
ctx := context.Background()
wispPath := t.TempDir() + "/wisp.db"
permPath := t.TempDir() + "/permanent.db"
wispStore, err := sqlite.New(ctx, wispPath)
if err != nil {
t.Fatalf("Failed to create wisp store: %v", err)
}
defer wispStore.Close()
if err := wispStore.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set wisp config: %v", err)
}
permStore, err := sqlite.New(ctx, permPath)
if err != nil {
t.Fatalf("Failed to create permanent store: %v", err)
}
defer permStore.Close()
if err := permStore.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("Failed to set permanent config: %v", err)
}
// Create a wisp molecule
wispRoot := &types.Issue{
Title: "Test Molecule",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
Wisp: true,
}
if err := wispStore.CreateIssue(ctx, wispRoot, "test"); err != nil {
t.Fatalf("Failed to create wisp root: %v", err)
}
subgraph := &MoleculeSubgraph{
Root: wispRoot,
Issues: []*types.Issue{wispRoot},
}
// Squash with keepChildren=true
result, err := squashWispToPermanent(ctx, wispStore, permStore, subgraph, true, "", "test")
if err != nil {
t.Fatalf("squashWispToPermanent failed: %v", err)
}
// Verify no deletion
if result.DeletedCount != 0 {
t.Errorf("DeletedCount = %d, want 0 (keep-children)", result.DeletedCount)
}
if !result.KeptChildren {
t.Error("KeptChildren should be true")
}
// Wisp should still exist
_, err = wispStore.GetIssue(ctx, wispRoot.ID)
if err != nil {
t.Error("Wisp should still exist with --keep-children")
}
// Digest should still be created
_, err = permStore.GetIssue(ctx, result.DigestID)
if err != nil {
t.Error("Digest should be created even with --keep-children")
}
}
// TestWispFilteringFromExport verifies that wisp issues are filtered // TestWispFilteringFromExport verifies that wisp issues are filtered
// from JSONL export (bd-687g). Wisp issues should only exist in SQLite, // from JSONL export (bd-687g). Wisp issues should only exist in SQLite,
// not in issues.jsonl, to prevent "zombie" resurrection after mol squash. // not in issues.jsonl, to prevent "zombie" resurrection after mol squash.
@@ -2238,7 +1956,7 @@ func TestBondProtoMolWithRef(t *testing.T) {
// Bond proto to patrol with custom child ref // Bond proto to patrol with custom child ref
vars := map[string]string{"polecat_name": "ace"} vars := map[string]string{"polecat_name": "ace"}
childRef := "arm-{{polecat_name}}" childRef := "arm-{{polecat_name}}"
result, err := bondProtoMol(ctx, s, protoRoot, patrol, types.BondTypeSequential, vars, childRef, "test", false) result, err := bondProtoMol(ctx, s, protoRoot, patrol, types.BondTypeSequential, vars, childRef, "test", false, false)
if err != nil { if err != nil {
t.Fatalf("bondProtoMol failed: %v", err) t.Fatalf("bondProtoMol failed: %v", err)
} }
@@ -2309,14 +2027,14 @@ func TestBondProtoMolMultipleArms(t *testing.T) {
// Bond arm-ace // Bond arm-ace
varsAce := map[string]string{"name": "ace"} varsAce := map[string]string{"name": "ace"}
resultAce, err := bondProtoMol(ctx, s, proto, patrol, types.BondTypeParallel, varsAce, "arm-{{name}}", "test", false) resultAce, err := bondProtoMol(ctx, s, proto, patrol, types.BondTypeParallel, varsAce, "arm-{{name}}", "test", false, false)
if err != nil { if err != nil {
t.Fatalf("bondProtoMol (ace) failed: %v", err) t.Fatalf("bondProtoMol (ace) failed: %v", err)
} }
// Bond arm-nux // Bond arm-nux
varsNux := map[string]string{"name": "nux"} varsNux := map[string]string{"name": "nux"}
resultNux, err := bondProtoMol(ctx, s, proto, patrol, types.BondTypeParallel, varsNux, "arm-{{name}}", "test", false) resultNux, err := bondProtoMol(ctx, s, proto, patrol, types.BondTypeParallel, varsNux, "arm-{{name}}", "test", false, false)
if err != nil { if err != nil {
t.Fatalf("bondProtoMol (nux) failed: %v", err) t.Fatalf("bondProtoMol (nux) failed: %v", err)
} }

View File

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

View File

@@ -620,6 +620,9 @@ func FindAllDatabases() []DatabaseInfo {
// WispDirName is the default name for the wisp storage directory. // WispDirName is the default name for the wisp storage directory.
// This directory is a sibling to .beads/ and should be gitignored. // This directory is a sibling to .beads/ and should be gitignored.
// Wisps are ephemeral molecules - the "steam" in Gas Town's engine metaphor. // Wisps are ephemeral molecules - the "steam" in Gas Town's engine metaphor.
//
// Deprecated: Wisps are now stored in the main database with Wisp=true.
// The separate .beads-wisp/ directory is no longer used ("bd-bkul").
const WispDirName = ".beads-wisp" const WispDirName = ".beads-wisp"
// FindWispDir locates or determines the wisp storage directory. // FindWispDir locates or determines the wisp storage directory.
@@ -627,6 +630,9 @@ const WispDirName = ".beads-wisp"
// //
// Returns the path to the wisp directory (which may not exist yet). // Returns the path to the wisp directory (which may not exist yet).
// Returns empty string if no .beads directory can be found. // Returns empty string if no .beads directory can be found.
//
// Deprecated: Wisps are now stored in the main database with Wisp=true.
// The separate .beads-wisp/ directory is no longer used ("bd-bkul").
func FindWispDir() string { func FindWispDir() string {
beadsDir := FindBeadsDir() beadsDir := FindBeadsDir()
if beadsDir == "" { if beadsDir == "" {
@@ -642,6 +648,9 @@ func FindWispDir() string {
// FindWispDatabasePath returns the path to the wisp database file. // FindWispDatabasePath returns the path to the wisp database file.
// Creates the wisp directory if it doesn't exist. // Creates the wisp directory if it doesn't exist.
// Returns empty string if no .beads directory can be found. // Returns empty string if no .beads directory can be found.
//
// Deprecated: Wisps are now stored in the main database with Wisp=true.
// The separate .beads-wisp/ directory is no longer used ("bd-bkul").
func FindWispDatabasePath() (string, error) { func FindWispDatabasePath() (string, error) {
wispDir := FindWispDir() wispDir := FindWispDir()
if wispDir == "" { if wispDir == "" {
@@ -660,6 +669,9 @@ func FindWispDatabasePath() (string, error) {
// Creates the database and directory if they don't exist. // Creates the database and directory if they don't exist.
// The wisp database uses the same schema as the main database. // The wisp database uses the same schema as the main database.
// Automatically copies issue_prefix from the main beads config if not set. // Automatically copies issue_prefix from the main beads config if not set.
//
// Deprecated: Wisps are now stored in the main database with Wisp=true.
// The separate .beads-wisp/ directory is no longer used ("bd-bkul").
func NewWispStorage(ctx context.Context) (Storage, error) { func NewWispStorage(ctx context.Context) (Storage, error) {
dbPath, err := FindWispDatabasePath() dbPath, err := FindWispDatabasePath()
if err != nil { if err != nil {
@@ -696,6 +708,9 @@ func NewWispStorage(ctx context.Context) (Storage, error) {
// EnsureWispGitignore ensures the wisp directory is gitignored. // EnsureWispGitignore ensures the wisp directory is gitignored.
// This should be called after creating the wisp directory. // This should be called after creating the wisp directory.
//
// Deprecated: Wisps are now stored in the main database with Wisp=true.
// The separate .beads-wisp/ directory is no longer used ("bd-bkul").
func EnsureWispGitignore() error { func EnsureWispGitignore() error {
beadsDir := FindBeadsDir() beadsDir := FindBeadsDir()
if beadsDir == "" { if beadsDir == "" {
@@ -743,6 +758,9 @@ func EnsureWispGitignore() error {
// IsWispDatabase checks if a database path is a wisp database. // IsWispDatabase checks if a database path is a wisp database.
// Returns true if the database is in a .beads-wisp directory. // Returns true if the database is in a .beads-wisp directory.
//
// Deprecated: Wisps are now stored in the main database with Wisp=true.
// The separate .beads-wisp/ directory is no longer used ("bd-bkul").
func IsWispDatabase(dbPath string) bool { func IsWispDatabase(dbPath string) bool {
if dbPath == "" { if dbPath == "" {
return false return false