feat: implement gt sling and wisp hook system (gt-qvn7.1)
Phase 1 of tracer bullet: Slinging Handoff - Add internal/wisp package for ephemeral work attachment - Add gt sling command to attach work and restart - Update gt prime to check/burn slung work on hook - Add .beads-wisp/ to gitignore
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
package wisp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Common errors.
|
||||
var (
|
||||
ErrNoWispDir = errors.New("wisp directory does not exist")
|
||||
ErrNoHook = errors.New("no hook file found")
|
||||
ErrInvalidWisp = errors.New("invalid wisp format")
|
||||
)
|
||||
|
||||
// EnsureDir ensures the .beads-wisp 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 dir, nil
|
||||
}
|
||||
|
||||
// WispPath returns the full path to a wisp file.
|
||||
func WispPath(root, filename string) string {
|
||||
return filepath.Join(root, WispDir, filename)
|
||||
}
|
||||
|
||||
// HookPath returns the full path to an agent's hook file.
|
||||
func HookPath(root, agent string) string {
|
||||
return WispPath(root, HookFilename(agent))
|
||||
}
|
||||
|
||||
// WriteSlungWork writes a slung work wisp to the agent's hook.
|
||||
func WriteSlungWork(root, agent string, sw *SlungWork) error {
|
||||
dir, err := EnsureDir(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, HookFilename(agent))
|
||||
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) {
|
||||
path := HookPath(root, agent)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, ErrNoHook
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read hook: %w", err)
|
||||
}
|
||||
|
||||
var sw SlungWork
|
||||
if err := json.Unmarshal(data, &sw); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidWisp, err)
|
||||
}
|
||||
|
||||
if sw.Type != TypeSlungWork {
|
||||
return nil, fmt.Errorf("%w: expected slung-work, got %s", ErrInvalidWisp, sw.Type)
|
||||
}
|
||||
|
||||
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)
|
||||
err := os.Remove(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil // already burned
|
||||
}
|
||||
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)
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ListHooks returns a list of agents with active hooks.
|
||||
func ListHooks(root string) ([]string, error) {
|
||||
dir := filepath.Join(root, WispDir)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var agents []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if len(name) > len(HookPrefix)+len(HookSuffix) &&
|
||||
name[:len(HookPrefix)] == HookPrefix &&
|
||||
name[len(name)-len(HookSuffix):] == HookSuffix {
|
||||
agent := name[len(HookPrefix) : len(name)-len(HookSuffix)]
|
||||
agents = append(agents, agent)
|
||||
}
|
||||
}
|
||||
return agents, nil
|
||||
}
|
||||
|
||||
// writeJSON is a helper to write JSON files atomically.
|
||||
func writeJSON(path string, v interface{}) error {
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal json: %w", err)
|
||||
}
|
||||
|
||||
// Write to temp file then rename for atomicity
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||
return fmt.Errorf("write temp: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp) // cleanup on failure
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Package wisp provides ephemeral molecule 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)
|
||||
//
|
||||
// Unlike regular molecules in .beads/, wisps are burned after use.
|
||||
package wisp
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// WispType identifies the kind of wisp.
|
||||
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 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"
|
||||
|
||||
// HookPrefix is the filename prefix for hook files.
|
||||
const HookPrefix = "hook-"
|
||||
|
||||
// HookSuffix is the filename suffix for hook files.
|
||||
const HookSuffix = ".json"
|
||||
|
||||
// Wisp is the common header for all wisp types.
|
||||
type Wisp struct {
|
||||
// Type identifies what kind of wisp this is.
|
||||
Type WispType `json:"type"`
|
||||
|
||||
// CreatedAt is when the wisp was created.
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// CreatedBy identifies who created the wisp (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.
|
||||
type SlungWork struct {
|
||||
Wisp
|
||||
|
||||
// BeadID is the issue/bead to work on (e.g., "gt-xxx").
|
||||
BeadID string `json:"bead_id"`
|
||||
|
||||
// Context is optional additional context from the slinger.
|
||||
Context string `json:"context,omitempty"`
|
||||
|
||||
// Subject is optional subject line (used in handoff mail).
|
||||
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.
|
||||
func NewSlungWork(beadID, createdBy string) *SlungWork {
|
||||
return &SlungWork{
|
||||
Wisp: Wisp{
|
||||
Type: TypeSlungWork,
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: createdBy,
|
||||
},
|
||||
BeadID: beadID,
|
||||
}
|
||||
}
|
||||
|
||||
// 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