Files
gastown/internal/cmd/spawn.go
Steve Yegge d3164f72a6 refactor: Move pending spawn tracking to gt spawn pending (hq-466n)
- Moved pending.go from deacon/ to polecat/ package
- Changed storage location from deacon/pending.json to spawn/pending.json
- Added 'gt spawn pending' subcommand for listing/clearing pending spawns
- Deprecated 'gt deacon pending' (still works, shows deprecation notice)

This decouples pending spawn observation from the Deacon role - anyone
can now observe pending polecats (Mayor, humans, debugging, etc.).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 13:50:26 -08:00

881 lines
30 KiB
Go

package cmd
import (
"bytes"
"encoding/json"
"fmt"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/refinery"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/wisp"
"github.com/steveyegge/gastown/internal/witness"
"github.com/steveyegge/gastown/internal/workspace"
)
// Spawn command flags
var (
spawnIssue string
spawnMessage string
spawnCreate bool
spawnNoStart bool
spawnPolecat string
spawnRig string
spawnMolecule string
spawnForce bool
spawnAccount string
// spawn pending flags
spawnPendingLines int
)
var spawnCmd = &cobra.Command{
Use: "spawn [rig/polecat | rig]",
Aliases: []string{"sp"},
GroupID: GroupWork,
Short: "Spawn a polecat with work assignment",
Long: `Spawn a polecat with a work assignment.
Use 'gt spawn pending' to view spawns waiting to be triggered.
Assigns an issue or task to a polecat and starts a session. If no polecat
is specified, auto-selects an idle polecat in the rig.
Issue-based spawns automatically use mol-polecat-work for structured workflow
with crash recovery checkpoints. Use --molecule to override with a different
molecule, or -m/--message for free-form tasks without a molecule.
Examples:
gt spawn gastown/Toast --issue gt-abc # uses mol-polecat-work
gt spawn gastown --issue gt-def # auto-select polecat
gt spawn gastown/Nux -m "Fix the tests" # free-form task (no molecule)
gt spawn gastown/Capable --issue gt-xyz --create # create if missing
# Flag-based selection (rig inferred from current directory):
gt spawn --issue gt-xyz --polecat Angharad
gt spawn --issue gt-abc --rig gastown --polecat Toast
# With custom molecule workflow:
gt spawn --issue gt-abc --molecule mol-engineer-box`,
Args: cobra.MaximumNArgs(1),
RunE: runSpawn,
}
var spawnPendingCmd = &cobra.Command{
Use: "pending [session-to-clear]",
Short: "List pending spawns with captured output (for AI observation)",
Long: `List pending polecat spawns with their terminal output for AI analysis.
This shows spawns waiting to be triggered (Claude is still initializing).
The terminal output helps determine if Claude is ready.
Workflow:
1. Run 'gt spawn pending' to see pending spawns and their output
2. Analyze the output to determine if Claude is ready (look for "> " prompt)
3. Run 'gt nudge <session> "Begin."' to trigger ready polecats
4. Run 'gt spawn pending <session>' to clear from pending list
Examples:
gt spawn pending # List all pending with output
gt spawn pending gastown/p-abc123 # Clear specific session from pending`,
Args: cobra.MaximumNArgs(1),
RunE: runSpawnPending,
}
func init() {
spawnCmd.Flags().StringVar(&spawnIssue, "issue", "", "Beads issue ID to assign")
spawnCmd.Flags().StringVarP(&spawnMessage, "message", "m", "", "Free-form task description")
spawnCmd.Flags().BoolVar(&spawnCreate, "create", false, "Create polecat if it doesn't exist")
spawnCmd.Flags().BoolVar(&spawnNoStart, "no-start", false, "Assign work but don't start session")
spawnCmd.Flags().StringVar(&spawnPolecat, "polecat", "", "Polecat name (alternative to positional arg)")
spawnCmd.Flags().StringVar(&spawnRig, "rig", "", "Rig name (defaults to current directory's rig)")
spawnCmd.Flags().StringVar(&spawnMolecule, "molecule", "", "Molecule ID to instantiate on the issue")
spawnCmd.Flags().BoolVar(&spawnForce, "force", false, "Force spawn even if polecat has unread mail")
spawnCmd.Flags().StringVar(&spawnAccount, "account", "", "Claude Code account handle to use (overrides default)")
// spawn pending flags
spawnPendingCmd.Flags().IntVarP(&spawnPendingLines, "lines", "n", 15,
"Number of terminal lines to capture per session")
spawnCmd.AddCommand(spawnPendingCmd)
rootCmd.AddCommand(spawnCmd)
}
// BeadsIssue represents a beads issue from JSON output.
type BeadsIssue struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Priority int `json:"priority"`
Type string `json:"issue_type"`
Status string `json:"status"`
}
func runSpawn(cmd *cobra.Command, args []string) error {
if spawnIssue == "" && spawnMessage == "" {
return fmt.Errorf("must specify --issue or -m/--message")
}
// --molecule requires --issue
if spawnMolecule != "" && spawnIssue == "" {
return fmt.Errorf("--molecule requires --issue to be specified")
}
// Auto-use mol-polecat-work for issue-based spawns (Phase 3: Polecat Work Cycle)
// This gives polecats a structured workflow with checkpoints for crash recovery.
// Can be overridden with explicit --molecule flag.
if spawnIssue != "" && spawnMolecule == "" {
spawnMolecule = "mol-polecat-work"
}
// Find workspace first (needed for rig inference)
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
var rigName, polecatName string
// Determine rig and polecat from positional arg or flags
if len(args) > 0 {
// Parse address: rig/polecat or just rig
rigName, polecatName, err = parseSpawnAddress(args[0])
if err != nil {
return err
}
} else {
// No positional arg - use flags
polecatName = spawnPolecat
rigName = spawnRig
// If no --rig flag, infer from current directory
if rigName == "" {
rigName, err = inferRigFromCwd(townRoot)
if err != nil {
return fmt.Errorf("cannot determine rig: %w\nUse --rig to specify explicitly or provide rig/polecat as positional arg", err)
}
}
}
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
}
g := git.NewGit(townRoot)
rigMgr := rig.NewManager(townRoot, rigsConfig, g)
r, err := rigMgr.GetRig(rigName)
if err != nil {
return fmt.Errorf("rig '%s' not found", rigName)
}
// Get polecat manager
polecatGit := git.NewGit(r.Path)
polecatMgr := polecat.NewManager(r, polecatGit)
// Router for mail operations (used for checking inbox and sending assignments)
router := mail.NewRouter(r.Path)
// Auto-select polecat if not specified
if polecatName == "" {
polecatName, err = selectIdlePolecat(polecatMgr, r)
if err != nil {
// If --create is set, allocate a name from the pool
if spawnCreate {
polecatName, err = polecatMgr.AllocateName()
if err != nil {
return fmt.Errorf("allocating polecat name: %w", err)
}
fmt.Printf("Allocated polecat name: %s\n", polecatName)
} else {
return fmt.Errorf("auto-select polecat: %w", err)
}
} else {
fmt.Printf("Auto-selected polecat: %s\n", polecatName)
}
}
// Address for this polecat (used for mail operations)
polecatAddress := fmt.Sprintf("%s/%s", rigName, polecatName)
// Check if polecat exists
existingPolecat, err := polecatMgr.Get(polecatName)
polecatExists := err == nil
if polecatExists {
// Polecat exists - we'll recreate it fresh after safety checks
// Check if polecat is currently working (cannot interrupt active work)
if existingPolecat.State == polecat.StateWorking {
return fmt.Errorf("polecat '%s' is already working on %s", polecatName, existingPolecat.Issue)
}
// Check for uncommitted work (safety check before recreating)
pGit := git.NewGit(existingPolecat.ClonePath)
workStatus, checkErr := pGit.CheckUncommittedWork()
if checkErr == nil && !workStatus.Clean() {
fmt.Printf("\n%s Polecat has uncommitted work:\n", style.Warning.Render("⚠"))
if workStatus.HasUncommittedChanges {
fmt.Printf(" • %d uncommitted change(s)\n", len(workStatus.ModifiedFiles)+len(workStatus.UntrackedFiles))
}
if workStatus.StashCount > 0 {
fmt.Printf(" • %d stash(es)\n", workStatus.StashCount)
}
if workStatus.UnpushedCommits > 0 {
fmt.Printf(" • %d unpushed commit(s)\n", workStatus.UnpushedCommits)
}
fmt.Println()
if !spawnForce {
return fmt.Errorf("polecat '%s' has uncommitted work (%s)\nCommit or stash changes before spawning, or use --force to proceed anyway",
polecatName, workStatus.String())
}
fmt.Printf("%s Proceeding with --force (uncommitted work will be lost)\n",
style.Dim.Render("Warning:"))
}
// Check for unread mail (indicates existing unstarted work)
mailbox, mailErr := router.GetMailbox(polecatAddress)
if mailErr == nil {
_, unread, _ := mailbox.Count()
if unread > 0 && !spawnForce {
return fmt.Errorf("polecat '%s' has %d unread message(s) in inbox (possible existing work assignment)\nUse --force to override, or let the polecat process its inbox first",
polecatName, unread)
} else if unread > 0 {
fmt.Printf("%s Polecat has %d unread message(s), proceeding with --force\n",
style.Dim.Render("Warning:"), unread)
}
}
// Recreate the polecat with a fresh worktree (latest code from main)
fmt.Printf("Recreating polecat %s with fresh worktree...\n", polecatName)
if _, err = polecatMgr.Recreate(polecatName, spawnForce); err != nil {
return fmt.Errorf("recreating polecat: %w", err)
}
fmt.Printf("%s Fresh worktree created\n", style.Bold.Render("✓"))
} else if err == polecat.ErrPolecatNotFound {
// Polecat doesn't exist - create new one
if !spawnCreate {
return fmt.Errorf("polecat '%s' not found (use --create to create)", polecatName)
}
fmt.Printf("Creating polecat %s...\n", polecatName)
if _, err = polecatMgr.Add(polecatName); err != nil {
return fmt.Errorf("creating polecat: %w", err)
}
} else {
return fmt.Errorf("getting polecat: %w", err)
}
// Get the polecat object to access its worktree path for hook file
polecatObj, err := polecatMgr.Get(polecatName)
if err != nil {
return fmt.Errorf("getting polecat after creation: %w", err)
}
// Beads operations use rig-level beads (at rig root, not mayor/rig)
beadsPath := r.Path
// Sync beads to ensure fresh state before spawn operations
if err := syncBeads(beadsPath, true); err != nil {
// Non-fatal - continue with possibly stale beads
fmt.Printf("%s beads sync: %v\n", style.Dim.Render("Warning:"), err)
}
// Track molecule context for work assignment mail
var moleculeCtx *MoleculeContext
// Handle molecule instantiation if specified
if spawnMolecule != "" {
// Molecule instantiation uses three separate bd commands:
// 1. bd pour - creates issues from proto template
// 2. bd update - sets status to in_progress (claims work)
// 3. bd pin - pins root for session recovery
// This keeps bd as pure data operations and gt as orchestration.
fmt.Printf("Running molecule %s on %s...\n", spawnMolecule, spawnIssue)
// Step 1: Pour the molecule (create issues from template)
pourCmd := exec.Command("bd", "--no-daemon", "pour", spawnMolecule,
"--var", "issue="+spawnIssue, "--json")
pourCmd.Dir = beadsPath
var pourStdout, pourStderr bytes.Buffer
pourCmd.Stdout = &pourStdout
pourCmd.Stderr = &pourStderr
if err := pourCmd.Run(); err != nil {
errMsg := strings.TrimSpace(pourStderr.String())
if errMsg != "" {
return fmt.Errorf("pouring molecule: %s", errMsg)
}
return fmt.Errorf("pouring molecule: %w", err)
}
// Parse pour output to get root ID
var pourResult struct {
NewEpicID string `json:"new_epic_id"`
IDMapping map[string]string `json:"id_mapping"`
Created int `json:"created"`
}
if err := json.Unmarshal(pourStdout.Bytes(), &pourResult); err != nil {
return fmt.Errorf("parsing pour result: %w", err)
}
rootID := pourResult.NewEpicID
fmt.Printf("%s Molecule poured: %s (%d steps)\n",
style.Bold.Render("✓"), rootID, pourResult.Created-1) // -1 for root
// Step 2: Set status to in_progress (claim work)
updateCmd := exec.Command("bd", "--no-daemon", "update", rootID, "--status=in_progress")
updateCmd.Dir = beadsPath
if err := updateCmd.Run(); err != nil {
return fmt.Errorf("setting molecule status: %w", err)
}
// Step 3: Pin the root for session recovery
pinCmd := exec.Command("bd", "--no-daemon", "pin", rootID)
pinCmd.Dir = beadsPath
if err := pinCmd.Run(); err != nil {
return fmt.Errorf("pinning molecule: %w", err)
}
// Build molecule context for work assignment
moleculeCtx = &MoleculeContext{
MoleculeID: spawnMolecule,
RootIssueID: rootID,
TotalSteps: pourResult.Created - 1, // -1 for root
StepNumber: 1, // Starting on first step
}
// Update spawnIssue to be the molecule root (for assignment tracking)
spawnIssue = rootID
}
// Get or create issue
var issue *BeadsIssue
var assignmentID string
if spawnIssue != "" {
// Use existing issue
issue, err = fetchBeadsIssue(beadsPath, spawnIssue)
if err != nil {
return fmt.Errorf("fetching issue %s: %w", spawnIssue, err)
}
assignmentID = spawnIssue
} else {
// Create a beads issue for free-form task
fmt.Printf("Creating beads issue for task...\n")
issue, err = createBeadsTask(beadsPath, spawnMessage)
if err != nil {
return fmt.Errorf("creating task issue: %w", err)
}
assignmentID = issue.ID
fmt.Printf("Created issue %s\n", assignmentID)
}
// Assign issue to polecat (sets issue.assignee in beads)
if err := polecatMgr.AssignIssue(polecatName, assignmentID); err != nil {
return fmt.Errorf("assigning issue: %w", err)
}
fmt.Printf("%s Assigned %s to %s/%s\n",
style.Bold.Render("✓"),
assignmentID, rigName, polecatName)
// Write hook file to polecat's worktree so gt mol status can find it
// This puts work on the polecat's hook for the propulsion protocol
sw := wisp.NewSlungWork(assignmentID, "mayor/")
if moleculeCtx != nil {
sw.Subject = fmt.Sprintf("Molecule: %s", moleculeCtx.MoleculeID)
sw.Context = fmt.Sprintf("Step %d/%d of %s", moleculeCtx.StepNumber, moleculeCtx.TotalSteps, moleculeCtx.MoleculeID)
} else if issue != nil {
sw.Subject = issue.Title
}
if err := wisp.WriteSlungWork(polecatObj.ClonePath, polecatAddress, sw); err != nil {
fmt.Printf("%s creating hook file: %v\n", style.Dim.Render("Warning:"), err)
} else {
fmt.Printf("%s Hook file created in polecat worktree\n", style.Bold.Render("✓"))
}
// Sync beads to push assignment changes
if err := syncBeads(beadsPath, false); err != nil {
// Non-fatal warning
fmt.Printf("%s beads push: %v\n", style.Dim.Render("Warning:"), err)
}
// Stop here if --no-start
if spawnNoStart {
fmt.Printf("\n %s\n", style.Dim.Render("Use 'gt session start' to start the session"))
return nil
}
// Send work assignment mail to polecat inbox (before starting session)
workMsg := buildWorkAssignmentMail(issue, spawnMessage, polecatAddress, moleculeCtx)
fmt.Printf("Sending work assignment to %s inbox...\n", polecatAddress)
if err := router.Send(workMsg); err != nil {
return fmt.Errorf("sending work assignment: %w", err)
}
fmt.Printf("%s Work assignment sent\n", style.Bold.Render("✓"))
// Resolve account for Claude config
accountsPath := constants.MayorAccountsPath(townRoot)
claudeConfigDir, accountHandle, err := config.ResolveAccountConfigDir(accountsPath, spawnAccount)
if err != nil {
return fmt.Errorf("resolving account: %w", err)
}
if accountHandle != "" {
fmt.Printf("Using account: %s\n", accountHandle)
}
// Start session
t := tmux.NewTmux()
sessMgr := session.NewManager(t, r)
// Check if already running
running, _ := sessMgr.IsRunning(polecatName)
if running {
fmt.Printf("Session already running\n")
} else {
// Start new session
fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName)
startOpts := session.StartOptions{
ClaudeConfigDir: claudeConfigDir,
}
if err := sessMgr.Start(polecatName, startOpts); err != nil {
return fmt.Errorf("starting session: %w", err)
}
}
fmt.Printf("%s Session started. Attach with: %s\n",
style.Bold.Render("✓"),
style.Dim.Render(fmt.Sprintf("gt session at %s/%s", rigName, polecatName)))
// NOTE: We do NOT send a nudge here. Claude Code takes 10-20+ seconds to initialize,
// and sending keys before the prompt is ready causes them to be mangled.
// The Deacon will poll with WaitForClaudeReady and send a trigger when ready.
// The polecat's SessionStart hook runs gt prime, and work assignment is in its inbox.
// Notify Witness and Deacon about the spawn for monitoring
// Use town-level beads for cross-agent mail (gt-c6b: mail coordination uses town-level)
townRouter := mail.NewRouter(townRoot)
sender := detectSender()
sessionName := sessMgr.SessionName(polecatName)
// Notify Witness with POLECAT_STARTED message (ephemeral - lifecycle ping)
witnessAddr := fmt.Sprintf("%s/witness", rigName)
witnessNotification := &mail.Message{
To: witnessAddr,
From: sender,
Subject: fmt.Sprintf("POLECAT_STARTED %s", polecatName),
Body: fmt.Sprintf("Issue: %s\nSession: %s", assignmentID, sessionName),
Wisp: true,
}
if err := townRouter.Send(witnessNotification); err != nil {
fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("Warning: could not notify witness: %v", err)))
} else {
fmt.Printf(" %s\n", style.Dim.Render("Witness notified of polecat start"))
}
// Notify Deacon with POLECAT_STARTED message (ephemeral - lifecycle ping)
deaconAddr := "deacon/"
deaconNotification := &mail.Message{
To: deaconAddr,
From: sender,
Subject: fmt.Sprintf("POLECAT_STARTED %s/%s", rigName, polecatName),
Body: fmt.Sprintf("Issue: %s\nSession: %s", assignmentID, sessionName),
Wisp: true,
}
if err := townRouter.Send(deaconNotification); err != nil {
fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("Warning: could not notify deacon: %v", err)))
} else {
fmt.Printf(" %s\n", style.Dim.Render("Deacon notified of polecat start"))
}
// Auto-start infrastructure if not running (redundant system - Witness also self-checks)
// This ensures the merge queue and polecat monitor are alive to handle work
refineryMgr := refinery.NewManager(r)
if refStatus, err := refineryMgr.Status(); err == nil && refStatus.State != refinery.StateRunning {
fmt.Printf("Starting refinery for %s...\n", rigName)
if err := refineryMgr.Start(false); err != nil {
if err != refinery.ErrAlreadyRunning {
fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("Warning: could not start refinery: %v", err)))
}
} else {
fmt.Printf(" %s\n", style.Dim.Render("Refinery started"))
}
}
witnessMgr := witness.NewManager(r)
if witStatus, err := witnessMgr.Status(); err == nil && witStatus.State != witness.StateRunning {
fmt.Printf("Starting witness for %s...\n", rigName)
if err := witnessMgr.Start(); err != nil {
if err != witness.ErrAlreadyRunning {
fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("Warning: could not start witness: %v", err)))
}
} else {
fmt.Printf(" %s\n", style.Dim.Render("Witness started"))
}
}
return nil
}
// parseSpawnAddress parses "rig/polecat" or "rig".
func parseSpawnAddress(addr string) (rigName, polecatName string, err error) {
if strings.Contains(addr, "/") {
parts := strings.SplitN(addr, "/", 2)
if parts[0] == "" {
return "", "", fmt.Errorf("invalid address: missing rig name")
}
return parts[0], parts[1], nil
}
return addr, "", nil
}
// selectIdlePolecat finds an idle polecat in the rig.
func selectIdlePolecat(mgr *polecat.Manager, r *rig.Rig) (string, error) {
polecats, err := mgr.List()
if err != nil {
return "", err
}
// Prefer idle polecats
for _, pc := range polecats {
if pc.State == polecat.StateIdle {
return pc.Name, nil
}
}
// Accept active polecats without current work
for _, pc := range polecats {
if pc.State == polecat.StateActive && pc.Issue == "" {
return pc.Name, nil
}
}
// Check rig's polecat list for any we haven't loaded yet
for _, name := range r.Polecats {
found := false
for _, pc := range polecats {
if pc.Name == name {
found = true
break
}
}
if !found {
return name, nil
}
}
return "", fmt.Errorf("no available polecats in rig '%s'", r.Name)
}
// fetchBeadsIssue gets issue details from beads CLI.
func fetchBeadsIssue(rigPath, issueID string) (*BeadsIssue, error) {
cmd := exec.Command("bd", "show", issueID, "--json")
cmd.Dir = rigPath
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return nil, fmt.Errorf("%s", errMsg)
}
return nil, err
}
// bd show --json returns an array, take the first element
var issues []BeadsIssue
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
return nil, fmt.Errorf("parsing issue: %w", err)
}
if len(issues) == 0 {
return nil, fmt.Errorf("issue not found: %s", issueID)
}
return &issues[0], nil
}
// createBeadsTask creates a new beads task issue for a free-form task message.
func createBeadsTask(rigPath, message string) (*BeadsIssue, error) {
// Truncate message for title if too long
title := message
if len(title) > 60 {
title = title[:57] + "..."
}
// Use bd create to make a new task issue
cmd := exec.Command("bd", "create",
"--title="+title,
"--type=task",
"--priority=2",
"--description="+message,
"--json")
cmd.Dir = rigPath
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return nil, fmt.Errorf("%s", errMsg)
}
return nil, err
}
// bd create --json returns the created issue
var issue BeadsIssue
if err := json.Unmarshal(stdout.Bytes(), &issue); err != nil {
return nil, fmt.Errorf("parsing created issue: %w", err)
}
return &issue, nil
}
// syncBeads runs bd sync in the given directory.
// This ensures beads state is fresh before spawn operations.
func syncBeads(workDir string, fromMain bool) error {
args := []string{"sync"}
if fromMain {
args = append(args, "--from-main")
}
cmd := exec.Command("bd", args...)
cmd.Dir = workDir
return cmd.Run()
}
// buildSpawnContext creates the initial context message for the polecat.
// Deprecated: Use buildWorkAssignmentMail instead for mail-based work assignment.
func buildSpawnContext(issue *BeadsIssue, message string) string {
var sb strings.Builder
sb.WriteString("[SPAWN] You have been assigned work.\n\n")
if issue != nil {
sb.WriteString(fmt.Sprintf("Issue: %s\n", issue.ID))
sb.WriteString(fmt.Sprintf("Title: %s\n", issue.Title))
sb.WriteString(fmt.Sprintf("Priority: P%d\n", issue.Priority))
sb.WriteString(fmt.Sprintf("Type: %s\n", issue.Type))
if issue.Description != "" {
sb.WriteString(fmt.Sprintf("\nDescription:\n%s\n", issue.Description))
}
} else if message != "" {
sb.WriteString(fmt.Sprintf("Task: %s\n", message))
}
sb.WriteString("\n## Workflow\n")
sb.WriteString("1. Run `gt prime` to load polecat context\n")
sb.WriteString("2. Work on your task, commit changes regularly\n")
sb.WriteString("3. Run `bd close <issue-id>` when done\n")
sb.WriteString("4. Run `bd sync` to push beads changes\n")
sb.WriteString("5. Run `gt done` to signal completion (branch stays local)\n")
return sb.String()
}
// MoleculeContext contains information about a molecule workflow assignment.
type MoleculeContext struct {
MoleculeID string // The molecule template ID (proto)
RootIssueID string // The created 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 a wisp (not durable mol)
}
// buildWorkAssignmentMail creates a work assignment mail message for a polecat.
// This replaces tmux-based context injection with persistent mailbox delivery.
// If moleculeCtx is non-nil, includes molecule workflow instructions.
func buildWorkAssignmentMail(issue *BeadsIssue, message, polecatAddress string, moleculeCtx *MoleculeContext) *mail.Message {
var subject string
var body strings.Builder
if issue != nil {
if moleculeCtx != nil {
subject = fmt.Sprintf("🧬 Molecule: %s (step %d/%d)", issue.Title, moleculeCtx.StepNumber, moleculeCtx.TotalSteps)
} else {
subject = fmt.Sprintf("📋 Work Assignment: %s", issue.Title)
}
body.WriteString(fmt.Sprintf("Issue: %s\n", issue.ID))
body.WriteString(fmt.Sprintf("Title: %s\n", issue.Title))
body.WriteString(fmt.Sprintf("Priority: P%d\n", issue.Priority))
body.WriteString(fmt.Sprintf("Type: %s\n", issue.Type))
if issue.Description != "" {
body.WriteString(fmt.Sprintf("\nDescription:\n%s\n", issue.Description))
}
} else if message != "" {
// Truncate for subject if too long
titleText := message
if len(titleText) > 50 {
titleText = titleText[:47] + "..."
}
subject = fmt.Sprintf("📋 Work Assignment: %s", titleText)
body.WriteString(fmt.Sprintf("Task: %s\n", message))
}
// Add molecule context if present
if moleculeCtx != nil {
body.WriteString("\n## Molecule Workflow\n")
body.WriteString(fmt.Sprintf("You are working on step %d of %d in molecule %s.\n", moleculeCtx.StepNumber, moleculeCtx.TotalSteps, moleculeCtx.MoleculeID))
body.WriteString(fmt.Sprintf("Molecule root: %s\n\n", moleculeCtx.RootIssueID))
body.WriteString("After completing this step:\n")
body.WriteString("1. Run `bd close <step-id>`\n")
body.WriteString("2. Run `bd ready --parent " + moleculeCtx.RootIssueID + "` to find next ready steps\n")
body.WriteString("3. If more steps are ready, continue working on them\n")
body.WriteString("4. When all steps are done, run `gt done` to signal completion\n\n")
}
body.WriteString("\n## Workflow\n")
body.WriteString("1. Run `gt prime` to load polecat context\n")
body.WriteString("2. Work on your task, commit changes regularly\n")
body.WriteString("3. Run `bd close <issue-id>` when done\n")
if moleculeCtx != nil {
body.WriteString("4. Check `bd ready --parent " + moleculeCtx.RootIssueID + "` for more steps\n")
body.WriteString("5. Repeat steps 2-4 for each ready step\n")
body.WriteString("6. When all steps done: run `bd sync`, then `gt done`\n")
} else {
body.WriteString("4. Run `bd sync` to push beads changes\n")
body.WriteString("5. Run `gt done` to signal completion (branch stays local)\n")
}
body.WriteString("\n## Handoff Protocol\n")
body.WriteString("Before signaling done, ensure:\n")
body.WriteString("- Git status is clean (no uncommitted changes)\n")
body.WriteString("- Issue is closed with `bd close`\n")
body.WriteString("- Beads are synced with `bd sync`\n")
body.WriteString("\nThe `gt done` command verifies these and signals the Witness.\n")
return &mail.Message{
From: "mayor/",
To: polecatAddress,
Subject: subject,
Body: body.String(),
Priority: mail.PriorityHigh,
Type: mail.TypeTask,
}
}
// runSpawnPending shows pending spawns with captured output for AI observation.
// This is the ZFC-compliant way to observe polecats waiting to be triggered.
func runSpawnPending(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// If session argument provided, clear it from pending
if len(args) == 1 {
return clearSpawnPending(townRoot, args[0])
}
// Step 1: Check inbox for new POLECAT_STARTED messages
pending, err := polecat.CheckInboxForSpawns(townRoot)
if err != nil {
return fmt.Errorf("checking inbox: %w", err)
}
if len(pending) == 0 {
fmt.Printf("%s No pending spawns\n", style.Dim.Render("○"))
return nil
}
t := tmux.NewTmux()
fmt.Printf("%s Pending spawns (%d):\n\n", style.Bold.Render("●"), len(pending))
for i, ps := range pending {
// Check if session still exists
running, err := t.HasSession(ps.Session)
if err != nil {
fmt.Printf("Session: %s\n", ps.Session)
fmt.Printf(" Status: error checking session: %v\n\n", err)
continue
}
if !running {
fmt.Printf("Session: %s\n", ps.Session)
fmt.Printf(" Status: session no longer exists\n\n")
continue
}
// Capture terminal output for AI analysis
output, err := t.CapturePane(ps.Session, spawnPendingLines)
if err != nil {
fmt.Printf("Session: %s\n", ps.Session)
fmt.Printf(" Status: error capturing output: %v\n\n", err)
continue
}
// Print session info
fmt.Printf("Session: %s\n", ps.Session)
fmt.Printf(" Rig: %s\n", ps.Rig)
fmt.Printf(" Polecat: %s\n", ps.Polecat)
if ps.Issue != "" {
fmt.Printf(" Issue: %s\n", ps.Issue)
}
fmt.Printf(" Spawned: %s ago\n", time.Since(ps.SpawnedAt).Round(time.Second))
fmt.Printf(" Terminal output (last %d lines):\n", spawnPendingLines)
fmt.Println(strings.Repeat("─", 50))
fmt.Println(output)
fmt.Println(strings.Repeat("─", 50))
if i < len(pending)-1 {
fmt.Println()
}
}
fmt.Println()
fmt.Printf("%s To trigger a ready polecat:\n", style.Dim.Render("→"))
fmt.Printf(" gt nudge <session> \"Begin.\"\n")
return nil
}
// clearSpawnPending removes a session from the pending list.
func clearSpawnPending(townRoot, session string) error {
pending, err := polecat.LoadPending(townRoot)
if err != nil {
return fmt.Errorf("loading pending: %w", err)
}
var remaining []*polecat.PendingSpawn
found := false
for _, ps := range pending {
if ps.Session == session {
found = true
continue
}
remaining = append(remaining, ps)
}
if !found {
return fmt.Errorf("session %s not found in pending list", session)
}
if err := polecat.SavePending(townRoot, remaining); err != nil {
return fmt.Errorf("saving pending: %w", err)
}
fmt.Printf("%s Cleared %s from pending list\n", style.Bold.Render("✓"), session)
return nil
}