diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go new file mode 100644 index 00000000..73ffdf89 --- /dev/null +++ b/internal/cmd/sling.go @@ -0,0 +1,725 @@ +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 +}