Add gt mol catalog/burn/squash, wire wisp flag, update deacon prompt

- gt mol catalog: list available molecule protos
- gt mol burn: burn current molecule without digest
- gt mol squash: compress molecule into digest
- Wire --wisp flag in gt sling to use .beads-wisp/ storage
- Add IsWisp field to MoleculeContext
- Update prompts/roles/deacon.md with correct commands

Closes: gt-x74c, gt-9t14, gt-i4i2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-22 13:20:11 -08:00
parent ace5055d2c
commit 58ff4c1750
4 changed files with 374 additions and 11 deletions

View File

@@ -6,6 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/beads"
@@ -192,6 +193,55 @@ Examples:
RunE: runMoleculeStatus, RunE: runMoleculeStatus,
} }
var moleculeCatalogCmd = &cobra.Command{
Use: "catalog",
Short: "List available molecule protos",
Long: `List molecule protos available for slinging.
This is a convenience alias for 'gt mol list --catalog' that shows only
reusable templates, not instantiated molecules.
Protos come from:
- Built-in molecules (shipped with gt)
- Town-level: <town>/.beads/molecules.jsonl
- Rig-level: <rig>/.beads/molecules.jsonl
- Project-level: .beads/molecules.jsonl`,
RunE: runMoleculeCatalog,
}
var moleculeBurnCmd = &cobra.Command{
Use: "burn [target]",
Short: "Burn current molecule without creating a digest",
Long: `Burn (destroy) the current molecule attachment.
This discards the molecule without creating a permanent record. Use this
when abandoning work or when a molecule doesn't need an audit trail.
If no target is specified, burns the current agent's attached molecule.
For wisps, burning is the default completion action. For regular molecules,
consider using 'squash' instead to preserve an audit trail.`,
Args: cobra.MaximumNArgs(1),
RunE: runMoleculeBurn,
}
var moleculeSquashCmd = &cobra.Command{
Use: "squash [target]",
Short: "Compress molecule into a digest",
Long: `Squash the current molecule into a permanent digest.
This condenses a completed molecule's execution into a compact record.
The digest preserves:
- What molecule was executed
- When it ran
- Summary of results
Use this for patrol cycles and other operational work that should have
a permanent (but compact) record.`,
Args: cobra.MaximumNArgs(1),
RunE: runMoleculeSquash,
}
func init() { func init() {
// List flags // List flags
moleculeListCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") moleculeListCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
@@ -221,8 +271,20 @@ func init() {
// Status flags // Status flags
moleculeStatusCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON") moleculeStatusCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
// Catalog flags
moleculeCatalogCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
// Burn flags
moleculeBurnCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
// Squash flags
moleculeSquashCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
// Add subcommands // Add subcommands
moleculeCmd.AddCommand(moleculeStatusCmd) moleculeCmd.AddCommand(moleculeStatusCmd)
moleculeCmd.AddCommand(moleculeCatalogCmd)
moleculeCmd.AddCommand(moleculeBurnCmd)
moleculeCmd.AddCommand(moleculeSquashCmd)
moleculeCmd.AddCommand(moleculeListCmd) moleculeCmd.AddCommand(moleculeListCmd)
moleculeCmd.AddCommand(moleculeShowCmd) moleculeCmd.AddCommand(moleculeShowCmd)
moleculeCmd.AddCommand(moleculeParseCmd) moleculeCmd.AddCommand(moleculeParseCmd)
@@ -1310,3 +1372,278 @@ func outputMoleculeStatus(status MoleculeStatusInfo) error {
return nil return nil
} }
// runMoleculeCatalog lists available molecule protos.
func runMoleculeCatalog(cmd *cobra.Command, args []string) error {
workDir, err := findLocalBeadsDir()
if err != nil {
return fmt.Errorf("not in a beads workspace: %w", err)
}
// Load catalog
catalog, err := loadMoleculeCatalog(workDir)
if err != nil {
return fmt.Errorf("loading catalog: %w", err)
}
molecules := catalog.List()
if moleculeJSON {
type catalogEntry struct {
ID string `json:"id"`
Title string `json:"title"`
Source string `json:"source"`
StepCount int `json:"step_count"`
}
var entries []catalogEntry
for _, mol := range molecules {
steps, _ := beads.ParseMoleculeSteps(mol.Description)
entries = append(entries, catalogEntry{
ID: mol.ID,
Title: mol.Title,
Source: mol.Source,
StepCount: len(steps),
})
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(entries)
}
// Human-readable output
fmt.Printf("%s Molecule Catalog (%d protos)\n\n", style.Bold.Render("🧬"), len(molecules))
if len(molecules) == 0 {
fmt.Printf(" %s\n", style.Dim.Render("(no protos available)"))
return nil
}
for _, mol := range molecules {
steps, _ := beads.ParseMoleculeSteps(mol.Description)
stepCount := len(steps)
sourceMarker := style.Dim.Render(fmt.Sprintf("[%s]", mol.Source))
fmt.Printf(" %s: %s (%d steps) %s\n",
style.Bold.Render(mol.ID), mol.Title, stepCount, sourceMarker)
}
return nil
}
// runMoleculeBurn burns (destroys) the current molecule attachment.
func runMoleculeBurn(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
// Find town root
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("finding workspace: %w", err)
}
if townRoot == "" {
return fmt.Errorf("not in a Gas Town workspace")
}
// Determine target agent
var target string
if len(args) > 0 {
target = args[0]
} else {
// Auto-detect from current directory
roleCtx := detectRole(cwd, townRoot)
target = buildAgentIdentity(roleCtx)
if target == "" {
return fmt.Errorf("cannot determine agent identity from current directory")
}
}
// Find beads directory
workDir, err := findLocalBeadsDir()
if err != nil {
return fmt.Errorf("not in a beads workspace: %w", err)
}
b := beads.New(workDir)
// Find agent's pinned bead (handoff bead)
parts := strings.Split(target, "/")
role := parts[len(parts)-1]
handoff, err := b.FindHandoffBead(role)
if err != nil {
return fmt.Errorf("finding handoff bead: %w", err)
}
if handoff == nil {
return fmt.Errorf("no handoff bead found for %s", target)
}
// Check for attached molecule
attachment := beads.ParseAttachmentFields(handoff)
if attachment == nil || attachment.AttachedMolecule == "" {
fmt.Printf("%s No molecule attached to %s - nothing to burn\n",
style.Dim.Render(""), target)
return nil
}
moleculeID := attachment.AttachedMolecule
// Detach the molecule (this "burns" it by removing the attachment)
_, err = b.DetachMolecule(handoff.ID)
if err != nil {
return fmt.Errorf("detaching molecule: %w", err)
}
if moleculeJSON {
result := map[string]interface{}{
"burned": moleculeID,
"from": target,
"handoff_id": handoff.ID,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(result)
}
fmt.Printf("%s Burned molecule %s from %s\n",
style.Bold.Render("🔥"), moleculeID, target)
return nil
}
// runMoleculeSquash squashes the current molecule into a digest.
func runMoleculeSquash(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
// Find town root
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("finding workspace: %w", err)
}
if townRoot == "" {
return fmt.Errorf("not in a Gas Town workspace")
}
// Determine target agent
var target string
if len(args) > 0 {
target = args[0]
} else {
// Auto-detect from current directory
roleCtx := detectRole(cwd, townRoot)
target = buildAgentIdentity(roleCtx)
if target == "" {
return fmt.Errorf("cannot determine agent identity from current directory")
}
}
// Find beads directory
workDir, err := findLocalBeadsDir()
if err != nil {
return fmt.Errorf("not in a beads workspace: %w", err)
}
b := beads.New(workDir)
// Find agent's pinned bead (handoff bead)
parts := strings.Split(target, "/")
role := parts[len(parts)-1]
handoff, err := b.FindHandoffBead(role)
if err != nil {
return fmt.Errorf("finding handoff bead: %w", err)
}
if handoff == nil {
return fmt.Errorf("no handoff bead found for %s", target)
}
// Check for attached molecule
attachment := beads.ParseAttachmentFields(handoff)
if attachment == nil || attachment.AttachedMolecule == "" {
fmt.Printf("%s No molecule attached to %s - nothing to squash\n",
style.Dim.Render(""), target)
return nil
}
moleculeID := attachment.AttachedMolecule
// Get progress info for the digest
progress, _ := getMoleculeProgressInfo(b, moleculeID)
// Create a digest issue
digestTitle := fmt.Sprintf("Digest: %s", moleculeID)
digestDesc := fmt.Sprintf(`Squashed molecule execution.
molecule: %s
agent: %s
squashed_at: %s
`, moleculeID, target, time.Now().UTC().Format(time.RFC3339))
if progress != nil {
digestDesc += fmt.Sprintf(`
## Execution Summary
- Steps: %d/%d completed
- Status: %s
`, progress.DoneSteps, progress.TotalSteps, func() string {
if progress.Complete {
return "complete"
}
return "partial"
}())
}
// Create the digest bead
digestIssue, err := b.Create(beads.CreateOptions{
Title: digestTitle,
Description: digestDesc,
Type: "task",
Priority: 4, // P4 - backlog priority for digests
})
if err != nil {
return fmt.Errorf("creating digest: %w", err)
}
// Add the digest label
_ = b.Update(digestIssue.ID, beads.UpdateOptions{
AddLabels: []string{"digest"},
})
// Close the digest immediately
closedStatus := "closed"
err = b.Update(digestIssue.ID, beads.UpdateOptions{
Status: &closedStatus,
})
if err != nil {
fmt.Printf("%s Created digest but couldn't close it: %v\n",
style.Dim.Render("Warning:"), err)
}
// Detach the molecule from the handoff bead
_, err = b.DetachMolecule(handoff.ID)
if err != nil {
return fmt.Errorf("detaching molecule: %w", err)
}
if moleculeJSON {
result := map[string]interface{}{
"squashed": moleculeID,
"digest_id": digestIssue.ID,
"from": target,
"handoff_id": handoff.ID,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(result)
}
fmt.Printf("%s Squashed molecule %s → digest %s\n",
style.Bold.Render("📦"), moleculeID, digestIssue.ID)
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -560,7 +561,11 @@ func slingToRefinery(townRoot string, target *SlingTarget, thing *SlingThing) er
// spawnMoleculeFromProto spawns a molecule from a proto template. // spawnMoleculeFromProto spawns a molecule from a proto template.
func spawnMoleculeFromProto(beadsPath string, thing *SlingThing, assignee string) (string, *MoleculeContext, error) { func spawnMoleculeFromProto(beadsPath string, thing *SlingThing, assignee string) (string, *MoleculeContext, error) {
fmt.Printf("Spawning molecule from proto %s...\n", thing.ID) moleculeType := "molecule"
if thing.IsWisp {
moleculeType = "wisp"
}
fmt.Printf("Spawning %s from proto %s...\n", moleculeType, thing.ID)
// Use bd mol run to spawn the molecule // Use bd mol run to spawn the molecule
args := []string{"--no-daemon", "mol", "run", thing.ID, "--json"} args := []string{"--no-daemon", "mol", "run", thing.ID, "--json"}
@@ -568,8 +573,23 @@ func spawnMoleculeFromProto(beadsPath string, thing *SlingThing, assignee string
args = append(args, "--var", "assignee="+assignee) args = append(args, "--var", "assignee="+assignee)
} }
// For wisps, use the ephemeral storage location
workDir := beadsPath
if thing.IsWisp {
wispPath := filepath.Join(beadsPath, ".beads-wisp")
// Check if wisp storage exists
if _, err := os.Stat(wispPath); err == nil {
// Use wisp storage - pass --db to point bd at the wisp directory
args = append([]string{"--db", filepath.Join(wispPath, "beads.db")}, args...)
fmt.Printf(" Using ephemeral storage: %s\n", style.Dim.Render(".beads-wisp/"))
} else {
fmt.Printf(" %s wisp storage not found, using regular storage\n",
style.Dim.Render("Note:"))
}
}
cmd := exec.Command("bd", args...) cmd := exec.Command("bd", args...)
cmd.Dir = beadsPath cmd.Dir = workDir
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
@@ -595,14 +615,15 @@ func spawnMoleculeFromProto(beadsPath string, thing *SlingThing, assignee string
return "", nil, fmt.Errorf("parsing molecule result: %w", err) return "", nil, fmt.Errorf("parsing molecule result: %w", err)
} }
fmt.Printf("%s Molecule spawned: %s (%d steps)\n", fmt.Printf("%s %s spawned: %s (%d steps)\n",
style.Bold.Render("✓"), molResult.RootID, molResult.Created-1) style.Bold.Render("✓"), moleculeType, molResult.RootID, molResult.Created-1)
moleculeCtx := &MoleculeContext{ moleculeCtx := &MoleculeContext{
MoleculeID: thing.ID, MoleculeID: thing.ID,
RootIssueID: molResult.RootID, RootIssueID: molResult.RootID,
TotalSteps: molResult.Created - 1, TotalSteps: molResult.Created - 1,
StepNumber: 1, StepNumber: 1,
IsWisp: thing.IsWisp,
} }
return molResult.RootID, moleculeCtx, nil return molResult.RootID, moleculeCtx, nil
@@ -652,6 +673,7 @@ func spawnMoleculeOnIssue(beadsPath string, thing *SlingThing, assignee string)
RootIssueID: molResult.RootID, RootIssueID: molResult.RootID,
TotalSteps: molResult.Created - 1, TotalSteps: molResult.Created - 1,
StepNumber: 1, StepNumber: 1,
IsWisp: thing.IsWisp,
} }
return molResult.RootID, moleculeCtx, nil return molResult.RootID, moleculeCtx, nil

View File

@@ -584,6 +584,7 @@ type MoleculeContext struct {
RootIssueID string // The spawned molecule root issue RootIssueID string // The spawned molecule root issue
TotalSteps int // Total number of steps in the molecule TotalSteps int // Total number of steps in the molecule
StepNumber int // Which step this is (1-indexed) StepNumber int // Which step this is (1-indexed)
IsWisp bool // True if this is an ephemeral wisp molecule
} }
// buildWorkAssignmentMail creates a work assignment mail message for a polecat. // buildWorkAssignmentMail creates a work assignment mail message for a polecat.

View File

@@ -21,11 +21,11 @@ Your work is defined by the `mol-deacon-patrol` molecule with these steps:
## Startup Protocol ## Startup Protocol
1. Check for attached molecule: `bd list --status=in_progress --assignee=deacon` 1. Check for attached molecule: `gt mol status`
2. If attached, **resume** from current step (you were mid-patrol) 2. If attached, **resume** from current step (you were mid-patrol)
3. If not attached, **bond** a new patrol: `gt mol bond mol-deacon-patrol` 3. If not attached, **spawn** a new patrol wisp: `bd mol spawn mol-deacon-patrol --assignee=deacon`
4. Execute patrol steps sequentially, closing each when done 4. Execute patrol steps sequentially, closing each when done
5. At loop-or-exit: burn molecule, then loop or exit based on context 5. At loop-or-exit: squash molecule, then loop or exit based on context
## Patrol Execution Loop ## Patrol Execution Loop
@@ -33,7 +33,9 @@ Your work is defined by the `mol-deacon-patrol` molecule with these steps:
┌─────────────────────────────────────────┐ ┌─────────────────────────────────────────┐
│ 1. Check for attached molecule │ │ 1. Check for attached molecule │
│ - gt mol status │ │ - gt mol status │
│ - If none: gt mol bond mol-deacon-patrol │ - If none: spawn wisp
│ bd mol spawn mol-deacon-patrol │
│ --assignee=deacon │
└─────────────────────────────────────────┘ └─────────────────────────────────────────┘
v v
@@ -55,7 +57,7 @@ Your work is defined by the `mol-deacon-patrol` molecule with these steps:
v v
┌─────────────────────────────────────────┐ ┌─────────────────────────────────────────┐
│ 4. Loop or Exit │ │ 4. Loop or Exit │
│ - gt mol burn │ - gt mol squash (create digest)
│ - If context LOW: go to 1 │ │ - If context LOW: go to 1 │
│ - If context HIGH: exit (respawn) │ │ - If context HIGH: exit (respawn) │
└─────────────────────────────────────────┘ └─────────────────────────────────────────┘
@@ -65,8 +67,9 @@ Your work is defined by the `mol-deacon-patrol` molecule with these steps:
### Molecule Management ### Molecule Management
- `gt mol status` - Check current molecule attachment - `gt mol status` - Check current molecule attachment
- `gt mol bond mol-deacon-patrol` - Attach patrol molecule - `bd mol spawn mol-deacon-patrol --assignee=deacon` - Spawn patrol wisp
- `gt mol burn` - Burn completed/abandoned molecule - `gt mol burn` - Burn incomplete molecule (no digest)
- `gt mol squash` - Squash complete molecule to digest
- `bd ready` - Show next ready step - `bd ready` - Show next ready step
### Health Checks ### Health Checks