Files
beads/cmd/bd/wisp.go
aleiby 0b6df198a5 fix(ready): exclude molecule steps from bd ready by default (#1246)
* fix(ready): exclude molecule steps from bd ready by default (GH#1239)

Add ID prefix constants (IDPrefixMol, IDPrefixWisp) to types.go as single
source of truth. Update pour.go and wisp.go to use these constants.

GetReadyWork now excludes issues with -mol- in their ID when no explicit
type filter is specified. Users can still see mol steps with --type=task.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(ready): config-driven ID pattern exclusion (GH#1239)

Add ready.exclude_id_patterns config for excluding IDs from bd ready.
Default patterns: -mol-, -wisp- (molecule steps and wisps).

Changes:
- Add IncludeMolSteps to WorkFilter for internal callers
- Update findGateReadyMolecules and getMoleculeCurrentStep to use it
- Make exclusion patterns config-driven via ready.exclude_id_patterns
- Remove hardcoded MolStepIDPattern() in favor of config
- Add test for custom patterns (e.g., gastown's -role-)

Usage: bd config set ready.exclude_id_patterns "-mol-,-wisp-,-role-"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: remove -role- example from ready.go comments

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: remove GH#1239 references from code comments

Issue references belong in commit messages, not code.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:30:15 -08:00

691 lines
20 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"os"
"slices"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
)
// Wisp commands - manage ephemeral molecules
//
// Wisps are ephemeral issues with Ephemeral=true in the main database.
// They're used for patrol cycles and operational loops that shouldn't
// be exported to JSONL (and thus not synced via git).
//
// Commands:
// bd mol wisp list - List all wisps in current context
// bd mol wisp gc - Garbage collect orphaned wisps
var wispCmd = &cobra.Command{
Use: "wisp [proto-id]",
Short: "Create or manage wisps (ephemeral molecules)",
Long: `Create or manage wisps - EPHEMERAL molecules for operational workflows.
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).
WHEN TO USE WISP vs POUR:
wisp (vapor): Ephemeral work that auto-cleans up
- Release workflows (one-time execution)
- Patrol cycles (deacon, witness, refinery)
- Health checks and diagnostics
- Any operational workflow without audit value
pour (liquid): Persistent work that needs audit trail
- Feature implementations spanning multiple sessions
- Work you may need to reference later
- Anything worth preserving in git history
TIP: Formulas can specify phase:"vapor" to recommend wisp usage.
If you use pour on a vapor-phase formula, you'll get a warning.
The wisp lifecycle:
1. Create: bd mol wisp <proto> or bd create --ephemeral
2. Execute: Normal bd operations work on wisp issues
3. Squash: bd mol squash <id> (clears Ephemeral flag, promotes to persistent)
4. Or burn: bd mol burn <id> (deletes without creating digest)
Examples:
bd mol wisp beads-release --var version=1.0 # Release workflow
bd mol wisp mol-patrol # Ephemeral patrol cycle
bd mol wisp list # List all wisps
bd mol wisp gc # Garbage collect old wisps
Subcommands:
list List all wisps in current context
gc Garbage collect orphaned wisps`,
Args: cobra.MaximumNArgs(1),
Run: runWisp,
}
// WispListItem represents a wisp in list output
type WispListItem struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Priority int `json:"priority"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Old bool `json:"old,omitempty"` // Not updated in 24+ hours
}
// WispListResult is the JSON output for wisp list
type WispListResult struct {
Wisps []WispListItem `json:"wisps"`
Count int `json:"count"`
OldCount int `json:"old_count,omitempty"`
}
// OldThreshold is how old a wisp must be to be flagged as old (time-based, for ephemeral cleanup)
const OldThreshold = 24 * time.Hour
// 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{
Use: "create <proto-id>",
Short: "Instantiate a proto as a wisp (solid -> vapor)",
Long: `Create a wisp from a proto - sublimation from solid to vapor.
This is the chemistry-inspired command for creating ephemeral work from templates.
The resulting wisp is stored in the main database with Ephemeral=true and NOT exported to JSONL.
Phase transition: Proto (solid) -> Wisp (vapor)
Use wisp for:
- Patrol cycles (deacon, witness)
- Health checks and monitoring
- One-shot orchestration runs
- Routine operations with no audit value
The wisp will:
- Be stored in main database with Ephemeral=true flag
- NOT be exported to JSONL (and thus not synced via git)
- Either evaporate (burn) or condense to digest (squash)
Examples:
bd mol wisp create mol-patrol # Ephemeral patrol cycle
bd mol wisp create mol-health-check # One-time health check
bd mol wisp create mol-diagnostics --var target=db # Diagnostic run`,
Args: cobra.ExactArgs(1),
Run: runWispCreate,
}
func runWispCreate(cmd *cobra.Command, args []string) {
CheckReadonly("wisp create")
ctx := rootCtx
// Wisp create requires direct store access (daemon auto-bypassed for wisp ops)
if store == nil {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
fmt.Fprintf(os.Stderr, "Hint: run 'bd init' or 'bd import' to initialize the database\n")
os.Exit(1)
}
dryRun, _ := cmd.Flags().GetBool("dry-run")
varFlags, _ := cmd.Flags().GetStringArray("var")
// Parse variables
vars := make(map[string]string)
for _, v := range varFlags {
parts := strings.SplitN(v, "=", 2)
if len(parts) != 2 {
fmt.Fprintf(os.Stderr, "Error: invalid variable format '%s', expected 'key=value'\n", v)
os.Exit(1)
}
vars[parts[0]] = parts[1]
}
// Try to load as formula first (ephemeral proto)
// If that fails, fall back to loading from DB (legacy proto beads)
var subgraph *TemplateSubgraph
var protoID string
// Try to cook formula inline (ephemeral protos)
// This works for any valid formula name, not just "mol-" prefixed ones
// Pass vars for step condition filtering (bd-7zka.1)
sg, err := resolveAndCookFormulaWithVars(args[0], nil, vars)
if err == nil {
subgraph = sg
protoID = sg.Root.ID
}
if subgraph == nil {
// Resolve proto ID (legacy path)
protoID = args[0]
// Try to resolve partial ID if it doesn't look like a full ID
if !strings.HasPrefix(protoID, "bd-") && !strings.HasPrefix(protoID, "gt-") && !strings.HasPrefix(protoID, "mol-") {
// Might be a partial ID, try to resolve
if resolved, err := resolvePartialIDDirect(ctx, protoID); err == nil {
protoID = resolved
}
}
// Check if it's a named molecule (mol-xxx) - look up in catalog
if strings.HasPrefix(protoID, "mol-") {
// Find the proto by name
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{
Labels: []string{MoleculeLabel},
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error searching for proto: %v\n", err)
os.Exit(1)
}
found := false
for _, issue := range issues {
if strings.Contains(issue.Title, protoID) || issue.ID == protoID {
protoID = issue.ID
found = true
break
}
}
if !found {
fmt.Fprintf(os.Stderr, "Error: '%s' not found as formula or proto\n", args[0])
fmt.Fprintf(os.Stderr, "Hint: run 'bd formula list' to see available formulas\n")
os.Exit(1)
}
}
// Load the proto
// Note: GetIssue returns (nil, nil) for not-found, so check both
protoIssue, err := store.GetIssue(ctx, protoID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading proto %s: %v\n", protoID, err)
os.Exit(1)
}
if protoIssue == nil {
fmt.Fprintf(os.Stderr, "Error: proto not found: %s\n", protoID)
os.Exit(1)
}
if !isProtoIssue(protoIssue) {
fmt.Fprintf(os.Stderr, "Error: %s is not a proto (missing '%s' label)\n", protoID, MoleculeLabel)
os.Exit(1)
}
// Load the proto subgraph from DB
subgraph, err = loadTemplateSubgraph(ctx, store, protoID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading proto: %v\n", err)
os.Exit(1)
}
}
// Apply variable defaults from formula
vars = applyVariableDefaults(vars, subgraph)
// Check for missing required variables (those without defaults)
requiredVars := extractRequiredVariables(subgraph)
var missingVars []string
for _, v := range requiredVars {
if _, ok := vars[v]; !ok {
missingVars = append(missingVars, v)
}
}
if len(missingVars) > 0 {
fmt.Fprintf(os.Stderr, "Error: missing required variables: %s\n", strings.Join(missingVars, ", "))
fmt.Fprintf(os.Stderr, "Provide them with: --var %s=<value>\n", missingVars[0])
os.Exit(1)
}
if dryRun {
fmt.Printf("\nDry run: would create wisp with %d issues from proto %s\n\n", len(subgraph.Issues), protoID)
fmt.Printf("Storage: main database (ephemeral=true, not exported to JSONL)\n\n")
for _, issue := range subgraph.Issues {
newTitle := substituteVariables(issue.Title, vars)
fmt.Printf(" - %s (from %s)\n", newTitle, issue.ID)
}
return
}
// Spawn as ephemeral in main database (Ephemeral=true, skips JSONL export)
// Use wisp prefix for distinct visual recognition (see types.IDPrefixWisp)
result, err := spawnMolecule(ctx, store, subgraph, vars, "", actor, true, types.IDPrefixWisp)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating wisp: %v\n", err)
os.Exit(1)
}
// Wisp issues are in main db but don't trigger JSONL export (Ephemeral flag excludes them)
if jsonOutput {
type wispCreateResult struct {
*InstantiateResult
Phase string `json:"phase"`
}
outputJSON(wispCreateResult{result, "vapor"})
return
}
fmt.Printf("%s Created wisp: %d issues\n", ui.RenderPass("✓"), result.Created)
fmt.Printf(" Root issue: %s\n", result.NewEpicID)
fmt.Printf(" Phase: vapor (ephemeral, not exported to JSONL)\n")
fmt.Printf("\nNext steps:\n")
fmt.Printf(" bd close %s.<step> # Complete steps\n", result.NewEpicID)
fmt.Printf(" bd mol squash %s # Condense to digest (promotes to persistent)\n", result.NewEpicID)
fmt.Printf(" bd mol burn %s # Discard without creating digest\n", result.NewEpicID)
}
// isProtoIssue checks if an issue is a proto (has the template label)
func isProtoIssue(issue *types.Issue) bool {
for _, label := range issue.Labels {
if label == MoleculeLabel {
return true
}
}
return false
}
// resolvePartialIDDirect resolves a partial ID directly from store
func resolvePartialIDDirect(ctx context.Context, partial string) (string, error) {
// Try direct lookup first
// Note: GetIssue returns (nil, nil) for not-found, so check both
if issue, err := store.GetIssue(ctx, partial); err == nil && issue != nil {
return issue.ID, nil
}
// Search by prefix
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{
IDs: []string{partial + "*"},
})
if err != nil {
return "", err
}
if len(issues) == 1 {
return issues[0].ID, nil
}
if len(issues) > 1 {
return "", fmt.Errorf("ambiguous ID: %s matches %d issues", partial, len(issues))
}
return "", fmt.Errorf("not found: %s", partial)
}
var wispListCmd = &cobra.Command{
Use: "list",
Short: "List all wisps in current context",
Long: `List all wisps (ephemeral molecules) in the current context.
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).
The list shows:
- ID: Issue ID of the wisp
- Title: Wisp title
- Status: Current status (open, in_progress, closed)
- Started: When the wisp was created
- Updated: Last modification time
Old wisp detection:
- Old wisps haven't been updated in 24+ hours
- Use 'bd mol wisp gc' to clean up old/abandoned wisps
Examples:
bd mol wisp list # List all wisps
bd mol wisp list --json # JSON output for programmatic use
bd mol wisp list --all # Include closed wisps`,
Run: runWispList,
}
func runWispList(cmd *cobra.Command, args []string) {
ctx := rootCtx
showAll, _ := cmd.Flags().GetBool("all")
// Check for database connection
if store == nil && daemonClient == nil {
if jsonOutput {
outputJSON(WispListResult{
Wisps: []WispListItem{},
Count: 0,
})
} else {
fmt.Println("No database connection")
}
return
}
// Query wisps from main database using Ephemeral filter
ephemeralFlag := true
var issues []*types.Issue
var err error
if daemonClient != nil {
// Use daemon RPC
resp, rpcErr := daemonClient.List(&rpc.ListArgs{
Ephemeral: &ephemeralFlag,
})
if rpcErr != nil {
err = rpcErr
} else {
if jsonErr := json.Unmarshal(resp.Data, &issues); jsonErr != nil {
err = jsonErr
}
}
} else {
// Direct database access
filter := types.IssueFilter{
Ephemeral: &ephemeralFlag,
}
issues, err = store.SearchIssues(ctx, "", filter)
}
if err != nil {
fmt.Fprintf(os.Stderr, "Error listing wisps: %v\n", err)
os.Exit(1)
}
// Filter closed issues unless --all is specified
if !showAll {
var filtered []*types.Issue
for _, issue := range issues {
if issue.Status != types.StatusClosed {
filtered = append(filtered, issue)
}
}
issues = filtered
}
// Convert to list items and detect old wisps
now := time.Now()
items := make([]WispListItem, 0, len(issues))
oldCount := 0
for _, issue := range issues {
item := WispListItem{
ID: issue.ID,
Title: issue.Title,
Status: string(issue.Status),
Priority: issue.Priority,
CreatedAt: issue.CreatedAt,
UpdatedAt: issue.UpdatedAt,
}
// Check if old (not updated in 24+ hours)
if now.Sub(issue.UpdatedAt) > OldThreshold {
item.Old = true
oldCount++
}
items = append(items, item)
}
// Sort by updated_at descending (most recent first)
slices.SortFunc(items, func(a, b WispListItem) int {
return b.UpdatedAt.Compare(a.UpdatedAt) // descending order
})
result := WispListResult{
Wisps: items,
Count: len(items),
OldCount: oldCount,
}
if jsonOutput {
outputJSON(result)
return
}
// Human-readable output
if len(items) == 0 {
fmt.Println("No wisps found")
return
}
fmt.Printf("Wisps (%d):\n\n", len(items))
// Print header
fmt.Printf("%-12s %-10s %-4s %-46s %s\n",
"ID", "STATUS", "PRI", "TITLE", "UPDATED")
fmt.Println(strings.Repeat("-", 90))
for _, item := range items {
// Truncate title if too long
title := item.Title
if len(title) > 44 {
title = title[:41] + "..."
}
// Format status with color
status := ui.RenderStatus(item.Status)
// Format updated time
updated := formatTimeAgo(item.UpdatedAt)
if item.Old {
updated = ui.RenderWarn(updated + " ⚠")
}
fmt.Printf("%-12s %-10s P%-3d %-46s %s\n",
item.ID, status, item.Priority, title, updated)
}
// Print warnings
if oldCount > 0 {
fmt.Printf("\n%s %d old wisp(s) (not updated in 24+ hours)\n",
ui.RenderWarn("⚠"), oldCount)
fmt.Println(" Hint: Use 'bd mol wisp gc' to clean up old wisps")
}
}
// formatTimeAgo returns a human-readable relative time
func formatTimeAgo(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
mins := int(d.Minutes())
if mins == 1 {
return "1 min ago"
}
return fmt.Sprintf("%d mins ago", mins)
case d < 24*time.Hour:
hours := int(d.Hours())
if hours == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", hours)
case d < 7*24*time.Hour:
days := int(d.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
default:
return t.Format("2006-01-02")
}
}
var wispGCCmd = &cobra.Command{
Use: "gc",
Short: "Garbage collect old/abandoned wisps",
Long: `Garbage collect old or abandoned wisps from the database.
A wisp is considered abandoned if:
- It hasn't been updated in --age duration and is not closed
Abandoned wisps are deleted without creating a digest. Use 'bd mol squash'
if you want to preserve a summary before garbage collection.
Note: This uses time-based cleanup, appropriate for ephemeral wisps.
For graph-pressure staleness detection (blocking other work), see 'bd mol stale'.
Examples:
bd mol wisp gc # Clean abandoned wisps (default: 1h threshold)
bd mol wisp gc --dry-run # Preview what would be cleaned
bd mol wisp gc --age 24h # Custom age threshold
bd mol wisp gc --all # Also clean closed wisps older than threshold`,
Run: runWispGC,
}
// WispGCResult is the JSON output for wisp gc
type WispGCResult struct {
CleanedIDs []string `json:"cleaned_ids"`
CleanedCount int `json:"cleaned_count"`
Candidates int `json:"candidates,omitempty"`
DryRun bool `json:"dry_run,omitempty"`
}
func runWispGC(cmd *cobra.Command, args []string) {
CheckReadonly("wisp gc")
ctx := rootCtx
dryRun, _ := cmd.Flags().GetBool("dry-run")
ageStr, _ := cmd.Flags().GetString("age")
cleanAll, _ := cmd.Flags().GetBool("all")
// Parse age threshold
ageThreshold := time.Hour // Default 1 hour
if ageStr != "" {
var err error
ageThreshold, err = time.ParseDuration(ageStr)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid --age duration: %v\n", err)
os.Exit(1)
}
}
// Wisp gc requires direct store access for deletion (daemon auto-bypassed for wisp ops)
if store == nil {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
fmt.Fprintf(os.Stderr, "Hint: run 'bd init' or 'bd import' to initialize the database\n")
os.Exit(1)
}
// Query wisps from main database using Ephemeral filter
ephemeralFlag := true
filter := types.IssueFilter{
Ephemeral: &ephemeralFlag,
}
issues, err := store.SearchIssues(ctx, "", filter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error listing wisps: %v\n", err)
os.Exit(1)
}
// Find old/abandoned wisps
now := time.Now()
var abandoned []*types.Issue
for _, issue := range issues {
// Skip closed issues unless --all is specified
if issue.Status == types.StatusClosed && !cleanAll {
continue
}
// Check if old (not updated within age threshold)
if now.Sub(issue.UpdatedAt) > ageThreshold {
abandoned = append(abandoned, issue)
}
}
if len(abandoned) == 0 {
if jsonOutput {
outputJSON(WispGCResult{
CleanedIDs: []string{},
CleanedCount: 0,
DryRun: dryRun,
})
} else {
fmt.Println("No abandoned wisps found")
}
return
}
if dryRun {
if jsonOutput {
ids := make([]string, len(abandoned))
for i, o := range abandoned {
ids[i] = o.ID
}
outputJSON(WispGCResult{
CleanedIDs: ids,
Candidates: len(abandoned),
CleanedCount: 0,
DryRun: true,
})
} else {
fmt.Printf("Dry run: would clean %d abandoned wisp(s):\n\n", len(abandoned))
for _, issue := range abandoned {
age := formatTimeAgo(issue.UpdatedAt)
fmt.Printf(" %s: %s (last updated: %s)\n", issue.ID, issue.Title, age)
}
fmt.Printf("\nRun without --dry-run to delete these wisps.\n")
}
return
}
// Delete abandoned wisps
var cleanedIDs []string
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
if !ok {
fmt.Fprintf(os.Stderr, "Error: wisp gc requires SQLite storage backend\n")
os.Exit(1)
}
for _, issue := range abandoned {
if err := sqliteStore.DeleteIssue(ctx, issue.ID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to delete %s: %v\n", issue.ID, err)
continue
}
cleanedIDs = append(cleanedIDs, issue.ID)
}
result := WispGCResult{
CleanedIDs: cleanedIDs,
CleanedCount: len(cleanedIDs),
}
if jsonOutput {
outputJSON(result)
return
}
fmt.Printf("%s Cleaned %d abandoned wisp(s)\n", ui.RenderPass("✓"), result.CleanedCount)
for _, id := range cleanedIDs {
fmt.Printf(" - %s\n", id)
}
}
func init() {
// Wisp command flags (for direct create: bd mol wisp <proto>)
wispCmd.Flags().StringArray("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().StringArray("var", []string{}, "Variable substitution (key=value)")
wispCreateCmd.Flags().Bool("dry-run", false, "Preview what would be created")
wispListCmd.Flags().Bool("all", false, "Include closed wisps")
wispGCCmd.Flags().Bool("dry-run", false, "Preview what would be cleaned")
wispGCCmd.Flags().String("age", "1h", "Age threshold for abandoned wisp detection")
wispGCCmd.Flags().Bool("all", false, "Also clean closed wisps older than threshold")
wispCmd.AddCommand(wispCreateCmd)
wispCmd.AddCommand(wispListCmd)
wispCmd.AddCommand(wispGCCmd)
molCmd.AddCommand(wispCmd)
}