When slinging work to a polecat, run bd commands from the polecat's worktree directory instead of town root. This enables redirect-based routing to work correctly since the polecat's .beads/redirect file points to the canonical database location. Adds hookWorkDir variable to track the polecat's clone path and passes it to updateAgentHookBead for proper beads access.
1207 lines
40 KiB
Go
1207 lines
40 KiB
Go
package cmd
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base32"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
"github.com/steveyegge/gastown/internal/config"
|
|
"github.com/steveyegge/gastown/internal/dog"
|
|
"github.com/steveyegge/gastown/internal/events"
|
|
"github.com/steveyegge/gastown/internal/session"
|
|
"github.com/steveyegge/gastown/internal/style"
|
|
"github.com/steveyegge/gastown/internal/tmux"
|
|
"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
|
|
- No-tmux mode for manual agent operation
|
|
- 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 --molecule mol-review # Use specific workflow
|
|
gt sling gp-abc greenplace --create # Create polecat if missing
|
|
gt sling gp-abc greenplace --naked # No-tmux (manual start)
|
|
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
|
|
|
|
Quality Levels (shorthand for polecat workflows):
|
|
gt sling gp-abc greenplace --quality=basic # mol-polecat-basic (trivial fixes)
|
|
gt sling gp-abc greenplace --quality=shiny # mol-polecat-shiny (standard)
|
|
gt sling gp-abc greenplace --quality=chrome # mol-polecat-chrome (max rigor)
|
|
|
|
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.`,
|
|
Args: cobra.RangeArgs(1, 2),
|
|
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
|
|
|
|
// 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
|
|
slingForce bool // --force: force spawn even if polecat has unread mail
|
|
slingAccount string // --account: Claude Code account handle to use
|
|
slingQuality string // --quality: shorthand for polecat workflow (basic|shiny|chrome)
|
|
slingNoConvoy bool // --no-convoy: skip auto-convoy creation
|
|
)
|
|
|
|
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(&slingNaked, "naked", false, "No-tmux mode: assign work but skip session creation (manual start)")
|
|
slingCmd.Flags().BoolVar(&slingCreate, "create", false, "Create polecat if it doesn't exist")
|
|
slingCmd.Flags().StringVar(&slingMolecule, "molecule", "", "Molecule workflow to instantiate on the bead")
|
|
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().StringVarP(&slingQuality, "quality", "q", "", "Polecat workflow quality level (basic|shiny|chrome)")
|
|
slingCmd.Flags().BoolVar(&slingNoConvoy, "no-convoy", false, "Skip auto-convoy creation for single-issue sling")
|
|
|
|
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)")
|
|
}
|
|
|
|
// --var is only for standalone formula mode, not formula-on-bead mode
|
|
if slingOnTarget != "" && len(slingVars) > 0 {
|
|
return fmt.Errorf("--var cannot be used with --on (formula-on-bead mode doesn't support variables)")
|
|
}
|
|
|
|
// --quality is shorthand for formula-on-bead with polecat workflow
|
|
// Convert: gt sling gp-abc greenplace --quality=shiny
|
|
// To: gt sling mol-polecat-shiny --on gt-abc gastown
|
|
if slingQuality != "" {
|
|
qualityFormula, err := qualityToFormula(slingQuality)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// The first arg should be the bead, and we wrap it with the formula
|
|
if slingOnTarget != "" {
|
|
return fmt.Errorf("--quality cannot be used with --on (both specify formula)")
|
|
}
|
|
slingOnTarget = args[0] // The bead becomes --on target
|
|
args[0] = qualityFormula // The formula becomes first arg
|
|
}
|
|
|
|
// Determine mode based on flags and argument types
|
|
var beadID string
|
|
var formulaName string
|
|
|
|
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 bead
|
|
beadID = firstArg
|
|
} else {
|
|
// Not a bead - try as standalone formula
|
|
if err := verifyFormulaExists(firstArg); err == nil {
|
|
// Standalone formula mode: gt sling <formula> [target]
|
|
return runSlingFormula(args)
|
|
}
|
|
// 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 err error
|
|
|
|
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)
|
|
if slingNaked {
|
|
fmt.Printf(" --naked: would skip tmux session\n")
|
|
}
|
|
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,
|
|
Naked: slingNaked,
|
|
Account: slingAccount,
|
|
Create: slingCreate,
|
|
HookBead: beadID, // Set atomically at spawn time
|
|
}
|
|
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
|
|
|
|
// Wake witness and refinery to monitor the new polecat
|
|
wakeRigAgents(rigName)
|
|
}
|
|
} else {
|
|
// Slinging to an existing agent
|
|
// Skip pane lookup if --naked (agent may be terminated)
|
|
targetAgent, targetPane, _, err = resolveTargetAgent(target, slingNaked)
|
|
if err != nil {
|
|
return fmt.Errorf("resolving target: %w", err)
|
|
}
|
|
}
|
|
} else {
|
|
// Slinging to self
|
|
targetAgent, targetPane, _, err = resolveSelfTarget()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// 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 pinned (guard against accidental re-sling)
|
|
info, err := getBeadInfo(beadID)
|
|
if err != nil {
|
|
return fmt.Errorf("checking bead status: %w", err)
|
|
}
|
|
if info.Status == "pinned" && !slingForce {
|
|
assignee := info.Assignee
|
|
if assignee == "" {
|
|
assignee = "(unknown)"
|
|
}
|
|
return fmt.Errorf("bead %s is already pinned to %s\nUse --force to re-sling", beadID, assignee)
|
|
}
|
|
|
|
// Auto-convoy: check if issue is already tracked by a convoy
|
|
// If not, create one for dashboard visibility (unless --no-convoy is set)
|
|
if !slingNoConvoy && formulaName == "" {
|
|
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)
|
|
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)
|
|
}
|
|
}
|
|
|
|
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\"\n", formulaName, info.Title)
|
|
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)
|
|
|
|
// Step 1: Cook the formula (ensures proto exists)
|
|
cookCmd := exec.Command("bd", "cook", formulaName)
|
|
cookCmd.Stderr = os.Stderr
|
|
if err := cookCmd.Run(); err != nil {
|
|
return fmt.Errorf("cooking formula %s: %w", formulaName, err)
|
|
}
|
|
|
|
// Step 2: Create wisp with feature variable from bead title
|
|
featureVar := fmt.Sprintf("feature=%s", info.Title)
|
|
wispArgs := []string{"mol", "wisp", formulaName, "--var", featureVar, "--json"}
|
|
wispCmd := exec.Command("bd", wispArgs...)
|
|
wispCmd.Stderr = os.Stderr
|
|
wispOut, err := wispCmd.Output()
|
|
if err != nil {
|
|
return fmt.Errorf("creating wisp for formula %s: %w", formulaName, err)
|
|
}
|
|
|
|
// Parse wisp output to get the root ID
|
|
var wispResult struct {
|
|
RootID string `json:"root_id"`
|
|
}
|
|
if err := json.Unmarshal(wispOut, &wispResult); err != nil {
|
|
return fmt.Errorf("parsing wisp output: %w", err)
|
|
}
|
|
wispRootID := wispResult.RootID
|
|
fmt.Printf("%s Formula wisp created: %s\n", style.Bold.Render("✓"), wispRootID)
|
|
|
|
// Step 3: Bond wisp to original bead (creates compound)
|
|
bondArgs := []string{"mol", "bond", wispRootID, beadID, "--json"}
|
|
bondCmd := exec.Command("bd", bondArgs...)
|
|
bondCmd.Stderr = os.Stderr
|
|
bondOut, err := bondCmd.Output()
|
|
if err != nil {
|
|
return fmt.Errorf("bonding formula to bead: %w", err)
|
|
}
|
|
|
|
// Parse bond output - the wisp root becomes the compound root
|
|
// After bonding, we hook the wisp root (which now contains the original bead)
|
|
var bondResult struct {
|
|
RootID string `json:"root_id"`
|
|
}
|
|
if err := json.Unmarshal(bondOut, &bondResult); err != nil {
|
|
// Fallback: use wisp root as the compound root
|
|
fmt.Printf("%s Could not parse bond output, using wisp root\n", style.Dim.Render("Warning:"))
|
|
} else if bondResult.RootID != "" {
|
|
wispRootID = bondResult.RootID
|
|
}
|
|
|
|
fmt.Printf("%s Formula bonded to %s\n", style.Bold.Render("✓"), beadID)
|
|
|
|
// Update beadID to hook the compound root instead of bare bead
|
|
beadID = wispRootID
|
|
}
|
|
|
|
// Hook the bead using bd update
|
|
// Run from polecat's worktree if available (for redirect-based routing),
|
|
// otherwise from town root (for prefix-based routing via routes.jsonl)
|
|
hookCmd := exec.Command("bd", "update", beadID, "--status=hooked", "--assignee="+targetAgent)
|
|
if hookWorkDir != "" {
|
|
hookCmd.Dir = hookWorkDir
|
|
} else {
|
|
// Fallback to town root for non-rig targets
|
|
townRoot, err := workspace.FindFromCwd()
|
|
if err != nil {
|
|
return fmt.Errorf("finding town root for bead routing: %w", err)
|
|
}
|
|
hookCmd.Dir = townRoot
|
|
}
|
|
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)
|
|
updateAgentHookBead(targetAgent, beadID, hookWorkDir)
|
|
|
|
// 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("✓"))
|
|
}
|
|
}
|
|
|
|
// 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 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
|
|
}
|
|
|
|
// storeArgsInBead stores args in the bead's description using attached_args field.
|
|
// This enables no-tmux mode where agents discover args via gt prime / bd show.
|
|
func storeArgsInBead(beadID, args string) error {
|
|
// Get the bead to preserve existing description content
|
|
showCmd := exec.Command("bd", "show", beadID, "--json")
|
|
out, err := showCmd.Output()
|
|
if err != nil {
|
|
return fmt.Errorf("fetching bead: %w", err)
|
|
}
|
|
|
|
// Parse the bead
|
|
var issues []beads.Issue
|
|
if err := json.Unmarshal(out, &issues); err != nil {
|
|
return fmt.Errorf("parsing bead: %w", err)
|
|
}
|
|
if len(issues) == 0 {
|
|
return fmt.Errorf("bead not found")
|
|
}
|
|
issue := &issues[0]
|
|
|
|
// Get or create attachment fields
|
|
fields := beads.ParseAttachmentFields(issue)
|
|
if fields == nil {
|
|
fields = &beads.AttachmentFields{}
|
|
}
|
|
|
|
// Set the args
|
|
fields.AttachedArgs = args
|
|
|
|
// Update the description
|
|
newDesc := beads.SetAttachmentFields(issue, fields)
|
|
|
|
// Update the bead
|
|
updateCmd := exec.Command("bd", "update", beadID, "--description="+newDesc)
|
|
updateCmd.Stderr = os.Stderr
|
|
if err := updateCmd.Run(); err != nil {
|
|
return fmt.Errorf("updating bead description: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// injectStartPrompt sends a prompt to the target pane to start working.
|
|
// Uses the reliable nudge pattern: literal mode + 500ms debounce + separate Enter.
|
|
func injectStartPrompt(pane, beadID, subject, args string) error {
|
|
if pane == "" {
|
|
return fmt.Errorf("no target pane")
|
|
}
|
|
|
|
// Build the prompt to inject
|
|
var prompt string
|
|
if args != "" {
|
|
// Args provided - include them prominently in the prompt
|
|
if subject != "" {
|
|
prompt = fmt.Sprintf("Work slung: %s (%s). Args: %s. Start working now - use these args to guide your execution.", beadID, subject, args)
|
|
} else {
|
|
prompt = fmt.Sprintf("Work slung: %s. Args: %s. Start working now - use these args to guide your execution.", beadID, args)
|
|
}
|
|
} else if subject != "" {
|
|
prompt = fmt.Sprintf("Work slung: %s (%s). Start working on it now - no questions, just begin.", beadID, subject)
|
|
} else {
|
|
prompt = fmt.Sprintf("Work slung: %s. Start working on it now - run `gt hook` to see the hook, then begin.", beadID)
|
|
}
|
|
|
|
// Use the reliable nudge pattern (same as gt nudge / tmux.NudgeSession)
|
|
t := tmux.NewTmux()
|
|
return t.NudgePane(pane, prompt)
|
|
}
|
|
|
|
// resolveTargetAgent converts a target spec to agent ID, pane, and hook root.
|
|
// If skipPane is true, skip tmux pane lookup (for --naked mode).
|
|
func resolveTargetAgent(target string, skipPane bool) (agentID string, pane string, hookRoot string, err error) {
|
|
// First resolve to session name
|
|
sessionName, err := resolveRoleToSession(target)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
|
|
// Convert session name to agent ID format (this doesn't require tmux)
|
|
agentID = sessionToAgentID(sessionName)
|
|
|
|
// Skip pane lookup if requested (--naked mode)
|
|
if skipPane {
|
|
return agentID, "", "", nil
|
|
}
|
|
|
|
// Get the pane for that session
|
|
pane, err = getSessionPane(sessionName)
|
|
if err != nil {
|
|
return "", "", "", fmt.Errorf("getting pane for %s: %w", sessionName, err)
|
|
}
|
|
|
|
// Get the target's working directory for hook storage
|
|
t := tmux.NewTmux()
|
|
hookRoot, err = t.GetPaneWorkDir(sessionName)
|
|
if err != nil {
|
|
return "", "", "", fmt.Errorf("getting working dir for %s: %w", sessionName, err)
|
|
}
|
|
|
|
return agentID, pane, hookRoot, nil
|
|
}
|
|
|
|
// sessionToAgentID converts a session name to agent ID format.
|
|
// Uses session.ParseSessionName for consistent parsing across the codebase.
|
|
func sessionToAgentID(sessionName string) string {
|
|
identity, err := session.ParseSessionName(sessionName)
|
|
if err != nil {
|
|
// Fallback for unparseable sessions
|
|
return sessionName
|
|
}
|
|
return identity.Address()
|
|
}
|
|
|
|
// verifyBeadExists checks that the bead exists using bd show.
|
|
func verifyBeadExists(beadID string) error {
|
|
cmd := exec.Command("bd", "show", beadID, "--json")
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("bead '%s' not found (bd show failed)", beadID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// beadInfo holds status and assignee for a bead.
|
|
type beadInfo struct {
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
Assignee string `json:"assignee"`
|
|
}
|
|
|
|
// getBeadInfo returns status and assignee for a bead.
|
|
func getBeadInfo(beadID string) (*beadInfo, error) {
|
|
cmd := exec.Command("bd", "show", beadID, "--json")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("bead '%s' not found", beadID)
|
|
}
|
|
// bd show --json returns an array (issue + dependents), take first element
|
|
var infos []beadInfo
|
|
if err := json.Unmarshal(out, &infos); err != nil {
|
|
return nil, fmt.Errorf("parsing bead info: %w", err)
|
|
}
|
|
if len(infos) == 0 {
|
|
return nil, fmt.Errorf("bead '%s' not found", beadID)
|
|
}
|
|
return &infos[0], nil
|
|
}
|
|
|
|
// detectCloneRoot finds the root of the current git clone.
|
|
func detectCloneRoot() (string, error) {
|
|
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("not in a git repository")
|
|
}
|
|
return strings.TrimSpace(string(out)), nil
|
|
}
|
|
|
|
// resolveSelfTarget determines agent identity, pane, and hook root for slinging to self.
|
|
func resolveSelfTarget() (agentID string, pane string, hookRoot string, err error) {
|
|
roleInfo, err := GetRole()
|
|
if err != nil {
|
|
return "", "", "", fmt.Errorf("detecting role: %w", err)
|
|
}
|
|
|
|
// Build agent identity from role
|
|
switch roleInfo.Role {
|
|
case RoleMayor:
|
|
agentID = "mayor"
|
|
case RoleDeacon:
|
|
agentID = "deacon"
|
|
case RoleWitness:
|
|
agentID = fmt.Sprintf("%s/witness", roleInfo.Rig)
|
|
case RoleRefinery:
|
|
agentID = fmt.Sprintf("%s/refinery", roleInfo.Rig)
|
|
case RolePolecat:
|
|
agentID = fmt.Sprintf("%s/polecats/%s", roleInfo.Rig, roleInfo.Polecat)
|
|
case RoleCrew:
|
|
agentID = fmt.Sprintf("%s/crew/%s", roleInfo.Rig, roleInfo.Polecat)
|
|
default:
|
|
return "", "", "", fmt.Errorf("cannot determine agent identity (role: %s)", roleInfo.Role)
|
|
}
|
|
|
|
pane = os.Getenv("TMUX_PANE")
|
|
hookRoot = roleInfo.Home
|
|
if hookRoot == "" {
|
|
// Fallback to git root if home not determined
|
|
hookRoot, err = detectCloneRoot()
|
|
if err != nil {
|
|
return "", "", "", fmt.Errorf("detecting clone root: %w", err)
|
|
}
|
|
}
|
|
|
|
return agentID, pane, hookRoot, nil
|
|
}
|
|
|
|
// verifyFormulaExists checks that the formula exists using bd formula show.
|
|
// Formulas are TOML files (.formula.toml).
|
|
func verifyFormulaExists(formulaName string) error {
|
|
// Try bd formula show (handles all formula file formats)
|
|
cmd := exec.Command("bd", "formula", "show", formulaName)
|
|
if err := cmd.Run(); err == nil {
|
|
return nil
|
|
}
|
|
|
|
// Try with mol- prefix
|
|
cmd = exec.Command("bd", "formula", "show", "mol-"+formulaName)
|
|
if err := cmd.Run(); err == nil {
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("formula '%s' not found (check 'bd formula list')", formulaName)
|
|
}
|
|
|
|
// runSlingFormula handles standalone formula slinging.
|
|
// Flow: cook → wisp → attach to hook → nudge
|
|
func runSlingFormula(args []string) error {
|
|
formulaName := args[0]
|
|
|
|
// Determine target (self or specified)
|
|
var target string
|
|
if len(args) > 1 {
|
|
target = args[1]
|
|
}
|
|
|
|
// Resolve target agent and pane
|
|
var targetAgent string
|
|
var targetPane string
|
|
var err error
|
|
|
|
if target != "" {
|
|
// 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)
|
|
if slingNaked {
|
|
fmt.Printf(" --naked: would skip tmux session\n")
|
|
}
|
|
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,
|
|
Naked: slingNaked,
|
|
Account: slingAccount,
|
|
Create: slingCreate,
|
|
}
|
|
spawnInfo, spawnErr := SpawnPolecatForSling(rigName, spawnOpts)
|
|
if spawnErr != nil {
|
|
return fmt.Errorf("spawning polecat: %w", spawnErr)
|
|
}
|
|
targetAgent = spawnInfo.AgentID()
|
|
targetPane = spawnInfo.Pane
|
|
|
|
// Wake witness and refinery to monitor the new polecat
|
|
wakeRigAgents(rigName)
|
|
}
|
|
} else {
|
|
// Slinging to an existing agent
|
|
// Skip pane lookup if --naked (agent may be terminated)
|
|
targetAgent, targetPane, _, err = resolveTargetAgent(target, slingNaked)
|
|
if err != nil {
|
|
return fmt.Errorf("resolving target: %w", err)
|
|
}
|
|
}
|
|
} else {
|
|
// Slinging to self
|
|
targetAgent, targetPane, _, err = resolveSelfTarget()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
fmt.Printf("%s Slinging formula %s to %s...\n", style.Bold.Render("🎯"), formulaName, targetAgent)
|
|
|
|
if slingDryRun {
|
|
fmt.Printf("Would cook formula: %s\n", formulaName)
|
|
fmt.Printf("Would create wisp and pin to: %s\n", targetAgent)
|
|
for _, v := range slingVars {
|
|
fmt.Printf(" --var %s\n", v)
|
|
}
|
|
fmt.Printf("Would nudge pane: %s\n", targetPane)
|
|
return nil
|
|
}
|
|
|
|
// Step 1: Cook the formula (ensures proto exists)
|
|
fmt.Printf(" Cooking formula...\n")
|
|
cookArgs := []string{"cook", formulaName}
|
|
cookCmd := exec.Command("bd", cookArgs...)
|
|
cookCmd.Stderr = os.Stderr
|
|
if err := cookCmd.Run(); err != nil {
|
|
return fmt.Errorf("cooking formula: %w", err)
|
|
}
|
|
|
|
// Step 2: Create wisp instance (ephemeral)
|
|
fmt.Printf(" Creating wisp...\n")
|
|
wispArgs := []string{"mol", "wisp", formulaName}
|
|
for _, v := range slingVars {
|
|
wispArgs = append(wispArgs, "--var", v)
|
|
}
|
|
wispArgs = append(wispArgs, "--json")
|
|
|
|
wispCmd := exec.Command("bd", wispArgs...)
|
|
wispCmd.Stderr = os.Stderr // Show wisp errors to user
|
|
wispOut, err := wispCmd.Output()
|
|
if err != nil {
|
|
return fmt.Errorf("creating wisp: %w", err)
|
|
}
|
|
|
|
// Parse wisp output to get the root ID
|
|
var wispResult struct {
|
|
RootID string `json:"root_id"`
|
|
}
|
|
if err := json.Unmarshal(wispOut, &wispResult); err != nil {
|
|
// Fallback: use formula name as identifier, but warn user
|
|
fmt.Printf("%s Could not parse wisp output, using formula name as ID\n", style.Dim.Render("Warning:"))
|
|
wispResult.RootID = formulaName
|
|
}
|
|
|
|
fmt.Printf("%s Wisp created: %s\n", style.Bold.Render("✓"), wispResult.RootID)
|
|
|
|
// Step 3: Hook the wisp bead using bd update (discovery-based approach)
|
|
// Run from town root to enable prefix-based routing via routes.jsonl
|
|
townRoot, err := workspace.FindFromCwd()
|
|
if err != nil {
|
|
return fmt.Errorf("finding town root for bead routing: %w", err)
|
|
}
|
|
hookCmd := exec.Command("bd", "update", wispResult.RootID, "--status=hooked", "--assignee="+targetAgent)
|
|
hookCmd.Dir = townRoot
|
|
hookCmd.Stderr = os.Stderr
|
|
if err := hookCmd.Run(); err != nil {
|
|
return fmt.Errorf("hooking wisp bead: %w", err)
|
|
}
|
|
fmt.Printf("%s Attached to hook (status=hooked)\n", style.Bold.Render("✓"))
|
|
|
|
// Log sling event to activity feed (formula slinging)
|
|
actor := detectActor()
|
|
payload := events.SlingPayload(wispResult.RootID, targetAgent)
|
|
payload["formula"] = formulaName
|
|
_ = events.LogFeed(events.TypeSling, actor, payload)
|
|
|
|
// Update agent bead's hook_bead field (ZFC: agents track their current work)
|
|
// Note: formula slinging uses town root as workDir (no polecat-specific path)
|
|
updateAgentHookBead(targetAgent, wispResult.RootID, "")
|
|
|
|
// Store args in wisp bead if provided (no-tmux mode: beads as data plane)
|
|
if slingArgs != "" {
|
|
if err := storeArgsInBead(wispResult.RootID, slingArgs); err != nil {
|
|
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("✓"))
|
|
}
|
|
}
|
|
|
|
// Step 4: Nudge to start (graceful if no tmux)
|
|
if targetPane == "" {
|
|
fmt.Printf("%s No pane to nudge (agent will discover work via gt prime)\n", style.Dim.Render("○"))
|
|
return nil
|
|
}
|
|
|
|
var prompt string
|
|
if slingArgs != "" {
|
|
prompt = fmt.Sprintf("Formula %s slung. Args: %s. Run `gt hook` to see your hook, then execute using these args.", formulaName, slingArgs)
|
|
} else {
|
|
prompt = fmt.Sprintf("Formula %s slung. Run `gt hook` to see your hook, then execute the steps.", formulaName)
|
|
}
|
|
t := tmux.NewTmux()
|
|
if err := t.NudgePane(targetPane, prompt); 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 Nudged to start\n", style.Bold.Render("▶"))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// updateAgentHookBead updates the agent bead's hook_bead field when work is slung.
|
|
// This enables the witness to see what each agent is working on.
|
|
//
|
|
// If workDir is provided (e.g., polecat's clone path), bd commands run from there
|
|
// to access the correct beads database via redirect. Otherwise falls back to town root.
|
|
// Running from the polecat's worktree is important because it has a .beads/redirect file
|
|
// that points to the correct canonical database for redirect-based routing.
|
|
func updateAgentHookBead(agentID, beadID, workDir string) {
|
|
// Convert agent ID to agent bead ID
|
|
// Format examples (canonical: prefix-rig-role-name):
|
|
// greenplace/crew/max -> gt-greenplace-crew-max
|
|
// greenplace/polecats/Toast -> gt-greenplace-polecat-Toast
|
|
// mayor -> gt-mayor
|
|
// greenplace/witness -> gt-greenplace-witness
|
|
agentBeadID := agentIDToBeadID(agentID)
|
|
if agentBeadID == "" {
|
|
return
|
|
}
|
|
|
|
// Determine the directory to run bd commands from:
|
|
// - If workDir is provided (polecat's clone path), use it for redirect-based routing
|
|
// - Otherwise fall back to town root for prefix-based routing via routes.jsonl
|
|
bdWorkDir := workDir
|
|
if bdWorkDir == "" {
|
|
townRoot, err := workspace.FindFromCwd()
|
|
if err != nil {
|
|
// Not in a Gas Town workspace - can't update agent bead
|
|
fmt.Fprintf(os.Stderr, "Warning: couldn't find town root to update agent hook: %v\n", err)
|
|
return
|
|
}
|
|
bdWorkDir = townRoot
|
|
}
|
|
|
|
bd := beads.New(bdWorkDir)
|
|
if err := bd.UpdateAgentState(agentBeadID, "running", &beadID); err != nil {
|
|
// Log warning instead of silent ignore - helps debug cross-beads issues
|
|
fmt.Fprintf(os.Stderr, "Warning: couldn't update agent %s hook to %s: %v\n", agentBeadID, beadID, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// wakeRigAgents wakes the witness and refinery for a rig after polecat dispatch.
|
|
// This ensures the patrol agents are ready to monitor and merge.
|
|
func wakeRigAgents(rigName string) {
|
|
// Boot the rig (idempotent - no-op if already running)
|
|
bootCmd := exec.Command("gt", "rig", "boot", rigName)
|
|
_ = bootCmd.Run() // Ignore errors - rig might already be running
|
|
|
|
// Nudge witness and refinery to clear any backoff
|
|
t := tmux.NewTmux()
|
|
witnessSession := fmt.Sprintf("gt-%s-witness", rigName)
|
|
refinerySession := fmt.Sprintf("gt-%s-refinery", rigName)
|
|
|
|
// Silent nudges - sessions might not exist yet
|
|
_ = t.NudgeSession(witnessSession, "Polecat dispatched - check for work")
|
|
_ = t.NudgeSession(refinerySession, "Polecat dispatched - check for merge requests")
|
|
}
|
|
|
|
// detectActor returns the current agent's actor string for event logging.
|
|
func detectActor() string {
|
|
roleInfo, err := GetRole()
|
|
if err != nil {
|
|
return "unknown"
|
|
}
|
|
return roleInfo.ActorString()
|
|
}
|
|
|
|
// agentIDToBeadID converts an agent ID to its corresponding agent bead ID.
|
|
// Uses canonical naming: prefix-rig-role-name
|
|
// This function uses "gt-" prefix by default. For non-gastown rigs, use the
|
|
// appropriate *WithPrefix functions that accept the rig's configured prefix.
|
|
func agentIDToBeadID(agentID string) string {
|
|
// Handle simple cases (town-level agents)
|
|
if agentID == "mayor" {
|
|
return beads.MayorBeadID()
|
|
}
|
|
if agentID == "deacon" {
|
|
return beads.DeaconBeadID()
|
|
}
|
|
|
|
// Parse path-style agent IDs
|
|
parts := strings.Split(agentID, "/")
|
|
if len(parts) < 2 {
|
|
return ""
|
|
}
|
|
|
|
rig := parts[0]
|
|
|
|
switch {
|
|
case len(parts) == 2 && parts[1] == "witness":
|
|
return beads.WitnessBeadID(rig)
|
|
case len(parts) == 2 && parts[1] == "refinery":
|
|
return beads.RefineryBeadID(rig)
|
|
case len(parts) == 3 && parts[1] == "crew":
|
|
return beads.CrewBeadID(rig, parts[2])
|
|
case len(parts) == 3 && parts[1] == "polecats":
|
|
return beads.PolecatBeadID(rig, parts[2])
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// qualityToFormula converts a quality level to the corresponding polecat workflow formula.
|
|
func qualityToFormula(quality string) (string, error) {
|
|
switch strings.ToLower(quality) {
|
|
case "basic", "b":
|
|
return "mol-polecat-basic", nil
|
|
case "shiny", "s":
|
|
return "mol-polecat-shiny", nil
|
|
case "chrome", "c":
|
|
return "mol-polecat-chrome", nil
|
|
default:
|
|
return "", fmt.Errorf("invalid quality level '%s' (use: basic, shiny, or chrome)", quality)
|
|
}
|
|
}
|
|
|
|
// IsDogTarget checks if target is a dog target pattern.
|
|
// Returns the dog name (or empty for pool dispatch) and true if it's a dog target.
|
|
// Patterns:
|
|
// - "deacon/dogs" -> ("", true) - dispatch to any idle dog
|
|
// - "deacon/dogs/alpha" -> ("alpha", true) - dispatch to specific dog
|
|
func IsDogTarget(target string) (dogName string, isDog bool) {
|
|
target = strings.ToLower(target)
|
|
|
|
// Check for exact "deacon/dogs" (pool dispatch)
|
|
if target == "deacon/dogs" {
|
|
return "", true
|
|
}
|
|
|
|
// Check for "deacon/dogs/<name>" (specific dog)
|
|
if strings.HasPrefix(target, "deacon/dogs/") {
|
|
name := strings.TrimPrefix(target, "deacon/dogs/")
|
|
if name != "" && !strings.Contains(name, "/") {
|
|
return name, true
|
|
}
|
|
}
|
|
|
|
return "", false
|
|
}
|
|
|
|
// DogDispatchInfo contains information about a dog dispatch.
|
|
type DogDispatchInfo struct {
|
|
DogName string // Name of the dog
|
|
AgentID string // Agent ID format (deacon/dogs/<name>)
|
|
Pane string // Tmux pane (empty if no session)
|
|
Spawned bool // True if dog was spawned (new)
|
|
}
|
|
|
|
// DispatchToDog finds or spawns a dog for work dispatch.
|
|
// If dogName is empty, finds an idle dog from the pool.
|
|
// If create is true and no dogs exist, creates one.
|
|
func DispatchToDog(dogName string, create bool) (*DogDispatchInfo, error) {
|
|
townRoot, err := workspace.FindFromCwd()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("finding town root: %w", err)
|
|
}
|
|
|
|
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
|
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading rigs config: %w", err)
|
|
}
|
|
|
|
mgr := dog.NewManager(townRoot, rigsConfig)
|
|
|
|
var targetDog *dog.Dog
|
|
var spawned bool
|
|
|
|
if dogName != "" {
|
|
// Specific dog requested
|
|
targetDog, err = mgr.Get(dogName)
|
|
if err != nil {
|
|
if create {
|
|
// Create the dog if it doesn't exist
|
|
targetDog, err = mgr.Add(dogName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating dog %s: %w", dogName, err)
|
|
}
|
|
fmt.Printf("✓ Created dog %s\n", dogName)
|
|
spawned = true
|
|
} else {
|
|
return nil, fmt.Errorf("dog %s not found (use --create to add)", dogName)
|
|
}
|
|
}
|
|
} else {
|
|
// Pool dispatch - find an idle dog
|
|
targetDog, err = mgr.GetIdleDog()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("finding idle dog: %w", err)
|
|
}
|
|
|
|
if targetDog == nil {
|
|
if create {
|
|
// No idle dogs - create one
|
|
newName := generateDogName(mgr)
|
|
targetDog, err = mgr.Add(newName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating dog %s: %w", newName, err)
|
|
}
|
|
fmt.Printf("✓ Created dog %s (pool was empty)\n", newName)
|
|
spawned = true
|
|
} else {
|
|
return nil, fmt.Errorf("no idle dogs available (use --create to add)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mark dog as working
|
|
if err := mgr.SetState(targetDog.Name, dog.StateWorking); err != nil {
|
|
return nil, fmt.Errorf("setting dog state: %w", err)
|
|
}
|
|
|
|
// Build agent ID
|
|
agentID := fmt.Sprintf("deacon/dogs/%s", targetDog.Name)
|
|
|
|
// Try to find tmux session for the dog (dogs may run in tmux like polecats)
|
|
sessionName := fmt.Sprintf("gt-deacon-%s", targetDog.Name)
|
|
t := tmux.NewTmux()
|
|
var pane string
|
|
if has, _ := t.HasSession(sessionName); has {
|
|
// Get the pane from the session
|
|
pane, _ = getSessionPane(sessionName)
|
|
}
|
|
|
|
return &DogDispatchInfo{
|
|
DogName: targetDog.Name,
|
|
AgentID: agentID,
|
|
Pane: pane,
|
|
Spawned: spawned,
|
|
}, nil
|
|
}
|
|
|
|
// generateDogName creates a unique dog name for pool expansion.
|
|
func generateDogName(mgr *dog.Manager) string {
|
|
// Use Greek alphabet for dog names
|
|
names := []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel"}
|
|
|
|
dogs, _ := mgr.List()
|
|
existing := make(map[string]bool)
|
|
for _, d := range dogs {
|
|
existing[d.Name] = true
|
|
}
|
|
|
|
for _, name := range names {
|
|
if !existing[name] {
|
|
return name
|
|
}
|
|
}
|
|
|
|
// Fallback: numbered dogs
|
|
for i := 1; i <= 100; i++ {
|
|
name := fmt.Sprintf("dog%d", i)
|
|
if !existing[name] {
|
|
return name
|
|
}
|
|
}
|
|
|
|
return fmt.Sprintf("dog%d", len(dogs)+1)
|
|
}
|
|
|
|
// slingGenerateShortID generates a short random ID (5 lowercase chars).
|
|
func slingGenerateShortID() string {
|
|
b := make([]byte, 3)
|
|
rand.Read(b)
|
|
return strings.ToLower(base32.StdEncoding.EncodeToString(b)[:5])
|
|
}
|
|
|
|
// isTrackedByConvoy checks if an issue is already being tracked by a convoy.
|
|
// Returns the convoy ID if tracked, empty string otherwise.
|
|
func isTrackedByConvoy(beadID string) string {
|
|
townRoot, err := workspace.FindFromCwd()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
// Query town beads for any convoy that tracks this issue
|
|
// Convoys use "tracks" dependency type: convoy -> tracked issue
|
|
townBeads := filepath.Join(townRoot, ".beads")
|
|
dbPath := filepath.Join(townBeads, "beads.db")
|
|
|
|
// Query dependencies where this bead is being tracked
|
|
// Also check for external reference format: external:rig:issue-id
|
|
query := fmt.Sprintf(`
|
|
SELECT d.issue_id
|
|
FROM dependencies d
|
|
JOIN issues i ON d.issue_id = i.id
|
|
WHERE d.type = 'tracks'
|
|
AND i.issue_type = 'convoy'
|
|
AND (d.depends_on_id = '%s' OR d.depends_on_id LIKE '%%:%s')
|
|
LIMIT 1
|
|
`, beadID, beadID)
|
|
|
|
queryCmd := exec.Command("sqlite3", dbPath, query)
|
|
out, err := queryCmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
convoyID := strings.TrimSpace(string(out))
|
|
return convoyID
|
|
}
|
|
|
|
// createAutoConvoy creates an auto-convoy for a single issue and tracks it.
|
|
// Returns the created convoy ID.
|
|
func createAutoConvoy(beadID, beadTitle string) (string, error) {
|
|
townRoot, err := workspace.FindFromCwd()
|
|
if err != nil {
|
|
return "", fmt.Errorf("finding town root: %w", err)
|
|
}
|
|
|
|
townBeads := filepath.Join(townRoot, ".beads")
|
|
|
|
// Generate convoy ID with cv- prefix
|
|
convoyID := fmt.Sprintf("hq-cv-%s", slingGenerateShortID())
|
|
|
|
// Create convoy with title "Work: <issue-title>"
|
|
convoyTitle := fmt.Sprintf("Work: %s", beadTitle)
|
|
description := fmt.Sprintf("Auto-created convoy tracking %s", beadID)
|
|
|
|
createArgs := []string{
|
|
"create",
|
|
"--type=convoy",
|
|
"--id=" + convoyID,
|
|
"--title=" + convoyTitle,
|
|
"--description=" + description,
|
|
}
|
|
|
|
createCmd := exec.Command("bd", createArgs...)
|
|
createCmd.Dir = townBeads
|
|
createCmd.Stderr = os.Stderr
|
|
|
|
if err := createCmd.Run(); err != nil {
|
|
return "", fmt.Errorf("creating convoy: %w", err)
|
|
}
|
|
|
|
// Add tracking relation: convoy tracks the issue
|
|
depArgs := []string{"dep", "add", convoyID, beadID, "--type=tracks"}
|
|
depCmd := exec.Command("bd", depArgs...)
|
|
depCmd.Dir = townBeads
|
|
depCmd.Stderr = os.Stderr
|
|
|
|
if err := depCmd.Run(); err != nil {
|
|
// Convoy was created but tracking failed - log warning but continue
|
|
fmt.Printf("%s Could not add tracking relation: %v\n", style.Dim.Render("Warning:"), err)
|
|
}
|
|
|
|
return convoyID, nil
|
|
}
|