refactor: remove legacy .beads-wisp infrastructure (gt-5klh)

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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-24 21:34:14 -08:00
parent d30cf5e48c
commit 7e568770de
12 changed files with 148 additions and 964 deletions

3
.gitignore vendored
View File

@@ -29,6 +29,3 @@ gt
# Runtime state
state.json
.runtime/
# Ephemeral wisps (never tracked)
.beads-wisp/

View File

@@ -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/<name>/ # 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 `<rig>/.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 <proto> --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-<agent>.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 | `<rig>/.beads-wisp/` | Per cycle |
| **Witness** | mol-witness-patrol | `<rig>/.beads-wisp/` | Per cycle |
| **Refinery** | mol-refinery-cycle | `<rig>/.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-<role>-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 <proto> --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)

View File

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

View File

@@ -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
└────────┬────────┘
┌──────┴──────┐

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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-<agent>.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 <bead-id>` 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-<agent>.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