Files
gastown/internal/cmd/dog.go
markov-kernel e7145cfd77 fix: Make Mayor/Deacon session names include town name
Session names `gt-mayor` and `gt-deacon` were hardcoded, causing tmux
session name collisions when running multiple towns simultaneously.

Changed to `gt-{town}-mayor` and `gt-{town}-deacon` format (e.g.,
`gt-ai-mayor`) to allow concurrent multi-town operation.

Key changes:
- session.MayorSessionName() and DeaconSessionName() now take townName param
- Added workspace.GetTownName() helper to load town name from config
- Updated all callers in cmd/, daemon/, doctor/, mail/, rig/, templates/
- Updated tests with new session name format
- Bead IDs remain unchanged (already scoped by .beads/ directory)

Fixes #60

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:37:05 +01:00

593 lines
13 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/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
)
var dogCmd = &cobra.Command{
Use: "dog",
Aliases: []string{"dogs"},
GroupID: GroupAgents,
Short: "Manage dogs (Deacon's helper workers)",
Long: `Manage dogs in the kennel.
Dogs are reusable helper workers managed by the Deacon for infrastructure
and cleanup tasks. Unlike polecats (single-rig, ephemeral), dogs handle
cross-rig infrastructure work with worktrees into each rig.
The kennel is located at ~/gt/deacon/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,
}
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")
// Add subcommands
dogCmd.AddCommand(dogAddCmd)
dogCmd.AddCommand(dogRemoveCmd)
dogCmd.AddCommand(dogListCmd)
dogCmd.AddCommand(dogCallCmd)
dogCmd.AddCommand(dogStatusCmd)
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)
}
}