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:
Steve Yegge
2025-12-24 16:07:56 -08:00
parent 5560b64083
commit b2f1b58f13
8 changed files with 1612 additions and 2458 deletions

File diff suppressed because it is too large Load Diff

3
.gitignore vendored
View File

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

1
go.mod
View File

@@ -18,4 +18,5 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/sys v0.19.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

1
go.sum
View File

@@ -27,4 +27,5 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -14,6 +14,7 @@ import (
"github.com/steveyegge/gastown/internal/lock"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/templates"
"github.com/steveyegge/gastown/internal/wisp"
"github.com/steveyegge/gastown/internal/workspace"
)
@@ -95,6 +96,9 @@ func runPrime(cmd *cobra.Command, args []string) error {
// Output attachment status (for autonomous work detection)
outputAttachmentStatus(ctx)
// Check for slung work on hook (from gt sling)
checkSlungWork(ctx)
// Output molecule context if working on a molecule step
outputMoleculeContext(ctx)
@@ -1155,6 +1159,95 @@ func outputRefineryPatrolContext(ctx RoleContext) {
}
}
// checkSlungWork checks for slung work on the agent's hook.
// If found, displays it prominently and tells the agent to execute it.
// The wisp is burned after the agent acknowledges it.
func checkSlungWork(ctx RoleContext) {
// Determine agent identity for hook lookup
agentID := getAgentIdentity(ctx)
if agentID == "" {
return
}
// Check for hook file in the clone root
cloneRoot := ctx.WorkDir
sw, err := wisp.ReadHook(cloneRoot, agentID)
if err != nil {
if errors.Is(err, wisp.ErrNoHook) {
// No hook - normal case, nothing to do
return
}
// Other error - log but continue
return
}
// Found slung work! Display prominently
fmt.Println()
fmt.Printf("%s\n\n", style.Bold.Render("## 🎯 SLUNG WORK ON HOOK"))
fmt.Printf("Work was slung onto your hook and awaits execution.\n\n")
fmt.Printf(" Bead ID: %s\n", style.Bold.Render(sw.BeadID))
if sw.Subject != "" {
fmt.Printf(" Subject: %s\n", sw.Subject)
}
if sw.Context != "" {
fmt.Printf(" Context: %s\n", sw.Context)
}
fmt.Printf(" Slung by: %s\n", sw.CreatedBy)
fmt.Printf(" Slung at: %s\n", sw.CreatedAt.Format("2006-01-02 15:04:05"))
fmt.Println()
// Show the bead details
fmt.Println("**Bead details:**")
cmd := exec.Command("bd", "show", sw.BeadID)
cmd.Dir = cloneRoot
var stdout bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = nil
if cmd.Run() == nil {
// Show first 20 lines of bead details
lines := strings.Split(stdout.String(), "\n")
maxLines := 20
if len(lines) > maxLines {
lines = lines[:maxLines]
lines = append(lines, "...")
}
for _, line := range lines {
fmt.Printf(" %s\n", line)
}
}
fmt.Println()
// The propulsion principle
fmt.Println(style.Bold.Render("→ PROPULSION PRINCIPLE: Work is on your hook. RUN IT."))
fmt.Println(" Begin working on this bead immediately. No human input needed.")
fmt.Println()
// Burn the hook now that it's been read
if err := wisp.BurnHook(cloneRoot, agentID); err != nil {
fmt.Printf("%s Warning: could not burn hook: %v\n", style.Dim.Render("⚠"), err)
}
}
// getAgentIdentity returns the agent identity string for hook lookup.
func getAgentIdentity(ctx RoleContext) string {
switch ctx.Role {
case RoleCrew:
return fmt.Sprintf("%s/crew/%s", ctx.Rig, ctx.Polecat)
case RolePolecat:
return fmt.Sprintf("%s/polecats/%s", ctx.Rig, ctx.Polecat)
case RoleMayor:
return "mayor"
case RoleDeacon:
return "deacon"
case RoleWitness:
return fmt.Sprintf("%s/witness", ctx.Rig)
case RoleRefinery:
return fmt.Sprintf("%s/refinery", ctx.Rig)
default:
return ""
}
}
// acquireIdentityLock checks and acquires the identity lock for worker roles.
// This prevents multiple agents from claiming the same worker identity.
// Returns an error if another agent already owns this identity.

File diff suppressed because it is too large Load Diff

181
internal/wisp/io.go Normal file
View File

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

131
internal/wisp/types.go Normal file
View File

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