Files
gastown/internal/cmd/spawn.go
Steve Yegge 4c064aff5d feat(molecule): add molecule CLI commands and spawn integration
Add `gt molecule` subcommands for managing workflow templates:
- list: Show all molecules (type=molecule issues)
- show: Display molecule with parsed step structure
- parse: Validate molecule and show parsed details
- instantiate: Create child beads from molecule template
- instances: Show all instantiations of a molecule

Also add `--molecule` flag to `gt spawn` for molecule-based workflows.
When specified, the molecule is instantiated on the parent issue first,
then the polecat is spawned on the first ready step.

🤝 Co-authored-by: Claude <noreply@anthropic.com>
2025-12-19 12:00:30 -08:00

437 lines
12 KiB
Go

package cmd
import (
"bytes"
"encoding/json"
"fmt"
"math/rand"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/polecat"
"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/workspace"
)
// polecatNames are Mad Max: Fury Road themed names for auto-generated polecats.
var polecatNames = []string{
"Nux", "Toast", "Capable", "Cheedo", "Dag", "Rictus", "Slit", "Morsov",
"Ace", "Coma", "Valkyrie", "Keeper", "Vuvalini", "Organic", "Immortan",
"Corpus", "Doof", "Scabrous", "Splendid", "Fragile",
}
// Spawn command flags
var (
spawnIssue string
spawnMessage string
spawnCreate bool
spawnNoStart bool
spawnPolecat string
spawnRig string
spawnMolecule string
)
var spawnCmd = &cobra.Command{
Use: "spawn [rig/polecat | rig]",
Aliases: []string{"sp"},
Short: "Spawn a polecat with work assignment",
Long: `Spawn a polecat with a work assignment.
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.
When --molecule is specified, the molecule is first instantiated on the parent
issue (creating child steps), then the polecat is spawned on the first ready step.
Examples:
gt spawn gastown/Toast --issue gt-abc
gt spawn gastown --issue gt-def # auto-select polecat
gt spawn gastown/Nux -m "Fix the tests" # free-form task
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 molecule workflow:
gt spawn --issue gt-abc --molecule mol-engineer-box`,
Args: cobra.MaximumNArgs(1),
RunE: runSpawn,
}
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")
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")
}
// 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)
// Auto-select polecat if not specified
if polecatName == "" {
polecatName, err = selectIdlePolecat(polecatMgr, r)
if err != nil {
// If --create is set, generate a new polecat name instead of failing
if spawnCreate {
polecatName = generatePolecatName(polecatMgr)
fmt.Printf("Generated polecat name: %s\n", polecatName)
} else {
return fmt.Errorf("auto-select polecat: %w", err)
}
} else {
fmt.Printf("Auto-selected polecat: %s\n", polecatName)
}
}
// Check/create polecat
pc, err := polecatMgr.Get(polecatName)
if err != nil {
if err == polecat.ErrPolecatNotFound {
if !spawnCreate {
return fmt.Errorf("polecat '%s' not found (use --create to create)", polecatName)
}
fmt.Printf("Creating polecat %s...\n", polecatName)
pc, err = polecatMgr.Add(polecatName)
if err != nil {
return fmt.Errorf("creating polecat: %w", err)
}
} else {
return fmt.Errorf("getting polecat: %w", err)
}
}
// Check polecat state
if pc.State == polecat.StateWorking {
return fmt.Errorf("polecat '%s' is already working on %s", polecatName, pc.Issue)
}
// Handle molecule instantiation if specified
if spawnMolecule != "" {
b := beads.New(r.Path)
// Get the molecule
mol, err := b.Show(spawnMolecule)
if err != nil {
return fmt.Errorf("getting molecule %s: %w", spawnMolecule, err)
}
if mol.Type != "molecule" {
return fmt.Errorf("%s is not a molecule (type: %s)", spawnMolecule, mol.Type)
}
// Validate the molecule
if err := beads.ValidateMolecule(mol); err != nil {
return fmt.Errorf("invalid molecule: %w", err)
}
// Get the parent issue
parent, err := b.Show(spawnIssue)
if err != nil {
return fmt.Errorf("getting parent issue %s: %w", spawnIssue, err)
}
// Instantiate the molecule
fmt.Printf("Instantiating molecule %s on %s...\n", spawnMolecule, spawnIssue)
steps, err := b.InstantiateMolecule(mol, parent, beads.InstantiateOptions{})
if err != nil {
return fmt.Errorf("instantiating molecule: %w", err)
}
fmt.Printf("%s Created %d steps\n", style.Bold.Render("✓"), len(steps))
for _, step := range steps {
fmt.Printf(" %s: %s\n", style.Dim.Render(step.ID), step.Title)
}
// Find the first ready step (one with no dependencies)
var firstReadyStep *beads.Issue
for _, step := range steps {
if len(step.DependsOn) == 0 {
firstReadyStep = step
break
}
}
if firstReadyStep == nil {
return fmt.Errorf("no ready step found in molecule (all steps have dependencies)")
}
// Switch to spawning on the first ready step
fmt.Printf("\nSpawning on first ready step: %s\n", firstReadyStep.ID)
spawnIssue = firstReadyStep.ID
}
// Get issue details if specified
var issue *BeadsIssue
if spawnIssue != "" {
issue, err = fetchBeadsIssue(r.Path, spawnIssue)
if err != nil {
return fmt.Errorf("fetching issue %s: %w", spawnIssue, err)
}
}
// Assign issue/task to polecat
assignmentID := spawnIssue
if assignmentID == "" {
assignmentID = "task:" + time.Now().Format("20060102-150405")
}
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)
// 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
}
// Start session
t := tmux.NewTmux()
sessMgr := session.NewManager(t, r)
// Check if already running
running, _ := sessMgr.IsRunning(polecatName)
if running {
// Just inject the context
fmt.Printf("Session already running, injecting context...\n")
} else {
// Start new session
fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName)
if err := sessMgr.Start(polecatName, session.StartOptions{}); err != nil {
return fmt.Errorf("starting session: %w", err)
}
// Wait for Claude to fully initialize (needs 4-5s for prompt)
fmt.Printf("Waiting for Claude to initialize...\n")
time.Sleep(5 * time.Second)
}
// Inject initial context
context := buildSpawnContext(issue, spawnMessage)
fmt.Printf("Injecting work assignment...\n")
if err := sessMgr.Inject(polecatName, context); err != nil {
return fmt.Errorf("injecting context: %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)))
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
}
// generatePolecatName generates a unique polecat name that doesn't conflict with existing ones.
func generatePolecatName(mgr *polecat.Manager) string {
existing, _ := mgr.List()
existingNames := make(map[string]bool)
for _, p := range existing {
existingNames[p.Name] = true
}
// Try to find an unused name from the list
// Shuffle to avoid always picking the same name
shuffled := make([]string, len(polecatNames))
copy(shuffled, polecatNames)
rand.Shuffle(len(shuffled), func(i, j int) {
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
})
for _, name := range shuffled {
if !existingNames[name] {
return name
}
}
// All names taken, generate one with a number suffix
base := shuffled[0]
for i := 2; ; i++ {
name := fmt.Sprintf("%s%d", base, i)
if !existingNames[name] {
return name
}
}
}
// 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
}
// buildSpawnContext creates the initial context message for the polecat.
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("\nWork on this task. When complete, commit your changes and signal DONE.\n")
return sb.String()
}