package cmd import ( "encoding/json" "fmt" "os" "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/dog" "github.com/steveyegge/gastown/internal/mail" "github.com/steveyegge/gastown/internal/plugin" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/workspace" ) // Dog command flags var ( dogListJSON bool dogStatusJSON bool dogForce bool dogRemoveAll bool dogCallAll bool // Dispatch flags dogDispatchPlugin string dogDispatchRig string dogDispatchCreate bool dogDispatchDog string dogDispatchJSON bool dogDispatchDryRun bool ) var dogCmd = &cobra.Command{ Use: "dog", Aliases: []string{"dogs"}, GroupID: GroupAgents, Short: "Manage dogs (cross-rig infrastructure workers)", Long: `Manage dogs - reusable workers for infrastructure and cleanup. CATS VS DOGS: Polecats (cats) build features. One rig. Ephemeral (one task, then nuked). Dogs clean up messes. Cross-rig. Reusable (multiple tasks, eventually recycled). Dogs are managed by the Deacon for town-level work: - Infrastructure tasks (rebuilding, syncing, migrations) - Cleanup operations (orphan branches, stale files) - Cross-rig work that spans multiple projects Each dog has worktrees into every configured rig, enabling cross-project operations. Dogs return to idle state after completing work (unlike cats). The kennel is at ~/gt/deacon/dogs/. The Deacon dispatches work to dogs.`, } var dogAddCmd = &cobra.Command{ Use: "add ", Short: "Create a new dog in the kennel", Long: `Create a new dog in the kennel with multi-rig worktrees. Each dog gets a worktree per configured rig (e.g., gastown, beads). The dog starts in idle state, ready to receive work from the Deacon. Example: gt dog add alpha gt dog add bravo`, Args: cobra.ExactArgs(1), RunE: runDogAdd, } var dogRemoveCmd = &cobra.Command{ Use: "remove ... | --all", Short: "Remove dogs from the kennel", Long: `Remove one or more dogs from the kennel. Removes all worktrees and the dog directory. Use --force to remove even if dog is in working state. Examples: gt dog remove alpha gt dog remove alpha bravo gt dog remove --all gt dog remove alpha --force`, Args: func(cmd *cobra.Command, args []string) error { if dogRemoveAll { return nil } if len(args) < 1 { return fmt.Errorf("requires at least 1 dog name (or use --all)") } return nil }, RunE: runDogRemove, } var dogListCmd = &cobra.Command{ Use: "list", Aliases: []string{"ls"}, Short: "List all dogs in the kennel", Long: `List all dogs in the kennel with their status. Shows each dog's state (idle/working), current work assignment, and last active timestamp. Examples: gt dog list gt dog list --json`, RunE: runDogList, } var dogCallCmd = &cobra.Command{ Use: "call [name]", Short: "Wake idle dog(s) for work", Long: `Wake an idle dog to prepare for work. With a name, wakes the specific dog. With --all, wakes all idle dogs. Without arguments, wakes one idle dog (if available). This updates the dog's last-active timestamp and can trigger session creation for the dog's worktrees. Examples: gt dog call alpha gt dog call --all gt dog call`, RunE: runDogCall, } var dogStatusCmd = &cobra.Command{ Use: "status [name]", Short: "Show detailed dog status", Long: `Show detailed status for a specific dog or summary for all dogs. With a name, shows detailed info including: - State (idle/working) - Current work assignment - Worktree paths per rig - Last active timestamp Without a name, shows pack summary: - Total dogs - Idle/working counts - Pack health Examples: gt dog status alpha gt dog status gt dog status --json`, RunE: runDogStatus, } var dogDispatchCmd = &cobra.Command{ Use: "dispatch --plugin ", Short: "Dispatch plugin execution to a dog", Long: `Dispatch a plugin for execution by a dog worker. This is the formalized command for sending plugin work to dogs. The Deacon uses this during patrol cycles to dispatch plugins with open gates. The command: 1. Finds the plugin definition (plugin.md) 2. Assigns work to an idle dog (marks as working) 3. Sends mail with plugin instructions to the dog 4. Returns immediately (non-blocking) The dog discovers the work via its mail inbox and executes the plugin instructions. On completion, the dog sends DOG_DONE mail to deacon/. Examples: gt dog dispatch --plugin rebuild-gt gt dog dispatch --plugin rebuild-gt --rig gastown gt dog dispatch --plugin rebuild-gt --dog alpha gt dog dispatch --plugin rebuild-gt --create gt dog dispatch --plugin rebuild-gt --dry-run gt dog dispatch --plugin rebuild-gt --json`, RunE: runDogDispatch, } var dogDoneCmd = &cobra.Command{ Use: "done [name]", Short: "Mark a dog as idle (work complete)", Long: `Mark a dog as idle after completing its work. Dogs call this command after finishing plugin execution to reset their state to idle, allowing them to receive new work dispatches. If no name is provided, attempts to detect the current dog from BD_ACTOR. Examples: gt dog done alpha # Explicit dog name gt dog done # Auto-detect from BD_ACTOR (e.g., "deacon/dogs/alpha")`, RunE: runDogDone, } func init() { // List flags dogListCmd.Flags().BoolVar(&dogListJSON, "json", false, "Output as JSON") // Remove flags dogRemoveCmd.Flags().BoolVarP(&dogForce, "force", "f", false, "Force removal even if working") dogRemoveCmd.Flags().BoolVar(&dogRemoveAll, "all", false, "Remove all dogs") // Call flags dogCallCmd.Flags().BoolVar(&dogCallAll, "all", false, "Wake all idle dogs") // Status flags dogStatusCmd.Flags().BoolVar(&dogStatusJSON, "json", false, "Output as JSON") // Dispatch flags dogDispatchCmd.Flags().StringVar(&dogDispatchPlugin, "plugin", "", "Plugin name to dispatch (required)") dogDispatchCmd.Flags().StringVar(&dogDispatchRig, "rig", "", "Limit plugin search to specific rig") dogDispatchCmd.Flags().StringVar(&dogDispatchDog, "dog", "", "Dispatch to specific dog (default: any idle)") dogDispatchCmd.Flags().BoolVar(&dogDispatchCreate, "create", false, "Create a dog if none idle") dogDispatchCmd.Flags().BoolVar(&dogDispatchJSON, "json", false, "Output as JSON") dogDispatchCmd.Flags().BoolVarP(&dogDispatchDryRun, "dry-run", "n", false, "Show what would be done without doing it") _ = dogDispatchCmd.MarkFlagRequired("plugin") // Add subcommands dogCmd.AddCommand(dogAddCmd) dogCmd.AddCommand(dogRemoveCmd) dogCmd.AddCommand(dogListCmd) dogCmd.AddCommand(dogCallCmd) dogCmd.AddCommand(dogStatusCmd) dogCmd.AddCommand(dogDispatchCmd) dogCmd.AddCommand(dogDoneCmd) rootCmd.AddCommand(dogCmd) } // getDogManager creates a dog.Manager with the current town root. func getDogManager() (*dog.Manager, 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) } return dog.NewManager(townRoot, rigsConfig), nil } func runDogAdd(cmd *cobra.Command, args []string) error { name := args[0] // Validate name if strings.ContainsAny(name, "/\\. ") { return fmt.Errorf("dog name cannot contain /, \\, ., or spaces") } mgr, err := getDogManager() if err != nil { return err } d, err := mgr.Add(name) if err != nil { return fmt.Errorf("adding dog %s: %w", name, err) } fmt.Printf("✓ Created dog %s in kennel\n", style.Bold.Render(name)) fmt.Printf(" Path: %s\n", d.Path) fmt.Printf(" Worktrees:\n") for rigName, path := range d.Worktrees { fmt.Printf(" %s: %s\n", rigName, path) } // Create agent bead for the dog townRoot, _ := workspace.FindFromCwd() if townRoot != "" { b := beads.New(townRoot) location := filepath.Join("deacon", "dogs", name) issue, err := b.CreateDogAgentBead(name, location) if err != nil { // Non-fatal: warn but don't fail dog creation fmt.Printf(" Warning: could not create agent bead: %v\n", err) } else { fmt.Printf(" Agent bead: %s\n", issue.ID) } } return nil } func runDogRemove(cmd *cobra.Command, args []string) error { mgr, err := getDogManager() if err != nil { return err } var names []string if dogRemoveAll { dogs, err := mgr.List() if err != nil { return fmt.Errorf("listing dogs: %w", err) } for _, d := range dogs { names = append(names, d.Name) } if len(names) == 0 { fmt.Println("No dogs in kennel") return nil } } else { names = args } // Get beads client for cleanup townRoot, _ := workspace.FindFromCwd() var b *beads.Beads if townRoot != "" { b = beads.New(townRoot) } for _, name := range names { d, err := mgr.Get(name) if err != nil { fmt.Printf("Warning: dog %s not found, skipping\n", name) continue } // Check if working if d.State == dog.StateWorking && !dogForce { return fmt.Errorf("dog %s is working (use --force to remove anyway)", name) } if err := mgr.Remove(name); err != nil { return fmt.Errorf("removing dog %s: %w", name, err) } fmt.Printf("✓ Removed dog %s\n", name) // Delete agent bead for the dog if b != nil { if err := b.DeleteDogAgentBead(name); err != nil { // Non-fatal: warn but don't fail dog removal fmt.Printf(" Warning: could not delete agent bead: %v\n", err) } } } return nil } func runDogList(cmd *cobra.Command, args []string) error { mgr, err := getDogManager() if err != nil { return err } dogs, err := mgr.List() if err != nil { return fmt.Errorf("listing dogs: %w", err) } if len(dogs) == 0 { if dogListJSON { fmt.Println("[]") } else { fmt.Println("No dogs in kennel") } return nil } if dogListJSON { type DogListItem struct { Name string `json:"name"` State dog.State `json:"state"` Work string `json:"work,omitempty"` LastActive time.Time `json:"last_active"` Worktrees map[string]string `json:"worktrees,omitempty"` } var items []DogListItem for _, d := range dogs { items = append(items, DogListItem{ Name: d.Name, State: d.State, Work: d.Work, LastActive: d.LastActive, Worktrees: d.Worktrees, }) } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(items) } // Pretty print fmt.Println(style.Bold.Render("The Pack")) fmt.Println() idleCount := 0 workingCount := 0 for _, d := range dogs { stateIcon := "○" stateStyle := style.Dim if d.State == dog.StateWorking { stateIcon = "●" stateStyle = style.Bold workingCount++ } else { idleCount++ } line := fmt.Sprintf(" %s %s", stateIcon, stateStyle.Render(d.Name)) if d.Work != "" { line += fmt.Sprintf(" → %s", style.Dim.Render(d.Work)) } fmt.Println(line) } fmt.Println() fmt.Printf(" %d idle, %d working\n", idleCount, workingCount) return nil } func runDogCall(cmd *cobra.Command, args []string) error { mgr, err := getDogManager() if err != nil { return err } if dogCallAll { // Wake all idle dogs dogs, err := mgr.List() if err != nil { return fmt.Errorf("listing dogs: %w", err) } woken := 0 for _, d := range dogs { if d.State == dog.StateIdle { if err := mgr.SetState(d.Name, dog.StateIdle); err != nil { fmt.Printf("Warning: failed to wake %s: %v\n", d.Name, err) continue } woken++ fmt.Printf("✓ Called %s\n", d.Name) } } if woken == 0 { fmt.Println("No idle dogs to call") } else { fmt.Printf("\n%d dog(s) ready\n", woken) } return nil } if len(args) > 0 { // Wake specific dog name := args[0] d, err := mgr.Get(name) if err != nil { return fmt.Errorf("getting dog %s: %w", name, err) } if d.State == dog.StateWorking { fmt.Printf("Dog %s is already working\n", name) return nil } if err := mgr.SetState(name, dog.StateIdle); err != nil { return fmt.Errorf("waking dog %s: %w", name, err) } fmt.Printf("✓ Called %s - ready for work\n", name) return nil } // Wake one idle dog d, err := mgr.GetIdleDog() if err != nil { return fmt.Errorf("getting idle dog: %w", err) } if d == nil { fmt.Println("No idle dogs available") return nil } if err := mgr.SetState(d.Name, dog.StateIdle); err != nil { return fmt.Errorf("waking dog %s: %w", d.Name, err) } fmt.Printf("✓ Called %s - ready for work\n", d.Name) return nil } func runDogStatus(cmd *cobra.Command, args []string) error { mgr, err := getDogManager() if err != nil { return err } if len(args) > 0 { // Show specific dog status name := args[0] return showDogStatus(mgr, name) } // Show pack summary return showPackStatus(mgr) } func runDogDone(cmd *cobra.Command, args []string) error { mgr, err := getDogManager() if err != nil { return err } var name string if len(args) > 0 { name = args[0] } else { // Try to detect from BD_ACTOR (e.g., "deacon/dogs/alpha") actor := os.Getenv("BD_ACTOR") if actor != "" && strings.HasPrefix(actor, "deacon/dogs/") { name = strings.TrimPrefix(actor, "deacon/dogs/") } if name == "" { return fmt.Errorf("no dog name provided and could not detect from BD_ACTOR") } } if err := mgr.ClearWork(name); err != nil { return fmt.Errorf("marking dog %s as done: %w", name, err) } fmt.Printf("✓ %s marked as idle (ready for new work)\n", name) return nil } func showDogStatus(mgr *dog.Manager, name string) error { d, err := mgr.Get(name) if err != nil { return fmt.Errorf("getting dog %s: %w", name, err) } if dogStatusJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(d) } fmt.Printf("Dog: %s\n\n", style.Bold.Render(d.Name)) fmt.Printf(" State: %s\n", d.State) if d.Work != "" { fmt.Printf(" Work: %s\n", d.Work) } else { fmt.Printf(" Work: %s\n", style.Dim.Render("(none)")) } fmt.Printf(" Path: %s\n", d.Path) fmt.Printf(" Last Active: %s\n", dogFormatTimeAgo(d.LastActive)) fmt.Printf(" Created: %s\n", d.CreatedAt.Format("2006-01-02 15:04")) if len(d.Worktrees) > 0 { fmt.Println("\nWorktrees:") for rigName, path := range d.Worktrees { // Check if worktree exists exists := "✓" if _, err := os.Stat(path); os.IsNotExist(err) { exists = "✗" } fmt.Printf(" %s %s: %s\n", exists, rigName, path) } } // Check for tmux session townRoot, _ := workspace.FindFromCwd() if townRoot != "" { townName, err := workspace.GetTownName(townRoot) if err == nil { sessionName := fmt.Sprintf("gt-%s-deacon-%s", townName, name) tm := tmux.NewTmux() if has, _ := tm.HasSession(sessionName); has { fmt.Printf("\nSession: %s (running)\n", sessionName) } } } return nil } func showPackStatus(mgr *dog.Manager) error { dogs, err := mgr.List() if err != nil { return fmt.Errorf("listing dogs: %w", err) } if dogStatusJSON { type PackStatus struct { Total int `json:"total"` Idle int `json:"idle"` Working int `json:"working"` KennelDir string `json:"kennel_dir"` } townRoot, _ := workspace.FindFromCwd() status := PackStatus{ Total: len(dogs), KennelDir: filepath.Join(townRoot, "deacon", "dogs"), } for _, d := range dogs { if d.State == dog.StateIdle { status.Idle++ } else { status.Working++ } } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(status) } fmt.Println(style.Bold.Render("Pack Status")) fmt.Println() if len(dogs) == 0 { fmt.Println(" No dogs in kennel") fmt.Println() fmt.Println(" Use 'gt dog add ' to add a dog") return nil } idleCount := 0 workingCount := 0 for _, d := range dogs { if d.State == dog.StateIdle { idleCount++ } else { workingCount++ } } fmt.Printf(" Total: %d\n", len(dogs)) fmt.Printf(" Idle: %d\n", idleCount) fmt.Printf(" Working: %d\n", workingCount) if idleCount > 0 { fmt.Println() fmt.Println(style.Dim.Render(" Ready for work. Use 'gt dog call' to wake.")) } return nil } // dogFormatTimeAgo formats a time as a relative string like "2 hours ago". func dogFormatTimeAgo(t time.Time) string { if t.IsZero() { return "(unknown)" } d := time.Since(t) switch { case d < time.Minute: return "just now" case d < time.Hour: mins := int(d.Minutes()) if mins == 1 { return "1 minute ago" } return fmt.Sprintf("%d minutes ago", mins) case d < 24*time.Hour: hours := int(d.Hours()) if hours == 1 { return "1 hour ago" } return fmt.Sprintf("%d hours ago", hours) default: days := int(d.Hours() / 24) if days == 1 { return "1 day ago" } return fmt.Sprintf("%d days ago", days) } } // runDogDispatch dispatches plugin execution to a dog worker. func runDogDispatch(cmd *cobra.Command, args []string) error { townRoot, err := workspace.FindFromCwd() if err != nil { return fmt.Errorf("finding town root: %w", err) } // Get rig names for plugin scanner rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) if err != nil { return fmt.Errorf("loading rigs config: %w", err) } var rigNames []string for rigName := range rigsConfig.Rigs { rigNames = append(rigNames, rigName) } // If --rig specified, search only that rig if dogDispatchRig != "" { rigNames = []string{dogDispatchRig} } // Find the plugin using scanner scanner := plugin.NewScanner(townRoot, rigNames) p, err := scanner.GetPlugin(dogDispatchPlugin) if err != nil { return fmt.Errorf("finding plugin: %w", err) } // Get dog manager (reuse rigsConfig from above) mgr := dog.NewManager(townRoot, rigsConfig) // Find target dog var targetDog *dog.Dog var dogCreated bool if dogDispatchDog != "" { // Specific dog requested targetDog, err = mgr.Get(dogDispatchDog) if err != nil { return fmt.Errorf("getting dog %s: %w", dogDispatchDog, err) } if targetDog.State == dog.StateWorking { return fmt.Errorf("dog %s is already working", dogDispatchDog) } } else { // Find idle dog from pool targetDog, err = mgr.GetIdleDog() if err != nil { return fmt.Errorf("finding idle dog: %w", err) } if targetDog == nil { if dogDispatchCreate { // Create a new dog (reuse generateDogName from sling_dog.go) newName := generateDogName(mgr) if dogDispatchDryRun { targetDog = &dog.Dog{Name: newName, State: dog.StateIdle} dogCreated = true } else { targetDog, err = mgr.Add(newName) if err != nil { return fmt.Errorf("creating dog %s: %w", newName, err) } dogCreated = true // Create agent bead for the dog b := beads.New(townRoot) location := filepath.Join("deacon", "dogs", newName) if _, beadErr := b.CreateDogAgentBead(newName, location); beadErr != nil { // Non-fatal warning if !dogDispatchJSON { fmt.Printf(" Warning: could not create agent bead: %v\n", beadErr) } } } } else { return fmt.Errorf("no idle dogs available (use --create to add one)") } } } // Prepare dispatch result for JSON output workDesc := fmt.Sprintf("plugin:%s", p.Name) result := dogDispatchResult{ Plugin: p.Name, PluginPath: p.Path, Dog: targetDog.Name, DogCreated: dogCreated, Work: workDesc, DryRun: dogDispatchDryRun, } if p.RigName != "" { result.PluginRig = p.RigName } // Dry-run mode: show what would happen and exit if dogDispatchDryRun { if dogDispatchJSON { return json.NewEncoder(os.Stdout).Encode(result) } fmt.Printf("Dry run - would dispatch:\n") fmt.Printf(" Plugin: %s\n", p.Name) if p.RigName != "" { fmt.Printf(" Location: %s/plugins/%s\n", p.RigName, p.Name) } else { fmt.Printf(" Location: plugins/%s (town-level)\n", p.Name) } fmt.Printf(" Dog: %s%s\n", targetDog.Name, ifStr(dogCreated, " (would create)", "")) fmt.Printf(" Work: %s\n", workDesc) return nil } // Assign work FIRST (before sending mail) to prevent race condition // If this fails, we haven't sent any mail yet if err := mgr.AssignWork(targetDog.Name, workDesc); err != nil { return fmt.Errorf("assigning work to dog: %w", err) } // Create and send mail message with plugin instructions dogAddress := fmt.Sprintf("deacon/dogs/%s", targetDog.Name) subject := fmt.Sprintf("Plugin: %s", p.Name) body := formatPluginMailBody(p) router := mail.NewRouterWithTownRoot(townRoot, townRoot) msg := &mail.Message{ From: "deacon/", To: dogAddress, Subject: subject, Body: body, Timestamp: time.Now(), } if err := router.Send(msg); err != nil { // Rollback: clear work assignment since mail failed if clearErr := mgr.ClearWork(targetDog.Name); clearErr != nil { // Log rollback failure but return original error if !dogDispatchJSON { fmt.Printf(" Warning: rollback failed: %v\n", clearErr) } } return fmt.Errorf("sending plugin mail to dog: %w", err) } // Spawn a session for the dog to execute the work. // Without a session, the dog's mail inbox is never checked. // See: https://github.com/steveyegge/gastown/issues/XXX (dog dispatch doesn't execute) t := tmux.NewTmux() townName, err := workspace.GetTownName(townRoot) if err != nil { townName = "gt" // fallback } dogSessionName := fmt.Sprintf("gt-%s-deacon-%s", townName, targetDog.Name) // Kill any stale session first if has, _ := t.HasSession(dogSessionName); has { _ = t.KillSessionWithProcesses(dogSessionName) } // Build startup command with initial prompt to check mail and execute plugin // Use BuildDogStartupCommand to properly set BD_ACTOR=deacon/dogs/ in the startup command initialPrompt := fmt.Sprintf("I am dog %s. Check my mail inbox with 'gt mail inbox' and execute the plugin instructions I received.", targetDog.Name) startCmd := config.BuildDogStartupCommand(targetDog.Name, townRoot, targetDog.Path, initialPrompt) // Create session from dog's directory if err := t.NewSessionWithCommand(dogSessionName, targetDog.Path, startCmd); err != nil { if !dogDispatchJSON { fmt.Printf(" Warning: could not spawn dog session: %v\n", err) } // Non-fatal: mail was sent, dog is marked as working, but no session to execute // The deacon or human can manually start the session later } // Success - output result if dogDispatchJSON { return json.NewEncoder(os.Stdout).Encode(result) } fmt.Printf("%s Found plugin: %s\n", style.Bold.Render("✓"), p.Name) if p.RigName != "" { fmt.Printf(" Location: %s/plugins/%s\n", p.RigName, p.Name) } else { fmt.Printf(" Location: plugins/%s (town-level)\n", p.Name) } if dogCreated { fmt.Printf("%s Created dog %s (pool was empty)\n", style.Bold.Render("✓"), targetDog.Name) } fmt.Printf("%s Dispatching to dog: %s\n", style.Bold.Render("🐕"), targetDog.Name) fmt.Printf("%s Plugin dispatched (non-blocking)\n", style.Bold.Render("✓")) fmt.Printf(" Dog: %s\n", targetDog.Name) fmt.Printf(" Work: %s\n", workDesc) return nil } // dogDispatchResult is the JSON output for gt dog dispatch. type dogDispatchResult struct { Plugin string `json:"plugin"` PluginRig string `json:"plugin_rig,omitempty"` PluginPath string `json:"plugin_path"` Dog string `json:"dog"` DogCreated bool `json:"dog_created,omitempty"` Work string `json:"work"` DryRun bool `json:"dry_run,omitempty"` } // ifStr returns ifTrue if cond is true, otherwise ifFalse. func ifStr(cond bool, ifTrue, ifFalse string) string { if cond { return ifTrue } return ifFalse } // formatPluginMailBody formats the plugin as instructions for the dog. func formatPluginMailBody(p *plugin.Plugin) string { var sb strings.Builder sb.WriteString("Execute the following plugin:\n\n") sb.WriteString(fmt.Sprintf("**Plugin**: %s\n", p.Name)) sb.WriteString(fmt.Sprintf("**Description**: %s\n", p.Description)) if p.RigName != "" { sb.WriteString(fmt.Sprintf("**Rig**: %s\n", p.RigName)) } if p.Execution != nil && p.Execution.Timeout != "" { sb.WriteString(fmt.Sprintf("**Timeout**: %s\n", p.Execution.Timeout)) } sb.WriteString("\n---\n\n") sb.WriteString("## Instructions\n\n") sb.WriteString(p.Instructions) sb.WriteString("\n\n---\n\n") sb.WriteString("After completion:\n") sb.WriteString("1. Create a wisp to record the result (success/failure)\n") sb.WriteString("2. Send DOG_DONE mail to deacon/\n") sb.WriteString("3. Return to idle state\n") return sb.String() }