package cmd import ( "bytes" "encoding/json" "fmt" "os/exec" "path/filepath" "strings" "time" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/mail" "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" ) // Sling command flags var ( slingWisp bool // Create ephemeral molecule (wisp) slingMolecule string // Molecule proto when slinging an issue slingPriority int // Override priority (P0-P4) slingForce bool // Re-sling even if hook has work slingNoStart bool // Assign work but don't start session slingCreate bool // Create polecat if it doesn't exist ) var slingCmd = &cobra.Command{ Use: "sling ", Short: "Unified work dispatch command", Long: `Sling work at an agent - the universal Gas Town work dispatch. This command implements spawn + assign + pin in one operation. Based on the Universal Gas Town Propulsion Principle: "If you find something on your hook, YOU RUN IT." Arguments: thing What to sling: proto name, issue ID, or epic ID target Who to sling at: agent address (polecat/name, deacon/, etc.) Examples: gt sling feature polecat/alpha # Spawn feature mol, sling to alpha gt sling gt-xyz polecat/beta -m bugfix # Sling issue with bugfix workflow gt sling patrol deacon/ --wisp # Ephemeral patrol wisp gt sling gt-epic-batch refinery/ # Batch work to refinery What Happens When You Sling: 1. SPAWN (if proto) - Create molecule from template 2. ASSIGN - Assign molecule/issue to target agent 3. PIN - Put work on agent's hook (pinned bead) 4. IGNITION - Agent wakes and runs the work`, Args: cobra.ExactArgs(2), RunE: runSling, } func init() { slingCmd.Flags().BoolVar(&slingWisp, "wisp", false, "Create ephemeral molecule (burned on complete)") slingCmd.Flags().StringVarP(&slingMolecule, "molecule", "m", "", "Molecule proto when slinging an issue") slingCmd.Flags().IntVarP(&slingPriority, "priority", "p", -1, "Override priority (0-4)") slingCmd.Flags().BoolVar(&slingForce, "force", false, "Re-sling even if hook has work") slingCmd.Flags().BoolVar(&slingNoStart, "no-start", false, "Assign work but don't start session") slingCmd.Flags().BoolVar(&slingCreate, "create", false, "Create polecat if it doesn't exist") rootCmd.AddCommand(slingCmd) } // SlingThing represents what's being slung. type SlingThing struct { Kind string // "proto", "issue", or "epic" ID string // The identifier (proto name or issue ID) Proto string // If Kind=="issue" and --molecule set, the proto name IsWisp bool // If --wisp flag set } // SlingTarget represents who's being slung at. type SlingTarget struct { Kind string // "polecat", "deacon", "witness", "refinery" Rig string // Rig name (empty for town-level agents) Name string // Agent name (for polecats) } func runSling(cmd *cobra.Command, args []string) error { thingArg := args[0] targetArg := args[1] // Find workspace townRoot, err := workspace.FindFromCwdOrError() if err != nil { return fmt.Errorf("not in a Gas Town workspace: %w", err) } // Parse target first (needed to determine rig context) target, err := parseSlingTarget(targetArg, townRoot) if err != nil { return fmt.Errorf("invalid target: %w", err) } // Get rig context rigPath := filepath.Join(townRoot, target.Rig) beadsPath := rigPath // Parse thing (needs beads context for proto lookup) thing, err := parseSlingThing(thingArg, beadsPath) if err != nil { return fmt.Errorf("invalid thing: %w", err) } // Apply flags to thing thing.Proto = slingMolecule thing.IsWisp = slingWisp fmt.Printf("Slinging %s %s at %s\n", thing.Kind, style.Bold.Render(thing.ID), style.Bold.Render(targetArg)) // Route based on target kind switch target.Kind { case "polecat": return slingToPolecat(townRoot, target, thing) case "deacon": return slingToDeacon(townRoot, target, thing) case "witness": return slingToWitness(townRoot, target, thing) case "refinery": return slingToRefinery(townRoot, target, thing) default: return fmt.Errorf("unknown target kind: %s", target.Kind) } } // parseSlingThing parses the argument. // Returns the kind (proto, issue, epic) and ID. func parseSlingThing(arg, beadsPath string) (*SlingThing, error) { // Check if it looks like an issue ID (has a prefix like gt-, bd-, hq-) if looksLikeIssueID(arg) { // Fetch the issue to check its type b := beads.New(beadsPath) issue, err := b.Show(arg) if err != nil { return nil, fmt.Errorf("issue not found: %s", arg) } kind := "issue" if issue.Type == "epic" { kind = "epic" } return &SlingThing{ Kind: kind, ID: arg, }, nil } // Otherwise, assume it's a proto name // Validate that the proto exists in the catalog catalog, err := loadMoleculeCatalog(beadsPath) if err != nil { return nil, fmt.Errorf("loading catalog: %w", err) } // Try both the exact name and with "mol-" prefix protoID := arg if catalog.Get(protoID) == nil { protoID = "mol-" + arg if catalog.Get(protoID) == nil { return nil, fmt.Errorf("proto not found: %s (tried %s and mol-%s)", arg, arg, arg) } } return &SlingThing{ Kind: "proto", ID: protoID, }, nil } // parseSlingTarget parses the argument. // Format: polecat/name, deacon/, witness/, refinery/ // Or with rig: gastown/polecat/name, gastown/witness func parseSlingTarget(arg, townRoot string) (*SlingTarget, error) { parts := strings.Split(arg, "/") // Handle various formats switch len(parts) { case 1: // Single word like "deacon" - need rig context rigName, err := inferRigFromCwd(townRoot) if err != nil { return nil, fmt.Errorf("cannot infer rig: %w", err) } return parseAgentKind(parts[0], "", rigName) case 2: // Could be: polecat/name, rig/role, or role/ (trailing slash) first, second := parts[0], parts[1] // Check for trailing slash (e.g., "deacon/") if second == "" { rigName, err := inferRigFromCwd(townRoot) if err != nil { return nil, fmt.Errorf("cannot infer rig: %w", err) } return parseAgentKind(first, "", rigName) } // Check if first is a known role if isAgentRole(first) { // It's role/name (e.g., polecat/alpha) rigName, err := inferRigFromCwd(townRoot) if err != nil { return nil, fmt.Errorf("cannot infer rig: %w", err) } return parseAgentKind(first, second, rigName) } // Otherwise it's rig/role (e.g., gastown/deacon) return parseAgentKind(second, "", first) case 3: // rig/role/name (e.g., gastown/polecat/alpha) rigName, role, name := parts[0], parts[1], parts[2] return parseAgentKind(role, name, rigName) default: return nil, fmt.Errorf("invalid target format: %s", arg) } } // parseAgentKind creates a SlingTarget from parsed components. func parseAgentKind(role, name, rigName string) (*SlingTarget, error) { role = strings.ToLower(role) switch role { case "polecat", "polecats": if name == "" { return nil, fmt.Errorf("polecat target requires a name (e.g., polecat/alpha)") } return &SlingTarget{Kind: "polecat", Rig: rigName, Name: name}, nil case "deacon": return &SlingTarget{Kind: "deacon", Rig: rigName}, nil case "witness": return &SlingTarget{Kind: "witness", Rig: rigName}, nil case "refinery": return &SlingTarget{Kind: "refinery", Rig: rigName}, nil default: // Might be a polecat name without "polecat/" prefix // Try to detect by checking if it's a valid rig name return &SlingTarget{Kind: "polecat", Rig: rigName, Name: role}, nil } } // isAgentRole returns true if the string is a known agent role. func isAgentRole(s string) bool { switch strings.ToLower(s) { case "polecat", "polecats", "deacon", "witness", "refinery": return true } return false } // looksLikeIssueID returns true if the string looks like a beads issue ID. func looksLikeIssueID(s string) bool { // Issue IDs have a prefix followed by a dash // Common prefixes: gt-, bd-, hq- prefixes := []string{"gt-", "bd-", "hq-", "beads-"} for _, prefix := range prefixes { if strings.HasPrefix(s, prefix) { return true } } return false } // slingToPolecat handles slinging work to a polecat. func slingToPolecat(townRoot string, target *SlingTarget, thing *SlingThing) error { 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(target.Rig) if err != nil { return fmt.Errorf("rig '%s' not found", target.Rig) } // Get polecat manager polecatGit := git.NewGit(r.Path) polecatMgr := polecat.NewManager(r, polecatGit) polecatName := target.Name polecatAddress := fmt.Sprintf("%s/%s", target.Rig, polecatName) // Router for mail operations router := mail.NewRouter(r.Path) // Check if polecat exists existingPolecat, err := polecatMgr.Get(polecatName) polecatExists := err == nil if polecatExists { // Check for existing work on hook (unless --force) if !slingForce { if err := checkHookCollision(polecatAddress, r.Path); err != nil { return err } } // Check for uncommitted work pGit := git.NewGit(existingPolecat.ClonePath) workStatus, checkErr := pGit.CheckUncommittedWork() if checkErr == nil && !workStatus.Clean() { fmt.Printf("\n%s Polecat has uncommitted work:\n", style.Warning.Render("⚠")) if workStatus.HasUncommittedChanges { fmt.Printf(" • %d uncommitted change(s)\n", len(workStatus.ModifiedFiles)+len(workStatus.UntrackedFiles)) } if workStatus.StashCount > 0 { fmt.Printf(" • %d stash(es)\n", workStatus.StashCount) } if workStatus.UnpushedCommits > 0 { fmt.Printf(" • %d unpushed commit(s)\n", workStatus.UnpushedCommits) } fmt.Println() if !slingForce { return fmt.Errorf("polecat '%s' has uncommitted work\nUse --force to proceed anyway", polecatName) } fmt.Printf("%s Proceeding with --force\n", style.Dim.Render("Warning:")) } // Check for unread mail mailbox, mailErr := router.GetMailbox(polecatAddress) if mailErr == nil { _, unread, _ := mailbox.Count() if unread > 0 && !slingForce { return fmt.Errorf("polecat '%s' has %d unread message(s)\nUse --force to override", polecatName, unread) } else if unread > 0 { fmt.Printf("%s Polecat has %d unread message(s), proceeding with --force\n", style.Dim.Render("Warning:"), unread) } } // Recreate polecat with fresh worktree fmt.Printf("Recreating polecat %s with fresh worktree...\n", polecatName) if _, err = polecatMgr.Recreate(polecatName, slingForce); err != nil { return fmt.Errorf("recreating polecat: %w", err) } fmt.Printf("%s Fresh worktree created\n", style.Bold.Render("✓")) } else if err == polecat.ErrPolecatNotFound { if !slingCreate { return fmt.Errorf("polecat '%s' not found (use --create to create)", polecatName) } fmt.Printf("Creating polecat %s...\n", polecatName) if _, err = polecatMgr.Add(polecatName); err != nil { return fmt.Errorf("creating polecat: %w", err) } } else { return fmt.Errorf("getting polecat: %w", err) } beadsPath := r.Path // Sync beads if err := syncBeads(beadsPath, true); err != nil { fmt.Printf("%s beads sync: %v\n", style.Dim.Render("Warning:"), err) } // Process the thing based on its kind var issueID string var moleculeCtx *MoleculeContext switch thing.Kind { case "proto": // Spawn molecule from proto issueID, moleculeCtx, err = spawnMoleculeFromProto(beadsPath, thing, polecatAddress) if err != nil { return err } case "issue": issueID = thing.ID if thing.Proto != "" { // Sling issue with molecule workflow issueID, moleculeCtx, err = spawnMoleculeOnIssue(beadsPath, thing, polecatAddress) if err != nil { return err } } case "epic": // Epics go to refinery, not polecats return fmt.Errorf("epics should be slung at refinery/, not polecat/") } // Assign issue to polecat if err := polecatMgr.AssignIssue(polecatName, issueID); err != nil { return fmt.Errorf("assigning issue: %w", err) } fmt.Printf("%s Assigned %s to %s\n", style.Bold.Render("✓"), issueID, polecatAddress) // Pin to hook (update handoff bead with attachment) if err := pinToHook(beadsPath, polecatAddress, issueID, moleculeCtx); err != nil { fmt.Printf("%s Could not pin to hook: %v\n", style.Dim.Render("Warning:"), err) } else { fmt.Printf("%s Pinned to hook\n", style.Bold.Render("✓")) } // Sync beads if err := syncBeads(beadsPath, false); err != nil { fmt.Printf("%s beads push: %v\n", style.Dim.Render("Warning:"), err) } if slingNoStart { fmt.Printf("\n %s\n", style.Dim.Render("Use 'gt session start' to start the session")) return nil } // Fetch the issue for mail content b := beads.New(beadsPath) issue, _ := b.Show(issueID) var beadsIssue *BeadsIssue if issue != nil { beadsIssue = &BeadsIssue{ ID: issue.ID, Title: issue.Title, Description: issue.Description, Priority: issue.Priority, Type: issue.Type, Status: issue.Status, } } // Send work assignment mail workMsg := buildWorkAssignmentMail(beadsIssue, "", polecatAddress, moleculeCtx) fmt.Printf("Sending work assignment to %s inbox...\n", polecatAddress) if err := router.Send(workMsg); err != nil { return fmt.Errorf("sending work assignment: %w", err) } fmt.Printf("%s Work assignment sent\n", style.Bold.Render("✓")) // Start session t := tmux.NewTmux() sessMgr := session.NewManager(t, r) running, _ := sessMgr.IsRunning(polecatName) if running { fmt.Printf("Session already running, notifying to check inbox...\n") time.Sleep(500 * time.Millisecond) } else { fmt.Printf("Starting session for %s...\n", polecatAddress) if err := sessMgr.Start(polecatName, session.StartOptions{}); err != nil { return fmt.Errorf("starting session: %w", err) } time.Sleep(3 * time.Second) } fmt.Printf("%s Session started. Attach with: %s\n", style.Bold.Render("✓"), style.Dim.Render(fmt.Sprintf("gt session at %s", polecatAddress))) // Nudge polecat sessionName := sessMgr.SessionName(polecatName) nudgeMsg := fmt.Sprintf("You have a work assignment. Run 'gt mail inbox' to see it, then start working on issue %s.", issueID) if err := t.NudgeSession(sessionName, nudgeMsg); err != nil { fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("Warning: could not nudge: %v", err))) } else { fmt.Printf(" %s\n", style.Dim.Render("Polecat nudged to start working")) } // Notify Witness townRouter := mail.NewRouter(townRoot) witnessAddr := fmt.Sprintf("%s/witness", target.Rig) sender := detectSender() spawnNotification := &mail.Message{ To: witnessAddr, From: sender, Subject: fmt.Sprintf("SLING: %s starting on %s", polecatName, issueID), Body: fmt.Sprintf("Polecat slung.\n\nPolecat: %s\nIssue: %s\nSession: %s\nSlung by: %s", polecatName, issueID, sessionName, sender), } if err := townRouter.Send(spawnNotification); err != nil { fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("Warning: could not notify witness: %v", err))) } else { fmt.Printf(" %s\n", style.Dim.Render("Witness notified")) } return nil } // slingToDeacon handles slinging work to the deacon. func slingToDeacon(townRoot string, target *SlingTarget, thing *SlingThing) error { if thing.Kind != "proto" { return fmt.Errorf("deacon only accepts protos (like 'patrol'), not issues") } if !thing.IsWisp { fmt.Printf("%s Deacon work should be ephemeral. Consider using --wisp\n", style.Dim.Render("Note:")) } // For deacon, we just need to update its hook and send mail beadsPath := filepath.Join(townRoot, target.Rig) // Sync beads if err := syncBeads(beadsPath, true); err != nil { fmt.Printf("%s beads sync: %v\n", style.Dim.Render("Warning:"), err) } // Spawn the molecule from proto deaconAddress := fmt.Sprintf("%s/deacon", target.Rig) issueID, moleculeCtx, err := spawnMoleculeFromProto(beadsPath, thing, deaconAddress) if err != nil { return err } // Pin to deacon's hook if err := pinToHook(beadsPath, deaconAddress, issueID, moleculeCtx); err != nil { fmt.Printf("%s Could not pin to hook: %v\n", style.Dim.Render("Warning:"), err) } else { fmt.Printf("%s Pinned to deacon hook\n", style.Bold.Render("✓")) } // Sync beads if err := syncBeads(beadsPath, false); err != nil { fmt.Printf("%s beads push: %v\n", style.Dim.Render("Warning:"), err) } fmt.Printf("%s Deacon will run %s on next patrol\n", style.Bold.Render("✓"), thing.ID) return nil } // slingToWitness handles slinging work to the witness. func slingToWitness(townRoot string, target *SlingTarget, thing *SlingThing) error { // Similar to deacon - update hook and optionally signal return fmt.Errorf("slinging to witness not yet implemented") } // slingToRefinery handles slinging work to the refinery. func slingToRefinery(townRoot string, target *SlingTarget, thing *SlingThing) error { if thing.Kind != "epic" { return fmt.Errorf("refinery accepts epics for batch processing, not %s", thing.Kind) } // Refinery batch processing not yet implemented return fmt.Errorf("slinging epics to refinery not yet implemented") } // spawnMoleculeFromProto spawns a molecule from a proto template. func spawnMoleculeFromProto(beadsPath string, thing *SlingThing, assignee string) (string, *MoleculeContext, error) { fmt.Printf("Spawning molecule from proto %s...\n", thing.ID) // Use bd mol run to spawn the molecule args := []string{"--no-daemon", "mol", "run", thing.ID, "--json"} if assignee != "" { args = append(args, "--var", "assignee="+assignee) } cmd := exec.Command("bd", args...) cmd.Dir = beadsPath var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { errMsg := strings.TrimSpace(stderr.String()) if errMsg != "" { return "", nil, fmt.Errorf("running molecule: %s", errMsg) } return "", nil, fmt.Errorf("running molecule: %w", err) } // Parse result var molResult struct { RootID string `json:"root_id"` IDMapping map[string]string `json:"id_mapping"` Created int `json:"created"` Assignee string `json:"assignee"` Pinned bool `json:"pinned"` } if err := json.Unmarshal(stdout.Bytes(), &molResult); err != nil { return "", nil, fmt.Errorf("parsing molecule result: %w", err) } fmt.Printf("%s Molecule spawned: %s (%d steps)\n", style.Bold.Render("✓"), molResult.RootID, molResult.Created-1) moleculeCtx := &MoleculeContext{ MoleculeID: thing.ID, RootIssueID: molResult.RootID, TotalSteps: molResult.Created - 1, StepNumber: 1, } return molResult.RootID, moleculeCtx, nil } // spawnMoleculeOnIssue spawns a molecule workflow on an existing issue. func spawnMoleculeOnIssue(beadsPath string, thing *SlingThing, assignee string) (string, *MoleculeContext, error) { fmt.Printf("Running molecule %s on issue %s...\n", thing.Proto, thing.ID) args := []string{"--no-daemon", "mol", "run", thing.Proto, "--var", "issue=" + thing.ID, "--json"} if assignee != "" { args = append(args, "--var", "assignee="+assignee) } cmd := exec.Command("bd", args...) cmd.Dir = beadsPath var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { errMsg := strings.TrimSpace(stderr.String()) if errMsg != "" { return "", nil, fmt.Errorf("running molecule: %s", errMsg) } return "", nil, fmt.Errorf("running molecule: %w", err) } var molResult struct { RootID string `json:"root_id"` IDMapping map[string]string `json:"id_mapping"` Created int `json:"created"` Assignee string `json:"assignee"` Pinned bool `json:"pinned"` } if err := json.Unmarshal(stdout.Bytes(), &molResult); err != nil { return "", nil, fmt.Errorf("parsing molecule result: %w", err) } fmt.Printf("%s Molecule %s applied to %s (%d steps)\n", style.Bold.Render("✓"), thing.Proto, thing.ID, molResult.Created-1) moleculeCtx := &MoleculeContext{ MoleculeID: thing.Proto, RootIssueID: molResult.RootID, TotalSteps: molResult.Created - 1, StepNumber: 1, } return molResult.RootID, moleculeCtx, nil } // checkHookCollision checks if the agent's hook already has work. func checkHookCollision(agentAddress, beadsPath string) error { // Parse agent address to get the role for handoff bead lookup parts := strings.Split(agentAddress, "/") var role string if len(parts) >= 2 { role = parts[len(parts)-1] // Last part is the name/role } else { role = parts[0] } b := beads.New(beadsPath) handoff, err := b.FindHandoffBead(role) if err != nil { // Can't check, assume OK return nil } if handoff == nil { // No handoff bead exists, no collision return nil } // Check if there's an attached molecule attachment := beads.ParseAttachmentFields(handoff) if attachment != nil && attachment.AttachedMolecule != "" { return fmt.Errorf("hook already occupied by %s\nUse --force to re-sling", attachment.AttachedMolecule) } return nil } // pinToHook pins work to an agent's hook by updating their handoff bead. func pinToHook(beadsPath, agentAddress, issueID string, moleculeCtx *MoleculeContext) error { // Parse agent address to get the role parts := strings.Split(agentAddress, "/") var role string if len(parts) >= 2 { role = parts[len(parts)-1] } else { role = parts[0] } b := beads.New(beadsPath) // Get or create handoff bead handoff, err := b.GetOrCreateHandoffBead(role) if err != nil { return fmt.Errorf("getting handoff bead: %w", err) } // Determine what to attach attachedMolecule := issueID if moleculeCtx != nil && moleculeCtx.RootIssueID != "" { attachedMolecule = moleculeCtx.RootIssueID } // Attach molecule to handoff bead _, err = b.AttachMolecule(handoff.ID, attachedMolecule) if err != nil { return fmt.Errorf("attaching molecule: %w", err) } return nil }