diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index 3fb0c6c3..d2d4e307 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -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 ' 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/", rigName) + targetPane = "" + hookRoot = fmt.Sprintf("", 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/", rigName) + targetPane = "" + hookRoot = fmt.Sprintf("", 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 diff --git a/internal/cmd/spawn.go b/internal/cmd/spawn.go index 7e285613..5f6543ee 100644 --- a/internal/cmd/spawn.go +++ b/internal/cmd/spawn.go @@ -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 # 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 +} +