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 # Runtime state
state.json state.json
.runtime/ .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 ## Overview
**Wisps** are transient molecule execution traces - the "steam" in Gas Town's engine **Wisps** are ephemeral issues - transient workflow state that should not be synced
metaphor. Claude is fire; Claude Code is a Steam engine; Gas Town is a Steam Train, to the shared repository. They're used for operational messages (like lifecycle mail)
with Beads as the tracks. Wisps are steam vapors that dissipate after the work is done. and patrol cycle traces that would otherwise accumulate unbounded.
## Core Principle ## Core Principle
**Wisps are local operational state, not project history.** **Wisps are regular issues with `Wisp: true` flag.**
| Artifact | Storage | Git Tracked | Purpose | The old architecture used a separate `.beads-wisp/` directory. This was over-engineered.
|----------|---------|-------------|---------| The simplified approach:
| 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 |
## 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
``` ### Creating Wisps
~/gt/gastown/ # Rig root (not a git repo)
├── .beads-wisp/ # Shared wisp storage (rig-level, gitignored) ```bash
│ └── issues.jsonl # In-progress wisps (Deacon, Witness, Refinery) # Create an ephemeral issue
bd create --title "Patrol cycle" --wisp
├── mayor/rig/ # Mayor's canonical clone
│ └── .beads/ # CANONICAL rig beads (versioned) # Send ephemeral mail (automatically sets Wisp=true)
│ ├── issues.jsonl # Permanent issues + digests gt mail send --wisp -s "Lifecycle: spawn" -m "..."
│ └── 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
``` ```
### Key Points ### Sync Filtering
1. **`.beads-wisp/` is at rig root** - Outside any git clone, naturally isolated When `bd sync` exports to JSONL for git:
2. **All rig agents share `<rig>/.beads-wisp/`** - Deacon, Witness, Refinery - Issues with `Wisp: true` are **excluded**
3. **Digests go to canonical `.beads/`** - Permanent record after squash - Only permanent issues are synced to remote
4. **Wisps are deleted after squash/burn** - No accumulation - No separate directory needed
5. **Polecats don't use wisps** - Each assignment is a deliverable with audit value
### Gitignore Entry ### Querying
Add to `.beads/.gitignore`: ```bash
``` # List all issues (including wisps)
.beads-wisp/ bd list
# List only wisps
bd list --wisp
# List only permanent issues
bd list --no-wisp
``` ```
Or add to rig-level `.gitignore`: ## Use Cases
```
**/.beads-wisp/ ### 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
``` Deacon, Witness, and Refinery run continuous loops. Each cycle would create
bd mol bond <proto> --wisp accumulating history. Wisps let them track cycle state without permanent records.
┌─────────────────────────┐
│ .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
└─────────────────────────┘
```
## 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: ## Decision Matrix
| 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
| Question | Answer | Use | | Question | Answer | Use |
|----------|--------|-----| |----------|--------|-----|
| Is this work repetitive/cyclic? | Yes | Wisp | | Should this sync to remote? | No | Wisp |
| Does the outcome matter more than the trace? | Yes | Wisp | | Is this operational/lifecycle? | Yes | Wisp |
| Would this accumulate unbounded over time? | Yes | Wisp | | Would this accumulate unbounded? | Yes | Wisp |
| Is this a discrete deliverable? | Yes | Regular Mol | | Does this need audit trail? | Yes | Regular issue |
| Might I need to reference this later? | Yes | Regular Mol | | Might others need to see this? | Yes | Regular issue |
| Does this represent user-requested work? | Yes | Regular Mol |
## Patrol Pattern ## Migration from .beads-wisp/
Every role using wisps must implement this pattern: The old `.beads-wisp/` directories can be deleted:
```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
```bash ```bash
# Bond with wisp flag (--ephemeral is an alias) # Remove legacy wisp directories
bd mol bond <proto> --wisp rm -rf ~/gt/.beads-wisp/
# Creates in .beads-wisp/ instead of .beads/ rm -rf ~/gt/gastown/.beads-wisp/
find ~/gt -type d -name '.beads-wisp' -exec rm -rf {} +
# List wisps
bd wisp list
# Shows in-progress wisps
# Garbage collect orphaned wisps
bd wisp gc
# Cleans up wisps from crashed processes
``` ```
### Storage Behavior No migration needed - these contained transient data with no long-term value.
| Command | With `--wisp` | Without | ## Related
|---------|---------------|---------|
| `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
- [molecules.md](molecules.md) - Molecule system (wisps can be molecule instances)
- [architecture.md](architecture.md) - Overall Gas Town architecture - [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.NewLinkedPaneCheck())
d.Register(doctor.NewThemeCheck()) 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 // Patrol system checks
d.Register(doctor.NewPatrolMoleculesExistCheck()) d.Register(doctor.NewPatrolMoleculesExistCheck())
d.Register(doctor.NewPatrolHooksWiredCheck()) d.Register(doctor.NewPatrolHooksWiredCheck())

View File

@@ -35,7 +35,7 @@ LIFECYCLE:
▼ instantiate/bond ▼ instantiate/bond
┌─────────────────┐ ┌─────────────────┐
│ Mol (durable) │ ← tracked in .beads/ │ 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: This creates a rig container with:
- config.json Rig configuration - config.json Rig configuration
- .beads/ Rig-level issue tracking (initialized) - .beads/ Rig-level issue tracking (initialized)
- .beads-wisp/ Local wisp/molecule tracking (gitignored)
- plugins/ Rig-level plugin directory - plugins/ Rig-level plugin directory
- refinery/rig/ Canonical main clone - refinery/rig/ Canonical main clone
- mayor/rig/ Mayor's working 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(" %s/\n", name)
fmt.Printf(" ├── config.json\n") fmt.Printf(" ├── config.json\n")
fmt.Printf(" ├── .beads/ (prefix: %s)\n", newRig.Config.Prefix) 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(" ├── plugins/ (rig-level plugins)\n")
fmt.Printf(" ├── refinery/rig/ (canonical main)\n") fmt.Printf(" ├── refinery/rig/ (canonical main)\n")
fmt.Printf(" ├── mayor/rig/ (mayor's clone)\n") fmt.Printf(" ├── mayor/rig/ (mayor's clone)\n")

View File

@@ -9,6 +9,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"github.com/steveyegge/gastown/internal/config"
) )
// PatrolMoleculesExistCheck verifies that patrol molecules exist for each rig. // PatrolMoleculesExistCheck verifies that patrol molecules exist for each rig.
@@ -259,8 +261,9 @@ func (c *PatrolNotStuckCheck) Run(ctx *CheckContext) *CheckResult {
var stuckWisps []string var stuckWisps []string
for _, rigName := range rigs { for _, rigName := range rigs {
wispPath := filepath.Join(ctx.TownRoot, rigName, ".beads-wisp", "issues.jsonl") // Check main beads database for wisps (issues with Wisp=true)
stuck := c.checkStuckWisps(wispPath, rigName) beadsPath := filepath.Join(ctx.TownRoot, rigName, ".beads", "issues.jsonl")
stuck := c.checkStuckWisps(beadsPath, rigName)
stuckWisps = append(stuckWisps, stuck...) stuckWisps = append(stuckWisps, stuck...)
} }
@@ -457,3 +460,26 @@ func (c *PatrolRolesHavePromptsCheck) Run(ctx *CheckContext) *CheckResult {
Message: "All patrol role prompt templates found", 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 package doctor
import ( // Legacy wisp directory checks removed.
"encoding/json" // Wisps are now just a flag on regular beads issues (Wisp: true).
"fmt" // Hook files are stored in .beads/ alongside other beads data.
"os" //
"os/exec" // These checks were for the old .beads-wisp/ directory infrastructure:
"path/filepath" // - WispExistsCheck: checked if .beads-wisp/ exists
"time" // - WispGitCheck: checked if .beads-wisp/ is a git repo
// - WispOrphansCheck: checked for old wisps
"github.com/steveyegge/gastown/internal/config" // - WispSizeCheck: checked size of .beads-wisp/
) // - WispStaleCheck: checked for inactive wisps
//
// WispExistsCheck verifies that .beads-wisp/ exists for each rig. // All removed as of the wisp simplification (gt-5klh, bd-bkul).
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)
}

View File

@@ -1,5 +1,5 @@
// Package mrqueue provides wisp-based merge request queue storage. // Package mrqueue provides merge request queue storage.
// MRs are ephemeral - stored locally in .beads-wisp/mq/ and deleted after merge. // MRs are stored locally in .beads/mq/ and deleted after merge.
// This avoids sync overhead for transient MR state. // This avoids sync overhead for transient MR state.
package mrqueue package mrqueue
@@ -28,31 +28,31 @@ type MR struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
// Queue manages the MR wisp storage. // Queue manages the MR storage.
type Queue struct { 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. // New creates a new MR queue for the given rig path.
func New(rigPath string) *Queue { func New(rigPath string) *Queue {
return &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. // NewFromWorkdir creates a queue by finding the rig root from a working directory.
func NewFromWorkdir(workdir string) (*Queue, error) { 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 dir := workdir
for { for {
wispDir := filepath.Join(dir, ".beads-wisp") beadsDir := filepath.Join(dir, ".beads")
if info, err := os.Stat(wispDir); err == nil && info.IsDir() { if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
return &Queue{dir: filepath.Join(wispDir, "mq")}, nil return &Queue{dir: filepath.Join(beadsDir, "mq")}, nil
} }
parent := filepath.Dir(dir) parent := filepath.Dir(dir)
if parent == 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 dir = parent
} }

View File

@@ -281,11 +281,6 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) {
return nil, fmt.Errorf("initializing beads: %w", err) 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 // Seed patrol molecules for this rig
if err := m.seedPatrolMolecules(rigPath); err != nil { if err := m.seedPatrolMolecules(rigPath); err != nil {
// Non-fatal: log warning but continue // Non-fatal: log warning but continue
@@ -371,34 +366,6 @@ func (m *Manager) initBeads(rigPath, prefix string) error {
return nil 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. // ensureGitignoreEntry adds an entry to .gitignore if it doesn't already exist.
func (m *Manager) ensureGitignoreEntry(gitignorePath, entry string) error { func (m *Manager) ensureGitignoreEntry(gitignorePath, entry string) error {
// Read existing content // 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) { func TestEnsureGitignoreEntry_AddsEntry(t *testing.T) {
root, rigsConfig := setupTestTown(t) root, rigsConfig := setupTestTown(t)
manager := NewManager(root, rigsConfig, git.NewGit(root)) manager := NewManager(root, rigsConfig, git.NewGit(root))

View File

@@ -10,21 +10,21 @@ import (
// Common errors. // Common errors.
var ( var (
ErrNoWispDir = errors.New("wisp directory does not exist") ErrNoWispDir = errors.New("beads directory does not exist")
ErrNoHook = errors.New("no hook file found") 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) { func EnsureDir(root string) (string, error) {
dir := filepath.Join(root, WispDir) dir := filepath.Join(root, WispDir)
if err := os.MkdirAll(dir, 0755); err != nil { 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 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 { func WispPath(root, filename string) string {
return filepath.Join(root, WispDir, filename) return filepath.Join(root, WispDir, filename)
} }
@@ -34,7 +34,7 @@ func HookPath(root, agent string) string {
return WispPath(root, HookFilename(agent)) 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 { func WriteSlungWork(root, agent string, sw *SlungWork) error {
dir, err := EnsureDir(root) dir, err := EnsureDir(root)
if err != nil { if err != nil {
@@ -45,18 +45,6 @@ func WriteSlungWork(root, agent string, sw *SlungWork) error {
return writeJSON(path, sw) 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. // ReadHook reads the slung work from an agent's hook file.
// Returns ErrNoHook if no hook file exists. // Returns ErrNoHook if no hook file exists.
func ReadHook(root, agent string) (*SlungWork, error) { func ReadHook(root, agent string) (*SlungWork, error) {
@@ -82,31 +70,6 @@ func ReadHook(root, agent string) (*SlungWork, error) {
return &sw, nil 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. // BurnHook removes an agent's hook file after it has been picked up.
func BurnHook(root, agent string) error { func BurnHook(root, agent string) error {
path := HookPath(root, agent) path := HookPath(root, agent)
@@ -117,17 +80,6 @@ func BurnHook(root, agent string) error {
return err 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. // HasHook checks if an agent has a hook file.
func HasHook(root, agent string) bool { func HasHook(root, agent string) bool {
path := HookPath(root, agent) 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 // Hooks are used to attach work to an agent for restart-and-resume:
// never git-tracked. They are used for: // - hook-<agent>.json files track what bead is assigned to an agent
// - Slung work: attaching a bead to an agent's hook for restart-and-resume // - Created by `gt hook`, `gt sling`, `gt handoff`
// - Patrol cycles: ephemeral state for continuous loops (Deacon, Witness, etc) // - 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 package wisp
import ( import (
"time" "time"
) )
// WispType identifies the kind of wisp. // WispType identifies the kind of hook file.
type WispType string type WispType string
const ( const (
// TypeSlungWork is a wisp that attaches a bead to an agent's hook. // TypeSlungWork is a hook that attaches a bead to an agent's hook.
// Created by `gt sling <bead-id>` and burned after pickup. // Created by `gt hook`, `gt sling`, or `gt handoff`, and burned after pickup.
TypeSlungWork WispType = "slung-work" 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). // WispDir is the directory where hook files are stored.
const WispDir = ".beads-wisp" // Hook files (hook-<agent>.json) live alongside other beads data.
const WispDir = ".beads"
// HookPrefix is the filename prefix for hook files. // HookPrefix is the filename prefix for hook files.
const HookPrefix = "hook-" const HookPrefix = "hook-"
@@ -34,20 +32,20 @@ const HookPrefix = "hook-"
// HookSuffix is the filename suffix for hook files. // HookSuffix is the filename suffix for hook files.
const HookSuffix = ".json" const HookSuffix = ".json"
// Wisp is the common header for all wisp types. // Wisp is the common header for hook files.
type Wisp struct { type Wisp struct {
// Type identifies what kind of wisp this is. // Type identifies what kind of hook file this is.
Type WispType `json:"type"` Type WispType `json:"type"`
// CreatedAt is when the wisp was created. // CreatedAt is when the hook was created.
CreatedAt time.Time `json:"created_at"` 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"` CreatedBy string `json:"created_by"`
} }
// SlungWork represents work attached to an agent's hook. // 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 { type SlungWork struct {
Wisp Wisp
@@ -61,46 +59,7 @@ type SlungWork struct {
Subject string `json:"subject,omitempty"` Subject string `json:"subject,omitempty"`
} }
// PatrolCycle represents the execution state of a patrol loop. // NewSlungWork creates a new slung work hook file.
// 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.
func NewSlungWork(beadID, createdBy string) *SlungWork { func NewSlungWork(beadID, createdBy string) *SlungWork {
return &SlungWork{ return &SlungWork{
Wisp: Wisp{ 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. // HookFilename returns the filename for an agent's hook file.
func HookFilename(agent string) string { func HookFilename(agent string) string {
return HookPrefix + agent + HookSuffix return HookPrefix + agent + HookSuffix