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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -29,6 +29,3 @@ gt
|
||||
# Runtime state
|
||||
state.json
|
||||
.runtime/
|
||||
|
||||
# Ephemeral wisps (never tracked)
|
||||
.beads-wisp/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌──────┴──────┐
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user