From 58ff4c1750a855933e35351bd2803f33aae7cd75 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Mon, 22 Dec 2025 13:20:11 -0800 Subject: [PATCH] Add gt mol catalog/burn/squash, wire wisp flag, update deacon prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/cmd/molecule.go | 337 +++++++++++++++++++++++++++++++++++++++ internal/cmd/sling.go | 30 +++- internal/cmd/spawn.go | 1 + prompts/roles/deacon.md | 17 +- 4 files changed, 374 insertions(+), 11 deletions(-) diff --git a/internal/cmd/molecule.go b/internal/cmd/molecule.go index 2fd00026..a4906792 100644 --- a/internal/cmd/molecule.go +++ b/internal/cmd/molecule.go @@ -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: /.beads/molecules.jsonl +- Rig-level: /.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 +} diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index 73ffdf89..2c768c93 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -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 diff --git a/internal/cmd/spawn.go b/internal/cmd/spawn.go index d701e0fe..ed6be29a 100644 --- a/internal/cmd/spawn.go +++ b/internal/cmd/spawn.go @@ -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. diff --git a/prompts/roles/deacon.md b/prompts/roles/deacon.md index a741fdf3..59296a56 100644 --- a/prompts/roles/deacon.md +++ b/prompts/roles/deacon.md @@ -21,11 +21,11 @@ Your work is defined by the `mol-deacon-patrol` molecule with these steps: ## 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) -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 -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 @@ -33,7 +33,9 @@ Your work is defined by the `mol-deacon-patrol` molecule with these steps: ┌─────────────────────────────────────────┐ │ 1. Check for attached molecule │ │ - gt mol status │ -│ - If none: gt mol bond mol-deacon-patrol │ +│ - If none: spawn wisp │ +│ bd mol spawn mol-deacon-patrol │ +│ --assignee=deacon │ └─────────────────────────────────────────┘ │ v @@ -55,7 +57,7 @@ Your work is defined by the `mol-deacon-patrol` molecule with these steps: v ┌─────────────────────────────────────────┐ │ 4. Loop or Exit │ -│ - gt mol burn │ +│ - gt mol squash (create digest) │ │ - If context LOW: go to 1 │ │ - If context HIGH: exit (respawn) │ └─────────────────────────────────────────┘ @@ -65,8 +67,9 @@ Your work is defined by the `mol-deacon-patrol` molecule with these steps: ### Molecule Management - `gt mol status` - Check current molecule attachment -- `gt mol bond mol-deacon-patrol` - Attach patrol molecule -- `gt mol burn` - Burn completed/abandoned molecule +- `bd mol spawn mol-deacon-patrol --assignee=deacon` - Spawn patrol wisp +- `gt mol burn` - Burn incomplete molecule (no digest) +- `gt mol squash` - Squash complete molecule to digest - `bd ready` - Show next ready step ### Health Checks