Files
gastown/internal/cmd/sling.go
rictus a8406554a0 feat(mayor): add delegation hierarchy guidance to role template
Add explicit guidance on the Mayor → Crew → Polecats delegation model:
- Crew are coordinators for epics/goals needing decomposition
- Polecats are executors for well-defined tasks
- Include decision framework table for work type routing

Closes: gt-9jd
2026-01-26 10:31:16 -08:00

561 lines
23 KiB
Go

package cmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/events"
"github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
var slingCmd = &cobra.Command{
Use: "sling <bead-or-formula> [target]",
GroupID: GroupWork,
Short: "Assign work to an agent (THE unified work dispatch command)",
Long: `Sling work onto an agent's hook and start working immediately.
This is THE command for assigning work in Gas Town. It handles:
- Existing agents (mayor, crew, witness, refinery)
- Auto-spawning polecats when target is a rig
- Dispatching to dogs (Deacon's helper workers)
- Formula instantiation and wisp creation
- Auto-convoy creation for dashboard visibility
Auto-Convoy:
When slinging a single issue (not a formula), sling automatically creates
a convoy to track the work unless --no-convoy is specified. This ensures
all work appears in 'gt convoy list', even "swarm of one" assignments.
gt sling gt-abc gastown # Creates "Work: <issue-title>" convoy
gt sling gt-abc gastown --no-convoy # Skip auto-convoy creation
Target Resolution:
gt sling gt-abc # Self (current agent)
gt sling gt-abc crew # Crew worker in current rig
gt sling gp-abc greenplace # Auto-spawn polecat in rig
gt sling gt-abc greenplace/Toast # Specific polecat
gt sling gt-abc mayor # Mayor
gt sling gt-abc deacon/dogs # Auto-dispatch to idle dog
gt sling gt-abc deacon/dogs/alpha # Specific dog
Spawning Options (when target is a rig):
gt sling gp-abc greenplace --create # Create polecat if missing
gt sling gp-abc greenplace --force # Ignore unread mail
gt sling gp-abc greenplace --account work # Use specific Claude account
Natural Language Args:
gt sling gt-abc --args "patch release"
gt sling code-review --args "focus on security"
The --args string is stored in the bead and shown via gt prime. Since the
executor is an LLM, it interprets these instructions naturally.
Formula Slinging:
gt sling mol-release mayor/ # Cook + wisp + attach + nudge
gt sling towers-of-hanoi --var disks=3
Formula-on-Bead (--on flag):
gt sling mol-review --on gt-abc # Apply formula to existing work
gt sling shiny --on gt-abc crew # Apply formula, sling to crew
Compare:
gt hook <bead> # Just attach (no action)
gt sling <bead> # Attach + start now (keep context)
gt handoff <bead> # Attach + restart (fresh context)
The propulsion principle: if it's on your hook, YOU RUN IT.
Batch Slinging:
gt sling gt-abc gt-def gt-ghi gastown # Sling multiple beads to a rig
When multiple beads are provided with a rig target, each bead gets its own
polecat. This parallelizes work dispatch without running gt sling N times.`,
Args: cobra.MinimumNArgs(1),
RunE: runSling,
}
var (
slingSubject string
slingMessage string
slingDryRun bool
slingOnTarget string // --on flag: target bead when slinging a formula
slingVars []string // --var flag: formula variables (key=value)
slingArgs string // --args flag: natural language instructions for executor
slingHookRawBead bool // --hook-raw-bead: hook raw bead without default formula (expert mode)
// Flags migrated for polecat spawning (used by sling for work assignment)
slingCreate bool // --create: create polecat if it doesn't exist
slingForce bool // --force: force spawn even if polecat has unread mail
slingAccount string // --account: Claude Code account handle to use
slingAgent string // --agent: override runtime agent for this sling/spawn
slingNoConvoy bool // --no-convoy: skip auto-convoy creation
slingNoMerge bool // --no-merge: skip merge queue on completion (for upstream PRs/human review)
slingEpic string // --epic: link auto-created convoy to parent epic
slingConvoy string // --convoy: add to existing convoy instead of creating new
)
func init() {
slingCmd.Flags().StringVarP(&slingSubject, "subject", "s", "", "Context subject for the work")
slingCmd.Flags().StringVarP(&slingMessage, "message", "m", "", "Context message for the work")
slingCmd.Flags().BoolVarP(&slingDryRun, "dry-run", "n", false, "Show what would be done")
slingCmd.Flags().StringVar(&slingOnTarget, "on", "", "Apply formula to existing bead (implies wisp scaffolding)")
slingCmd.Flags().StringArrayVar(&slingVars, "var", nil, "Formula variable (key=value), can be repeated")
slingCmd.Flags().StringVarP(&slingArgs, "args", "a", "", "Natural language instructions for the executor (e.g., 'patch release')")
// Flags for polecat spawning (when target is a rig)
slingCmd.Flags().BoolVar(&slingCreate, "create", false, "Create polecat if it doesn't exist")
slingCmd.Flags().BoolVar(&slingForce, "force", false, "Force spawn even if polecat has unread mail")
slingCmd.Flags().StringVar(&slingAccount, "account", "", "Claude Code account handle to use")
slingCmd.Flags().StringVar(&slingAgent, "agent", "", "Override agent/runtime for this sling (e.g., claude, gemini, codex, or custom alias)")
slingCmd.Flags().BoolVar(&slingNoConvoy, "no-convoy", false, "Skip auto-convoy creation for single-issue sling")
slingCmd.Flags().BoolVar(&slingHookRawBead, "hook-raw-bead", false, "Hook raw bead without default formula (expert mode)")
slingCmd.Flags().BoolVar(&slingNoMerge, "no-merge", false, "Skip merge queue on completion (keep work on feature branch for review)")
slingCmd.Flags().StringVar(&slingEpic, "epic", "", "Link auto-created convoy to parent epic")
slingCmd.Flags().StringVar(&slingConvoy, "convoy", "", "Add to existing convoy instead of creating new")
rootCmd.AddCommand(slingCmd)
}
func runSling(cmd *cobra.Command, args []string) error {
// Polecats cannot sling - check early before writing anything
if polecatName := os.Getenv("GT_POLECAT"); polecatName != "" {
return fmt.Errorf("polecats cannot sling (use gt done for handoff)")
}
// Get town root early - needed for BEADS_DIR when running bd commands
// This ensures hq-* beads are accessible even when running from polecat worktree
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("finding town root: %w", err)
}
townBeadsDir := filepath.Join(townRoot, ".beads")
// Batch mode detection: multiple beads with rig target
// Pattern: gt sling gt-abc gt-def gt-ghi gastown
// When len(args) > 2 and last arg is a rig, sling each bead to its own polecat
if len(args) > 2 {
lastArg := args[len(args)-1]
if rigName, isRig := IsRigName(lastArg); isRig {
return runBatchSling(args[:len(args)-1], rigName, townBeadsDir)
}
}
// Determine mode based on flags and argument types
var beadID string
var formulaName string
attachedMoleculeID := ""
if slingOnTarget != "" {
// Formula-on-bead mode: gt sling <formula> --on <bead>
formulaName = args[0]
beadID = slingOnTarget
// Verify both exist
if err := verifyBeadExists(beadID); err != nil {
return err
}
if err := verifyFormulaExists(formulaName); err != nil {
return err
}
} else {
// Could be bead mode or standalone formula mode
firstArg := args[0]
// Try as bead first
if err := verifyBeadExists(firstArg); err == nil {
// It's a verified bead
beadID = firstArg
} else {
// Not a verified bead - try as standalone formula
if err := verifyFormulaExists(firstArg); err == nil {
// Standalone formula mode: gt sling <formula> [target]
return runSlingFormula(args)
}
// Not a formula either - check if it looks like a bead ID (routing issue workaround).
// Accept it and let the actual bd update fail later if the bead doesn't exist.
// This fixes: gt sling bd-ka761 beads/crew/dave failing with 'not a valid bead or formula'
if looksLikeBeadID(firstArg) {
beadID = firstArg
} else {
// Neither bead nor formula
return fmt.Errorf("'%s' is not a valid bead or formula", firstArg)
}
}
}
// Determine target agent (self or specified)
var targetAgent string
var targetPane string
var hookWorkDir string // Working directory for running bd hook commands
var hookSetAtomically bool // True if hook was set during polecat spawn (skip redundant update)
if len(args) > 1 {
target := args[1]
// Resolve "." to current agent identity (like git's "." meaning current directory)
if target == "." {
targetAgent, targetPane, _, err = resolveSelfTarget()
if err != nil {
return fmt.Errorf("resolving self for '.' target: %w", err)
}
} else if dogName, isDog := IsDogTarget(target); isDog {
if slingDryRun {
if dogName == "" {
fmt.Printf("Would dispatch to idle dog in kennel\n")
} else {
fmt.Printf("Would dispatch to dog '%s'\n", dogName)
}
targetAgent = fmt.Sprintf("deacon/dogs/%s", dogName)
if dogName == "" {
targetAgent = "deacon/dogs/<idle>"
}
targetPane = "<dog-pane>"
} else {
// Dispatch to dog
dispatchInfo, dispatchErr := DispatchToDog(dogName, slingCreate)
if dispatchErr != nil {
return fmt.Errorf("dispatching to dog: %w", dispatchErr)
}
targetAgent = dispatchInfo.AgentID
targetPane = dispatchInfo.Pane
fmt.Printf("Dispatched to dog %s\n", dispatchInfo.DogName)
}
} else if rigName, isRig := IsRigName(target); isRig {
// Check if target is a rig name (auto-spawn polecat)
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>"
} else {
// Spawn a fresh polecat in the rig
fmt.Printf("Target is rig '%s', spawning fresh polecat...\n", rigName)
spawnOpts := SlingSpawnOptions{
Force: slingForce,
Account: slingAccount,
Create: slingCreate,
HookBead: beadID, // Set atomically at spawn time
Agent: slingAgent,
}
spawnInfo, spawnErr := SpawnPolecatForSling(rigName, spawnOpts)
if spawnErr != nil {
return fmt.Errorf("spawning polecat: %w", spawnErr)
}
targetAgent = spawnInfo.AgentID()
targetPane = spawnInfo.Pane
hookWorkDir = spawnInfo.ClonePath // Run bd commands from polecat's worktree
hookSetAtomically = true // Hook was set during spawn (GH #gt-mzyk5)
// Wake witness and refinery to monitor the new polecat
wakeRigAgents(rigName)
}
} else {
// Slinging to an existing agent
var targetWorkDir string
targetAgent, targetPane, targetWorkDir, err = resolveTargetAgent(target)
if err != nil {
// Check if this is a dead polecat (no active session)
// If so, spawn a fresh polecat instead of failing
if isPolecatTarget(target) {
// Extract rig name from polecat target (format: rig/polecats/name)
parts := strings.Split(target, "/")
if len(parts) >= 3 && parts[1] == "polecats" {
rigName := parts[0]
fmt.Printf("Target polecat has no active session, spawning fresh polecat in rig '%s'...\n", rigName)
spawnOpts := SlingSpawnOptions{
Force: slingForce,
Account: slingAccount,
Create: slingCreate,
HookBead: beadID,
Agent: slingAgent,
}
spawnInfo, spawnErr := SpawnPolecatForSling(rigName, spawnOpts)
if spawnErr != nil {
return fmt.Errorf("spawning polecat to replace dead polecat: %w", spawnErr)
}
targetAgent = spawnInfo.AgentID()
targetPane = spawnInfo.Pane
hookWorkDir = spawnInfo.ClonePath
hookSetAtomically = true // Hook was set during spawn (GH #gt-mzyk5)
// Wake witness and refinery to monitor the new polecat
wakeRigAgents(rigName)
} else {
return fmt.Errorf("resolving target: %w", err)
}
} else {
return fmt.Errorf("resolving target: %w", err)
}
}
// Use target's working directory for bd commands (needed for redirect-based routing)
if targetWorkDir != "" {
hookWorkDir = targetWorkDir
}
}
} else {
// Slinging to self
var selfWorkDir string
targetAgent, targetPane, selfWorkDir, err = resolveSelfTarget()
if err != nil {
return err
}
// Use self's working directory for bd commands
if selfWorkDir != "" {
hookWorkDir = selfWorkDir
}
}
// Display what we're doing
if formulaName != "" {
fmt.Printf("%s Slinging formula %s on %s to %s...\n", style.Bold.Render("🎯"), formulaName, beadID, targetAgent)
} else {
fmt.Printf("%s Slinging %s to %s...\n", style.Bold.Render("🎯"), beadID, targetAgent)
}
// Check if bead is already assigned (guard against accidental re-sling)
info, err := getBeadInfo(beadID)
if err != nil {
return fmt.Errorf("checking bead status: %w", err)
}
if (info.Status == "pinned" || info.Status == "hooked") && !slingForce {
assignee := info.Assignee
if assignee == "" {
assignee = "(unknown)"
}
return fmt.Errorf("bead %s is already %s to %s\nUse --force to re-sling", beadID, info.Status, assignee)
}
// Handle --force when bead is already hooked: send shutdown to old polecat and unhook
if info.Status == "hooked" && slingForce && info.Assignee != "" {
fmt.Printf("%s Bead already hooked to %s, forcing reassignment...\n", style.Warning.Render("⚠"), info.Assignee)
// Determine requester identity from env vars, fall back to "gt-sling"
requester := "gt-sling"
if polecat := os.Getenv("GT_POLECAT"); polecat != "" {
requester = polecat
} else if user := os.Getenv("USER"); user != "" {
requester = user
}
// Extract rig name from assignee (e.g., "gastown/polecats/Toast" -> "gastown")
assigneeParts := strings.Split(info.Assignee, "/")
if len(assigneeParts) >= 3 && assigneeParts[1] == "polecats" {
oldRigName := assigneeParts[0]
oldPolecatName := assigneeParts[2]
// Send LIFECYCLE:Shutdown to witness - will auto-nuke if clean,
// otherwise create cleanup wisp for manual intervention
if townRoot != "" {
router := mail.NewRouter(townRoot)
shutdownMsg := &mail.Message{
From: "gt-sling",
To: fmt.Sprintf("%s/witness", oldRigName),
Subject: fmt.Sprintf("LIFECYCLE:Shutdown %s", oldPolecatName),
Body: fmt.Sprintf("Reason: work_reassigned\nRequestedBy: %s\nBead: %s\nNewAssignee: %s", requester, beadID, targetAgent),
Type: mail.TypeTask,
Priority: mail.PriorityHigh,
}
if err := router.Send(shutdownMsg); err != nil {
fmt.Printf("%s Could not send shutdown to witness: %v\n", style.Dim.Render("Warning:"), err)
} else {
fmt.Printf("%s Sent LIFECYCLE:Shutdown to %s/witness for %s\n", style.Bold.Render("→"), oldRigName, oldPolecatName)
}
}
}
// Unhook the bead from old owner (set status back to open)
unhookCmd := exec.Command("bd", "--no-daemon", "update", beadID, "--status=open", "--assignee=")
unhookCmd.Dir = beads.ResolveHookDir(townRoot, beadID, "")
if err := unhookCmd.Run(); err != nil {
fmt.Printf("%s Could not unhook bead from old owner: %v\n", style.Dim.Render("Warning:"), err)
}
}
// Convoy handling: --convoy adds to existing, otherwise auto-create (unless --no-convoy)
if slingConvoy != "" {
// Use existing convoy specified by --convoy flag
if slingDryRun {
fmt.Printf("Would add to convoy %s\n", slingConvoy)
fmt.Printf("Would add tracking relation to %s\n", beadID)
} else {
if err := addToExistingConvoy(slingConvoy, beadID); err != nil {
return fmt.Errorf("adding to convoy: %w", err)
}
fmt.Printf("%s Added to convoy %s\n", style.Bold.Render("→"), slingConvoy)
}
} else if !slingNoConvoy && formulaName == "" {
// Auto-convoy: check if issue is already tracked by a convoy
// If not, create one for dashboard visibility
existingConvoy := isTrackedByConvoy(beadID)
if existingConvoy == "" {
if slingDryRun {
fmt.Printf("Would create convoy 'Work: %s'\n", info.Title)
fmt.Printf("Would add tracking relation to %s\n", beadID)
} else {
convoyID, err := createAutoConvoy(beadID, info.Title, slingEpic)
if err != nil {
// Log warning but don't fail - convoy is optional
fmt.Printf("%s Could not create auto-convoy: %v\n", style.Dim.Render("Warning:"), err)
} else {
fmt.Printf("%s Created convoy 🚚 %s\n", style.Bold.Render("→"), convoyID)
fmt.Printf(" Tracking: %s\n", beadID)
}
}
} else {
fmt.Printf("%s Already tracked by convoy %s\n", style.Dim.Render("○"), existingConvoy)
}
}
// Issue #288: Auto-apply mol-polecat-work when slinging bare bead to polecat.
// This ensures polecats get structured work guidance through formula-on-bead.
// Use --hook-raw-bead to bypass for expert/debugging scenarios.
if formulaName == "" && !slingHookRawBead && strings.Contains(targetAgent, "/polecats/") {
formulaName = "mol-polecat-work"
fmt.Printf(" Auto-applying %s for polecat work...\n", formulaName)
}
if slingDryRun {
if formulaName != "" {
fmt.Printf("Would instantiate formula %s:\n", formulaName)
fmt.Printf(" 1. bd cook %s\n", formulaName)
fmt.Printf(" 2. bd mol wisp %s --var feature=\"%s\" --var issue=\"%s\"\n", formulaName, info.Title, beadID)
fmt.Printf(" 3. bd mol bond <wisp-root> %s\n", beadID)
fmt.Printf(" 4. bd update <compound-root> --status=hooked --assignee=%s\n", targetAgent)
} else {
fmt.Printf("Would run: bd update %s --status=hooked --assignee=%s\n", beadID, targetAgent)
}
if slingSubject != "" {
fmt.Printf(" subject (in nudge): %s\n", slingSubject)
}
if slingMessage != "" {
fmt.Printf(" context: %s\n", slingMessage)
}
if slingArgs != "" {
fmt.Printf(" args (in nudge): %s\n", slingArgs)
}
fmt.Printf("Would inject start prompt to pane: %s\n", targetPane)
return nil
}
// Formula-on-bead mode: instantiate formula and bond to original bead
if formulaName != "" {
fmt.Printf(" Instantiating formula %s...\n", formulaName)
result, err := InstantiateFormulaOnBead(formulaName, beadID, info.Title, hookWorkDir, townRoot, false, slingVars)
if err != nil {
return fmt.Errorf("instantiating formula %s: %w", formulaName, err)
}
fmt.Printf("%s Formula wisp created: %s\n", style.Bold.Render("✓"), result.WispRootID)
fmt.Printf("%s Formula bonded to %s\n", style.Bold.Render("✓"), beadID)
// Record attached molecule - will be stored in BASE bead (not wisp).
// The base bead is hooked, and its attached_molecule points to the wisp.
// This enables:
// - gt hook/gt prime: read base bead, follow attached_molecule to show wisp steps
// - gt done: close attached_molecule (wisp) first, then close base bead
// - Compound resolution: base bead -> attached_molecule -> wisp
attachedMoleculeID = result.WispRootID
// NOTE: We intentionally keep beadID as the ORIGINAL base bead, not the wisp.
// The base bead is hooked so that:
// 1. gt done closes both the base bead AND the attached molecule (wisp)
// 2. The base bead's attached_molecule field points to the wisp for compound resolution
// Previously, this line incorrectly set beadID = wispRootID, causing:
// - Wisp hooked instead of base bead
// - attached_molecule stored as self-reference in wisp (meaningless)
// - Base bead left orphaned after gt done
}
// Hook the bead using bd update.
// See: https://github.com/steveyegge/gastown/issues/148
hookCmd := exec.Command("bd", "--no-daemon", "update", beadID, "--status=hooked", "--assignee="+targetAgent)
hookCmd.Dir = beads.ResolveHookDir(townRoot, beadID, hookWorkDir)
hookCmd.Stderr = os.Stderr
if err := hookCmd.Run(); err != nil {
return fmt.Errorf("hooking bead: %w", err)
}
fmt.Printf("%s Work attached to hook (status=hooked)\n", style.Bold.Render("✓"))
// Log sling event to activity feed
actor := detectActor()
_ = events.LogFeed(events.TypeSling, actor, events.SlingPayload(beadID, targetAgent))
// Update agent bead's hook_bead field (ZFC: agents track their current work)
// Skip if hook was already set atomically during polecat spawn - avoids "agent bead not found"
// error when polecat redirect setup fails (GH #gt-mzyk5: agent bead created in rig beads
// but updateAgentHookBead looks in polecat's local beads if redirect is missing).
if !hookSetAtomically {
updateAgentHookBead(targetAgent, beadID, hookWorkDir, townBeadsDir)
}
// Store dispatcher in bead description (enables completion notification to dispatcher)
if err := storeDispatcherInBead(beadID, actor); err != nil {
// Warn but don't fail - polecat will still complete work
fmt.Printf("%s Could not store dispatcher in bead: %v\n", style.Dim.Render("Warning:"), err)
}
// Store args in bead description (no-tmux mode: beads as data plane)
if slingArgs != "" {
if err := storeArgsInBead(beadID, slingArgs); err != nil {
// Warn but don't fail - args will still be in the nudge prompt
fmt.Printf("%s Could not store args in bead: %v\n", style.Dim.Render("Warning:"), err)
} else {
fmt.Printf("%s Args stored in bead (durable)\n", style.Bold.Render("✓"))
}
}
// Store no_merge flag in bead (skips merge queue on completion)
if slingNoMerge {
if err := storeNoMergeInBead(beadID, true); err != nil {
fmt.Printf("%s Could not store no_merge in bead: %v\n", style.Dim.Render("Warning:"), err)
} else {
fmt.Printf("%s No-merge mode enabled (work stays on feature branch)\n", style.Bold.Render("✓"))
}
}
// Record the attached molecule in the BASE bead's description.
// This field points to the wisp (compound root) and enables:
// - gt hook/gt prime: follow attached_molecule to show molecule steps
// - gt done: close attached_molecule (wisp) before closing hooked bead
// - Compound resolution: base bead -> attached_molecule -> wisp
if attachedMoleculeID != "" {
if err := storeAttachedMoleculeInBead(beadID, attachedMoleculeID); err != nil {
// Warn but don't fail - polecat can still work through steps
fmt.Printf("%s Could not store attached_molecule: %v\n", style.Dim.Render("Warning:"), err)
}
}
// Try to inject the "start now" prompt (graceful if no tmux)
if targetPane == "" {
fmt.Printf("%s No pane to nudge (agent will discover work via gt prime)\n", style.Dim.Render("○"))
} else {
// Ensure agent is ready before nudging (prevents race condition where
// message arrives before Claude has fully started - see issue #115)
sessionName := getSessionFromPane(targetPane)
if sessionName != "" {
if err := ensureAgentReady(sessionName); err != nil {
// Non-fatal: warn and continue, agent will discover work via gt prime
fmt.Printf("%s Could not verify agent ready: %v\n", style.Dim.Render("○"), err)
}
}
if err := injectStartPrompt(targetPane, beadID, slingSubject, slingArgs); err != nil {
// Graceful fallback for no-tmux mode
fmt.Printf("%s Could not nudge (no tmux?): %v\n", style.Dim.Render("○"), err)
fmt.Printf(" Agent will discover work via gt prime / bd show\n")
} else {
fmt.Printf("%s Start prompt sent\n", style.Bold.Render("▶"))
}
}
return nil
}