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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user