feat: add CLI spawn command for polecat work assignment
- gt spawn <rig/polecat> --issue <id>: Assign issue and start session - gt spawn <rig> --issue <id>: Auto-select idle polecat - gt spawn -m task: Free-form task assignment - --create: Create polecat if does not exist - --no-start: Assign without starting session - Fetches issue details from beads for rich context - Auto-selects idle polecats when polecat not specified Closes gt-u1j.19 Generated with Claude Code Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
288
internal/cmd/spawn.go
Normal file
288
internal/cmd/spawn.go
Normal file
@@ -0,0 +1,288 @@
|
||||
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/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"
|
||||
)
|
||||
|
||||
// Spawn command flags
|
||||
var (
|
||||
spawnIssue string
|
||||
spawnMessage string
|
||||
spawnCreate bool
|
||||
spawnNoStart bool
|
||||
)
|
||||
|
||||
var spawnCmd = &cobra.Command{
|
||||
Use: "spawn <rig/polecat> | <rig>",
|
||||
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.
|
||||
|
||||
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`,
|
||||
Args: cobra.ExactArgs(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")
|
||||
|
||||
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:"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")
|
||||
}
|
||||
|
||||
// Parse address: rig/polecat or just rig
|
||||
rigName, polecatName, err := parseSpawnAddress(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Find workspace and rig
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
rigsConfigPath := filepath.Join(townRoot, "config", "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 {
|
||||
return fmt.Errorf("auto-select polecat: %w", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 initialize
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
// Inject initial context
|
||||
context := buildSpawnContext(issue, spawnMessage)
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
var issue BeadsIssue
|
||||
if err := json.Unmarshal(stdout.Bytes(), &issue); err != nil {
|
||||
return nil, fmt.Errorf("parsing issue: %w", err)
|
||||
}
|
||||
|
||||
return &issue, 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()
|
||||
}
|
||||
Reference in New Issue
Block a user