feat: Remove gt spawn completely - gt sling is THE command (gt-1py3y)

Fully remove gt spawn from the codebase:

- Delete spawn.go, create polecat_spawn.go with just sling helpers
- Remove all gt spawn references from docs and CLAUDE.md
- Update code comments to reference gt sling

gt sling now handles ALL work dispatch:
- Existing agents: gt sling <bead> mayor/crew/witness
- Auto-spawn: gt sling <bead> <rig>
- No-tmux: gt sling <bead> <rig> --naked
- With args: gt sling <bead> --args "..."

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-26 18:33:22 -08:00
parent ff22c84cd6
commit 0ad427e4a8
13 changed files with 227 additions and 1115 deletions

View File

@@ -29,7 +29,7 @@ This command:
3. Optionally creates a GitHub repository
The .gitignore excludes:
- Polecat worktrees and rig clones (recreated with 'gt spawn' or 'gt rig add')
- Polecat worktrees and rig clones (recreated with 'gt sling' or 'gt rig add')
- Runtime state files (state.json, *.lock)
- OS and editor files
@@ -64,7 +64,7 @@ const HQGitignore = `# Gas Town HQ .gitignore
**/registry.json
# =============================================================================
# Rig git worktrees (recreate with 'gt spawn' or 'gt rig add')
# Rig git worktrees (recreate with 'gt sling' or 'gt rig add')
# =============================================================================
# Polecats - worker worktrees

View File

@@ -94,7 +94,7 @@ var polecatWakeCmd = &cobra.Command{
Long: `Resume a polecat to working state.
DEPRECATED: In the transient model, polecats are created fresh for each task
via 'gt spawn'. This command is kept for backward compatibility.
via 'gt sling'. This command is kept for backward compatibility.
Transitions: done → working
@@ -509,7 +509,7 @@ func runPolecatRemove(cmd *cobra.Command, args []string) error {
}
func runPolecatWake(cmd *cobra.Command, args []string) error {
fmt.Println(style.Warning.Render("DEPRECATED: Use 'gt spawn' to create fresh polecats instead"))
fmt.Println(style.Warning.Render("DEPRECATED: Use 'gt sling' to create fresh polecats instead"))
fmt.Println()
rigName, polecatName, err := parseAddress(args[0])

View File

@@ -0,0 +1,207 @@
// Package cmd provides polecat spawning utilities for gt sling.
package cmd
import (
"fmt"
"path/filepath"
"strings"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"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"
)
// SpawnedPolecatInfo contains info about a spawned polecat session.
type SpawnedPolecatInfo struct {
RigName string // Rig name (e.g., "gastown")
PolecatName string // Polecat name (e.g., "Toast")
ClonePath string // Path to polecat's git worktree
SessionName string // Tmux session name (e.g., "gt-gastown-p-Toast")
Pane string // Tmux pane ID
}
// AgentID returns the agent identifier (e.g., "gastown/polecats/Toast")
func (s *SpawnedPolecatInfo) AgentID() string {
return fmt.Sprintf("%s/polecats/%s", s.RigName, s.PolecatName)
}
// SlingSpawnOptions contains options for spawning a polecat via sling.
type SlingSpawnOptions struct {
Force bool // Force spawn even if polecat has uncommitted work
Naked bool // No-tmux mode: skip session creation
Account string // Claude Code account handle to use
Create bool // Create polecat if it doesn't exist (currently always true for sling)
}
// SpawnPolecatForSling creates a fresh polecat and optionally starts its session.
// This is used by gt sling when the target is a rig name.
// The caller (sling) handles hook attachment and nudging.
func SpawnPolecatForSling(rigName string, opts SlingSpawnOptions) (*SpawnedPolecatInfo, error) {
// Find workspace
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return nil, fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Load rig config
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 nil, fmt.Errorf("rig '%s' not found", rigName)
}
// Get polecat manager
polecatGit := git.NewGit(r.Path)
polecatMgr := polecat.NewManager(r, polecatGit)
// Allocate a new polecat name
polecatName, err := polecatMgr.AllocateName()
if err != nil {
return nil, fmt.Errorf("allocating polecat name: %w", err)
}
fmt.Printf("Allocated polecat: %s\n", polecatName)
// Check if polecat already exists (shouldn't, since we allocated fresh)
existingPolecat, err := polecatMgr.Get(polecatName)
if err == nil {
// Exists - recreate with fresh worktree
// Check for uncommitted work first
if !opts.Force {
pGit := git.NewGit(existingPolecat.ClonePath)
workStatus, checkErr := pGit.CheckUncommittedWork()
if checkErr == nil && !workStatus.Clean() {
return nil, fmt.Errorf("polecat '%s' has uncommitted work: %s\nUse --force to proceed anyway",
polecatName, workStatus.String())
}
}
fmt.Printf("Recreating polecat %s with fresh worktree...\n", polecatName)
if _, err = polecatMgr.Recreate(polecatName, opts.Force); err != nil {
return nil, fmt.Errorf("recreating polecat: %w", err)
}
} else if err == polecat.ErrPolecatNotFound {
// Create new polecat
fmt.Printf("Creating polecat %s...\n", polecatName)
if _, err = polecatMgr.Add(polecatName); err != nil {
return nil, fmt.Errorf("creating polecat: %w", err)
}
} else {
return nil, fmt.Errorf("getting polecat: %w", err)
}
// Get polecat object for path info
polecatObj, err := polecatMgr.Get(polecatName)
if err != nil {
return nil, fmt.Errorf("getting polecat after creation: %w", err)
}
// Handle naked mode (no-tmux)
if opts.Naked {
fmt.Println()
fmt.Printf("%s\n", style.Bold.Render("🔧 NO-TMUX MODE (--naked)"))
fmt.Printf("Polecat created. Agent must be started manually.\n\n")
fmt.Printf("To start the agent:\n")
fmt.Printf(" cd %s\n", polecatObj.ClonePath)
fmt.Printf(" claude # Or: claude-code\n\n")
fmt.Printf("Agent will discover work via gt prime on startup.\n")
return &SpawnedPolecatInfo{
RigName: rigName,
PolecatName: polecatName,
ClonePath: polecatObj.ClonePath,
SessionName: "", // No session in naked mode
Pane: "", // No pane in naked mode
}, nil
}
// Resolve account for Claude config
accountsPath := constants.MayorAccountsPath(townRoot)
claudeConfigDir, accountHandle, err := config.ResolveAccountConfigDir(accountsPath, opts.Account)
if err != nil {
return nil, 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("Starting session for %s/%s...\n", rigName, polecatName)
startOpts := session.StartOptions{
ClaudeConfigDir: claudeConfigDir,
}
if err := sessMgr.Start(polecatName, startOpts); err != nil {
return nil, fmt.Errorf("starting session: %w", err)
}
}
// Get session name and pane
sessionName := sessMgr.SessionName(polecatName)
pane, err := getSessionPane(sessionName)
if err != nil {
return nil, fmt.Errorf("getting pane for %s: %w", sessionName, err)
}
fmt.Printf("%s Polecat %s spawned\n", style.Bold.Render("✓"), polecatName)
return &SpawnedPolecatInfo{
RigName: rigName,
PolecatName: polecatName,
ClonePath: polecatObj.ClonePath,
SessionName: sessionName,
Pane: pane,
}, nil
}
// IsRigName checks if a target string is a rig name (not a role or path).
// Returns the rig name and true if it's a valid rig.
func IsRigName(target string) (string, bool) {
// If it contains a slash, it's a path format (rig/role or rig/crew/name)
if strings.Contains(target, "/") {
return "", false
}
// Check known non-rig role names
switch strings.ToLower(target) {
case "mayor", "may", "deacon", "dea", "crew", "witness", "wit", "refinery", "ref":
return "", false
}
// Try to load as a rig
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return "", false
}
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
return "", false
}
g := git.NewGit(townRoot)
rigMgr := rig.NewManager(townRoot, rigsConfig, g)
_, err = rigMgr.GetRig(target)
if err != nil {
return "", false
}
return target, true
}

View File

@@ -72,7 +72,7 @@ var (
slingVars []string // --var flag: formula variables (key=value)
slingArgs string // --args flag: natural language instructions for executor
// Flags migrated from gt spawn for unified work assignment
// Flags migrated for polecat spawning (used by sling for work assignment
slingNaked bool // --naked: no-tmux mode (skip session creation)
slingCreate bool // --create: create polecat if it doesn't exist
slingMolecule string // --molecule: workflow to instantiate on the bead

File diff suppressed because it is too large Load Diff