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"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
@@ -192,6 +193,55 @@ Examples:
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() {
// List flags
moleculeListCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
@@ -221,8 +271,20 @@ func init() {
// Status flags
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
moleculeCmd.AddCommand(moleculeStatusCmd)
moleculeCmd.AddCommand(moleculeCatalogCmd)
moleculeCmd.AddCommand(moleculeBurnCmd)
moleculeCmd.AddCommand(moleculeSquashCmd)
moleculeCmd.AddCommand(moleculeListCmd)
moleculeCmd.AddCommand(moleculeShowCmd)
moleculeCmd.AddCommand(moleculeParseCmd)
@@ -1310,3 +1372,278 @@ func outputMoleculeStatus(status MoleculeStatusInfo) error {
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"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -560,7 +561,11 @@ func slingToRefinery(townRoot string, target *SlingTarget, thing *SlingThing) er
// spawnMoleculeFromProto spawns a molecule from a proto template.
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
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)
}
// 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.Dir = beadsPath
cmd.Dir = workDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
@@ -595,14 +615,15 @@ func spawnMoleculeFromProto(beadsPath string, thing *SlingThing, assignee string
return "", nil, fmt.Errorf("parsing molecule result: %w", err)
}
fmt.Printf("%s Molecule spawned: %s (%d steps)\n",
style.Bold.Render("✓"), molResult.RootID, molResult.Created-1)
fmt.Printf("%s %s spawned: %s (%d steps)\n",
style.Bold.Render("✓"), moleculeType, molResult.RootID, molResult.Created-1)
moleculeCtx := &MoleculeContext{
MoleculeID: thing.ID,
RootIssueID: molResult.RootID,
TotalSteps: molResult.Created - 1,
StepNumber: 1,
IsWisp: thing.IsWisp,
}
return molResult.RootID, moleculeCtx, nil
@@ -652,6 +673,7 @@ func spawnMoleculeOnIssue(beadsPath string, thing *SlingThing, assignee string)
RootIssueID: molResult.RootID,
TotalSteps: molResult.Created - 1,
StepNumber: 1,
IsWisp: thing.IsWisp,
}
return molResult.RootID, moleculeCtx, nil

View File

@@ -584,6 +584,7 @@ type MoleculeContext struct {
RootIssueID string // The spawned molecule root issue
TotalSteps int // Total number of steps in the molecule
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.