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