feat: Auto-spawn polecats when gt sling targets a rig (gt-1py3y)

When `gt sling <bead> <rig>` is used with a rig name as target, sling now
automatically spawns a fresh polecat and slings work to it. This provides
a simpler alternative to `gt spawn --issue <bead> <rig>` for quick dispatch.

Changes:
- Add IsRigName() helper to detect bare rig names
- Add SpawnPolecatForSling() for lightweight polecat creation
- Update sling to detect rig targets and auto-spawn
- Update help text for both sling and spawn to document behavior

Design: spawn and sling remain distinct commands with different purposes:
- sling: Light spawn with hook + nudge (quick dispatch)
- spawn: Full workflow with mol-polecat-work, mail, witness notification

🤖 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 15:51:56 -08:00
parent e2b8f16c48
commit ad35855e93
2 changed files with 224 additions and 8 deletions

View File

@@ -36,6 +36,13 @@ Examples:
gt sling gt-abc -s "Fix the bug" # With context subject
gt sling gt-abc crew # Sling bead to crew worker
gt sling gt-abc gastown/crew/max # Sling bead to specific agent
gt sling gt-abc gastown # Auto-spawn polecat in rig (light spawn)
Auto-spawning polecats:
When target is a rig name (not a specific agent), sling automatically spawns
a fresh polecat and slings work to it. This is a light spawn - the polecat
starts with just the hook. For full molecule workflow with crash recovery,
use 'gt spawn --issue <bead> <rig>' instead.
Standalone formula slinging:
gt sling mol-town-shutdown mayor/ # Cook + wisp + attach + nudge
@@ -141,10 +148,33 @@ func runSling(cmd *cobra.Command, args []string) error {
var err error
if len(args) > 1 {
// Slinging to another agent
targetAgent, targetPane, hookRoot, err = resolveTargetAgent(args[1])
if err != nil {
return fmt.Errorf("resolving target: %w", err)
target := args[1]
// Check if target is a rig name (auto-spawn polecat)
if rigName, isRig := IsRigName(target); isRig {
if slingDryRun {
// Dry run - just indicate what would happen
fmt.Printf("Would spawn fresh polecat in rig '%s'\n", rigName)
targetAgent = fmt.Sprintf("%s/polecats/<new>", rigName)
targetPane = "<new-pane>"
hookRoot = fmt.Sprintf("<polecat-worktree-in-%s>", rigName)
} else {
// Spawn a fresh polecat in the rig
fmt.Printf("Target is rig '%s', spawning fresh polecat...\n", rigName)
spawnInfo, spawnErr := SpawnPolecatForSling(rigName, false)
if spawnErr != nil {
return fmt.Errorf("spawning polecat: %w", spawnErr)
}
targetAgent = spawnInfo.AgentID()
targetPane = spawnInfo.Pane
hookRoot = spawnInfo.ClonePath
}
} else {
// Slinging to an existing agent
targetAgent, targetPane, hookRoot, err = resolveTargetAgent(target)
if err != nil {
return fmt.Errorf("resolving target: %w", err)
}
}
} else {
// Slinging to self
@@ -380,10 +410,31 @@ func runSlingFormula(args []string) error {
var err error
if target != "" {
// Slinging to another agent
targetAgent, targetPane, hookRoot, err = resolveTargetAgent(target)
if err != nil {
return fmt.Errorf("resolving target: %w", err)
// Check if target is a rig name (auto-spawn polecat)
if rigName, isRig := IsRigName(target); isRig {
if slingDryRun {
// Dry run - just indicate what would happen
fmt.Printf("Would spawn fresh polecat in rig '%s'\n", rigName)
targetAgent = fmt.Sprintf("%s/polecats/<new>", rigName)
targetPane = "<new-pane>"
hookRoot = fmt.Sprintf("<polecat-worktree-in-%s>", rigName)
} else {
// Spawn a fresh polecat in the rig
fmt.Printf("Target is rig '%s', spawning fresh polecat...\n", rigName)
spawnInfo, spawnErr := SpawnPolecatForSling(rigName, false)
if spawnErr != nil {
return fmt.Errorf("spawning polecat: %w", spawnErr)
}
targetAgent = spawnInfo.AgentID()
targetPane = spawnInfo.Pane
hookRoot = spawnInfo.ClonePath
}
} else {
// Slinging to an existing agent
targetAgent, targetPane, hookRoot, err = resolveTargetAgent(target)
if err != nil {
return fmt.Errorf("resolving target: %w", err)
}
}
} else {
// Slinging to self

View File

@@ -57,6 +57,9 @@ 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.
SIMPLER ALTERNATIVE: For quick polecat spawns, use 'gt sling':
gt sling <bead> <rig> # Auto-spawns polecat, hooks work, starts immediately
Examples:
gt spawn gastown/Toast --issue gt-abc # uses mol-polecat-work
gt spawn gastown --issue gt-def # auto-select polecat
@@ -881,3 +884,165 @@ func clearSpawnPending(townRoot, session string) error {
return nil
}
// 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)
}
// SpawnPolecatForSling creates a fresh polecat and starts its session.
// This is a lightweight spawn for sling - it doesn't assign issues or send mail.
// The caller (sling) handles hook attachment and nudging.
func SpawnPolecatForSling(rigName string, force bool) (*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 !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, 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)
}
// Resolve account for Claude config
accountsPath := constants.MayorAccountsPath(townRoot)
claudeConfigDir, accountHandle, err := config.ResolveAccountConfigDir(accountsPath, "")
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
}