Files
gastown/internal/cmd/dog.go
dennis 7ff87ff012 docs: improve help text and add nudge documentation
Polish help text across all agent commands to clarify roles:
- crew: persistent workspaces vs ephemeral polecats
- deacon: town-level watchdog receiving heartbeats
- dog: cross-rig infrastructure workers (cats vs dogs)
- mayor: Chief of Staff for cross-rig coordination
- nudge: universal synchronous messaging API
- polecat: ephemeral one-task workers, self-cleaning
- refinery: merge queue serializer per rig
- witness: per-rig polecat health monitor

Add comprehensive gt nudge documentation to crew template explaining
when to use nudge vs mail, common patterns, and target shortcuts.

Add orphan-process-cleanup step to deacon patrol formula to clean up
claude subagent processes that fail to exit (TTY = "?").

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 02:55:39 -08:00

859 lines
22 KiB
Go

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 <name>",
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 <name>... | --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 <name>",
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,
}
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)
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 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 <name>' 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)
}
// 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()
}