From 7e568770de9f43e48bba8ca8e5558a06406d8957 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Wed, 24 Dec 2025 21:34:14 -0800 Subject: [PATCH] refactor: remove legacy .beads-wisp infrastructure (gt-5klh) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wisps are now just a flag on regular beads issues (Wisp=true). No separate directory needed - hooks stored in .beads/. Changes: - wisp package: WispDir now points to .beads/, removed PatrolCycle - manager.go: removed initWispBeads() - no separate dir to create - mrqueue.go: MRs stored in .beads/mq/ instead of .beads-wisp/mq/ - doctor: removed obsolete wisp directory checks - docs: updated wisp-architecture.md to reflect simplified model 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 3 - docs/wisp-architecture.md | 288 +++++------------ internal/cmd/doctor.go | 7 - internal/cmd/molecule.go | 2 +- internal/cmd/rig.go | 2 - internal/doctor/patrol_check.go | 30 +- internal/doctor/wisp_check.go | 529 +------------------------------- internal/mrqueue/mrqueue.go | 20 +- internal/rig/manager.go | 33 -- internal/rig/manager_test.go | 46 --- internal/wisp/io.go | 60 +--- internal/wisp/types.go | 92 ++---- 12 files changed, 148 insertions(+), 964 deletions(-) diff --git a/.gitignore b/.gitignore index e635ffa8..582d2133 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,3 @@ gt # Runtime state state.json .runtime/ - -# Ephemeral wisps (never tracked) -.beads-wisp/ diff --git a/docs/wisp-architecture.md b/docs/wisp-architecture.md index 16709774..b0446e67 100644 --- a/docs/wisp-architecture.md +++ b/docs/wisp-architecture.md @@ -1,252 +1,108 @@ -# Wisp Architecture: Transient Molecule Storage +# Wisp Architecture: Simplified -> Status: Design Spec v1 - December 2024 +> Status: Updated December 2024 - Simplified from separate directory to flag-based ## Overview -**Wisps** are transient molecule execution traces - the "steam" in Gas Town's engine -metaphor. Claude is fire; Claude Code is a Steam engine; Gas Town is a Steam Train, -with Beads as the tracks. Wisps are steam vapors that dissipate after the work is done. +**Wisps** are ephemeral issues - transient workflow state that should not be synced +to the shared repository. They're used for operational messages (like lifecycle mail) +and patrol cycle traces that would otherwise accumulate unbounded. ## Core Principle -**Wisps are local operational state, not project history.** +**Wisps are regular issues with `Wisp: true` flag.** -| Artifact | Storage | Git Tracked | Purpose | -|----------|---------|-------------|---------| -| Issues | `.beads/issues.jsonl` | Yes | Permanent project history | -| Wisps | `.beads-wisp/issues.jsonl` | **No** | Transient execution traces | -| Digests | `.beads/issues.jsonl` | Yes | Compressed summaries of squashed wisps | +The old architecture used a separate `.beads-wisp/` directory. This was over-engineered. +The simplified approach: -## Storage Architecture +| Old | New | +|-----|-----| +| `.beads-wisp/issues.jsonl` | `.beads/issues.jsonl` with `Wisp: true` | +| Separate directory, git init, gitignore | Single database, filtered on sync | +| Complex dual-inbox routing | Simple flag check | -### Directory Structure +## How It Works -``` -~/gt/gastown/ # Rig root (not a git repo) -├── .beads-wisp/ # Shared wisp storage (rig-level, gitignored) -│ └── issues.jsonl # In-progress wisps (Deacon, Witness, Refinery) -│ -├── mayor/rig/ # Mayor's canonical clone -│ └── .beads/ # CANONICAL rig beads (versioned) -│ ├── issues.jsonl # Permanent issues + digests -│ └── config.yaml -│ -├── refinery/rig/ # Refinery's clone -│ └── .beads/ # Inherits from mayor/rig -│ -├── witness/ # Witness (no clone needed) -│ -└── polecats// # Polecat worktrees - └── .beads/ # Inherits from mayor/rig +### Creating Wisps + +```bash +# Create an ephemeral issue +bd create --title "Patrol cycle" --wisp + +# Send ephemeral mail (automatically sets Wisp=true) +gt mail send --wisp -s "Lifecycle: spawn" -m "..." ``` -### Key Points +### Sync Filtering -1. **`.beads-wisp/` is at rig root** - Outside any git clone, naturally isolated -2. **All rig agents share `/.beads-wisp/`** - Deacon, Witness, Refinery -3. **Digests go to canonical `.beads/`** - Permanent record after squash -4. **Wisps are deleted after squash/burn** - No accumulation -5. **Polecats don't use wisps** - Each assignment is a deliverable with audit value +When `bd sync` exports to JSONL for git: +- Issues with `Wisp: true` are **excluded** +- Only permanent issues are synced to remote +- No separate directory needed -### Gitignore Entry +### Querying -Add to `.beads/.gitignore`: -``` -.beads-wisp/ +```bash +# List all issues (including wisps) +bd list + +# List only wisps +bd list --wisp + +# List only permanent issues +bd list --no-wisp ``` -Or add to rig-level `.gitignore`: -``` -**/.beads-wisp/ +## Use Cases + +### Ephemeral Mail (Lifecycle Messages) + +Spawn notifications, session handoffs, and other operational messages that: +- Don't need to be synced to remote +- Would accumulate unbounded +- Have no long-term audit value + +```bash +gt mail send gastown/polecats/nux --wisp \ + -s "LIFECYCLE: spawn" \ + -m "Work on issue gt-abc" ``` -## Wisp Lifecycle +### Patrol Cycle Traces -``` -bd mol bond --wisp - │ - ▼ -┌─────────────────────────┐ -│ .beads-wisp/ │ -│ └── issues.jsonl │ ← Wisp created here -│ └── {id, wisp: true, ...} -└────────────┬────────────┘ - │ - ┌────────┴────────┐ - ▼ ▼ -bd mol burn bd mol squash - │ │ - ▼ ▼ -(deleted) ┌─────────────────────────┐ - │ .beads/issues.jsonl │ - │ └── {id, type: digest} │ ← Digest here - └─────────────────────────┘ -``` +Deacon, Witness, and Refinery run continuous loops. Each cycle would create +accumulating history. Wisps let them track cycle state without permanent records. -## Role Assignments +### Hook Files -### Roles That Use Wisps +Agent hook files (`hook-.json`) are stored in `.beads/` but are local-only +runtime state, not synced. These track what work is assigned to each agent for +restart-and-resume. -These roles have repetitive/cyclic work that would accumulate without wisps: - -| Role | Molecule | Storage Location | Squash Frequency | -|------|----------|------------------|------------------| -| **Deacon** | mol-deacon-patrol | `/.beads-wisp/` | Per cycle | -| **Witness** | mol-witness-patrol | `/.beads-wisp/` | Per cycle | -| **Refinery** | mol-refinery-cycle | `/.beads-wisp/` | Per cycle | - -### Roles That Use Regular Molecules - -These roles do discrete work with audit value: - -| Role | Molecule | Storage | Reason | -|------|----------|---------|--------| -| **Polecat** | mol-polecat-work | .beads/issues.jsonl | Each assignment is a deliverable | -| **Mayor** | (ad-hoc) | .beads/issues.jsonl | Coordination has history value | -| **Crew** | (ad-hoc) | .beads/issues.jsonl | User work needs audit trail | - -### Decision Matrix +## Decision Matrix | Question | Answer | Use | |----------|--------|-----| -| Is this work repetitive/cyclic? | Yes | Wisp | -| Does the outcome matter more than the trace? | Yes | Wisp | -| Would this accumulate unbounded over time? | Yes | Wisp | -| Is this a discrete deliverable? | Yes | Regular Mol | -| Might I need to reference this later? | Yes | Regular Mol | -| Does this represent user-requested work? | Yes | Regular Mol | +| Should this sync to remote? | No | Wisp | +| Is this operational/lifecycle? | Yes | Wisp | +| Would this accumulate unbounded? | Yes | Wisp | +| Does this need audit trail? | Yes | Regular issue | +| Might others need to see this? | Yes | Regular issue | -## Patrol Pattern +## Migration from .beads-wisp/ -Every role using wisps must implement this pattern: - -```go -func patrolCycle() { - // 1. Bond wisp molecule - mol := bdMolBond("mol--patrol", "--wisp") - - // 2. Execute cycle steps - for _, step := range mol.Steps { - executeStep(step) - bdMolStep(step.ID, "--complete") - } - - // 3. Generate summary (agent cognition) - summary := generateCycleSummary() - - // 4. Squash - REQUIRED (this is the cleanup) - bdMolSquash(mol.ID, "--summary", summary) - // Wisp deleted from .beads-wisp/ - // Digest created in .beads/issues.jsonl - - // 5. Sleep until next cycle - time.Sleep(patrolInterval) -} -``` - -**Critical**: Without step 4 (squash), wisps become technical debt. - -## Beads Implementation Requirements - -For this architecture to work, Beads needs: - -### New Commands +The old `.beads-wisp/` directories can be deleted: ```bash -# Bond with wisp flag (--ephemeral is an alias) -bd mol bond --wisp -# Creates in .beads-wisp/ instead of .beads/ - -# List wisps -bd wisp list -# Shows in-progress wisps - -# Garbage collect orphaned wisps -bd wisp gc -# Cleans up wisps from crashed processes +# Remove legacy wisp directories +rm -rf ~/gt/.beads-wisp/ +rm -rf ~/gt/gastown/.beads-wisp/ +find ~/gt -type d -name '.beads-wisp' -exec rm -rf {} + ``` -### Storage Behavior +No migration needed - these contained transient data with no long-term value. -| Command | With `--wisp` | Without | -|---------|---------------|---------| -| `bd mol bond` | Creates in `.beads-wisp/` | Creates in `.beads/` | -| `bd mol step` | Updates in wisp store | Updates in permanent | -| `bd mol squash` | Deletes from wisp, creates digest in permanent | Creates digest in permanent | -| `bd mol burn` | Deletes from wisp | Marks abandoned in permanent | - -### Config - -```yaml -# .beads/config.yaml -wisp: - enabled: true - directory: ../.beads-wisp # Relative to .beads/ - auto_gc: true # Clean orphans on bd init -``` - -## Crash Recovery - -If a patrol crashes mid-cycle: - -1. **Wisp persists in `.beads-wisp/`** - Provides recovery breadcrumb -2. **On restart, agent can:** - - Resume from last step (if step tracking is granular) - - Or burn and start fresh (simpler for patrol loops) -3. **`bd wisp gc` cleans orphans** - Wisps older than threshold with no active process - -### Orphan Detection - -A wisp is orphaned if: -- `process_id` field exists and process is dead -- OR `updated_at` is older than threshold (e.g., 1 hour) -- AND molecule is not complete - -## Digest Format - -When a wisp is squashed, the digest captures the outcome: - -```json -{ - "id": "gt-xyz.digest-001", - "type": "digest", - "title": "Deacon patrol cycle @ 2024-12-21T10:30:00Z", - "description": "Checked 3 witnesses, 2 refineries. All healthy. Processed 5 mail items.", - "parent": "gt-xyz", - "squashed_from": "gt-xyz.wisp-001", - "created_at": "2024-12-21T10:32:00Z" -} -``` - -Digests are queryable: -```bash -bd list --type=digest --parent=gt-deacon-patrol -# Shows all patrol cycle summaries -``` - -## Migration Path - -For existing Gas Town installations: - -1. **Add `.beads-wisp/` to gitignore** (immediate) -2. **Update patrol runners to use `--wisp`** (as patched) -3. **No migration of existing data** - Fresh start for wisp storage -4. **Optional**: Remove old `.beads-ephemeral/` directories - -## Open Questions - -1. **Digest retention**: Should old digests be pruned? How old? -2. **Wisp schema**: Do wisps need additional fields (process_id, host, etc.)? -3. **Cross-process visibility**: Should `bd wisp list` show all wisps or just current process? - -## Related Documents +## Related +- [molecules.md](molecules.md) - Molecule system (wisps can be molecule instances) - [architecture.md](architecture.md) - Overall Gas Town architecture -- [patrol-system-design.md](../../../docs/patrol-system-design.md) - Patrol system design -- [molecules.md](molecules.md) - Molecule system details - -## Implementation Tracking - -- **Beads**: bd-kwjh (Wisp storage: transient molecule tracking) -- **Gas Town**: gt-3x0z.9 (mol-deacon-patrol uses wisps) diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index 49958516..3ae03860 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -71,13 +71,6 @@ func runDoctor(cmd *cobra.Command, args []string) error { d.Register(doctor.NewLinkedPaneCheck()) d.Register(doctor.NewThemeCheck()) - // Wisp storage checks - d.Register(doctor.NewWispExistsCheck()) - d.Register(doctor.NewWispGitCheck()) - d.Register(doctor.NewWispOrphansCheck()) - d.Register(doctor.NewWispSizeCheck()) - d.Register(doctor.NewWispStaleCheck()) - // Patrol system checks d.Register(doctor.NewPatrolMoleculesExistCheck()) d.Register(doctor.NewPatrolHooksWiredCheck()) diff --git a/internal/cmd/molecule.go b/internal/cmd/molecule.go index 9ab5ad02..fcf9dc7d 100644 --- a/internal/cmd/molecule.go +++ b/internal/cmd/molecule.go @@ -35,7 +35,7 @@ LIFECYCLE: ▼ instantiate/bond ┌─────────────────┐ │ Mol (durable) │ ← tracked in .beads/ - │ Wisp (ephemeral)│ ← tracked in .beads-wisp/ + │ Wisp (ephemeral)│ ← tracked in .beads/ with Wisp=true └────────┬────────┘ │ ┌──────┴──────┐ diff --git a/internal/cmd/rig.go b/internal/cmd/rig.go index 36d0b407..34a69439 100644 --- a/internal/cmd/rig.go +++ b/internal/cmd/rig.go @@ -44,7 +44,6 @@ var rigAddCmd = &cobra.Command{ This creates a rig container with: - config.json Rig configuration - .beads/ Rig-level issue tracking (initialized) - - .beads-wisp/ Local wisp/molecule tracking (gitignored) - plugins/ Rig-level plugin directory - refinery/rig/ Canonical main clone - mayor/rig/ Mayor's working clone @@ -218,7 +217,6 @@ func runRigAdd(cmd *cobra.Command, args []string) error { fmt.Printf(" %s/\n", name) fmt.Printf(" ├── config.json\n") fmt.Printf(" ├── .beads/ (prefix: %s)\n", newRig.Config.Prefix) - fmt.Printf(" ├── .beads-wisp/ (local wisp/molecule tracking)\n") fmt.Printf(" ├── plugins/ (rig-level plugins)\n") fmt.Printf(" ├── refinery/rig/ (canonical main)\n") fmt.Printf(" ├── mayor/rig/ (mayor's clone)\n") diff --git a/internal/doctor/patrol_check.go b/internal/doctor/patrol_check.go index 3b342486..d3191b75 100644 --- a/internal/doctor/patrol_check.go +++ b/internal/doctor/patrol_check.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/steveyegge/gastown/internal/config" ) // PatrolMoleculesExistCheck verifies that patrol molecules exist for each rig. @@ -259,8 +261,9 @@ func (c *PatrolNotStuckCheck) Run(ctx *CheckContext) *CheckResult { var stuckWisps []string for _, rigName := range rigs { - wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp", "issues.jsonl") - stuck := c.checkStuckWisps(wispPath, rigName) + // Check main beads database for wisps (issues with Wisp=true) + beadsPath := filepath.Join(ctx.TownRoot, rigName, ".beads", "issues.jsonl") + stuck := c.checkStuckWisps(beadsPath, rigName) stuckWisps = append(stuckWisps, stuck...) } @@ -457,3 +460,26 @@ func (c *PatrolRolesHavePromptsCheck) Run(ctx *CheckContext) *CheckResult { Message: "All patrol role prompt templates found", } } + +// discoverRigs finds all registered rigs. +func discoverRigs(townRoot string) ([]string, error) { + rigsPath := filepath.Join(townRoot, "mayor", "rigs.json") + data, err := os.ReadFile(rigsPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil // No rigs configured + } + return nil, err + } + + var rigsConfig config.RigsConfig + if err := json.Unmarshal(data, &rigsConfig); err != nil { + return nil, err + } + + var rigs []string + for name := range rigsConfig.Rigs { + rigs = append(rigs, name) + } + return rigs, nil +} diff --git a/internal/doctor/wisp_check.go b/internal/doctor/wisp_check.go index fb136fdf..663e34f8 100644 --- a/internal/doctor/wisp_check.go +++ b/internal/doctor/wisp_check.go @@ -1,519 +1,14 @@ package doctor -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "time" - - "github.com/steveyegge/gastown/internal/config" -) - -// WispExistsCheck verifies that .beads-wisp/ exists for each rig. -type WispExistsCheck struct { - FixableCheck - missingRigs []string // Cached for fix -} - -// NewWispExistsCheck creates a new wisp exists check. -func NewWispExistsCheck() *WispExistsCheck { - return &WispExistsCheck{ - FixableCheck: FixableCheck{ - BaseCheck: BaseCheck{ - CheckName: "wisp-exists", - CheckDescription: "Check if wisp directory exists for each rig", - }, - }, - } -} - -// Run checks if .beads-wisp/ exists for each rig. -func (c *WispExistsCheck) Run(ctx *CheckContext) *CheckResult { - c.missingRigs = nil // Reset cache - - // Find all rigs - rigs, err := c.discoverRigs(ctx.TownRoot) - if err != nil { - return &CheckResult{ - Name: c.Name(), - Status: StatusError, - Message: "Failed to discover rigs", - Details: []string{err.Error()}, - } - } - - if len(rigs) == 0 { - return &CheckResult{ - Name: c.Name(), - Status: StatusOK, - Message: "No rigs configured", - } - } - - // Check each rig - var missing []string - for _, rigName := range rigs { - wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp") - if _, err := os.Stat(wispPath); os.IsNotExist(err) { - missing = append(missing, rigName) - } - } - - if len(missing) > 0 { - c.missingRigs = missing - return &CheckResult{ - Name: c.Name(), - Status: StatusWarning, - Message: fmt.Sprintf("%d rig(s) missing wisp directory", len(missing)), - Details: missing, - FixHint: "Run 'gt doctor --fix' to create missing directories", - } - } - - return &CheckResult{ - Name: c.Name(), - Status: StatusOK, - Message: fmt.Sprintf("All %d rig(s) have wisp directory", len(rigs)), - } -} - -// Fix creates missing .beads-wisp/ directories. -func (c *WispExistsCheck) Fix(ctx *CheckContext) error { - for _, rigName := range c.missingRigs { - wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp") - if err := os.MkdirAll(wispPath, 0755); err != nil { - return fmt.Errorf("creating %s: %w", wispPath, err) - } - } - return nil -} - -// discoverRigs finds all registered rigs. -func (c *WispExistsCheck) discoverRigs(townRoot string) ([]string, error) { - rigsPath := filepath.Join(townRoot, "mayor", "rigs.json") - data, err := os.ReadFile(rigsPath) - if err != nil { - if os.IsNotExist(err) { - return nil, nil // No rigs configured - } - return nil, err - } - - var rigsConfig config.RigsConfig - if err := json.Unmarshal(data, &rigsConfig); err != nil { - return nil, err - } - - var rigs []string - for name := range rigsConfig.Rigs { - rigs = append(rigs, name) - } - return rigs, nil -} - -// WispGitCheck verifies that .beads-wisp/ is a valid git repo. -type WispGitCheck struct { - FixableCheck - invalidRigs []string // Cached for fix -} - -// NewWispGitCheck creates a new wisp git check. -func NewWispGitCheck() *WispGitCheck { - return &WispGitCheck{ - FixableCheck: FixableCheck{ - BaseCheck: BaseCheck{ - CheckName: "wisp-git", - CheckDescription: "Check if wisp directories are valid git repos", - }, - }, - } -} - -// Run checks if .beads-wisp/ directories are valid git repos. -func (c *WispGitCheck) Run(ctx *CheckContext) *CheckResult { - c.invalidRigs = nil // Reset cache - - // Find all rigs - rigs, err := discoverRigs(ctx.TownRoot) - if err != nil { - return &CheckResult{ - Name: c.Name(), - Status: StatusError, - Message: "Failed to discover rigs", - Details: []string{err.Error()}, - } - } - - if len(rigs) == 0 { - return &CheckResult{ - Name: c.Name(), - Status: StatusOK, - Message: "No rigs configured", - } - } - - // Check each rig that has a wisp dir - var invalid []string - var checked int - for _, rigName := range rigs { - wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp") - if _, err := os.Stat(wispPath); os.IsNotExist(err) { - continue // Skip if directory doesn't exist (handled by wisp-exists) - } - checked++ - - // Check if it's a valid git repo - gitDir := filepath.Join(wispPath, ".git") - if _, err := os.Stat(gitDir); os.IsNotExist(err) { - invalid = append(invalid, rigName) - } - } - - if checked == 0 { - return &CheckResult{ - Name: c.Name(), - Status: StatusOK, - Message: "No wisp directories to check", - } - } - - if len(invalid) > 0 { - c.invalidRigs = invalid - return &CheckResult{ - Name: c.Name(), - Status: StatusWarning, - Message: fmt.Sprintf("%d wisp directory(ies) not initialized as git", len(invalid)), - Details: invalid, - FixHint: "Run 'gt doctor --fix' to initialize git repos", - } - } - - return &CheckResult{ - Name: c.Name(), - Status: StatusOK, - Message: fmt.Sprintf("All %d wisp directories are valid git repos", checked), - } -} - -// Fix initializes git repos in wisp directories. -func (c *WispGitCheck) Fix(ctx *CheckContext) error { - for _, rigName := range c.invalidRigs { - wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp") - cmd := exec.Command("git", "init") - cmd.Dir = wispPath - if err := cmd.Run(); err != nil { - return fmt.Errorf("initializing git in %s: %w", wispPath, err) - } - - // Create config.yaml for wisp storage - configPath := filepath.Join(wispPath, "config.yaml") - configContent := "wisp: true\n# No sync-branch - wisps are local only\n" - if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { - return fmt.Errorf("creating config.yaml in %s: %w", wispPath, err) - } - } - return nil -} - -// WispOrphansCheck detects molecules started but never squashed (>24h old). -type WispOrphansCheck struct { - BaseCheck -} - -// NewWispOrphansCheck creates a new wisp orphans check. -func NewWispOrphansCheck() *WispOrphansCheck { - return &WispOrphansCheck{ - BaseCheck: BaseCheck{ - CheckName: "wisp-orphans", - CheckDescription: "Check for orphaned wisps (>24h old, never squashed)", - }, - } -} - -// Run checks for orphaned wisps. -func (c *WispOrphansCheck) Run(ctx *CheckContext) *CheckResult { - rigs, err := discoverRigs(ctx.TownRoot) - if err != nil { - return &CheckResult{ - Name: c.Name(), - Status: StatusError, - Message: "Failed to discover rigs", - Details: []string{err.Error()}, - } - } - - if len(rigs) == 0 { - return &CheckResult{ - Name: c.Name(), - Status: StatusOK, - Message: "No rigs configured", - } - } - - var orphans []string - cutoff := time.Now().Add(-24 * time.Hour) - - for _, rigName := range rigs { - wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp") - if _, err := os.Stat(wispPath); os.IsNotExist(err) { - continue - } - - // Look for molecule directories or issue files older than 24h - issuesPath := filepath.Join(wispPath, "issues.jsonl") - info, err := os.Stat(issuesPath) - if err != nil { - continue // No issues file - } - - // Check if the issues file is old and non-empty - if info.ModTime().Before(cutoff) && info.Size() > 0 { - orphans = append(orphans, fmt.Sprintf("%s: issues.jsonl last modified %s", - rigName, info.ModTime().Format("2006-01-02 15:04"))) - } - } - - if len(orphans) > 0 { - return &CheckResult{ - Name: c.Name(), - Status: StatusWarning, - Message: fmt.Sprintf("%d rig(s) have stale wisp data (>24h old)", len(orphans)), - Details: orphans, - FixHint: "Manual review required - these may contain unsquashed work", - } - } - - return &CheckResult{ - Name: c.Name(), - Status: StatusOK, - Message: "No orphaned wisps found", - } -} - -// WispSizeCheck warns if wisp repo is too large (>100MB). -type WispSizeCheck struct { - BaseCheck -} - -// NewWispSizeCheck creates a new wisp size check. -func NewWispSizeCheck() *WispSizeCheck { - return &WispSizeCheck{ - BaseCheck: BaseCheck{ - CheckName: "wisp-size", - CheckDescription: "Check if wisp directories are too large (>100MB)", - }, - } -} - -// Run checks the size of wisp directories. -func (c *WispSizeCheck) Run(ctx *CheckContext) *CheckResult { - rigs, err := discoverRigs(ctx.TownRoot) - if err != nil { - return &CheckResult{ - Name: c.Name(), - Status: StatusError, - Message: "Failed to discover rigs", - Details: []string{err.Error()}, - } - } - - if len(rigs) == 0 { - return &CheckResult{ - Name: c.Name(), - Status: StatusOK, - Message: "No rigs configured", - } - } - - const maxSize = 100 * 1024 * 1024 // 100MB - var oversized []string - - for _, rigName := range rigs { - wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp") - if _, err := os.Stat(wispPath); os.IsNotExist(err) { - continue - } - - size, err := dirSize(wispPath) - if err != nil { - continue - } - - if size > maxSize { - oversized = append(oversized, fmt.Sprintf("%s: %s", - rigName, formatSize(size))) - } - } - - if len(oversized) > 0 { - return &CheckResult{ - Name: c.Name(), - Status: StatusWarning, - Message: fmt.Sprintf("%d rig(s) have oversized wisp directories", len(oversized)), - Details: oversized, - FixHint: "Consider cleaning up old completed molecules", - } - } - - return &CheckResult{ - Name: c.Name(), - Status: StatusOK, - Message: "All wisp directories within size limits", - } -} - -// WispStaleCheck detects molecules with no activity in the last hour. -type WispStaleCheck struct { - BaseCheck -} - -// NewWispStaleCheck creates a new wisp stale check. -func NewWispStaleCheck() *WispStaleCheck { - return &WispStaleCheck{ - BaseCheck: BaseCheck{ - CheckName: "wisp-stale", - CheckDescription: "Check for stale wisps (no activity in last hour)", - }, - } -} - -// Run checks for stale wisps. -func (c *WispStaleCheck) Run(ctx *CheckContext) *CheckResult { - rigs, err := discoverRigs(ctx.TownRoot) - if err != nil { - return &CheckResult{ - Name: c.Name(), - Status: StatusError, - Message: "Failed to discover rigs", - Details: []string{err.Error()}, - } - } - - if len(rigs) == 0 { - return &CheckResult{ - Name: c.Name(), - Status: StatusOK, - Message: "No rigs configured", - } - } - - var stale []string - cutoff := time.Now().Add(-1 * time.Hour) - - for _, rigName := range rigs { - wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp") - if _, err := os.Stat(wispPath); os.IsNotExist(err) { - continue - } - - // Check for any recent activity in the wisp directory - // We look at the most recent modification time of any file - var mostRecent time.Time - _ = filepath.Walk(wispPath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil - } - if !info.IsDir() && info.ModTime().After(mostRecent) { - mostRecent = info.ModTime() - } - return nil - }) - - // If there are files and the most recent is older than 1 hour - if !mostRecent.IsZero() && mostRecent.Before(cutoff) { - stale = append(stale, fmt.Sprintf("%s: last activity %s ago", - rigName, formatDuration(time.Since(mostRecent)))) - } - } - - if len(stale) > 0 { - return &CheckResult{ - Name: c.Name(), - Status: StatusWarning, - Message: fmt.Sprintf("%d rig(s) have stale wisp activity", len(stale)), - Details: stale, - FixHint: "Check if polecats are stuck or crashed", - } - } - - return &CheckResult{ - Name: c.Name(), - Status: StatusOK, - Message: "No stale wisp activity detected", - } -} - -// Helper functions - -// discoverRigs finds all registered rigs (shared helper). -func discoverRigs(townRoot string) ([]string, error) { - rigsPath := filepath.Join(townRoot, "mayor", "rigs.json") - data, err := os.ReadFile(rigsPath) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - - var rigsConfig config.RigsConfig - if err := json.Unmarshal(data, &rigsConfig); err != nil { - return nil, err - } - - var rigs []string - for name := range rigsConfig.Rigs { - rigs = append(rigs, name) - } - return rigs, nil -} - -// dirSize calculates the total size of a directory. -func dirSize(path string) (int64, error) { - var size int64 - err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if !info.IsDir() { - size += info.Size() - } - return nil - }) - return size, err -} - -// formatSize formats bytes as human-readable size. -func formatSize(bytes int64) string { - const ( - KB = 1024 - MB = KB * 1024 - GB = MB * 1024 - ) - switch { - case bytes >= GB: - return fmt.Sprintf("%.1f GB", float64(bytes)/GB) - case bytes >= MB: - return fmt.Sprintf("%.1f MB", float64(bytes)/MB) - case bytes >= KB: - return fmt.Sprintf("%.1f KB", float64(bytes)/KB) - default: - return fmt.Sprintf("%d bytes", bytes) - } -} - -// formatDuration formats a duration as human-readable string. -func formatDuration(d time.Duration) string { - if d < time.Minute { - return fmt.Sprintf("%.0f seconds", d.Seconds()) - } - if d < time.Hour { - return fmt.Sprintf("%.0f minutes", d.Minutes()) - } - if d < 24*time.Hour { - return fmt.Sprintf("%.1f hours", d.Hours()) - } - return fmt.Sprintf("%.1f days", d.Hours()/24) -} +// Legacy wisp directory checks removed. +// Wisps are now just a flag on regular beads issues (Wisp: true). +// Hook files are stored in .beads/ alongside other beads data. +// +// These checks were for the old .beads-wisp/ directory infrastructure: +// - WispExistsCheck: checked if .beads-wisp/ exists +// - WispGitCheck: checked if .beads-wisp/ is a git repo +// - WispOrphansCheck: checked for old wisps +// - WispSizeCheck: checked size of .beads-wisp/ +// - WispStaleCheck: checked for inactive wisps +// +// All removed as of the wisp simplification (gt-5klh, bd-bkul). diff --git a/internal/mrqueue/mrqueue.go b/internal/mrqueue/mrqueue.go index 2221612a..5ae5cac8 100644 --- a/internal/mrqueue/mrqueue.go +++ b/internal/mrqueue/mrqueue.go @@ -1,5 +1,5 @@ -// Package mrqueue provides wisp-based merge request queue storage. -// MRs are ephemeral - stored locally in .beads-wisp/mq/ and deleted after merge. +// Package mrqueue provides merge request queue storage. +// MRs are stored locally in .beads/mq/ and deleted after merge. // This avoids sync overhead for transient MR state. package mrqueue @@ -28,31 +28,31 @@ type MR struct { CreatedAt time.Time `json:"created_at"` } -// Queue manages the MR wisp storage. +// Queue manages the MR storage. type Queue struct { - dir string // .beads-wisp/mq/ directory + dir string // .beads/mq/ directory } // New creates a new MR queue for the given rig path. func New(rigPath string) *Queue { return &Queue{ - dir: filepath.Join(rigPath, ".beads-wisp", "mq"), + dir: filepath.Join(rigPath, ".beads", "mq"), } } // NewFromWorkdir creates a queue by finding the rig root from a working directory. func NewFromWorkdir(workdir string) (*Queue, error) { - // Walk up to find .beads-wisp or rig root + // Walk up to find .beads or rig root dir := workdir for { - wispDir := filepath.Join(dir, ".beads-wisp") - if info, err := os.Stat(wispDir); err == nil && info.IsDir() { - return &Queue{dir: filepath.Join(wispDir, "mq")}, nil + beadsDir := filepath.Join(dir, ".beads") + if info, err := os.Stat(beadsDir); err == nil && info.IsDir() { + return &Queue{dir: filepath.Join(beadsDir, "mq")}, nil } parent := filepath.Dir(dir) if parent == dir { - return nil, fmt.Errorf("could not find .beads-wisp directory from %s", workdir) + return nil, fmt.Errorf("could not find .beads directory from %s", workdir) } dir = parent } diff --git a/internal/rig/manager.go b/internal/rig/manager.go index ed01d9e1..f894e178 100644 --- a/internal/rig/manager.go +++ b/internal/rig/manager.go @@ -281,11 +281,6 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) { return nil, fmt.Errorf("initializing beads: %w", err) } - // Initialize wisp beads for wisp/molecule tracking - if err := m.initWispBeads(rigPath); err != nil { - return nil, fmt.Errorf("initializing wisp beads: %w", err) - } - // Seed patrol molecules for this rig if err := m.seedPatrolMolecules(rigPath); err != nil { // Non-fatal: log warning but continue @@ -371,34 +366,6 @@ func (m *Manager) initBeads(rigPath, prefix string) error { return nil } -// initWispBeads initializes the wisp beads database at rig level. -// Wisp beads are local-only (no sync-branch) and used for runtime tracking -// of wisps and molecules. -func (m *Manager) initWispBeads(rigPath string) error { - beadsDir := filepath.Join(rigPath, ".beads-wisp") - if err := os.MkdirAll(beadsDir, 0755); err != nil { - return err - } - - // Initialize as a git repo (for local versioning, not for sync) - cmd := exec.Command("git", "init") - cmd.Dir = beadsDir - if err := cmd.Run(); err != nil { - return fmt.Errorf("git init: %w", err) - } - - // Create wisp config (no sync-branch needed) - configPath := filepath.Join(beadsDir, "config.yaml") - configContent := "wisp: true\n# No sync-branch - wisp is local only\n" - if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { - return err - } - - // Add .beads-wisp/ to .gitignore if not already present - gitignorePath := filepath.Join(rigPath, ".gitignore") - return m.ensureGitignoreEntry(gitignorePath, ".beads-wisp/") -} - // ensureGitignoreEntry adds an entry to .gitignore if it doesn't already exist. func (m *Manager) ensureGitignoreEntry(gitignorePath, entry string) error { // Read existing content diff --git a/internal/rig/manager_test.go b/internal/rig/manager_test.go index 4942759c..eaf3908f 100644 --- a/internal/rig/manager_test.go +++ b/internal/rig/manager_test.go @@ -193,52 +193,6 @@ func TestRigSummary(t *testing.T) { } } -func TestInitWispBeads(t *testing.T) { - root, rigsConfig := setupTestTown(t) - manager := NewManager(root, rigsConfig, git.NewGit(root)) - - rigPath := filepath.Join(root, "test-rig") - if err := os.MkdirAll(rigPath, 0755); err != nil { - t.Fatalf("mkdir: %v", err) - } - - if err := manager.initWispBeads(rigPath); err != nil { - t.Fatalf("initWispBeads: %v", err) - } - - // Verify directory was created - wispPath := filepath.Join(rigPath, ".beads-wisp") - if _, err := os.Stat(wispPath); os.IsNotExist(err) { - t.Error(".beads-wisp/ was not created") - } - - // Verify it's a git repo - gitPath := filepath.Join(wispPath, ".git") - if _, err := os.Stat(gitPath); os.IsNotExist(err) { - t.Error(".beads-wisp/ was not initialized as git repo") - } - - // Verify config.yaml was created with wisp: true - configPath := filepath.Join(wispPath, "config.yaml") - content, err := os.ReadFile(configPath) - if err != nil { - t.Fatalf("reading config.yaml: %v", err) - } - if string(content) != "wisp: true\n# No sync-branch - wisp is local only\n" { - t.Errorf("config.yaml content = %q, want wisp: true with comment", string(content)) - } - - // Verify .gitignore was updated - gitignorePath := filepath.Join(rigPath, ".gitignore") - ignoreContent, err := os.ReadFile(gitignorePath) - if err != nil { - t.Fatalf("reading .gitignore: %v", err) - } - if string(ignoreContent) != ".beads-wisp/\n" { - t.Errorf(".gitignore content = %q, want .beads-wisp/", string(ignoreContent)) - } -} - func TestEnsureGitignoreEntry_AddsEntry(t *testing.T) { root, rigsConfig := setupTestTown(t) manager := NewManager(root, rigsConfig, git.NewGit(root)) diff --git a/internal/wisp/io.go b/internal/wisp/io.go index 158e4f0d..59c3b6e3 100644 --- a/internal/wisp/io.go +++ b/internal/wisp/io.go @@ -10,21 +10,21 @@ import ( // Common errors. var ( - ErrNoWispDir = errors.New("wisp directory does not exist") + ErrNoWispDir = errors.New("beads directory does not exist") ErrNoHook = errors.New("no hook file found") - ErrInvalidWisp = errors.New("invalid wisp format") + ErrInvalidWisp = errors.New("invalid hook file format") ) -// EnsureDir ensures the .beads-wisp directory exists in the given root. +// EnsureDir ensures the .beads directory exists in the given root. func EnsureDir(root string) (string, error) { dir := filepath.Join(root, WispDir) if err := os.MkdirAll(dir, 0755); err != nil { - return "", fmt.Errorf("create wisp dir: %w", err) + return "", fmt.Errorf("create beads dir: %w", err) } return dir, nil } -// WispPath returns the full path to a wisp file. +// WispPath returns the full path to a file in the beads directory. func WispPath(root, filename string) string { return filepath.Join(root, WispDir, filename) } @@ -34,7 +34,7 @@ func HookPath(root, agent string) string { return WispPath(root, HookFilename(agent)) } -// WriteSlungWork writes a slung work wisp to the agent's hook. +// WriteSlungWork writes a slung work hook to the agent's hook file. func WriteSlungWork(root, agent string, sw *SlungWork) error { dir, err := EnsureDir(root) if err != nil { @@ -45,18 +45,6 @@ func WriteSlungWork(root, agent string, sw *SlungWork) error { return writeJSON(path, sw) } -// WritePatrolCycle writes a patrol cycle wisp. -func WritePatrolCycle(root, id string, pc *PatrolCycle) error { - dir, err := EnsureDir(root) - if err != nil { - return err - } - - filename := "patrol-" + id + ".json" - path := filepath.Join(dir, filename) - return writeJSON(path, pc) -} - // ReadHook reads the slung work from an agent's hook file. // Returns ErrNoHook if no hook file exists. func ReadHook(root, agent string) (*SlungWork, error) { @@ -82,31 +70,6 @@ func ReadHook(root, agent string) (*SlungWork, error) { return &sw, nil } -// ReadPatrolCycle reads a patrol cycle wisp. -func ReadPatrolCycle(root, id string) (*PatrolCycle, error) { - filename := "patrol-" + id + ".json" - path := WispPath(root, filename) - - data, err := os.ReadFile(path) - if os.IsNotExist(err) { - return nil, ErrNoHook // reuse error for "not found" - } - if err != nil { - return nil, fmt.Errorf("read patrol cycle: %w", err) - } - - var pc PatrolCycle - if err := json.Unmarshal(data, &pc); err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidWisp, err) - } - - if pc.Type != TypePatrolCycle { - return nil, fmt.Errorf("%w: expected patrol-cycle, got %s", ErrInvalidWisp, pc.Type) - } - - return &pc, nil -} - // BurnHook removes an agent's hook file after it has been picked up. func BurnHook(root, agent string) error { path := HookPath(root, agent) @@ -117,17 +80,6 @@ func BurnHook(root, agent string) error { return err } -// BurnPatrolCycle removes a patrol cycle wisp. -func BurnPatrolCycle(root, id string) error { - filename := "patrol-" + id + ".json" - path := WispPath(root, filename) - err := os.Remove(path) - if os.IsNotExist(err) { - return nil // already burned - } - return err -} - // HasHook checks if an agent has a hook file. func HasHook(root, agent string) bool { path := HookPath(root, agent) diff --git a/internal/wisp/types.go b/internal/wisp/types.go index 64902f5c..5ba06229 100644 --- a/internal/wisp/types.go +++ b/internal/wisp/types.go @@ -1,32 +1,30 @@ -// Package wisp provides ephemeral molecule support for Gas Town agents. +// Package wisp provides hook file support for Gas Town agents. // -// Wisps are short-lived workflow state that lives in .beads-wisp/ and is -// never git-tracked. They are used for: -// - Slung work: attaching a bead to an agent's hook for restart-and-resume -// - Patrol cycles: ephemeral state for continuous loops (Deacon, Witness, etc) +// Hooks are used to attach work to an agent for restart-and-resume: +// - hook-.json files track what bead is assigned to an agent +// - Created by `gt hook`, `gt sling`, `gt handoff` +// - Read on session start to restore work context +// - Burned after pickup // -// Unlike regular molecules in .beads/, wisps are burned after use. +// Hook files live in .beads/ alongside other beads data. package wisp import ( "time" ) -// WispType identifies the kind of wisp. +// WispType identifies the kind of hook file. type WispType string const ( - // TypeSlungWork is a wisp that attaches a bead to an agent's hook. - // Created by `gt sling ` and burned after pickup. + // TypeSlungWork is a hook that attaches a bead to an agent's hook. + // Created by `gt hook`, `gt sling`, or `gt handoff`, and burned after pickup. TypeSlungWork WispType = "slung-work" - - // TypePatrolCycle is a wisp tracking patrol execution state. - // Used by Deacon, Witness, Refinery for their continuous loops. - TypePatrolCycle WispType = "patrol-cycle" ) -// WispDir is the directory name for ephemeral wisps (not git-tracked). -const WispDir = ".beads-wisp" +// WispDir is the directory where hook files are stored. +// Hook files (hook-.json) live alongside other beads data. +const WispDir = ".beads" // HookPrefix is the filename prefix for hook files. const HookPrefix = "hook-" @@ -34,20 +32,20 @@ const HookPrefix = "hook-" // HookSuffix is the filename suffix for hook files. const HookSuffix = ".json" -// Wisp is the common header for all wisp types. +// Wisp is the common header for hook files. type Wisp struct { - // Type identifies what kind of wisp this is. + // Type identifies what kind of hook file this is. Type WispType `json:"type"` - // CreatedAt is when the wisp was created. + // CreatedAt is when the hook was created. CreatedAt time.Time `json:"created_at"` - // CreatedBy identifies who created the wisp (e.g., "crew/joe", "deacon"). + // CreatedBy identifies who created the hook (e.g., "crew/joe", "deacon"). CreatedBy string `json:"created_by"` } // SlungWork represents work attached to an agent's hook. -// Created by `gt sling` and burned after the agent picks it up. +// Created by `gt hook`, `gt sling`, or `gt handoff` and burned after pickup. type SlungWork struct { Wisp @@ -61,46 +59,7 @@ type SlungWork struct { Subject string `json:"subject,omitempty"` } -// PatrolCycle represents the execution state of a patrol loop. -// Used by roles that run continuous patrols (Deacon, Witness, Refinery). -type PatrolCycle struct { - Wisp - - // Formula is the patrol formula being executed (e.g., "mol-deacon-patrol"). - Formula string `json:"formula"` - - // CurrentStep is the ID of the step currently being executed. - CurrentStep string `json:"current_step"` - - // StepStates tracks completion state of each step. - StepStates map[string]StepState `json:"step_states,omitempty"` - - // CycleCount tracks how many complete cycles have been run. - CycleCount int `json:"cycle_count"` - - // LastCycleAt is when the last complete cycle finished. - LastCycleAt *time.Time `json:"last_cycle_at,omitempty"` -} - -// StepState represents the execution state of a single patrol step. -type StepState struct { - // Status is the current status: pending, in_progress, completed, skipped. - Status string `json:"status"` - - // StartedAt is when this step began execution. - StartedAt *time.Time `json:"started_at,omitempty"` - - // CompletedAt is when this step finished. - CompletedAt *time.Time `json:"completed_at,omitempty"` - - // Output is optional output from step execution. - Output string `json:"output,omitempty"` - - // Error is set if the step failed. - Error string `json:"error,omitempty"` -} - -// NewSlungWork creates a new slung work wisp. +// NewSlungWork creates a new slung work hook file. func NewSlungWork(beadID, createdBy string) *SlungWork { return &SlungWork{ Wisp: Wisp{ @@ -112,19 +71,6 @@ func NewSlungWork(beadID, createdBy string) *SlungWork { } } -// NewPatrolCycle creates a new patrol cycle wisp. -func NewPatrolCycle(formula, createdBy string) *PatrolCycle { - return &PatrolCycle{ - Wisp: Wisp{ - Type: TypePatrolCycle, - CreatedAt: time.Now(), - CreatedBy: createdBy, - }, - Formula: formula, - StepStates: make(map[string]StepState), - } -} - // HookFilename returns the filename for an agent's hook file. func HookFilename(agent string) string { return HookPrefix + agent + HookSuffix