Merge main, keeping main's manager.go and our FailureType tests

This commit is contained in:
Steve Yegge
2025-12-19 16:26:38 -08:00
82 changed files with 13666 additions and 1172 deletions

View File

@@ -7,6 +7,8 @@ import (
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
@@ -47,6 +49,7 @@ Commands:
gt crew at <name> Attach to crew workspace session
gt crew remove <name> Remove a crew workspace
gt crew refresh <name> Context cycling with mail-to-self handoff
gt crew restart <name> Kill and restart session fresh (alias: rs)
gt crew status [<name>] Show detailed workspace status`,
}
@@ -150,6 +153,57 @@ Examples:
RunE: runCrewStatus,
}
var crewRestartCmd = &cobra.Command{
Use: "restart <name>",
Aliases: []string{"rs"},
Short: "Kill and restart crew workspace session",
Long: `Kill the tmux session and restart fresh with Claude.
Useful when a crew member gets confused or needs a clean slate.
Unlike 'refresh', this does NOT send handoff mail - it's a clean start.
The command will:
1. Kill existing tmux session if running
2. Start fresh session with Claude
3. Run gt prime to reinitialize context
Examples:
gt crew restart dave # Restart dave's session
gt crew rs emma # Same, using alias`,
Args: cobra.ExactArgs(1),
RunE: runCrewRestart,
}
var crewRenameCmd = &cobra.Command{
Use: "rename <old-name> <new-name>",
Short: "Rename a crew workspace",
Long: `Rename a crew workspace.
Kills any running session, renames the directory, and updates state.
The new session will use the new name (gt-<rig>-crew-<new-name>).
Examples:
gt crew rename dave david # Rename dave to david
gt crew rename madmax max # Rename madmax to max`,
Args: cobra.ExactArgs(2),
RunE: runCrewRename,
}
var crewPristineCmd = &cobra.Command{
Use: "pristine [<name>]",
Short: "Sync crew workspaces with remote",
Long: `Ensure crew workspace(s) are up-to-date.
Runs git pull and bd sync for the specified crew, or all crew workers.
Reports any uncommitted changes that may need attention.
Examples:
gt crew pristine # Pristine all crew workers
gt crew pristine dave # Pristine specific worker
gt crew pristine --json # JSON output`,
RunE: runCrewPristine,
}
func init() {
// Add flags
crewAddCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to create crew workspace in")
@@ -170,6 +224,13 @@ func init() {
crewStatusCmd.Flags().StringVar(&crewRig, "rig", "", "Filter by rig name")
crewStatusCmd.Flags().BoolVar(&crewJSON, "json", false, "Output as JSON")
crewRenameCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use")
crewPristineCmd.Flags().StringVar(&crewRig, "rig", "", "Filter by rig name")
crewPristineCmd.Flags().BoolVar(&crewJSON, "json", false, "Output as JSON")
crewRestartCmd.Flags().StringVar(&crewRig, "rig", "", "Rig to use")
// Add subcommands
crewCmd.AddCommand(crewAddCmd)
crewCmd.AddCommand(crewListCmd)
@@ -177,6 +238,9 @@ func init() {
crewCmd.AddCommand(crewRemoveCmd)
crewCmd.AddCommand(crewRefreshCmd)
crewCmd.AddCommand(crewStatusCmd)
crewCmd.AddCommand(crewRenameCmd)
crewCmd.AddCommand(crewPristineCmd)
crewCmd.AddCommand(crewRestartCmd)
rootCmd.AddCommand(crewCmd)
}
@@ -254,19 +318,12 @@ func inferRigFromCwd(townRoot string) (string, error) {
return "", fmt.Errorf("not in workspace")
}
// First component should be the rig name
parts := filepath.SplitList(rel)
if len(parts) == 0 {
// Split on path separator instead
for i := 0; i < len(rel); i++ {
if rel[i] == filepath.Separator {
return rel[:i], nil
}
}
// No separator found, entire rel is the rig name
if rel != "" && rel != "." {
return rel, nil
}
// Normalize and split path - first component is the rig name
rel = filepath.ToSlash(rel)
parts := strings.Split(rel, "/")
if len(parts) > 0 && parts[0] != "" && parts[0] != "." {
return parts[0], nil
}
return "", fmt.Errorf("could not infer rig from current directory")
@@ -446,29 +503,114 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
}
// Set environment
t.SetEnvironment(sessionID, "GT_RIG", r.Name)
t.SetEnvironment(sessionID, "GT_CREW", name)
_ = t.SetEnvironment(sessionID, "GT_RIG", r.Name)
_ = t.SetEnvironment(sessionID, "GT_CREW", name)
// Wait for shell to be ready after session creation
if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil {
return fmt.Errorf("waiting for shell: %w", err)
}
// Start claude with skip permissions (crew workers are trusted like Mayor)
if err := t.SendKeys(sessionID, "claude --dangerously-skip-permissions"); err != nil {
return fmt.Errorf("starting claude: %w", err)
}
// Wait a moment for Claude to initialize, then prime it
// We send gt prime after a short delay to ensure Claude is ready
if err := t.SendKeysDelayed(sessionID, "gt prime", 2000); err != nil {
// Wait for Claude to start (pane command changes from shell to node)
shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"}
if err := t.WaitForCommand(sessionID, shells, 15*time.Second); err != nil {
fmt.Printf("Warning: Timeout waiting for Claude to start: %v\n", err)
}
// Send gt prime to initialize context
if err := t.SendKeys(sessionID, "gt prime"); err != nil {
// Non-fatal: Claude started but priming failed
fmt.Printf("Warning: Could not send prime command: %v\n", err)
}
fmt.Printf("%s Created session for %s/%s\n",
style.Bold.Render("✓"), r.Name, name)
} else {
// Session exists - check if Claude is still running
// Uses both pane command check and UI marker detection to avoid
// restarting when user is in a subshell spawned from Claude
if !t.IsClaudeRunning(sessionID) {
// Claude has exited, restart it
fmt.Printf("Claude exited, restarting...\n")
if err := t.SendKeys(sessionID, "claude --dangerously-skip-permissions"); err != nil {
return fmt.Errorf("restarting claude: %w", err)
}
// Wait for Claude to start, then prime
shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"}
if err := t.WaitForCommand(sessionID, shells, 15*time.Second); err != nil {
fmt.Printf("Warning: Timeout waiting for Claude to start: %v\n", err)
}
if err := t.SendKeys(sessionID, "gt prime"); err != nil {
fmt.Printf("Warning: Could not send prime command: %v\n", err)
}
// Send crew resume prompt after prime completes
crewPrompt := "Read your mail, act on anything urgent, else await instructions."
if err := t.SendKeysDelayed(sessionID, crewPrompt, 3000); err != nil {
fmt.Printf("Warning: Could not send resume prompt: %v\n", err)
}
}
}
// Check if we're already in the target session
if isInTmuxSession(sessionID) {
// We're in the session at a shell prompt - just start Claude directly
fmt.Printf("Starting Claude in current session...\n")
return execClaude()
}
// Attach to session using exec to properly forward TTY
return attachToTmuxSession(sessionID)
}
// isShellCommand checks if the command is a shell (meaning Claude has exited).
func isShellCommand(cmd string) bool {
shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"}
for _, shell := range shells {
if cmd == shell {
return true
}
}
return false
}
// execClaude execs claude, replacing the current process.
// Used when we're already in the target session and just need to start Claude.
func execClaude() error {
claudePath, err := exec.LookPath("claude")
if err != nil {
return fmt.Errorf("claude not found: %w", err)
}
// exec replaces current process with claude
args := []string{"claude", "--dangerously-skip-permissions"}
return syscall.Exec(claudePath, args, os.Environ())
}
// isInTmuxSession checks if we're currently inside the target tmux session.
func isInTmuxSession(targetSession string) bool {
// TMUX env var format: /tmp/tmux-501/default,12345,0
// We need to get the current session name via tmux display-message
tmuxEnv := os.Getenv("TMUX")
if tmuxEnv == "" {
return false // Not in tmux at all
}
// Get current session name
cmd := exec.Command("tmux", "display-message", "-p", "#{session_name}")
out, err := cmd.Output()
if err != nil {
return false
}
currentSession := strings.TrimSpace(string(out))
return currentSession == targetSession
}
// attachToTmuxSession attaches to a tmux session with proper TTY forwarding.
func attachToTmuxSession(sessionID string) error {
tmuxPath, err := exec.LookPath("tmux")
@@ -642,10 +784,15 @@ func runCrewRefresh(cmd *cobra.Command, args []string) error {
}
// Set environment
t.SetEnvironment(sessionID, "GT_RIG", r.Name)
t.SetEnvironment(sessionID, "GT_CREW", name)
_ = t.SetEnvironment(sessionID, "GT_RIG", r.Name)
_ = t.SetEnvironment(sessionID, "GT_CREW", name)
// Start claude
// Wait for shell to be ready
if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil {
return fmt.Errorf("waiting for shell: %w", err)
}
// Start claude (refresh uses regular permissions, reads handoff mail)
if err := t.SendKeys(sessionID, "claude"); err != nil {
return fmt.Errorf("starting claude: %w", err)
}
@@ -657,6 +804,76 @@ func runCrewRefresh(cmd *cobra.Command, args []string) error {
return nil
}
func runCrewRestart(cmd *cobra.Command, args []string) error {
name := args[0]
crewMgr, r, err := getCrewManager(crewRig)
if err != nil {
return err
}
// Get the crew worker
worker, err := crewMgr.Get(name)
if err != nil {
if err == crew.ErrCrewNotFound {
return fmt.Errorf("crew workspace '%s' not found", name)
}
return fmt.Errorf("getting crew worker: %w", err)
}
t := tmux.NewTmux()
sessionID := crewSessionName(r.Name, name)
// Kill existing session if running
if hasSession, _ := t.HasSession(sessionID); hasSession {
if err := t.KillSession(sessionID); err != nil {
return fmt.Errorf("killing old session: %w", err)
}
fmt.Printf("Killed session %s\n", sessionID)
}
// Start new session
if err := t.NewSession(sessionID, worker.ClonePath); err != nil {
return fmt.Errorf("creating session: %w", err)
}
// Set environment
t.SetEnvironment(sessionID, "GT_RIG", r.Name)
t.SetEnvironment(sessionID, "GT_CREW", name)
// Wait for shell to be ready
if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil {
return fmt.Errorf("waiting for shell: %w", err)
}
// Start claude with skip permissions (crew workers are trusted)
if err := t.SendKeys(sessionID, "claude --dangerously-skip-permissions"); err != nil {
return fmt.Errorf("starting claude: %w", err)
}
// Wait for Claude to start, then prime it
shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"}
if err := t.WaitForCommand(sessionID, shells, 15*time.Second); err != nil {
fmt.Printf("Warning: Timeout waiting for Claude to start: %v\n", err)
}
if err := t.SendKeys(sessionID, "gt prime"); err != nil {
// Non-fatal: Claude started but priming failed
fmt.Printf("Warning: Could not send prime command: %v\n", err)
}
// Send crew resume prompt after prime completes
crewPrompt := "Read your mail, act on anything urgent, else await instructions."
if err := t.SendKeysDelayed(sessionID, crewPrompt, 3000); err != nil {
fmt.Printf("Warning: Could not send resume prompt: %v\n", err)
}
fmt.Printf("%s Restarted crew workspace: %s/%s\n",
style.Bold.Render("✓"), r.Name, name)
fmt.Printf("Attach with: %s\n", style.Dim.Render(fmt.Sprintf("gt crew at %s", name)))
return nil
}
// CrewStatusItem represents detailed status for a crew worker.
type CrewStatusItem struct {
Name string `json:"name"`
@@ -794,3 +1011,112 @@ func runCrewStatus(cmd *cobra.Command, args []string) error {
return nil
}
func runCrewRename(cmd *cobra.Command, args []string) error {
oldName := args[0]
newName := args[1]
crewMgr, r, err := getCrewManager(crewRig)
if err != nil {
return err
}
// Kill any running session for the old name
t := tmux.NewTmux()
oldSessionID := crewSessionName(r.Name, oldName)
if hasSession, _ := t.HasSession(oldSessionID); hasSession {
if err := t.KillSession(oldSessionID); err != nil {
return fmt.Errorf("killing old session: %w", err)
}
fmt.Printf("Killed session %s\n", oldSessionID)
}
// Perform the rename
if err := crewMgr.Rename(oldName, newName); err != nil {
if err == crew.ErrCrewNotFound {
return fmt.Errorf("crew workspace '%s' not found", oldName)
}
if err == crew.ErrCrewExists {
return fmt.Errorf("crew workspace '%s' already exists", newName)
}
return fmt.Errorf("renaming crew workspace: %w", err)
}
fmt.Printf("%s Renamed crew workspace: %s/%s → %s/%s\n",
style.Bold.Render("✓"), r.Name, oldName, r.Name, newName)
fmt.Printf("New session will be: %s\n", style.Dim.Render(crewSessionName(r.Name, newName)))
return nil
}
func runCrewPristine(cmd *cobra.Command, args []string) error {
crewMgr, r, err := getCrewManager(crewRig)
if err != nil {
return err
}
var workers []*crew.CrewWorker
if len(args) > 0 {
// Specific worker
name := args[0]
worker, err := crewMgr.Get(name)
if err != nil {
if err == crew.ErrCrewNotFound {
return fmt.Errorf("crew workspace '%s' not found", name)
}
return fmt.Errorf("getting crew worker: %w", err)
}
workers = []*crew.CrewWorker{worker}
} else {
// All workers
workers, err = crewMgr.List()
if err != nil {
return fmt.Errorf("listing crew workers: %w", err)
}
}
if len(workers) == 0 {
fmt.Println("No crew workspaces found.")
return nil
}
var results []*crew.PristineResult
for _, w := range workers {
result, err := crewMgr.Pristine(w.Name)
if err != nil {
return fmt.Errorf("pristine %s: %w", w.Name, err)
}
results = append(results, result)
}
if crewJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(results)
}
// Text output
for _, result := range results {
fmt.Printf("%s %s/%s\n", style.Bold.Render("→"), r.Name, result.Name)
if result.HadChanges {
fmt.Printf(" %s\n", style.Bold.Render("⚠ Has uncommitted changes"))
}
if result.Pulled {
fmt.Printf(" %s git pull\n", style.Dim.Render("✓"))
} else if result.PullError != "" {
fmt.Printf(" %s git pull: %s\n", style.Bold.Render("✗"), result.PullError)
}
if result.Synced {
fmt.Printf(" %s bd sync\n", style.Dim.Render("✓"))
} else if result.SyncError != "" {
fmt.Printf(" %s bd sync: %s\n", style.Bold.Render("✗"), result.SyncError)
}
}
return nil
}

233
internal/cmd/daemon.go Normal file
View File

@@ -0,0 +1,233 @@
package cmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/daemon"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
var daemonCmd = &cobra.Command{
Use: "daemon",
Short: "Manage the Gas Town daemon",
Long: `Manage the Gas Town background daemon.
The daemon is a simple Go process that:
- Pokes agents periodically (heartbeat)
- Processes lifecycle requests (cycle, restart, shutdown)
- Restarts sessions when agents request cycling
The daemon is a "dumb scheduler" - all intelligence is in agents.`,
}
var daemonStartCmd = &cobra.Command{
Use: "start",
Short: "Start the daemon",
Long: `Start the Gas Town daemon in the background.
The daemon will run until stopped with 'gt daemon stop'.`,
RunE: runDaemonStart,
}
var daemonStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the daemon",
Long: `Stop the running Gas Town daemon.`,
RunE: runDaemonStop,
}
var daemonStatusCmd = &cobra.Command{
Use: "status",
Short: "Show daemon status",
Long: `Show the current status of the Gas Town daemon.`,
RunE: runDaemonStatus,
}
var daemonLogsCmd = &cobra.Command{
Use: "logs",
Short: "View daemon logs",
Long: `View the daemon log file.`,
RunE: runDaemonLogs,
}
var daemonRunCmd = &cobra.Command{
Use: "run",
Short: "Run daemon in foreground (internal)",
Hidden: true,
RunE: runDaemonRun,
}
var (
daemonLogLines int
daemonLogFollow bool
)
func init() {
daemonCmd.AddCommand(daemonStartCmd)
daemonCmd.AddCommand(daemonStopCmd)
daemonCmd.AddCommand(daemonStatusCmd)
daemonCmd.AddCommand(daemonLogsCmd)
daemonCmd.AddCommand(daemonRunCmd)
daemonLogsCmd.Flags().IntVarP(&daemonLogLines, "lines", "n", 50, "Number of lines to show")
daemonLogsCmd.Flags().BoolVarP(&daemonLogFollow, "follow", "f", false, "Follow log output")
rootCmd.AddCommand(daemonCmd)
}
func runDaemonStart(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Check if already running
running, pid, err := daemon.IsRunning(townRoot)
if err != nil {
return fmt.Errorf("checking daemon status: %w", err)
}
if running {
return fmt.Errorf("daemon already running (PID %d)", pid)
}
// Start daemon in background
// We use 'gt daemon run' as the actual daemon process
gtPath, err := os.Executable()
if err != nil {
return fmt.Errorf("finding executable: %w", err)
}
daemonCmd := exec.Command(gtPath, "daemon", "run")
daemonCmd.Dir = townRoot
// Detach from terminal
daemonCmd.Stdin = nil
daemonCmd.Stdout = nil
daemonCmd.Stderr = nil
if err := daemonCmd.Start(); err != nil {
return fmt.Errorf("starting daemon: %w", err)
}
// Wait a moment for the daemon to initialize
time.Sleep(200 * time.Millisecond)
// Verify it started
running, pid, err = daemon.IsRunning(townRoot)
if err != nil {
return fmt.Errorf("checking daemon status: %w", err)
}
if !running {
return fmt.Errorf("daemon failed to start (check logs with 'gt daemon logs')")
}
fmt.Printf("%s Daemon started (PID %d)\n", style.Bold.Render("✓"), pid)
return nil
}
func runDaemonStop(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
running, pid, err := daemon.IsRunning(townRoot)
if err != nil {
return fmt.Errorf("checking daemon status: %w", err)
}
if !running {
return fmt.Errorf("daemon is not running")
}
if err := daemon.StopDaemon(townRoot); err != nil {
return fmt.Errorf("stopping daemon: %w", err)
}
fmt.Printf("%s Daemon stopped (was PID %d)\n", style.Bold.Render("✓"), pid)
return nil
}
func runDaemonStatus(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
running, pid, err := daemon.IsRunning(townRoot)
if err != nil {
return fmt.Errorf("checking daemon status: %w", err)
}
if running {
fmt.Printf("%s Daemon is %s (PID %d)\n",
style.Bold.Render("●"),
style.Bold.Render("running"),
pid)
// Load state for more details
state, err := daemon.LoadState(townRoot)
if err == nil && !state.StartedAt.IsZero() {
fmt.Printf(" Started: %s\n", state.StartedAt.Format("2006-01-02 15:04:05"))
if !state.LastHeartbeat.IsZero() {
fmt.Printf(" Last heartbeat: %s (#%d)\n",
state.LastHeartbeat.Format("15:04:05"),
state.HeartbeatCount)
}
}
} else {
fmt.Printf("%s Daemon is %s\n",
style.Dim.Render("○"),
"not running")
fmt.Printf("\nStart with: %s\n", style.Dim.Render("gt daemon start"))
}
return nil
}
func runDaemonLogs(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
logFile := filepath.Join(townRoot, "daemon", "daemon.log")
if _, err := os.Stat(logFile); os.IsNotExist(err) {
return fmt.Errorf("no log file found at %s", logFile)
}
if daemonLogFollow {
// Use tail -f for following
tailCmd := exec.Command("tail", "-f", logFile)
tailCmd.Stdout = os.Stdout
tailCmd.Stderr = os.Stderr
return tailCmd.Run()
}
// Use tail -n for last N lines
tailCmd := exec.Command("tail", "-n", fmt.Sprintf("%d", daemonLogLines), logFile)
tailCmd.Stdout = os.Stdout
tailCmd.Stderr = os.Stderr
return tailCmd.Run()
}
func runDaemonRun(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
config := daemon.DefaultConfig(townRoot)
d, err := daemon.New(config)
if err != nil {
return fmt.Errorf("creating daemon: %w", err)
}
return d.Run()
}

274
internal/cmd/gitinit.go Normal file
View File

@@ -0,0 +1,274 @@
package cmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
var (
gitInitGitHub string
gitInitPrivate bool
)
var gitInitCmd = &cobra.Command{
Use: "git-init",
Short: "Initialize git repository for a Gas Town harness",
Long: `Initialize or configure git for an existing Gas Town harness.
This command:
1. Creates a comprehensive .gitignore for Gas Town
2. Initializes a git repository if not already present
3. Optionally creates a GitHub repository
The .gitignore excludes:
- Polecats and rig clones (recreated with 'gt spawn' or 'gt rig add')
- Runtime state files (state.json, *.lock)
- OS and editor files
And tracks:
- CLAUDE.md and role contexts
- .beads/ configuration and issues
- Rig configs and hop/ directory
Examples:
gt git-init # Init git with .gitignore
gt git-init --github=user/repo # Also create public GitHub repo
gt git-init --github=user/repo --private # Create private GitHub repo`,
RunE: runGitInit,
}
func init() {
gitInitCmd.Flags().StringVar(&gitInitGitHub, "github", "", "Create GitHub repo (format: owner/repo)")
gitInitCmd.Flags().BoolVar(&gitInitPrivate, "private", false, "Make GitHub repo private")
rootCmd.AddCommand(gitInitCmd)
}
// HarnessGitignore is the standard .gitignore for Gas Town harnesses
const HarnessGitignore = `# Gas Town Harness .gitignore
# Track: Role context, handoff docs, beads config/data, rig configs
# Ignore: Git clones (polecats, mayor/refinery rigs), runtime state
# =============================================================================
# Runtime state files (ephemeral)
# =============================================================================
**/state.json
**/*.lock
**/registry.json
# =============================================================================
# Rig git clones (recreate with 'gt spawn' or 'gt rig add')
# =============================================================================
# Polecats - worker clones
**/polecats/
# Mayor rig clones
**/mayor/rig/
# Refinery working clones
**/refinery/rig/
# Crew workspaces (user-managed)
**/crew/
# =============================================================================
# Rig runtime state directories
# =============================================================================
**/.gastown/
# =============================================================================
# Rig .beads symlinks (point to ignored mayor/rig/.beads, recreated on setup)
# =============================================================================
# Add rig-specific symlinks here, e.g.:
# gastown/.beads
# =============================================================================
# Rigs directory (clones created by 'gt rig add')
# =============================================================================
/rigs/*/
# =============================================================================
# OS and editor files
# =============================================================================
.DS_Store
*~
*.swp
*.swo
.vscode/
.idea/
# =============================================================================
# Explicitly track (override above patterns)
# =============================================================================
# Note: .beads/ has its own .gitignore that handles SQLite files
# and keeps issues.jsonl, metadata.json, config.yaml as source of truth
`
func runGitInit(cmd *cobra.Command, args []string) error {
// Find the harness root
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
harnessRoot, err := workspace.Find(cwd)
if err != nil || harnessRoot == "" {
return fmt.Errorf("not inside a Gas Town harness (run 'gt install' first)")
}
fmt.Printf("%s Initializing git for harness at %s\n\n",
style.Bold.Render("🔧"), style.Dim.Render(harnessRoot))
// Create .gitignore
gitignorePath := filepath.Join(harnessRoot, ".gitignore")
if err := createGitignore(gitignorePath); err != nil {
return err
}
// Initialize git if needed
gitDir := filepath.Join(harnessRoot, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
if err := initGitRepo(harnessRoot); err != nil {
return err
}
} else {
fmt.Printf(" ✓ Git repository already exists\n")
}
// Create GitHub repo if requested
if gitInitGitHub != "" {
if err := createGitHubRepo(harnessRoot, gitInitGitHub, gitInitPrivate); err != nil {
return err
}
}
fmt.Printf("\n%s Git initialization complete!\n", style.Bold.Render("✓"))
// Show next steps if no GitHub was created
if gitInitGitHub == "" {
fmt.Println()
fmt.Println("Next steps:")
fmt.Printf(" 1. Create initial commit: %s\n",
style.Dim.Render("git add . && git commit -m 'Initial Gas Town harness'"))
fmt.Printf(" 2. Create remote repo: %s\n",
style.Dim.Render("gt git-init --github=user/repo"))
}
return nil
}
func createGitignore(path string) error {
// Check if .gitignore already exists
if _, err := os.Stat(path); err == nil {
// Read existing content
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading existing .gitignore: %w", err)
}
// Check if it already has Gas Town section
if strings.Contains(string(content), "Gas Town Harness") {
fmt.Printf(" ✓ .gitignore already configured for Gas Town\n")
return nil
}
// Append to existing
combined := string(content) + "\n" + HarnessGitignore
if err := os.WriteFile(path, []byte(combined), 0644); err != nil {
return fmt.Errorf("updating .gitignore: %w", err)
}
fmt.Printf(" ✓ Updated .gitignore with Gas Town patterns\n")
return nil
}
// Create new .gitignore
if err := os.WriteFile(path, []byte(HarnessGitignore), 0644); err != nil {
return fmt.Errorf("creating .gitignore: %w", err)
}
fmt.Printf(" ✓ Created .gitignore\n")
return nil
}
func initGitRepo(path string) error {
cmd := exec.Command("git", "init")
cmd.Dir = path
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("git init failed: %w", err)
}
fmt.Printf(" ✓ Initialized git repository\n")
return nil
}
func createGitHubRepo(harnessRoot, repo string, private bool) error {
// Check if gh CLI is available
if _, err := exec.LookPath("gh"); err != nil {
return fmt.Errorf("GitHub CLI (gh) not found. Install it with: brew install gh")
}
// Parse owner/repo format
parts := strings.Split(repo, "/")
if len(parts) != 2 {
return fmt.Errorf("invalid GitHub repo format (expected owner/repo): %s", repo)
}
fmt.Printf(" → Creating GitHub repository %s...\n", repo)
// Build gh repo create command
args := []string{"repo", "create", repo, "--source", harnessRoot}
if private {
args = append(args, "--private")
} else {
args = append(args, "--public")
}
args = append(args, "--push")
cmd := exec.Command("gh", args...)
cmd.Dir = harnessRoot
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("gh repo create failed: %w", err)
}
fmt.Printf(" ✓ Created and pushed to GitHub: %s\n", repo)
return nil
}
// InitGitForHarness is the shared implementation for git initialization.
// It can be called from both 'gt git-init' and 'gt install --git'.
func InitGitForHarness(harnessRoot string, github string, private bool) error {
// Create .gitignore
gitignorePath := filepath.Join(harnessRoot, ".gitignore")
if err := createGitignore(gitignorePath); err != nil {
return err
}
// Initialize git if needed
gitDir := filepath.Join(harnessRoot, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
if err := initGitRepo(harnessRoot); err != nil {
return err
}
} else {
fmt.Printf(" ✓ Git repository already exists\n")
}
// Create GitHub repo if requested
if github != "" {
if err := createGitHubRepo(harnessRoot, github, private); err != nil {
return err
}
}
return nil
}

337
internal/cmd/handoff.go Normal file
View File

@@ -0,0 +1,337 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
// HandoffAction for handoff command.
type HandoffAction string
const (
HandoffCycle HandoffAction = "cycle" // Restart with handoff mail
HandoffRestart HandoffAction = "restart" // Fresh restart, no handoff
HandoffShutdown HandoffAction = "shutdown" // Terminate, no restart
)
var handoffCmd = &cobra.Command{
Use: "handoff",
Short: "Request lifecycle action (retirement/restart)",
Long: `Request a lifecycle action from your manager.
This command initiates graceful retirement:
1. Verifies git state is clean
2. Sends handoff mail to yourself (for cycle)
3. Sends lifecycle request to your manager
4. Sets requesting state and waits for retirement
Your manager (daemon for Mayor/Witness, witness for polecats) will
verify the request and terminate your session. For cycle/restart,
a new session starts and reads your handoff mail to continue work.
Flags:
--cycle Restart with handoff mail (default for Mayor/Witness)
--restart Fresh restart, no handoff context
--shutdown Terminate without restart (default for polecats)
Examples:
gt handoff # Use role-appropriate default
gt handoff --cycle # Restart with context handoff
gt handoff --restart # Fresh restart
`,
RunE: runHandoff,
}
var (
handoffCycle bool
handoffRestart bool
handoffShutdown bool
handoffForce bool
handoffMessage string
)
func init() {
handoffCmd.Flags().BoolVar(&handoffCycle, "cycle", false, "Restart with handoff mail")
handoffCmd.Flags().BoolVar(&handoffRestart, "restart", false, "Fresh restart, no handoff")
handoffCmd.Flags().BoolVar(&handoffShutdown, "shutdown", false, "Terminate without restart")
handoffCmd.Flags().BoolVarP(&handoffForce, "force", "f", false, "Skip pre-flight checks")
handoffCmd.Flags().StringVarP(&handoffMessage, "message", "m", "", "Handoff message for successor")
rootCmd.AddCommand(handoffCmd)
}
func runHandoff(cmd *cobra.Command, args []string) error {
// Detect our role
role := detectHandoffRole()
if role == RoleUnknown {
return fmt.Errorf("cannot detect agent role (set GT_ROLE or run from known context)")
}
// Determine action
action := determineAction(role)
fmt.Printf("Agent role: %s\n", style.Bold.Render(string(role)))
fmt.Printf("Action: %s\n", style.Bold.Render(string(action)))
// Find workspace
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Pre-flight checks (unless forced)
if !handoffForce {
if err := preFlightChecks(); err != nil {
return fmt.Errorf("pre-flight check failed: %w\n\nUse --force to skip checks", err)
}
}
// For cycle, update handoff bead for successor
if action == HandoffCycle {
if err := sendHandoffMail(role, townRoot); err != nil {
return fmt.Errorf("updating handoff bead: %w", err)
}
fmt.Printf("%s Updated handoff bead for successor\n", style.Bold.Render("✓"))
}
// Send lifecycle request to manager
manager := getManager(role)
if err := sendLifecycleRequest(manager, role, action, townRoot); err != nil {
return fmt.Errorf("sending lifecycle request: %w", err)
}
fmt.Printf("%s Sent %s request to %s\n", style.Bold.Render("✓"), action, manager)
// Set requesting state
if err := setRequestingState(role, action, townRoot); err != nil {
fmt.Printf("Warning: failed to set state: %v\n", err)
}
// Wait for retirement
fmt.Println()
fmt.Printf("%s Waiting for retirement...\n", style.Dim.Render("◌"))
fmt.Println(style.Dim.Render("(Manager will terminate this session)"))
// Block forever - manager will kill us
select {}
}
// detectHandoffRole figures out what kind of agent we are.
// Uses GT_ROLE env var, tmux session name, or directory context.
func detectHandoffRole() Role {
// Check GT_ROLE environment variable first
if role := os.Getenv("GT_ROLE"); role != "" {
switch strings.ToLower(role) {
case "mayor":
return RoleMayor
case "witness":
return RoleWitness
case "refinery":
return RoleRefinery
case "polecat":
return RolePolecat
case "crew":
return RoleCrew
}
}
// Check tmux session name
out, err := exec.Command("tmux", "display-message", "-p", "#{session_name}").Output()
if err == nil {
sessionName := strings.TrimSpace(string(out))
if sessionName == "gt-mayor" {
return RoleMayor
}
if strings.HasSuffix(sessionName, "-witness") {
return RoleWitness
}
if strings.HasSuffix(sessionName, "-refinery") {
return RoleRefinery
}
// Polecat sessions: gt-<rig>-<name>
if strings.HasPrefix(sessionName, "gt-") && strings.Count(sessionName, "-") >= 2 {
return RolePolecat
}
}
// Fall back to directory-based detection
cwd, err := os.Getwd()
if err != nil {
return RoleUnknown
}
townRoot, err := workspace.FindFromCwd()
if err != nil || townRoot == "" {
return RoleUnknown
}
ctx := detectRole(cwd, townRoot)
return ctx.Role
}
// determineAction picks the action based on flags or role default.
func determineAction(role Role) HandoffAction {
// Explicit flags take precedence
if handoffCycle {
return HandoffCycle
}
if handoffRestart {
return HandoffRestart
}
if handoffShutdown {
return HandoffShutdown
}
// Role-based defaults
switch role {
case RolePolecat:
return HandoffShutdown // Ephemeral, work is done
case RoleMayor, RoleWitness, RoleRefinery:
return HandoffCycle // Long-running, preserve context
case RoleCrew:
return HandoffCycle // Will only send mail, not actually retire
default:
return HandoffCycle
}
}
// preFlightChecks verifies it's safe to retire.
func preFlightChecks() error {
// Check git status
cmd := exec.Command("git", "status", "--porcelain")
out, err := cmd.Output()
if err != nil {
// Not a git repo, that's fine
return nil
}
if len(strings.TrimSpace(string(out))) > 0 {
return fmt.Errorf("uncommitted changes in git working tree")
}
return nil
}
// getManager returns the address of our lifecycle manager.
func getManager(role Role) string {
switch role {
case RoleMayor, RoleWitness:
return "daemon/"
case RolePolecat, RoleRefinery:
// Would need rig context to determine witness address
// For now, use a placeholder pattern
return "<rig>/witness"
case RoleCrew:
return "human" // Crew is human-managed
default:
return "daemon/"
}
}
// sendHandoffMail updates the pinned handoff bead for the successor to read.
func sendHandoffMail(role Role, townRoot string) error {
// Build handoff content
content := handoffMessage
if content == "" {
content = fmt.Sprintf(`🤝 HANDOFF: Session cycling
Time: %s
Role: %s
Action: cycle
Check bd ready for pending work.
Check gt mail inbox for messages received during transition.
`, time.Now().Format(time.RFC3339), role)
}
// Determine the handoff role key
// For role-specific handoffs, use the role name
roleKey := string(role)
// Update the pinned handoff bead
bd := beads.New(townRoot)
if err := bd.UpdateHandoffContent(roleKey, content); err != nil {
return fmt.Errorf("updating handoff bead: %w", err)
}
return nil
}
// sendLifecycleRequest sends the lifecycle request to our manager.
func sendLifecycleRequest(manager string, role Role, action HandoffAction, townRoot string) error {
if manager == "human" {
// Crew is human-managed, just print a message
fmt.Println(style.Dim.Render("(Crew sessions are human-managed, no lifecycle request sent)"))
return nil
}
subject := fmt.Sprintf("LIFECYCLE: %s requesting %s", role, action)
body := fmt.Sprintf(`Lifecycle request from %s.
Action: %s
Time: %s
Please verify state and execute lifecycle action.
`, role, action, time.Now().Format(time.RFC3339))
// Send via bd mail (syntax: bd mail send <recipient> -s <subject> -m <body>)
cmd := exec.Command("bd", "mail", "send", manager,
"-s", subject,
"-m", body,
)
cmd.Dir = townRoot
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%w: %s", err, string(out))
}
return nil
}
// setRequestingState updates state.json to indicate we're requesting lifecycle action.
func setRequestingState(role Role, action HandoffAction, townRoot string) error {
// Determine state file location based on role
var stateFile string
switch role {
case RoleMayor:
stateFile = filepath.Join(townRoot, "mayor", "state.json")
case RoleWitness:
// Would need rig context
stateFile = filepath.Join(townRoot, "witness", "state.json")
default:
// For other roles, use a generic location
stateFile = filepath.Join(townRoot, ".gastown", "agent-state.json")
}
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(stateFile), 0755); err != nil {
return err
}
// Read existing state or create new
state := make(map[string]interface{})
if data, err := os.ReadFile(stateFile); err == nil {
_ = json.Unmarshal(data, &state)
}
// Set requesting state
state["requesting_"+string(action)] = true
state["requesting_time"] = time.Now().Format(time.RFC3339)
// Write back
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return os.WriteFile(stateFile, data, 0644)
}

View File

@@ -64,7 +64,7 @@ func runInit(cmd *cobra.Command, args []string) error {
// Create .gitkeep to ensure directory is tracked if needed
gitkeep := filepath.Join(dirPath, ".gitkeep")
if _, err := os.Stat(gitkeep); os.IsNotExist(err) {
os.WriteFile(gitkeep, []byte(""), 0644)
_ = os.WriteFile(gitkeep, []byte(""), 0644)
}
fmt.Printf(" ✓ Created %s/\n", dir)

View File

@@ -4,10 +4,13 @@ import (
"encoding/json"
"fmt"
"os"
"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/style"
"github.com/steveyegge/gastown/internal/templates"
@@ -18,6 +21,9 @@ var (
installForce bool
installName string
installNoBeads bool
installGit bool
installGitHub string
installPrivate bool
)
var installCmd = &cobra.Command{
@@ -25,18 +31,24 @@ var installCmd = &cobra.Command{
Short: "Create a new Gas Town harness (workspace)",
Long: `Create a new Gas Town harness at the specified path.
A harness is the top-level directory where Gas Town is installed. It contains:
A harness is the top-level directory where Gas Town is installed - the root of
your workspace where all rigs and agents live. It contains:
- CLAUDE.md Mayor role context (Mayor runs from harness root)
- mayor/ Mayor config, state, and mail
- rigs/ Managed rig clones (created by 'gt rig add')
- .beads/redirect (optional) Default beads location
- mayor/ Mayor config, state, and rig registry
- rigs/ Managed rig containers (created by 'gt rig add')
- .beads/ Town-level beads DB (gm-* prefix for mayor mail)
If path is omitted, uses the current directory.
See docs/harness.md for advanced harness configurations including beads
redirects, multi-system setups, and harness templates.
Examples:
gt install ~/gt # Create harness at ~/gt
gt install . --name my-workspace # Initialize current dir
gt install ~/gt --no-beads # Skip .beads/redirect setup`,
gt install ~/gt # Create harness at ~/gt
gt install . --name my-workspace # Initialize current dir
gt install ~/gt --no-beads # Skip .beads/ initialization
gt install ~/gt --git # Also init git with .gitignore
gt install ~/gt --github=user/repo # Also create GitHub repo`,
Args: cobra.MaximumNArgs(1),
RunE: runInstall,
}
@@ -44,7 +56,10 @@ Examples:
func init() {
installCmd.Flags().BoolVarP(&installForce, "force", "f", false, "Overwrite existing harness")
installCmd.Flags().StringVarP(&installName, "name", "n", "", "Town name (defaults to directory name)")
installCmd.Flags().BoolVar(&installNoBeads, "no-beads", false, "Skip .beads/redirect setup")
installCmd.Flags().BoolVar(&installNoBeads, "no-beads", false, "Skip town beads initialization")
installCmd.Flags().BoolVar(&installGit, "git", false, "Initialize git with .gitignore")
installCmd.Flags().StringVar(&installGitHub, "github", "", "Create GitHub repo (format: owner/repo)")
installCmd.Flags().BoolVar(&installPrivate, "private", false, "Make GitHub repo private (use with --github)")
rootCmd.AddCommand(installCmd)
}
@@ -132,19 +147,6 @@ func runInstall(cmd *cobra.Command, args []string) error {
}
fmt.Printf(" ✓ Created rigs/\n")
// Create mayor mail directory
mailDir := filepath.Join(mayorDir, "mail")
if err := os.MkdirAll(mailDir, 0755); err != nil {
return fmt.Errorf("creating mail directory: %w", err)
}
// Create empty inbox
inboxPath := filepath.Join(mailDir, "inbox.jsonl")
if err := os.WriteFile(inboxPath, []byte{}, 0644); err != nil {
return fmt.Errorf("creating inbox: %w", err)
}
fmt.Printf(" ✓ Created mayor/mail/inbox.jsonl\n")
// Create mayor state.json
mayorState := &config.AgentState{
Role: "mayor",
@@ -163,29 +165,43 @@ func runInstall(cmd *cobra.Command, args []string) error {
fmt.Printf(" ✓ Created CLAUDE.md\n")
}
// Create .beads directory with redirect (optional)
// Initialize town-level beads database (optional)
// Town beads (gm- prefix) stores mayor mail, cross-rig coordination, and handoffs.
// Rig beads are separate and have their own prefixes.
if !installNoBeads {
beadsDir := filepath.Join(absPath, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
fmt.Printf(" %s Could not create .beads/: %v\n", style.Dim.Render("⚠"), err)
if err := initTownBeads(absPath); err != nil {
fmt.Printf(" %s Could not initialize town beads: %v\n", style.Dim.Render("⚠"), err)
} else {
// Create redirect file with placeholder
redirectPath := filepath.Join(beadsDir, "redirect")
redirectContent := "# Redirect to your main rig's beads\n# Example: gastown/.beads\n"
if err := os.WriteFile(redirectPath, []byte(redirectContent), 0644); err != nil {
fmt.Printf(" %s Could not create redirect: %v\n", style.Dim.Render("⚠"), err)
fmt.Printf(" ✓ Initialized .beads/ (town-level beads with gm- prefix)\n")
// Seed built-in molecules
if err := seedBuiltinMolecules(absPath); err != nil {
fmt.Printf(" %s Could not seed built-in molecules: %v\n", style.Dim.Render("⚠"), err)
} else {
fmt.Printf(" ✓ Created .beads/redirect (configure for your main rig)\n")
fmt.Printf(" ✓ Seeded built-in molecules\n")
}
}
}
// Initialize git if requested (--git or --github implies --git)
if installGit || installGitHub != "" {
fmt.Println()
if err := InitGitForHarness(absPath, installGitHub, installPrivate); err != nil {
return fmt.Errorf("git initialization failed: %w", err)
}
}
fmt.Printf("\n%s Harness created successfully!\n", style.Bold.Render("✓"))
fmt.Println()
fmt.Println("Next steps:")
fmt.Printf(" 1. Add a rig: %s\n", style.Dim.Render("gt rig add <name> <git-url>"))
fmt.Printf(" 2. Configure beads redirect: %s\n", style.Dim.Render("edit .beads/redirect"))
fmt.Printf(" 3. Start the Mayor: %s\n", style.Dim.Render("cd "+absPath+" && gt prime"))
step := 1
if !installGit && installGitHub == "" {
fmt.Printf(" %d. Initialize git: %s\n", step, style.Dim.Render("gt git-init"))
step++
}
fmt.Printf(" %d. Add a rig: %s\n", step, style.Dim.Render("gt rig add <name> <git-url>"))
step++
fmt.Printf(" %d. Start the Mayor: %s\n", step, style.Dim.Render("cd "+absPath+" && gt prime"))
return nil
}
@@ -218,3 +234,29 @@ func writeJSON(path string, data interface{}) error {
}
return os.WriteFile(path, content, 0644)
}
// initTownBeads initializes town-level beads database using bd init.
// Town beads use the "gm-" prefix for mayor mail and cross-rig coordination.
func initTownBeads(townPath string) error {
// Run: bd init --prefix gm
cmd := exec.Command("bd", "init", "--prefix", "gm")
cmd.Dir = townPath
output, err := cmd.CombinedOutput()
if err != nil {
// Check if beads is already initialized
if strings.Contains(string(output), "already initialized") {
return nil // Already initialized is fine
}
return fmt.Errorf("bd init failed: %s", strings.TrimSpace(string(output)))
}
return nil
}
// seedBuiltinMolecules creates built-in molecule definitions in the beads database.
// These molecules provide standard workflows like engineer-in-box, quick-fix, and research.
func seedBuiltinMolecules(townPath string) error {
b := beads.New(townPath)
_, err := b.SeedBuiltinMolecules()
return err
}

128
internal/cmd/issue.go Normal file
View File

@@ -0,0 +1,128 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/tmux"
)
var issueCmd = &cobra.Command{
Use: "issue",
Short: "Manage current issue for status line display",
}
var issueSetCmd = &cobra.Command{
Use: "set <issue-id>",
Short: "Set the current issue (shown in tmux status line)",
Args: cobra.ExactArgs(1),
RunE: runIssueSet,
}
var issueClearCmd = &cobra.Command{
Use: "clear",
Short: "Clear the current issue from status line",
RunE: runIssueClear,
}
var issueShowCmd = &cobra.Command{
Use: "show",
Short: "Show the current issue",
RunE: runIssueShow,
}
func init() {
rootCmd.AddCommand(issueCmd)
issueCmd.AddCommand(issueSetCmd)
issueCmd.AddCommand(issueClearCmd)
issueCmd.AddCommand(issueShowCmd)
}
func runIssueSet(cmd *cobra.Command, args []string) error {
issueID := args[0]
// Get current tmux session
session := os.Getenv("TMUX_PANE")
if session == "" {
// Try to detect from GT env vars
session = detectCurrentSession()
if session == "" {
return fmt.Errorf("not in a tmux session")
}
}
t := tmux.NewTmux()
if err := t.SetEnvironment(session, "GT_ISSUE", issueID); err != nil {
return fmt.Errorf("setting issue: %w", err)
}
fmt.Printf("Issue set to: %s\n", issueID)
return nil
}
func runIssueClear(cmd *cobra.Command, args []string) error {
session := os.Getenv("TMUX_PANE")
if session == "" {
session = detectCurrentSession()
if session == "" {
return fmt.Errorf("not in a tmux session")
}
}
t := tmux.NewTmux()
// Set to empty string to clear
if err := t.SetEnvironment(session, "GT_ISSUE", ""); err != nil {
return fmt.Errorf("clearing issue: %w", err)
}
fmt.Println("Issue cleared")
return nil
}
func runIssueShow(cmd *cobra.Command, args []string) error {
session := os.Getenv("TMUX_PANE")
if session == "" {
session = detectCurrentSession()
if session == "" {
return fmt.Errorf("not in a tmux session")
}
}
t := tmux.NewTmux()
issue, err := t.GetEnvironment(session, "GT_ISSUE")
if err != nil {
return fmt.Errorf("getting issue: %w", err)
}
if issue == "" {
fmt.Println("No issue set")
} else {
fmt.Printf("Current issue: %s\n", issue)
}
return nil
}
// detectCurrentSession tries to find the tmux session name from env.
func detectCurrentSession() string {
// Try to build session name from GT env vars
rig := os.Getenv("GT_RIG")
polecat := os.Getenv("GT_POLECAT")
crew := os.Getenv("GT_CREW")
if rig != "" {
if polecat != "" {
return fmt.Sprintf("gt-%s-%s", rig, polecat)
}
if crew != "" {
return fmt.Sprintf("gt-%s-crew-%s", rig, crew)
}
}
// Check if we're mayor
if os.Getenv("GT_ROLE") == "mayor" {
return "gt-mayor"
}
return ""
}

View File

@@ -1,6 +1,8 @@
package cmd
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
@@ -15,15 +17,20 @@ import (
// Mail command flags
var (
mailSubject string
mailBody string
mailPriority string
mailNotify bool
mailInboxJSON bool
mailReadJSON bool
mailInboxUnread bool
mailCheckInject bool
mailCheckJSON bool
mailSubject string
mailBody string
mailPriority string
mailType string
mailReplyTo string
mailNotify bool
mailInboxJSON bool
mailReadJSON bool
mailInboxUnread bool
mailCheckInject bool
mailCheckJSON bool
mailThreadJSON bool
mailReplySubject string
mailReplyMessage string
)
var mailCmd = &cobra.Command{
@@ -46,10 +53,21 @@ Addresses:
<rig>/<polecat> - Send to a specific polecat
<rig>/ - Broadcast to a rig
Message types:
task - Required processing
scavenge - Optional first-come work
notification - Informational (default)
reply - Response to message
Priority levels:
low, normal (default), high, urgent
Examples:
gt mail send gastown/Toast -s "Status check" -m "How's that bug fix going?"
gt mail send mayor/ -s "Work complete" -m "Finished gt-abc"
gt mail send gastown/ -s "All hands" -m "Swarm starting" --notify`,
gt mail send gastown/ -s "All hands" -m "Swarm starting" --notify
gt mail send gastown/Toast -s "Task" -m "Fix bug" --type task --priority high
gt mail send mayor/ -s "Re: Status" -m "Done" --reply-to msg-abc123`,
Args: cobra.ExactArgs(1),
RunE: runMailSend,
}
@@ -108,13 +126,45 @@ Examples:
RunE: runMailCheck,
}
var mailThreadCmd = &cobra.Command{
Use: "thread <thread-id>",
Short: "View a message thread",
Long: `View all messages in a conversation thread.
Shows messages in chronological order (oldest first).
Examples:
gt mail thread thread-abc123`,
Args: cobra.ExactArgs(1),
RunE: runMailThread,
}
var mailReplyCmd = &cobra.Command{
Use: "reply <message-id>",
Short: "Reply to a message",
Long: `Reply to a specific message.
This is a convenience command that automatically:
- Sets the reply-to field to the original message
- Prefixes the subject with "Re: " (if not already present)
- Sends to the original sender
Examples:
gt mail reply msg-abc123 -m "Thanks, working on it now"
gt mail reply msg-abc123 -s "Custom subject" -m "Reply body"`,
Args: cobra.ExactArgs(1),
RunE: runMailReply,
}
func init() {
// Send flags
mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)")
mailSendCmd.Flags().StringVarP(&mailBody, "message", "m", "", "Message body")
mailSendCmd.Flags().StringVar(&mailPriority, "priority", "normal", "Message priority (normal, high)")
mailSendCmd.Flags().StringVar(&mailPriority, "priority", "normal", "Message priority (low, normal, high, urgent)")
mailSendCmd.Flags().StringVar(&mailType, "type", "notification", "Message type (task, scavenge, notification, reply)")
mailSendCmd.Flags().StringVar(&mailReplyTo, "reply-to", "", "Message ID this is replying to")
mailSendCmd.Flags().BoolVarP(&mailNotify, "notify", "n", false, "Send tmux notification to recipient")
mailSendCmd.MarkFlagRequired("subject")
_ = mailSendCmd.MarkFlagRequired("subject")
// Inbox flags
mailInboxCmd.Flags().BoolVar(&mailInboxJSON, "json", false, "Output as JSON")
@@ -127,12 +177,22 @@ func init() {
mailCheckCmd.Flags().BoolVar(&mailCheckInject, "inject", false, "Output format for Claude Code hooks")
mailCheckCmd.Flags().BoolVar(&mailCheckJSON, "json", false, "Output as JSON")
// Thread flags
mailThreadCmd.Flags().BoolVar(&mailThreadJSON, "json", false, "Output as JSON")
// Reply flags
mailReplyCmd.Flags().StringVarP(&mailReplySubject, "subject", "s", "", "Override reply subject (default: Re: <original>)")
mailReplyCmd.Flags().StringVarP(&mailReplyMessage, "message", "m", "", "Reply message body (required)")
mailReplyCmd.MarkFlagRequired("message")
// Add subcommands
mailCmd.AddCommand(mailSendCmd)
mailCmd.AddCommand(mailInboxCmd)
mailCmd.AddCommand(mailReadCmd)
mailCmd.AddCommand(mailDeleteCmd)
mailCmd.AddCommand(mailCheckCmd)
mailCmd.AddCommand(mailThreadCmd)
mailCmd.AddCommand(mailReplyCmd)
rootCmd.AddCommand(mailCmd)
}
@@ -158,10 +218,36 @@ func runMailSend(cmd *cobra.Command, args []string) error {
}
// Set priority
if mailPriority == "high" || mailNotify {
msg.Priority = mail.ParsePriority(mailPriority)
if mailNotify && msg.Priority == mail.PriorityNormal {
msg.Priority = mail.PriorityHigh
}
// Set message type
msg.Type = mail.ParseMessageType(mailType)
// Handle reply-to: auto-set type to reply and look up thread
if mailReplyTo != "" {
msg.ReplyTo = mailReplyTo
if msg.Type == mail.TypeNotification {
msg.Type = mail.TypeReply
}
// Look up original message to get thread ID
router := mail.NewRouter(workDir)
mailbox, err := router.GetMailbox(from)
if err == nil {
if original, err := mailbox.Get(mailReplyTo); err == nil {
msg.ThreadID = original.ThreadID
}
}
}
// Generate thread ID for new threads
if msg.ThreadID == "" {
msg.ThreadID = generateThreadID()
}
// Send via router
router := mail.NewRouter(workDir)
if err := router.Send(msg); err != nil {
@@ -170,6 +256,9 @@ func runMailSend(cmd *cobra.Command, args []string) error {
fmt.Printf("%s Message sent to %s\n", style.Bold.Render("✓"), to)
fmt.Printf(" Subject: %s\n", mailSubject)
if msg.Type != mail.TypeNotification {
fmt.Printf(" Type: %s\n", msg.Type)
}
return nil
}
@@ -229,12 +318,16 @@ func runMailInbox(cmd *cobra.Command, args []string) error {
if msg.Read {
readMarker = "○"
}
typeMarker := ""
if msg.Type != "" && msg.Type != mail.TypeNotification {
typeMarker = fmt.Sprintf(" [%s]", msg.Type)
}
priorityMarker := ""
if msg.Priority == mail.PriorityHigh {
if msg.Priority == mail.PriorityHigh || msg.Priority == mail.PriorityUrgent {
priorityMarker = " " + style.Bold.Render("!")
}
fmt.Printf(" %s %s%s\n", readMarker, msg.Subject, priorityMarker)
fmt.Printf(" %s %s%s%s\n", readMarker, msg.Subject, typeMarker, priorityMarker)
fmt.Printf(" %s from %s\n",
style.Dim.Render(msg.ID),
msg.From)
@@ -270,7 +363,7 @@ func runMailRead(cmd *cobra.Command, args []string) error {
}
// Mark as read
mailbox.MarkRead(msgID)
_ = mailbox.MarkRead(msgID)
// JSON output
if mailReadJSON {
@@ -281,16 +374,30 @@ func runMailRead(cmd *cobra.Command, args []string) error {
// Human-readable output
priorityStr := ""
if msg.Priority == mail.PriorityHigh {
if msg.Priority == mail.PriorityUrgent {
priorityStr = " " + style.Bold.Render("[URGENT]")
} else if msg.Priority == mail.PriorityHigh {
priorityStr = " " + style.Bold.Render("[HIGH PRIORITY]")
}
fmt.Printf("%s %s%s\n\n", style.Bold.Render("Subject:"), msg.Subject, priorityStr)
typeStr := ""
if msg.Type != "" && msg.Type != mail.TypeNotification {
typeStr = fmt.Sprintf(" [%s]", msg.Type)
}
fmt.Printf("%s %s%s%s\n\n", style.Bold.Render("Subject:"), msg.Subject, typeStr, priorityStr)
fmt.Printf("From: %s\n", msg.From)
fmt.Printf("To: %s\n", msg.To)
fmt.Printf("Date: %s\n", msg.Timestamp.Format("2006-01-02 15:04:05"))
fmt.Printf("ID: %s\n", style.Dim.Render(msg.ID))
if msg.ThreadID != "" {
fmt.Printf("Thread: %s\n", style.Dim.Render(msg.ThreadID))
}
if msg.ReplyTo != "" {
fmt.Printf("Reply-To: %s\n", style.Dim.Render(msg.ReplyTo))
}
if msg.Body != "" {
fmt.Printf("\n%s\n", msg.Body)
}
@@ -386,6 +493,17 @@ func detectSender() string {
}
}
// If in a rig's crew directory, extract address
if strings.Contains(cwd, "/crew/") {
parts := strings.Split(cwd, "/crew/")
if len(parts) >= 2 {
rigPath := parts[0]
crewName := strings.Split(parts[1], "/")[0]
rigName := filepath.Base(rigPath)
return fmt.Sprintf("%s/%s", rigName, crewName)
}
}
// Default to mayor
return "mayor/"
}
@@ -467,3 +585,143 @@ func runMailCheck(cmd *cobra.Command, args []string) error {
}
return nil
}
func runMailThread(cmd *cobra.Command, args []string) error {
threadID := args[0]
// Find workspace
workDir, err := findBeadsWorkDir()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Determine which inbox
address := detectSender()
// Get mailbox and thread messages
router := mail.NewRouter(workDir)
mailbox, err := router.GetMailbox(address)
if err != nil {
return fmt.Errorf("getting mailbox: %w", err)
}
messages, err := mailbox.ListByThread(threadID)
if err != nil {
return fmt.Errorf("getting thread: %w", err)
}
// JSON output
if mailThreadJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(messages)
}
// Human-readable output
fmt.Printf("%s Thread: %s (%d messages)\n\n",
style.Bold.Render("🧵"), threadID, len(messages))
if len(messages) == 0 {
fmt.Printf(" %s\n", style.Dim.Render("(no messages in thread)"))
return nil
}
for i, msg := range messages {
typeMarker := ""
if msg.Type != "" && msg.Type != mail.TypeNotification {
typeMarker = fmt.Sprintf(" [%s]", msg.Type)
}
priorityMarker := ""
if msg.Priority == mail.PriorityHigh || msg.Priority == mail.PriorityUrgent {
priorityMarker = " " + style.Bold.Render("!")
}
if i > 0 {
fmt.Printf(" %s\n", style.Dim.Render("│"))
}
fmt.Printf(" %s %s%s%s\n", style.Bold.Render("●"), msg.Subject, typeMarker, priorityMarker)
fmt.Printf(" %s from %s to %s\n",
style.Dim.Render(msg.ID),
msg.From, msg.To)
fmt.Printf(" %s\n",
style.Dim.Render(msg.Timestamp.Format("2006-01-02 15:04")))
if msg.Body != "" {
fmt.Printf(" %s\n", msg.Body)
}
}
return nil
}
func runMailReply(cmd *cobra.Command, args []string) error {
msgID := args[0]
// Find workspace
workDir, err := findBeadsWorkDir()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Determine current address
from := detectSender()
// Get the original message
router := mail.NewRouter(workDir)
mailbox, err := router.GetMailbox(from)
if err != nil {
return fmt.Errorf("getting mailbox: %w", err)
}
original, err := mailbox.Get(msgID)
if err != nil {
return fmt.Errorf("getting message: %w", err)
}
// Build reply subject
subject := mailReplySubject
if subject == "" {
if strings.HasPrefix(original.Subject, "Re: ") {
subject = original.Subject
} else {
subject = "Re: " + original.Subject
}
}
// Create reply message
reply := &mail.Message{
From: from,
To: original.From, // Reply to sender
Subject: subject,
Body: mailReplyMessage,
Type: mail.TypeReply,
Priority: mail.PriorityNormal,
ReplyTo: msgID,
ThreadID: original.ThreadID,
}
// If original has no thread ID, create one
if reply.ThreadID == "" {
reply.ThreadID = generateThreadID()
}
// Send the reply
if err := router.Send(reply); err != nil {
return fmt.Errorf("sending reply: %w", err)
}
fmt.Printf("%s Reply sent to %s\n", style.Bold.Render("✓"), original.From)
fmt.Printf(" Subject: %s\n", subject)
if original.ThreadID != "" {
fmt.Printf(" Thread: %s\n", style.Dim.Render(original.ThreadID))
}
return nil
}
// generateThreadID creates a random thread ID for new message threads.
func generateThreadID() string {
b := make([]byte, 6)
_, _ = rand.Read(b)
return "thread-" + hex.EncodeToString(b)
}

View File

@@ -17,8 +17,9 @@ import (
const MayorSessionName = "gt-mayor"
var mayorCmd = &cobra.Command{
Use: "mayor",
Short: "Manage the Mayor session",
Use: "mayor",
Aliases: []string{"may"},
Short: "Manage the Mayor session",
Long: `Manage the Mayor tmux session.
The Mayor is the global coordinator for Gas Town, running as a persistent
@@ -82,12 +83,6 @@ func init() {
}
func runMayorStart(cmd *cobra.Command, args []string) error {
// Find workspace root
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
t := tmux.NewTmux()
// Check if session already exists
@@ -99,6 +94,25 @@ func runMayorStart(cmd *cobra.Command, args []string) error {
return fmt.Errorf("Mayor session already running. Attach with: gt mayor attach")
}
if err := startMayorSession(t); err != nil {
return err
}
fmt.Printf("%s Mayor session started. Attach with: %s\n",
style.Bold.Render("✓"),
style.Dim.Render("gt mayor attach"))
return nil
}
// startMayorSession creates and initializes the Mayor tmux session.
func startMayorSession(t *tmux.Tmux) error {
// Find workspace root
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Create session in workspace root
fmt.Println("Starting Mayor session...")
if err := t.NewSession(MayorSessionName, townRoot); err != nil {
@@ -106,18 +120,19 @@ func runMayorStart(cmd *cobra.Command, args []string) error {
}
// Set environment
t.SetEnvironment(MayorSessionName, "GT_ROLE", "mayor")
_ = t.SetEnvironment(MayorSessionName, "GT_ROLE", "mayor")
// Launch Claude with full permissions (Mayor is trusted)
command := "claude --dangerously-skip-permissions"
if err := t.SendKeys(MayorSessionName, command); err != nil {
// Apply Mayor theme
theme := tmux.MayorTheme()
_ = t.ConfigureGasTownSession(MayorSessionName, theme, "", "Mayor", "coordinator")
// Launch Claude - the startup hook handles 'gt prime' automatically
// Use SendKeysDelayed to allow shell initialization after NewSession
claudeCmd := `claude --dangerously-skip-permissions`
if err := t.SendKeysDelayed(MayorSessionName, claudeCmd, 200); err != nil {
return fmt.Errorf("sending command: %w", err)
}
fmt.Printf("%s Mayor session started. Attach with: %s\n",
style.Bold.Render("✓"),
style.Dim.Render("gt mayor attach"))
return nil
}
@@ -136,7 +151,7 @@ func runMayorStop(cmd *cobra.Command, args []string) error {
fmt.Println("Stopping Mayor session...")
// Try graceful shutdown first
t.SendKeysRaw(MayorSessionName, "C-c")
_ = t.SendKeysRaw(MayorSessionName, "C-c")
time.Sleep(100 * time.Millisecond)
// Kill the session
@@ -157,11 +172,14 @@ func runMayorAttach(cmd *cobra.Command, args []string) error {
return fmt.Errorf("checking session: %w", err)
}
if !running {
return errors.New("Mayor session is not running. Start with: gt mayor start")
// Auto-start if not running
fmt.Println("Mayor session not running, starting...")
if err := startMayorSession(t); err != nil {
return err
}
}
// Use exec to replace current process with tmux attach
// This is the standard pattern for attaching to tmux sessions
tmuxPath, err := exec.LookPath("tmux")
if err != nil {
return fmt.Errorf("tmux not found: %w", err)
@@ -222,19 +240,19 @@ func runMayorStatus(cmd *cobra.Command, args []string) error {
func runMayorRestart(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()
// Stop if running
running, err := t.HasSession(MayorSessionName)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
if running {
// Stop the current session
fmt.Println("Stopping Mayor session...")
t.SendKeysRaw(MayorSessionName, "C-c")
_ = t.SendKeysRaw(MayorSessionName, "C-c")
time.Sleep(100 * time.Millisecond)
if err := t.KillSession(MayorSessionName); err != nil {
return fmt.Errorf("killing session: %w", err)
}
fmt.Printf("%s Mayor session stopped.\n", style.Bold.Render("✓"))
}
// Start fresh

495
internal/cmd/molecule.go Normal file
View File

@@ -0,0 +1,495 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/style"
)
// Molecule command flags
var (
moleculeJSON bool
moleculeInstParent string
moleculeInstContext []string
)
var moleculeCmd = &cobra.Command{
Use: "molecule",
Short: "Molecule workflow commands",
Long: `Manage molecule workflow templates.
Molecules are composable workflow patterns stored as beads issues.
When instantiated on a parent issue, they create child beads forming a DAG.`,
}
var moleculeListCmd = &cobra.Command{
Use: "list",
Short: "List molecules",
Long: `List all molecule definitions.
Molecules are issues with type=molecule.`,
RunE: runMoleculeList,
}
var moleculeShowCmd = &cobra.Command{
Use: "show <id>",
Short: "Show molecule with parsed steps",
Long: `Show a molecule definition with its parsed steps.
Displays the molecule's title, description structure, and all defined steps
with their dependencies.`,
Args: cobra.ExactArgs(1),
RunE: runMoleculeShow,
}
var moleculeParseCmd = &cobra.Command{
Use: "parse <id>",
Short: "Validate and show parsed structure",
Long: `Parse and validate a molecule definition.
This command parses the molecule's step definitions and reports any errors.
Useful for debugging molecule definitions before instantiation.`,
Args: cobra.ExactArgs(1),
RunE: runMoleculeParse,
}
var moleculeInstantiateCmd = &cobra.Command{
Use: "instantiate <mol-id>",
Short: "Create steps from molecule template",
Long: `Instantiate a molecule on a parent issue.
Creates child issues for each step defined in the molecule, wiring up
dependencies according to the Needs: declarations.
Template variables ({{variable}}) can be substituted using --context flags.
Examples:
gt molecule instantiate mol-xyz --parent=gt-abc
gt molecule instantiate mol-xyz --parent=gt-abc --context feature=auth --context file=login.go`,
Args: cobra.ExactArgs(1),
RunE: runMoleculeInstantiate,
}
var moleculeInstancesCmd = &cobra.Command{
Use: "instances <mol-id>",
Short: "Show all instantiations of a molecule",
Long: `Show all parent issues that have instantiated this molecule.
Lists each instantiation with its status and progress.`,
Args: cobra.ExactArgs(1),
RunE: runMoleculeInstances,
}
func init() {
// List flags
moleculeListCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
// Show flags
moleculeShowCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
// Parse flags
moleculeParseCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
// Instantiate flags
moleculeInstantiateCmd.Flags().StringVar(&moleculeInstParent, "parent", "", "Parent issue ID (required)")
moleculeInstantiateCmd.Flags().StringArrayVar(&moleculeInstContext, "context", nil, "Context variable (key=value)")
moleculeInstantiateCmd.MarkFlagRequired("parent")
// Instances flags
moleculeInstancesCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
// Add subcommands
moleculeCmd.AddCommand(moleculeListCmd)
moleculeCmd.AddCommand(moleculeShowCmd)
moleculeCmd.AddCommand(moleculeParseCmd)
moleculeCmd.AddCommand(moleculeInstantiateCmd)
moleculeCmd.AddCommand(moleculeInstancesCmd)
rootCmd.AddCommand(moleculeCmd)
}
func runMoleculeList(cmd *cobra.Command, args []string) error {
workDir, err := findBeadsWorkDir()
if err != nil {
return fmt.Errorf("not in a beads workspace: %w", err)
}
b := beads.New(workDir)
issues, err := b.List(beads.ListOptions{
Type: "molecule",
Status: "all",
Priority: -1,
})
if err != nil {
return fmt.Errorf("listing molecules: %w", err)
}
if moleculeJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(issues)
}
// Human-readable output
fmt.Printf("%s Molecules (%d)\n\n", style.Bold.Render("🧬"), len(issues))
if len(issues) == 0 {
fmt.Printf(" %s\n", style.Dim.Render("(no molecules defined)"))
return nil
}
for _, mol := range issues {
statusMarker := ""
if mol.Status == "closed" {
statusMarker = " " + style.Dim.Render("[closed]")
}
// Parse steps to show count
steps, _ := beads.ParseMoleculeSteps(mol.Description)
stepCount := ""
if len(steps) > 0 {
stepCount = fmt.Sprintf(" (%d steps)", len(steps))
}
fmt.Printf(" %s: %s%s%s\n", style.Bold.Render(mol.ID), mol.Title, stepCount, statusMarker)
}
return nil
}
func runMoleculeShow(cmd *cobra.Command, args []string) error {
molID := args[0]
workDir, err := findBeadsWorkDir()
if err != nil {
return fmt.Errorf("not in a beads workspace: %w", err)
}
b := beads.New(workDir)
mol, err := b.Show(molID)
if err != nil {
return fmt.Errorf("getting molecule: %w", err)
}
if mol.Type != "molecule" {
return fmt.Errorf("%s is not a molecule (type: %s)", molID, mol.Type)
}
// Parse steps
steps, parseErr := beads.ParseMoleculeSteps(mol.Description)
// For JSON, include parsed steps
if moleculeJSON {
type moleculeOutput struct {
*beads.Issue
Steps []beads.MoleculeStep `json:"steps,omitempty"`
ParseError string `json:"parse_error,omitempty"`
}
out := moleculeOutput{Issue: mol, Steps: steps}
if parseErr != nil {
out.ParseError = parseErr.Error()
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
// Human-readable output
fmt.Printf("\n%s: %s\n", style.Bold.Render(mol.ID), mol.Title)
fmt.Printf("Type: %s\n", mol.Type)
if parseErr != nil {
fmt.Printf("\n%s Parse error: %s\n", style.Bold.Render("⚠"), parseErr)
}
// Show steps
fmt.Printf("\nSteps (%d):\n", len(steps))
if len(steps) == 0 {
fmt.Printf(" %s\n", style.Dim.Render("(no steps defined)"))
} else {
// Find which steps are ready (no dependencies)
for _, step := range steps {
needsStr := ""
if len(step.Needs) == 0 {
needsStr = style.Dim.Render("(ready first)")
} else {
needsStr = fmt.Sprintf("Needs: %s", strings.Join(step.Needs, ", "))
}
tierStr := ""
if step.Tier != "" {
tierStr = fmt.Sprintf(" [%s]", step.Tier)
}
fmt.Printf(" %-12s → %s%s\n", step.Ref, needsStr, tierStr)
}
}
// Count instances
instances, _ := findMoleculeInstances(b, molID)
fmt.Printf("\nInstances: %d\n", len(instances))
return nil
}
func runMoleculeParse(cmd *cobra.Command, args []string) error {
molID := args[0]
workDir, err := findBeadsWorkDir()
if err != nil {
return fmt.Errorf("not in a beads workspace: %w", err)
}
b := beads.New(workDir)
mol, err := b.Show(molID)
if err != nil {
return fmt.Errorf("getting molecule: %w", err)
}
// Validate the molecule
validationErr := beads.ValidateMolecule(mol)
// Parse steps regardless of validation
steps, parseErr := beads.ParseMoleculeSteps(mol.Description)
if moleculeJSON {
type parseOutput struct {
Valid bool `json:"valid"`
ValidationError string `json:"validation_error,omitempty"`
ParseError string `json:"parse_error,omitempty"`
Steps []beads.MoleculeStep `json:"steps"`
}
out := parseOutput{
Valid: validationErr == nil,
Steps: steps,
}
if validationErr != nil {
out.ValidationError = validationErr.Error()
}
if parseErr != nil {
out.ParseError = parseErr.Error()
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
// Human-readable output
fmt.Printf("\n%s: %s\n\n", style.Bold.Render(mol.ID), mol.Title)
if validationErr != nil {
fmt.Printf("%s Validation failed: %s\n\n", style.Bold.Render("✗"), validationErr)
} else {
fmt.Printf("%s Valid molecule\n\n", style.Bold.Render("✓"))
}
if parseErr != nil {
fmt.Printf("Parse error: %s\n\n", parseErr)
}
fmt.Printf("Parsed Steps (%d):\n", len(steps))
for i, step := range steps {
fmt.Printf("\n [%d] %s\n", i+1, style.Bold.Render(step.Ref))
if step.Title != step.Ref {
fmt.Printf(" Title: %s\n", step.Title)
}
if len(step.Needs) > 0 {
fmt.Printf(" Needs: %s\n", strings.Join(step.Needs, ", "))
}
if step.Tier != "" {
fmt.Printf(" Tier: %s\n", step.Tier)
}
if step.Instructions != "" {
// Show first line of instructions
firstLine := strings.SplitN(step.Instructions, "\n", 2)[0]
if len(firstLine) > 60 {
firstLine = firstLine[:57] + "..."
}
fmt.Printf(" Instructions: %s\n", style.Dim.Render(firstLine))
}
}
return nil
}
func runMoleculeInstantiate(cmd *cobra.Command, args []string) error {
molID := args[0]
workDir, err := findBeadsWorkDir()
if err != nil {
return fmt.Errorf("not in a beads workspace: %w", err)
}
b := beads.New(workDir)
// Get the molecule
mol, err := b.Show(molID)
if err != nil {
return fmt.Errorf("getting molecule: %w", err)
}
if mol.Type != "molecule" {
return fmt.Errorf("%s is not a molecule (type: %s)", molID, mol.Type)
}
// Validate molecule
if err := beads.ValidateMolecule(mol); err != nil {
return fmt.Errorf("invalid molecule: %w", err)
}
// Get the parent issue
parent, err := b.Show(moleculeInstParent)
if err != nil {
return fmt.Errorf("getting parent issue: %w", err)
}
// Parse context variables
ctx := make(map[string]string)
for _, kv := range moleculeInstContext {
parts := strings.SplitN(kv, "=", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid context format %q (expected key=value)", kv)
}
ctx[parts[0]] = parts[1]
}
// Instantiate the molecule
opts := beads.InstantiateOptions{Context: ctx}
steps, err := b.InstantiateMolecule(mol, parent, opts)
if err != nil {
return fmt.Errorf("instantiating molecule: %w", err)
}
fmt.Printf("%s Created %d steps from %s on %s\n\n",
style.Bold.Render("✓"), len(steps), molID, moleculeInstParent)
for _, step := range steps {
fmt.Printf(" %s: %s\n", style.Dim.Render(step.ID), step.Title)
}
return nil
}
func runMoleculeInstances(cmd *cobra.Command, args []string) error {
molID := args[0]
workDir, err := findBeadsWorkDir()
if err != nil {
return fmt.Errorf("not in a beads workspace: %w", err)
}
b := beads.New(workDir)
// Verify the molecule exists
mol, err := b.Show(molID)
if err != nil {
return fmt.Errorf("getting molecule: %w", err)
}
if mol.Type != "molecule" {
return fmt.Errorf("%s is not a molecule (type: %s)", molID, mol.Type)
}
// Find all instances
instances, err := findMoleculeInstances(b, molID)
if err != nil {
return fmt.Errorf("finding instances: %w", err)
}
if moleculeJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(instances)
}
// Human-readable output
fmt.Printf("\n%s Instances of %s (%d)\n\n",
style.Bold.Render("📋"), molID, len(instances))
if len(instances) == 0 {
fmt.Printf(" %s\n", style.Dim.Render("(no instantiations found)"))
return nil
}
fmt.Printf("%-16s %-12s %s\n",
style.Bold.Render("Parent"),
style.Bold.Render("Status"),
style.Bold.Render("Created"))
fmt.Println(strings.Repeat("-", 50))
for _, inst := range instances {
// Calculate progress from children
progress := ""
if len(inst.Children) > 0 {
closed := 0
for _, childID := range inst.Children {
child, err := b.Show(childID)
if err == nil && child.Status == "closed" {
closed++
}
}
progress = fmt.Sprintf(" (%d/%d complete)", closed, len(inst.Children))
}
statusStr := inst.Status
if inst.Status == "closed" {
statusStr = style.Dim.Render("done")
} else if inst.Status == "in_progress" {
statusStr = "active"
}
created := ""
if inst.CreatedAt != "" {
// Parse and format date
created = inst.CreatedAt[:10] // Just the date portion
}
fmt.Printf("%-16s %-12s %s%s\n", inst.ID, statusStr, created, progress)
}
return nil
}
// moleculeInstance represents an instantiation of a molecule.
type moleculeInstance struct {
*beads.Issue
}
// findMoleculeInstances finds all parent issues that have steps instantiated from the given molecule.
func findMoleculeInstances(b *beads.Beads, molID string) ([]*beads.Issue, error) {
// Get all issues and look for ones with children that have instantiated_from metadata
// This is a brute-force approach - could be optimized with better queries
// Strategy: search for issues whose descriptions contain "instantiated_from: <molID>"
allIssues, err := b.List(beads.ListOptions{Status: "all", Priority: -1})
if err != nil {
return nil, err
}
// Find issues that reference this molecule
parentIDs := make(map[string]bool)
for _, issue := range allIssues {
if strings.Contains(issue.Description, fmt.Sprintf("instantiated_from: %s", molID)) {
// This is a step - find its parent
if issue.Parent != "" {
parentIDs[issue.Parent] = true
}
}
}
// Fetch the parent issues
var parents []*beads.Issue
for parentID := range parentIDs {
parent, err := b.Show(parentID)
if err == nil {
parents = append(parents, parent)
}
}
return parents, nil
}

1736
internal/cmd/mq.go Normal file

File diff suppressed because it is too large Load Diff

214
internal/cmd/mq_test.go Normal file
View File

@@ -0,0 +1,214 @@
package cmd
import (
"testing"
)
func TestAddIntegrationBranchField(t *testing.T) {
tests := []struct {
name string
description string
branchName string
want string
}{
{
name: "empty description",
description: "",
branchName: "integration/gt-epic",
want: "integration_branch: integration/gt-epic",
},
{
name: "simple description",
description: "Epic for authentication",
branchName: "integration/gt-auth",
want: "integration_branch: integration/gt-auth\nEpic for authentication",
},
{
name: "existing integration_branch field",
description: "integration_branch: integration/old-epic\nSome description",
branchName: "integration/new-epic",
want: "integration_branch: integration/new-epic\nSome description",
},
{
name: "multiline description",
description: "Line 1\nLine 2\nLine 3",
branchName: "integration/gt-xyz",
want: "integration_branch: integration/gt-xyz\nLine 1\nLine 2\nLine 3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := addIntegrationBranchField(tt.description, tt.branchName)
if got != tt.want {
t.Errorf("addIntegrationBranchField() = %q, want %q", got, tt.want)
}
})
}
}
func TestParseBranchName(t *testing.T) {
tests := []struct {
name string
branch string
wantIssue string
wantWorker string
}{
{
name: "polecat branch format",
branch: "polecat/Nux/gt-xyz",
wantIssue: "gt-xyz",
wantWorker: "Nux",
},
{
name: "polecat branch with subtask",
branch: "polecat/Worker/gt-abc.1",
wantIssue: "gt-abc.1",
wantWorker: "Worker",
},
{
name: "simple issue branch",
branch: "gt-xyz",
wantIssue: "gt-xyz",
wantWorker: "",
},
{
name: "feature branch with issue",
branch: "feature/gt-abc-impl",
wantIssue: "gt-abc",
wantWorker: "",
},
{
name: "no issue pattern",
branch: "main",
wantIssue: "",
wantWorker: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := parseBranchName(tt.branch)
if info.Issue != tt.wantIssue {
t.Errorf("parseBranchName() Issue = %q, want %q", info.Issue, tt.wantIssue)
}
if info.Worker != tt.wantWorker {
t.Errorf("parseBranchName() Worker = %q, want %q", info.Worker, tt.wantWorker)
}
})
}
}
func TestFormatMRAge(t *testing.T) {
tests := []struct {
name string
createdAt string
wantOk bool // just check it doesn't panic/error
}{
{
name: "RFC3339 format",
createdAt: "2025-01-01T12:00:00Z",
wantOk: true,
},
{
name: "alternative format",
createdAt: "2025-01-01T12:00:00",
wantOk: true,
},
{
name: "invalid format",
createdAt: "not-a-date",
wantOk: true, // returns "?" for invalid
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := formatMRAge(tt.createdAt)
if tt.wantOk && result == "" {
t.Errorf("formatMRAge() returned empty for %s", tt.createdAt)
}
})
}
}
func TestGetDescriptionWithoutMRFields(t *testing.T) {
tests := []struct {
name string
description string
want string
}{
{
name: "empty description",
description: "",
want: "",
},
{
name: "only MR fields",
description: "branch: polecat/Nux/gt-xyz\ntarget: main\nworker: Nux",
want: "",
},
{
name: "mixed content",
description: "branch: polecat/Nux/gt-xyz\nSome custom notes\ntarget: main",
want: "Some custom notes",
},
{
name: "no MR fields",
description: "Just a regular description\nWith multiple lines",
want: "Just a regular description\nWith multiple lines",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := getDescriptionWithoutMRFields(tt.description)
if got != tt.want {
t.Errorf("getDescriptionWithoutMRFields() = %q, want %q", got, tt.want)
}
})
}
}
func TestTruncateString(t *testing.T) {
tests := []struct {
name string
s string
maxLen int
want string
}{
{
name: "short string",
s: "hello",
maxLen: 10,
want: "hello",
},
{
name: "exact length",
s: "hello",
maxLen: 5,
want: "hello",
},
{
name: "needs truncation",
s: "hello world",
maxLen: 8,
want: "hello...",
},
{
name: "very short max",
s: "hello",
maxLen: 3,
want: "hel",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := truncateString(tt.s, tt.maxLen)
if got != tt.want {
t.Errorf("truncateString() = %q, want %q", got, tt.want)
}
})
}
}

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/spf13/cobra"
@@ -26,8 +27,9 @@ var (
)
var polecatCmd = &cobra.Command{
Use: "polecat",
Short: "Manage polecats in rigs",
Use: "polecat",
Aliases: []string{"cat", "polecats"},
Short: "Manage polecats in rigs",
Long: `Manage polecat lifecycle in rigs.
Polecats are worker agents that operate in their own git clones.
@@ -39,11 +41,11 @@ var polecatListCmd = &cobra.Command{
Short: "List polecats in a rig",
Long: `List polecats in a rig or all rigs.
Output:
- Name
- State (idle/active/working/done/stuck)
- Current issue (if any)
- Session status (running/stopped)
In the ephemeral model, polecats exist only while working. The list shows
all currently active polecats with their states:
- working: Actively working on an issue
- done: Completed work, waiting for cleanup
- stuck: Needs assistance
Examples:
gt polecat list gastown
@@ -84,10 +86,13 @@ Example:
var polecatWakeCmd = &cobra.Command{
Use: "wake <rig>/<polecat>",
Short: "Mark polecat as active (ready for work)",
Long: `Mark polecat as active (ready for work).
Short: "(Deprecated) Resume a polecat to working state",
Long: `Resume a polecat to working state.
Transitions: idle → active
DEPRECATED: In the ephemeral model, polecats are created fresh for each task
via 'gt spawn'. This command is kept for backward compatibility.
Transitions: done → working
Example:
gt polecat wake gastown/Toast`,
@@ -97,11 +102,14 @@ Example:
var polecatSleepCmd = &cobra.Command{
Use: "sleep <rig>/<polecat>",
Short: "Mark polecat as idle (not available)",
Long: `Mark polecat as idle (not available).
Short: "(Deprecated) Mark polecat as done",
Long: `Mark polecat as done.
Transitions: active → idle
Fails if session is running (stop first).
DEPRECATED: In the ephemeral model, polecats use 'gt handoff' when complete,
which triggers automatic cleanup by the Witness. This command is kept for
backward compatibility.
Transitions: working → done
Example:
gt polecat sleep gastown/Toast`,
@@ -109,6 +117,63 @@ Example:
RunE: runPolecatSleep,
}
var polecatDoneCmd = &cobra.Command{
Use: "done <rig>/<polecat>",
Aliases: []string{"finish"},
Short: "Mark polecat as done with work and return to idle",
Long: `Mark polecat as done with work and return to idle.
Transitions: working/done/stuck → idle
Clears the assigned issue.
Fails if session is running (stop first).
Example:
gt polecat done gastown/Toast
gt polecat finish gastown/Toast`,
Args: cobra.ExactArgs(1),
RunE: runPolecatDone,
}
var polecatResetCmd = &cobra.Command{
Use: "reset <rig>/<polecat>",
Short: "Force reset polecat to idle state",
Long: `Force reset polecat to idle state.
Transitions: any state → idle
Clears the assigned issue.
Use when polecat is stuck in an unexpected state.
Fails if session is running (stop first).
Example:
gt polecat reset gastown/Toast`,
Args: cobra.ExactArgs(1),
RunE: runPolecatReset,
}
var polecatSyncCmd = &cobra.Command{
Use: "sync <rig>/<polecat>",
Short: "Sync beads for a polecat",
Long: `Sync beads for a polecat's worktree.
Runs 'bd sync' in the polecat's worktree to push local beads changes
to the shared sync branch and pull remote changes.
Use --all to sync all polecats in a rig.
Use --from-main to only pull (no push).
Examples:
gt polecat sync gastown/Toast
gt polecat sync gastown --all
gt polecat sync gastown/Toast --from-main`,
Args: cobra.MaximumNArgs(1),
RunE: runPolecatSync,
}
var (
polecatSyncAll bool
polecatSyncFromMain bool
)
func init() {
// List flags
polecatListCmd.Flags().BoolVar(&polecatListJSON, "json", false, "Output as JSON")
@@ -117,12 +182,19 @@ func init() {
// Remove flags
polecatRemoveCmd.Flags().BoolVarP(&polecatForce, "force", "f", false, "Force removal, bypassing checks")
// Sync flags
polecatSyncCmd.Flags().BoolVar(&polecatSyncAll, "all", false, "Sync all polecats in the rig")
polecatSyncCmd.Flags().BoolVar(&polecatSyncFromMain, "from-main", false, "Pull only, no push")
// Add subcommands
polecatCmd.AddCommand(polecatListCmd)
polecatCmd.AddCommand(polecatAddCmd)
polecatCmd.AddCommand(polecatRemoveCmd)
polecatCmd.AddCommand(polecatWakeCmd)
polecatCmd.AddCommand(polecatSleepCmd)
polecatCmd.AddCommand(polecatDoneCmd)
polecatCmd.AddCommand(polecatResetCmd)
polecatCmd.AddCommand(polecatSyncCmd)
rootCmd.AddCommand(polecatCmd)
}
@@ -223,11 +295,11 @@ func runPolecatList(cmd *cobra.Command, args []string) error {
}
if len(allPolecats) == 0 {
fmt.Println("No polecats found.")
fmt.Println("No active polecats found.")
return nil
}
fmt.Printf("%s\n\n", style.Bold.Render("Polecats"))
fmt.Printf("%s\n\n", style.Bold.Render("Active Polecats"))
for _, p := range allPolecats {
// Session indicator
sessionStatus := style.Dim.Render("○")
@@ -235,9 +307,15 @@ func runPolecatList(cmd *cobra.Command, args []string) error {
sessionStatus = style.Success.Render("●")
}
// Normalize state for display (legacy idle/active → working)
displayState := p.State
if p.State == polecat.StateIdle || p.State == polecat.StateActive {
displayState = polecat.StateWorking
}
// State color
stateStr := string(p.State)
switch p.State {
stateStr := string(displayState)
switch displayState {
case polecat.StateWorking:
stateStr = style.Info.Render(stateStr)
case polecat.StateStuck:
@@ -315,6 +393,9 @@ func runPolecatRemove(cmd *cobra.Command, args []string) error {
}
func runPolecatWake(cmd *cobra.Command, args []string) error {
fmt.Println(style.Warning.Render("DEPRECATED: Use 'gt spawn' to create fresh polecats instead"))
fmt.Println()
rigName, polecatName, err := parseAddress(args[0])
if err != nil {
return err
@@ -329,11 +410,41 @@ func runPolecatWake(cmd *cobra.Command, args []string) error {
return fmt.Errorf("waking polecat: %w", err)
}
fmt.Printf("%s Polecat %s is now active.\n", style.SuccessPrefix, polecatName)
fmt.Printf("%s Polecat %s is now working.\n", style.SuccessPrefix, polecatName)
return nil
}
func runPolecatSleep(cmd *cobra.Command, args []string) error {
fmt.Println(style.Warning.Render("DEPRECATED: Use 'gt handoff' from within a polecat session instead"))
fmt.Println()
rigName, polecatName, err := parseAddress(args[0])
if err != nil {
return err
}
mgr, r, err := getPolecatManager(rigName)
if err != nil {
return err
}
// Check if session is running
t := tmux.NewTmux()
sessMgr := session.NewManager(t, r)
running, _ := sessMgr.IsRunning(polecatName)
if running {
return fmt.Errorf("session is running. Use 'gt handoff' from the polecat session, or stop it with: gt session stop %s/%s", rigName, polecatName)
}
if err := mgr.Sleep(polecatName); err != nil {
return fmt.Errorf("marking polecat as done: %w", err)
}
fmt.Printf("%s Polecat %s is now done.\n", style.SuccessPrefix, polecatName)
return nil
}
func runPolecatDone(cmd *cobra.Command, args []string) error {
rigName, polecatName, err := parseAddress(args[0])
if err != nil {
return err
@@ -352,10 +463,117 @@ func runPolecatSleep(cmd *cobra.Command, args []string) error {
return fmt.Errorf("session is running. Stop it first with: gt session stop %s/%s", rigName, polecatName)
}
if err := mgr.Sleep(polecatName); err != nil {
return fmt.Errorf("sleeping polecat: %w", err)
if err := mgr.Finish(polecatName); err != nil {
return fmt.Errorf("finishing polecat: %w", err)
}
fmt.Printf("%s Polecat %s is now idle.\n", style.SuccessPrefix, polecatName)
return nil
}
func runPolecatReset(cmd *cobra.Command, args []string) error {
rigName, polecatName, err := parseAddress(args[0])
if err != nil {
return err
}
mgr, r, err := getPolecatManager(rigName)
if err != nil {
return err
}
// Check if session is running
t := tmux.NewTmux()
sessMgr := session.NewManager(t, r)
running, _ := sessMgr.IsRunning(polecatName)
if running {
return fmt.Errorf("session is running. Stop it first with: gt session stop %s/%s", rigName, polecatName)
}
if err := mgr.Reset(polecatName); err != nil {
return fmt.Errorf("resetting polecat: %w", err)
}
fmt.Printf("%s Polecat %s has been reset to idle.\n", style.SuccessPrefix, polecatName)
return nil
}
func runPolecatSync(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return fmt.Errorf("rig or rig/polecat address required")
}
// Parse address - could be "rig" or "rig/polecat"
rigName, polecatName, err := parseAddress(args[0])
if err != nil {
// Might just be a rig name
rigName = args[0]
polecatName = ""
}
mgr, r, err := getPolecatManager(rigName)
if err != nil {
return err
}
// Get list of polecats to sync
var polecatsToSync []string
if polecatSyncAll || polecatName == "" {
polecats, err := mgr.List()
if err != nil {
return fmt.Errorf("listing polecats: %w", err)
}
for _, p := range polecats {
polecatsToSync = append(polecatsToSync, p.Name)
}
} else {
polecatsToSync = []string{polecatName}
}
if len(polecatsToSync) == 0 {
fmt.Println("No polecats to sync.")
return nil
}
// Sync each polecat
var syncErrors []string
for _, name := range polecatsToSync {
polecatDir := filepath.Join(r.Path, "polecats", name)
// Check directory exists
if _, err := os.Stat(polecatDir); os.IsNotExist(err) {
syncErrors = append(syncErrors, fmt.Sprintf("%s: directory not found", name))
continue
}
// Build sync command
syncArgs := []string{"sync"}
if polecatSyncFromMain {
syncArgs = append(syncArgs, "--from-main")
}
fmt.Printf("Syncing %s/%s...\n", rigName, name)
syncCmd := exec.Command("bd", syncArgs...)
syncCmd.Dir = polecatDir
output, err := syncCmd.CombinedOutput()
if err != nil {
syncErrors = append(syncErrors, fmt.Sprintf("%s: %v", name, err))
if len(output) > 0 {
fmt.Printf(" %s\n", style.Dim.Render(string(output)))
}
} else {
fmt.Printf(" %s\n", style.Success.Render("✓ synced"))
}
}
if len(syncErrors) > 0 {
fmt.Printf("\n%s Some syncs failed:\n", style.Warning.Render("Warning:"))
for _, e := range syncErrors {
fmt.Printf(" - %s\n", e)
}
return fmt.Errorf("%d sync(s) failed", len(syncErrors))
}
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/templates"
"github.com/steveyegge/gastown/internal/workspace"
@@ -71,7 +72,14 @@ func runPrime(cmd *cobra.Command, args []string) error {
ctx := detectRole(cwd, townRoot)
// Output context
return outputPrimeContext(ctx)
if err := outputPrimeContext(ctx); err != nil {
return err
}
// Output handoff content if present
outputHandoffContent(ctx)
return nil
}
func detectRole(cwd, townRoot string) RoleContext {
@@ -307,3 +315,31 @@ func outputUnknownContext(ctx RoleContext) {
fmt.Println()
fmt.Printf("Town root: %s\n", style.Dim.Render(ctx.TownRoot))
}
// outputHandoffContent reads and displays the pinned handoff bead for the role.
func outputHandoffContent(ctx RoleContext) {
if ctx.Role == RoleUnknown {
return
}
// Get role key for handoff bead lookup
roleKey := string(ctx.Role)
bd := beads.New(ctx.TownRoot)
issue, err := bd.FindHandoffBead(roleKey)
if err != nil {
// Silently skip if beads lookup fails (might not be a beads repo)
return
}
if issue == nil || issue.Description == "" {
// No handoff content
return
}
// Display handoff content
fmt.Println()
fmt.Printf("%s\n\n", style.Bold.Render("## 🤝 Handoff from Previous Session"))
fmt.Println(issue.Description)
fmt.Println()
fmt.Println(style.Dim.Render("(Clear with: gt rig reset --handoff)"))
}

View File

@@ -23,8 +23,9 @@ var (
)
var refineryCmd = &cobra.Command{
Use: "refinery",
Short: "Manage the merge queue processor",
Use: "refinery",
Aliases: []string{"ref"},
Short: "Manage the merge queue processor",
Long: `Manage the Refinery merge queue processor for a rig.
The Refinery processes merge requests from polecats, merging their work

View File

@@ -2,21 +2,21 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"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/polecat"
"github.com/steveyegge/gastown/internal/refinery"
"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/witness"
"github.com/steveyegge/gastown/internal/workspace"
)
@@ -68,28 +68,45 @@ var rigRemoveCmd = &cobra.Command{
RunE: runRigRemove,
}
var rigInfoCmd = &cobra.Command{
Use: "info <name>",
Short: "Show detailed information about a rig",
Long: `Show detailed status information for a specific rig.
var rigResetCmd = &cobra.Command{
Use: "reset",
Short: "Reset rig state (handoff content, etc.)",
Long: `Reset various rig state.
Displays:
- Rig path and git URL
- Active polecats with status
- Refinery status
- Witness status
- Beads summary (open issues count)
By default, resets all resettable state. Use flags to reset specific items.
Example:
gt rig info gastown`,
Examples:
gt rig reset # Reset all state
gt rig reset --handoff # Clear handoff content only`,
RunE: runRigReset,
}
var rigShutdownCmd = &cobra.Command{
Use: "shutdown <rig>",
Short: "Gracefully stop all rig agents",
Long: `Stop all agents in a rig.
This command gracefully shuts down:
- All polecat sessions
- The refinery (if running)
- The witness (if running)
Use --force to skip graceful shutdown and kill immediately.
Examples:
gt rig shutdown gastown
gt rig shutdown gastown --force`,
Args: cobra.ExactArgs(1),
RunE: runRigInfo,
RunE: runRigShutdown,
}
// Flags
var (
rigAddPrefix string
rigAddCrew string
rigAddPrefix string
rigAddCrew string
rigResetHandoff bool
rigResetRole string
rigShutdownForce bool
)
func init() {
@@ -97,10 +114,16 @@ func init() {
rigCmd.AddCommand(rigAddCmd)
rigCmd.AddCommand(rigListCmd)
rigCmd.AddCommand(rigRemoveCmd)
rigCmd.AddCommand(rigInfoCmd)
rigCmd.AddCommand(rigResetCmd)
rigCmd.AddCommand(rigShutdownCmd)
rigAddCmd.Flags().StringVar(&rigAddPrefix, "prefix", "", "Beads issue prefix (default: derived from name)")
rigAddCmd.Flags().StringVar(&rigAddCrew, "crew", "main", "Default crew workspace name")
rigResetCmd.Flags().BoolVar(&rigResetHandoff, "handoff", false, "Clear handoff content")
rigResetCmd.Flags().StringVar(&rigResetRole, "role", "", "Role to reset (default: auto-detect from cwd)")
rigShutdownCmd.Flags().BoolVarP(&rigShutdownForce, "force", "f", false, "Force immediate shutdown")
}
func runRigAdd(cmd *cobra.Command, args []string) error {
@@ -262,14 +285,53 @@ func runRigRemove(cmd *cobra.Command, args []string) error {
return nil
}
func runRigReset(cmd *cobra.Command, args []string) error {
// Find workspace
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
// Determine role to reset
roleKey := rigResetRole
if roleKey == "" {
// Auto-detect from cwd
ctx := detectRole(cwd, townRoot)
if ctx.Role == RoleUnknown {
return fmt.Errorf("could not detect role from current directory; use --role to specify")
}
roleKey = string(ctx.Role)
}
// If no specific flags, reset all; otherwise only reset what's specified
resetAll := !rigResetHandoff
bd := beads.New(townRoot)
// Reset handoff content
if resetAll || rigResetHandoff {
if err := bd.ClearHandoffContent(roleKey); err != nil {
return fmt.Errorf("clearing handoff content: %w", err)
}
fmt.Printf("%s Cleared handoff content for %s\n", style.Success.Render("✓"), roleKey)
}
return nil
}
// Helper to check if path exists
func pathExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func runRigInfo(cmd *cobra.Command, args []string) error {
name := args[0]
func runRigShutdown(cmd *cobra.Command, args []string) error {
rigName := args[0]
// Find workspace
townRoot, err := workspace.FindFromCwdOrError()
@@ -277,317 +339,63 @@ func runRigInfo(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Load rigs config
// Load rigs config and get rig
rigsPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsPath)
if err != nil {
return fmt.Errorf("loading rigs config: %w", err)
rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)}
}
// Create rig manager and get the rig
g := git.NewGit(townRoot)
mgr := rig.NewManager(townRoot, rigsConfig, g)
r, err := mgr.GetRig(name)
rigMgr := rig.NewManager(townRoot, rigsConfig, g)
r, err := rigMgr.GetRig(rigName)
if err != nil {
return fmt.Errorf("rig not found: %s", name)
return fmt.Errorf("rig '%s' not found", rigName)
}
// Print rig header
fmt.Printf("%s\n", style.Bold.Render(r.Name))
fmt.Printf(" Path: %s\n", r.Path)
fmt.Printf(" Git: %s\n", r.GitURL)
if r.Config != nil && r.Config.Prefix != "" {
fmt.Printf(" Beads prefix: %s\n", r.Config.Prefix)
}
fmt.Println()
fmt.Printf("Shutting down rig %s...\n", style.Bold.Render(rigName))
// Show polecats
fmt.Printf("%s\n", style.Bold.Render("Polecats"))
polecatMgr := polecat.NewManager(r, g)
polecats, err := polecatMgr.List()
if err != nil || len(polecats) == 0 {
fmt.Printf(" %s\n", style.Dim.Render("(none)"))
} else {
for _, p := range polecats {
stateStr := formatPolecatState(p.State)
if p.Issue != "" {
fmt.Printf(" %s %s %s\n", p.Name, stateStr, style.Dim.Render(p.Issue))
} else {
fmt.Printf(" %s %s\n", p.Name, stateStr)
}
var errors []string
// 1. Stop all polecat sessions
t := tmux.NewTmux()
sessMgr := session.NewManager(t, r)
infos, err := sessMgr.List()
if err == nil && len(infos) > 0 {
fmt.Printf(" Stopping %d polecat session(s)...\n", len(infos))
if err := sessMgr.StopAll(rigShutdownForce); err != nil {
errors = append(errors, fmt.Sprintf("polecat sessions: %v", err))
}
}
fmt.Println()
// Show crew workers
fmt.Printf("%s\n", style.Bold.Render("Crew"))
if len(r.Crew) == 0 {
fmt.Printf(" %s\n", style.Dim.Render("(none)"))
} else {
for _, c := range r.Crew {
fmt.Printf(" %s\n", c)
// 2. Stop the refinery
refMgr := refinery.NewManager(r)
refStatus, err := refMgr.Status()
if err == nil && refStatus.State == refinery.StateRunning {
fmt.Printf(" Stopping refinery...\n")
if err := refMgr.Stop(); err != nil {
errors = append(errors, fmt.Sprintf("refinery: %v", err))
}
}
fmt.Println()
// Show refinery status
fmt.Printf("%s\n", style.Bold.Render("Refinery"))
if r.HasRefinery {
refMgr := refinery.NewManager(r)
refStatus, err := refMgr.Status()
if err != nil {
fmt.Printf(" %s %s\n", style.Warning.Render("!"), "Error loading status")
} else {
stateStr := formatRefineryState(refStatus.State)
fmt.Printf(" Status: %s\n", stateStr)
if refStatus.State == refinery.StateRunning && refStatus.PID > 0 {
fmt.Printf(" PID: %d\n", refStatus.PID)
}
if refStatus.CurrentMR != nil {
fmt.Printf(" Current: %s (%s)\n", refStatus.CurrentMR.Branch, refStatus.CurrentMR.Worker)
}
if refStatus.Stats.TotalMerged > 0 || refStatus.Stats.TotalFailed > 0 {
fmt.Printf(" Stats: %d merged, %d failed\n", refStatus.Stats.TotalMerged, refStatus.Stats.TotalFailed)
}
// 3. Stop the witness
witMgr := witness.NewManager(r)
witStatus, err := witMgr.Status()
if err == nil && witStatus.State == witness.StateRunning {
fmt.Printf(" Stopping witness...\n")
if err := witMgr.Stop(); err != nil {
errors = append(errors, fmt.Sprintf("witness: %v", err))
}
} else {
fmt.Printf(" %s\n", style.Dim.Render("(not configured)"))
}
fmt.Println()
// Show witness status
fmt.Printf("%s\n", style.Bold.Render("Witness"))
if r.HasWitness {
witnessState := loadWitnessState(r.Path)
if witnessState != nil {
fmt.Printf(" Last active: %s\n", formatTimeAgo(witnessState.LastActive))
if witnessState.Session != "" {
fmt.Printf(" Session: %s\n", witnessState.Session)
}
} else {
fmt.Printf(" %s\n", style.Success.Render("configured"))
}
} else {
fmt.Printf(" %s\n", style.Dim.Render("(not configured)"))
}
fmt.Println()
// Show mayor status
fmt.Printf("%s\n", style.Bold.Render("Mayor"))
if r.HasMayor {
mayorState := loadMayorState(r.Path)
if mayorState != nil {
fmt.Printf(" Last active: %s\n", formatTimeAgo(mayorState.LastActive))
} else {
fmt.Printf(" %s\n", style.Success.Render("configured"))
}
} else {
fmt.Printf(" %s\n", style.Dim.Render("(not configured)"))
}
fmt.Println()
// Show beads summary
fmt.Printf("%s\n", style.Bold.Render("Beads"))
beadsStats := getBeadsSummary(r.Path)
if beadsStats != nil {
fmt.Printf(" Open: %d In Progress: %d Closed: %d\n",
beadsStats.Open, beadsStats.InProgress, beadsStats.Closed)
if beadsStats.Blocked > 0 {
fmt.Printf(" Blocked: %d\n", beadsStats.Blocked)
}
} else {
fmt.Printf(" %s\n", style.Dim.Render("(beads not initialized)"))
}
if len(errors) > 0 {
fmt.Printf("\n%s Some agents failed to stop:\n", style.Warning.Render("⚠"))
for _, e := range errors {
fmt.Printf(" - %s\n", e)
}
return fmt.Errorf("shutdown incomplete")
}
fmt.Printf("%s Rig %s shut down successfully\n", style.Success.Render("✓"), rigName)
return nil
}
// formatPolecatState returns a styled string for polecat state.
func formatPolecatState(state polecat.State) string {
switch state {
case polecat.StateIdle:
return style.Dim.Render("idle")
case polecat.StateActive:
return style.Info.Render("active")
case polecat.StateWorking:
return style.Success.Render("working")
case polecat.StateDone:
return style.Success.Render("done")
case polecat.StateStuck:
return style.Warning.Render("stuck")
default:
return style.Dim.Render(string(state))
}
}
// formatRefineryState returns a styled string for refinery state.
func formatRefineryState(state refinery.State) string {
switch state {
case refinery.StateStopped:
return style.Dim.Render("stopped")
case refinery.StateRunning:
return style.Success.Render("running")
case refinery.StatePaused:
return style.Warning.Render("paused")
default:
return style.Dim.Render(string(state))
}
}
// loadWitnessState loads the witness state.json.
func loadWitnessState(rigPath string) *config.AgentState {
statePath := filepath.Join(rigPath, "witness", "state.json")
data, err := os.ReadFile(statePath)
if err != nil {
return nil
}
var state config.AgentState
if err := json.Unmarshal(data, &state); err != nil {
return nil
}
return &state
}
// loadMayorState loads the mayor state.json.
func loadMayorState(rigPath string) *config.AgentState {
statePath := filepath.Join(rigPath, "mayor", "state.json")
data, err := os.ReadFile(statePath)
if err != nil {
return nil
}
var state config.AgentState
if err := json.Unmarshal(data, &state); err != nil {
return nil
}
return &state
}
// formatTimeAgo formats a time as a human-readable "ago" string.
func formatTimeAgo(t time.Time) string {
if t.IsZero() {
return "never"
}
d := time.Since(t)
if d < time.Minute {
return "just now"
}
if d < time.Hour {
mins := int(d.Minutes())
if mins == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", mins)
}
if d < 24*time.Hour {
hours := int(d.Hours())
if hours == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", hours)
}
days := int(d.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
}
// BeadsSummary contains counts of issues by status.
type BeadsSummary struct {
Open int
InProgress int
Closed int
Blocked int
}
// getBeadsSummary runs bd stats to get beads summary.
func getBeadsSummary(rigPath string) *BeadsSummary {
// Check if .beads directory exists
beadsDir := filepath.Join(rigPath, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
return nil
}
// Try running bd stats --json (it may exit with code 1 but still output JSON)
cmd := exec.Command("bd", "stats", "--json")
cmd.Dir = rigPath
output, _ := cmd.CombinedOutput()
// Parse JSON output (bd stats --json may exit with error but still produce valid JSON)
var stats struct {
Open int `json:"open_issues"`
InProgress int `json:"in_progress_issues"`
Closed int `json:"closed_issues"`
Blocked int `json:"blocked_issues"`
}
if err := json.Unmarshal(output, &stats); err != nil {
// JSON parsing failed, try fallback
return getBeadsSummaryFallback(rigPath)
}
return &BeadsSummary{
Open: stats.Open,
InProgress: stats.InProgress,
Closed: stats.Closed,
Blocked: stats.Blocked,
}
}
// getBeadsSummaryFallback counts issues by parsing bd list output.
func getBeadsSummaryFallback(rigPath string) *BeadsSummary {
summary := &BeadsSummary{}
// Count open issues
if count := countBeadsIssues(rigPath, "open"); count >= 0 {
summary.Open = count
}
// Count in_progress issues
if count := countBeadsIssues(rigPath, "in_progress"); count >= 0 {
summary.InProgress = count
}
// Count closed issues
if count := countBeadsIssues(rigPath, "closed"); count >= 0 {
summary.Closed = count
}
// Count blocked issues
cmd := exec.Command("bd", "blocked")
cmd.Dir = rigPath
output, err := cmd.Output()
if err == nil {
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
// Filter out empty lines and header
count := 0
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "Blocked") && !strings.HasPrefix(line, "---") {
count++
}
}
summary.Blocked = count
}
return summary
}
// countBeadsIssues counts issues with a given status.
func countBeadsIssues(rigPath, status string) int {
cmd := exec.Command("bd", "list", "--status="+status)
cmd.Dir = rigPath
output, err := cmd.Output()
if err != nil {
return 0
}
// Count non-empty lines (each line is one issue)
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
count := 0
for _, line := range lines {
if strings.TrimSpace(line) != "" {
count++
}
}
return count
}

View File

@@ -3,8 +3,10 @@ package cmd
import (
"os"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/keepalive"
)
var rootCmd = &cobra.Command{
@@ -14,6 +16,12 @@ var rootCmd = &cobra.Command{
It coordinates agent spawning, work distribution, and communication
across distributed teams of AI agents working on shared codebases.`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// Signal agent activity by touching keepalive file
// Build command path: gt status, gt mail send, etc.
cmdPath := buildCommandPath(cmd)
keepalive.TouchWithArgs(cmdPath, args)
},
}
// Execute runs the root command
@@ -27,3 +35,13 @@ func init() {
// Global flags can be added here
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file")
}
// buildCommandPath walks the command hierarchy to build the full command path.
// For example: "gt mail send", "gt status", etc.
func buildCommandPath(cmd *cobra.Command) string {
var parts []string
for c := cmd; c != nil; c = c.Parent() {
parts = append([]string{c.Name()}, parts...)
}
return strings.Join(parts, " ")
}

View File

@@ -5,7 +5,9 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
@@ -29,8 +31,9 @@ var (
)
var sessionCmd = &cobra.Command{
Use: "session",
Short: "Manage polecat sessions",
Use: "session",
Aliases: []string{"sess"},
Short: "Manage polecat sessions",
Long: `Manage tmux sessions for polecats.
Sessions are tmux sessions running Claude for each polecat.
@@ -84,12 +87,17 @@ Shows session status, rig, and polecat name. Use --rig to filter by rig.`,
}
var sessionCaptureCmd = &cobra.Command{
Use: "capture <rig>/<polecat>",
Use: "capture <rig>/<polecat> [count]",
Short: "Capture recent session output",
Long: `Capture recent output from a polecat session.
Returns the last N lines of terminal output. Useful for checking progress.`,
Args: cobra.ExactArgs(1),
Returns the last N lines of terminal output. Useful for checking progress.
Examples:
gt session capture wyvern/Toast # Last 100 lines (default)
gt session capture wyvern/Toast 50 # Last 50 lines
gt session capture wyvern/Toast -n 50 # Same as above`,
Args: cobra.RangeArgs(1, 2),
RunE: runSessionCapture,
}
@@ -107,6 +115,27 @@ Examples:
RunE: runSessionInject,
}
var sessionRestartCmd = &cobra.Command{
Use: "restart <rig>/<polecat>",
Short: "Restart a polecat session",
Long: `Restart a polecat session (stop + start).
Gracefully stops the current session and starts a fresh one.
Use --force to skip graceful shutdown.`,
Args: cobra.ExactArgs(1),
RunE: runSessionRestart,
}
var sessionStatusCmd = &cobra.Command{
Use: "status <rig>/<polecat>",
Short: "Show session status details",
Long: `Show detailed status for a polecat session.
Displays running state, uptime, session info, and activity.`,
Args: cobra.ExactArgs(1),
RunE: runSessionStatus,
}
func init() {
// Start flags
sessionStartCmd.Flags().StringVar(&sessionIssue, "issue", "", "Issue ID to work on")
@@ -125,6 +154,9 @@ func init() {
sessionInjectCmd.Flags().StringVarP(&sessionMessage, "message", "m", "", "Message to inject")
sessionInjectCmd.Flags().StringVarP(&sessionFile, "file", "f", "", "File to read message from")
// Restart flags
sessionRestartCmd.Flags().BoolVarP(&sessionForce, "force", "f", false, "Force immediate shutdown")
// Add subcommands
sessionCmd.AddCommand(sessionStartCmd)
sessionCmd.AddCommand(sessionStopCmd)
@@ -132,6 +164,8 @@ func init() {
sessionCmd.AddCommand(sessionListCmd)
sessionCmd.AddCommand(sessionCaptureCmd)
sessionCmd.AddCommand(sessionInjectCmd)
sessionCmd.AddCommand(sessionRestartCmd)
sessionCmd.AddCommand(sessionStatusCmd)
rootCmd.AddCommand(sessionCmd)
}
@@ -351,7 +385,20 @@ func runSessionCapture(cmd *cobra.Command, args []string) error {
return err
}
output, err := mgr.Capture(polecatName, sessionLines)
// Use positional count if provided, otherwise use flag value
lines := sessionLines
if len(args) > 1 {
n, err := strconv.Atoi(args[1])
if err != nil {
return fmt.Errorf("invalid line count '%s': must be a number", args[1])
}
if n <= 0 {
return fmt.Errorf("line count must be positive, got %d", n)
}
lines = n
}
output, err := mgr.Capture(polecatName, lines)
if err != nil {
return fmt.Errorf("capturing output: %w", err)
}
@@ -393,3 +440,108 @@ func runSessionInject(cmd *cobra.Command, args []string) error {
style.Bold.Render("✓"), rigName, polecatName)
return nil
}
func runSessionRestart(cmd *cobra.Command, args []string) error {
rigName, polecatName, err := parseAddress(args[0])
if err != nil {
return err
}
mgr, _, err := getSessionManager(rigName)
if err != nil {
return err
}
// Check if running
running, err := mgr.IsRunning(polecatName)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
if running {
// Stop first
if sessionForce {
fmt.Printf("Force stopping session for %s/%s...\n", rigName, polecatName)
} else {
fmt.Printf("Stopping session for %s/%s...\n", rigName, polecatName)
}
if err := mgr.Stop(polecatName, sessionForce); err != nil {
return fmt.Errorf("stopping session: %w", err)
}
}
// Start fresh session
fmt.Printf("Starting session for %s/%s...\n", rigName, polecatName)
opts := session.StartOptions{}
if err := mgr.Start(polecatName, opts); err != nil {
return fmt.Errorf("starting session: %w", err)
}
fmt.Printf("%s Session restarted. Attach with: %s\n",
style.Bold.Render("✓"),
style.Dim.Render(fmt.Sprintf("gt session at %s/%s", rigName, polecatName)))
return nil
}
func runSessionStatus(cmd *cobra.Command, args []string) error {
rigName, polecatName, err := parseAddress(args[0])
if err != nil {
return err
}
mgr, _, err := getSessionManager(rigName)
if err != nil {
return err
}
// Get session info
info, err := mgr.Status(polecatName)
if err != nil {
return fmt.Errorf("getting status: %w", err)
}
// Format output
fmt.Printf("%s Session: %s/%s\n\n", style.Bold.Render("📺"), rigName, polecatName)
if info.Running {
fmt.Printf(" State: %s\n", style.Bold.Render("● running"))
} else {
fmt.Printf(" State: %s\n", style.Dim.Render("○ stopped"))
return nil
}
fmt.Printf(" Session ID: %s\n", info.SessionID)
if info.Attached {
fmt.Printf(" Attached: yes\n")
} else {
fmt.Printf(" Attached: no\n")
}
if !info.Created.IsZero() {
uptime := time.Since(info.Created)
fmt.Printf(" Created: %s\n", info.Created.Format("2006-01-02 15:04:05"))
fmt.Printf(" Uptime: %s\n", formatDuration(uptime))
}
fmt.Printf("\nAttach with: %s\n", style.Dim.Render(fmt.Sprintf("gt session at %s/%s", rigName, polecatName)))
return nil
}
// formatDuration formats a duration for human display.
func formatDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
}
hours := int(d.Hours())
mins := int(d.Minutes()) % 60
if hours >= 24 {
days := hours / 24
hours = hours % 24
return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
}
return fmt.Sprintf("%dh %dm", hours, mins)
}

View File

@@ -4,12 +4,14 @@ import (
"bytes"
"encoding/json"
"fmt"
"math/rand"
"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/polecat"
@@ -20,28 +22,49 @@ import (
"github.com/steveyegge/gastown/internal/workspace"
)
// polecatNames are Mad Max: Fury Road themed names for auto-generated polecats.
var polecatNames = []string{
"Nux", "Toast", "Capable", "Cheedo", "Dag", "Rictus", "Slit", "Morsov",
"Ace", "Coma", "Valkyrie", "Keeper", "Vuvalini", "Organic", "Immortan",
"Corpus", "Doof", "Scabrous", "Splendid", "Fragile",
}
// Spawn command flags
var (
spawnIssue string
spawnMessage string
spawnCreate bool
spawnNoStart bool
spawnIssue string
spawnMessage string
spawnCreate bool
spawnNoStart bool
spawnPolecat string
spawnRig string
spawnMolecule string
)
var spawnCmd = &cobra.Command{
Use: "spawn <rig/polecat> | <rig>",
Short: "Spawn a polecat with work assignment",
Use: "spawn [rig/polecat | rig]",
Aliases: []string{"sp"},
Short: "Spawn a polecat with work assignment",
Long: `Spawn a polecat with a work assignment.
Assigns an issue or task to a polecat and starts a session. If no polecat
is specified, auto-selects an idle polecat in the rig.
When --molecule is specified, the molecule is first instantiated on the parent
issue (creating child steps), then the polecat is spawned on the first ready step.
Examples:
gt spawn gastown/Toast --issue gt-abc
gt spawn gastown --issue gt-def # auto-select polecat
gt spawn gastown/Nux -m "Fix the tests" # free-form task
gt spawn gastown/Capable --issue gt-xyz --create # create if missing`,
Args: cobra.ExactArgs(1),
gt spawn gastown/Capable --issue gt-xyz --create # create if missing
# Flag-based selection (rig inferred from current directory):
gt spawn --issue gt-xyz --polecat Angharad
gt spawn --issue gt-abc --rig gastown --polecat Toast
# With molecule workflow:
gt spawn --issue gt-abc --molecule mol-engineer-box`,
Args: cobra.MaximumNArgs(1),
RunE: runSpawn,
}
@@ -50,6 +73,9 @@ func init() {
spawnCmd.Flags().StringVarP(&spawnMessage, "message", "m", "", "Free-form task description")
spawnCmd.Flags().BoolVar(&spawnCreate, "create", false, "Create polecat if it doesn't exist")
spawnCmd.Flags().BoolVar(&spawnNoStart, "no-start", false, "Assign work but don't start session")
spawnCmd.Flags().StringVar(&spawnPolecat, "polecat", "", "Polecat name (alternative to positional arg)")
spawnCmd.Flags().StringVar(&spawnRig, "rig", "", "Rig name (defaults to current directory's rig)")
spawnCmd.Flags().StringVar(&spawnMolecule, "molecule", "", "Molecule ID to instantiate on the issue")
rootCmd.AddCommand(spawnCmd)
}
@@ -69,18 +95,40 @@ func runSpawn(cmd *cobra.Command, args []string) error {
return fmt.Errorf("must specify --issue or -m/--message")
}
// Parse address: rig/polecat or just rig
rigName, polecatName, err := parseSpawnAddress(args[0])
if err != nil {
return err
// --molecule requires --issue
if spawnMolecule != "" && spawnIssue == "" {
return fmt.Errorf("--molecule requires --issue to be specified")
}
// Find workspace and rig
// Find workspace first (needed for rig inference)
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
var rigName, polecatName string
// Determine rig and polecat from positional arg or flags
if len(args) > 0 {
// Parse address: rig/polecat or just rig
rigName, polecatName, err = parseSpawnAddress(args[0])
if err != nil {
return err
}
} else {
// No positional arg - use flags
polecatName = spawnPolecat
rigName = spawnRig
// If no --rig flag, infer from current directory
if rigName == "" {
rigName, err = inferRigFromCwd(townRoot)
if err != nil {
return fmt.Errorf("cannot determine rig: %w\nUse --rig to specify explicitly or provide rig/polecat as positional arg", err)
}
}
}
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
if err != nil {
@@ -102,9 +150,16 @@ func runSpawn(cmd *cobra.Command, args []string) error {
if polecatName == "" {
polecatName, err = selectIdlePolecat(polecatMgr, r)
if err != nil {
return fmt.Errorf("auto-select polecat: %w", err)
// If --create is set, generate a new polecat name instead of failing
if spawnCreate {
polecatName = generatePolecatName(polecatMgr)
fmt.Printf("Generated polecat name: %s\n", polecatName)
} else {
return fmt.Errorf("auto-select polecat: %w", err)
}
} else {
fmt.Printf("Auto-selected polecat: %s\n", polecatName)
}
fmt.Printf("Auto-selected polecat: %s\n", polecatName)
}
// Check/create polecat
@@ -129,20 +184,92 @@ func runSpawn(cmd *cobra.Command, args []string) error {
return fmt.Errorf("polecat '%s' is already working on %s", polecatName, pc.Issue)
}
// Get issue details if specified
// Beads operations use mayor/rig directory (rig-level beads)
beadsPath := filepath.Join(r.Path, "mayor", "rig")
// Sync beads to ensure fresh state before spawn operations
if err := syncBeads(beadsPath, true); err != nil {
// Non-fatal - continue with possibly stale beads
fmt.Printf("%s beads sync: %v\n", style.Dim.Render("Warning:"), err)
}
// Handle molecule instantiation if specified
if spawnMolecule != "" {
b := beads.New(beadsPath)
// Get the molecule
mol, err := b.Show(spawnMolecule)
if err != nil {
return fmt.Errorf("getting molecule %s: %w", spawnMolecule, err)
}
if mol.Type != "molecule" {
return fmt.Errorf("%s is not a molecule (type: %s)", spawnMolecule, mol.Type)
}
// Validate the molecule
if err := beads.ValidateMolecule(mol); err != nil {
return fmt.Errorf("invalid molecule: %w", err)
}
// Get the parent issue
parent, err := b.Show(spawnIssue)
if err != nil {
return fmt.Errorf("getting parent issue %s: %w", spawnIssue, err)
}
// Instantiate the molecule
fmt.Printf("Instantiating molecule %s on %s...\n", spawnMolecule, spawnIssue)
steps, err := b.InstantiateMolecule(mol, parent, beads.InstantiateOptions{})
if err != nil {
return fmt.Errorf("instantiating molecule: %w", err)
}
fmt.Printf("%s Created %d steps\n", style.Bold.Render("✓"), len(steps))
for _, step := range steps {
fmt.Printf(" %s: %s\n", style.Dim.Render(step.ID), step.Title)
}
// Find the first ready step (one with no dependencies)
var firstReadyStep *beads.Issue
for _, step := range steps {
if len(step.DependsOn) == 0 {
firstReadyStep = step
break
}
}
if firstReadyStep == nil {
return fmt.Errorf("no ready step found in molecule (all steps have dependencies)")
}
// Switch to spawning on the first ready step
fmt.Printf("\nSpawning on first ready step: %s\n", firstReadyStep.ID)
spawnIssue = firstReadyStep.ID
}
// Get or create issue
var issue *BeadsIssue
var assignmentID string
if spawnIssue != "" {
issue, err = fetchBeadsIssue(r.Path, spawnIssue)
// Use existing issue
issue, err = fetchBeadsIssue(beadsPath, spawnIssue)
if err != nil {
return fmt.Errorf("fetching issue %s: %w", spawnIssue, err)
}
assignmentID = spawnIssue
} else {
// Create a beads issue for free-form task
fmt.Printf("Creating beads issue for task...\n")
issue, err = createBeadsTask(beadsPath, spawnMessage)
if err != nil {
return fmt.Errorf("creating task issue: %w", err)
}
assignmentID = issue.ID
fmt.Printf("Created issue %s\n", assignmentID)
}
// Assign issue/task to polecat
assignmentID := spawnIssue
if assignmentID == "" {
assignmentID = "task:" + time.Now().Format("20060102-150405")
}
// Assign issue to polecat (sets issue.assignee in beads)
if err := polecatMgr.AssignIssue(polecatName, assignmentID); err != nil {
return fmt.Errorf("assigning issue: %w", err)
}
@@ -151,6 +278,12 @@ func runSpawn(cmd *cobra.Command, args []string) error {
style.Bold.Render("✓"),
assignmentID, rigName, polecatName)
// Sync beads to push assignment changes
if err := syncBeads(beadsPath, false); err != nil {
// Non-fatal warning
fmt.Printf("%s beads push: %v\n", style.Dim.Render("Warning:"), err)
}
// Stop here if --no-start
if spawnNoStart {
fmt.Printf("\n %s\n", style.Dim.Render("Use 'gt session start' to start the session"))
@@ -172,12 +305,14 @@ func runSpawn(cmd *cobra.Command, args []string) error {
if err := sessMgr.Start(polecatName, session.StartOptions{}); err != nil {
return fmt.Errorf("starting session: %w", err)
}
// Wait for claude to initialize
time.Sleep(2 * time.Second)
// Wait for Claude to fully initialize (needs 4-5s for prompt)
fmt.Printf("Waiting for Claude to initialize...\n")
time.Sleep(5 * time.Second)
}
// Inject initial context
context := buildSpawnContext(issue, spawnMessage)
fmt.Printf("Injecting work assignment...\n")
if err := sessMgr.Inject(polecatName, context); err != nil {
return fmt.Errorf("injecting context: %w", err)
}
@@ -201,6 +336,38 @@ func parseSpawnAddress(addr string) (rigName, polecatName string, err error) {
return addr, "", nil
}
// generatePolecatName generates a unique polecat name that doesn't conflict with existing ones.
func generatePolecatName(mgr *polecat.Manager) string {
existing, _ := mgr.List()
existingNames := make(map[string]bool)
for _, p := range existing {
existingNames[p.Name] = true
}
// Try to find an unused name from the list
// Shuffle to avoid always picking the same name
shuffled := make([]string, len(polecatNames))
copy(shuffled, polecatNames)
rand.Shuffle(len(shuffled), func(i, j int) {
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
})
for _, name := range shuffled {
if !existingNames[name] {
return name
}
}
// All names taken, generate one with a number suffix
base := shuffled[0]
for i := 2; ; i++ {
name := fmt.Sprintf("%s%d", base, i)
if !existingNames[name] {
return name
}
}
}
// selectIdlePolecat finds an idle polecat in the rig.
func selectIdlePolecat(mgr *polecat.Manager, r *rig.Rig) (string, error) {
polecats, err := mgr.List()
@@ -268,6 +435,56 @@ func fetchBeadsIssue(rigPath, issueID string) (*BeadsIssue, error) {
return &issues[0], nil
}
// createBeadsTask creates a new beads task issue for a free-form task message.
func createBeadsTask(rigPath, message string) (*BeadsIssue, error) {
// Truncate message for title if too long
title := message
if len(title) > 60 {
title = title[:57] + "..."
}
// Use bd create to make a new task issue
cmd := exec.Command("bd", "create",
"--title="+title,
"--type=task",
"--priority=2",
"--description="+message,
"--json")
cmd.Dir = rigPath
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("%s", errMsg)
}
return nil, err
}
// bd create --json returns the created issue
var issue BeadsIssue
if err := json.Unmarshal(stdout.Bytes(), &issue); err != nil {
return nil, fmt.Errorf("parsing created issue: %w", err)
}
return &issue, nil
}
// syncBeads runs bd sync in the given directory.
// This ensures beads state is fresh before spawn operations.
func syncBeads(workDir string, fromMain bool) error {
args := []string{"sync"}
if fromMain {
args = append(args, "--from-main")
}
cmd := exec.Command("bd", args...)
cmd.Dir = workDir
return cmd.Run()
}
// buildSpawnContext creates the initial context message for the polecat.
func buildSpawnContext(issue *BeadsIssue, message string) string {
var sb strings.Builder
@@ -286,7 +503,14 @@ func buildSpawnContext(issue *BeadsIssue, message string) string {
sb.WriteString(fmt.Sprintf("Task: %s\n", message))
}
sb.WriteString("\nWork on this task. When complete, commit your changes and signal DONE.\n")
sb.WriteString("\n## Workflow\n")
sb.WriteString("1. Run `gt prime` to load polecat context\n")
sb.WriteString("2. Run `bd sync --from-main` to get fresh beads\n")
sb.WriteString("3. Work on your task, commit changes\n")
sb.WriteString("4. Run `bd close <issue-id>` when done\n")
sb.WriteString("5. Run `bd sync` to push beads changes\n")
sb.WriteString("6. Push code: `git push origin HEAD`\n")
sb.WriteString("7. Signal DONE with summary\n")
return sb.String()
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/crew"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/style"
@@ -17,8 +18,9 @@ import (
var statusJSON bool
var statusCmd = &cobra.Command{
Use: "status",
Short: "Show overall town status",
Use: "status",
Aliases: []string{"stat"},
Short: "Show overall town status",
Long: `Display the current status of the Gas Town workspace.
Shows town name, registered rigs, active polecats, and witness status.`,
@@ -43,6 +45,8 @@ type RigStatus struct {
Name string `json:"name"`
Polecats []string `json:"polecats"`
PolecatCount int `json:"polecat_count"`
Crews []string `json:"crews"`
CrewCount int `json:"crew_count"`
HasWitness bool `json:"has_witness"`
HasRefinery bool `json:"has_refinery"`
}
@@ -51,6 +55,7 @@ type RigStatus struct {
type StatusSum struct {
RigCount int `json:"rig_count"`
PolecatCount int `json:"polecat_count"`
CrewCount int `json:"crew_count"`
WitnessCount int `json:"witness_count"`
RefineryCount int `json:"refinery_count"`
}
@@ -103,10 +108,22 @@ func runStatus(cmd *cobra.Command, args []string) error {
HasWitness: r.HasWitness,
HasRefinery: r.HasRefinery,
}
// Count crew workers
crewGit := git.NewGit(r.Path)
crewMgr := crew.NewManager(r, crewGit)
if workers, err := crewMgr.List(); err == nil {
for _, w := range workers {
rs.Crews = append(rs.Crews, w.Name)
}
rs.CrewCount = len(workers)
}
status.Rigs = append(status.Rigs, rs)
// Update summary
status.Summary.PolecatCount += len(r.Polecats)
status.Summary.CrewCount += rs.CrewCount
if r.HasWitness {
status.Summary.WitnessCount++
}
@@ -138,6 +155,7 @@ func outputStatusText(status TownStatus) error {
fmt.Printf("%s\n", style.Bold.Render("Summary"))
fmt.Printf(" Rigs: %d\n", status.Summary.RigCount)
fmt.Printf(" Polecats: %d\n", status.Summary.PolecatCount)
fmt.Printf(" Crews: %d\n", status.Summary.CrewCount)
fmt.Printf(" Witnesses: %d\n", status.Summary.WitnessCount)
fmt.Printf(" Refineries: %d\n", status.Summary.RefineryCount)
@@ -157,6 +175,9 @@ func outputStatusText(status TownStatus) error {
if r.HasRefinery {
indicators += " 🏭"
}
if r.CrewCount > 0 {
indicators += " 👤"
}
fmt.Printf(" %s%s\n", style.Bold.Render(r.Name), indicators)
@@ -165,6 +186,10 @@ func outputStatusText(status TownStatus) error {
} else {
fmt.Printf(" %s\n", style.Dim.Render("No polecats"))
}
if len(r.Crews) > 0 {
fmt.Printf(" Crews: %v\n", r.Crews)
}
}
return nil

145
internal/cmd/statusline.go Normal file
View File

@@ -0,0 +1,145 @@
package cmd
import (
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/tmux"
)
var (
statusLineSession string
)
var statusLineCmd = &cobra.Command{
Use: "status-line",
Short: "Output status line content for tmux (internal use)",
Hidden: true, // Internal command called by tmux
RunE: runStatusLine,
}
func init() {
rootCmd.AddCommand(statusLineCmd)
statusLineCmd.Flags().StringVar(&statusLineSession, "session", "", "Tmux session name")
}
func runStatusLine(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()
// Get session environment
var rigName, polecat, crew, issue, role string
if statusLineSession != "" {
rigName, _ = t.GetEnvironment(statusLineSession, "GT_RIG")
polecat, _ = t.GetEnvironment(statusLineSession, "GT_POLECAT")
crew, _ = t.GetEnvironment(statusLineSession, "GT_CREW")
issue, _ = t.GetEnvironment(statusLineSession, "GT_ISSUE")
role, _ = t.GetEnvironment(statusLineSession, "GT_ROLE")
} else {
// Fallback to process environment
rigName = os.Getenv("GT_RIG")
polecat = os.Getenv("GT_POLECAT")
crew = os.Getenv("GT_CREW")
issue = os.Getenv("GT_ISSUE")
role = os.Getenv("GT_ROLE")
}
// Determine identity and output based on role
if role == "mayor" || statusLineSession == "gt-mayor" {
return runMayorStatusLine(t)
}
// Build mail identity
var identity string
if rigName != "" {
if polecat != "" {
identity = fmt.Sprintf("%s/%s", rigName, polecat)
} else if crew != "" {
identity = fmt.Sprintf("%s/%s", rigName, crew)
}
}
// Build status parts
var parts []string
// Current issue
if issue != "" {
parts = append(parts, issue)
}
// Mail count
if identity != "" {
unread := getUnreadMailCount(identity)
if unread > 0 {
parts = append(parts, fmt.Sprintf("\U0001F4EC %d", unread)) // mail emoji
}
}
// Output
if len(parts) > 0 {
fmt.Print(strings.Join(parts, " | ") + " |")
}
return nil
}
func runMayorStatusLine(t *tmux.Tmux) error {
// Count active sessions by listing tmux sessions
sessions, err := t.ListSessions()
if err != nil {
return nil // Silent fail
}
// Count gt-* sessions (polecats) and rigs
polecatCount := 0
rigs := make(map[string]bool)
for _, s := range sessions {
if strings.HasPrefix(s, "gt-") && s != "gt-mayor" {
polecatCount++
// Extract rig name: gt-<rig>-<worker>
parts := strings.SplitN(s, "-", 3)
if len(parts) >= 2 {
rigs[parts[1]] = true
}
}
}
rigCount := len(rigs)
// Get mayor mail
unread := getUnreadMailCount("mayor/")
// Build status
var parts []string
parts = append(parts, fmt.Sprintf("%d polecats", polecatCount))
parts = append(parts, fmt.Sprintf("%d rigs", rigCount))
if unread > 0 {
parts = append(parts, fmt.Sprintf("\U0001F4EC %d", unread))
}
fmt.Print(strings.Join(parts, " | ") + " |")
return nil
}
// getUnreadMailCount returns unread mail count for an identity.
// Fast path - returns 0 on any error.
func getUnreadMailCount(identity string) int {
// Find workspace
workDir, err := findBeadsWorkDir()
if err != nil {
return 0
}
// Create mailbox using beads
mailbox := mail.NewMailboxBeads(identity, workDir)
// Get count
_, unread, err := mailbox.Count()
if err != nil {
return 0
}
return unread
}

View File

@@ -11,9 +11,12 @@ import (
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/git"
"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/swarm"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace"
)
@@ -116,7 +119,7 @@ func init() {
swarmCreateCmd.Flags().StringSliceVar(&swarmWorkers, "worker", nil, "Polecat names to assign (repeatable)")
swarmCreateCmd.Flags().BoolVar(&swarmStart, "start", false, "Start swarm immediately after creation")
swarmCreateCmd.Flags().StringVar(&swarmTarget, "target", "main", "Target branch for landing")
swarmCreateCmd.MarkFlagRequired("epic")
_ = swarmCreateCmd.MarkFlagRequired("epic")
// Status flags
swarmStatusCmd.Flags().BoolVar(&swarmStatusJSON, "json", false, "Output as JSON")
@@ -291,13 +294,14 @@ func runSwarmCreate(cmd *cobra.Command, args []string) error {
func runSwarmStart(cmd *cobra.Command, args []string) error {
swarmID := args[0]
// Find the swarm
// Find the swarm and its rig
rigs, _, err := getAllRigs()
if err != nil {
return err
}
var store *SwarmStore
var foundRig *rig.Rig
for _, r := range rigs {
s, err := LoadSwarmStore(r.Path)
@@ -307,6 +311,7 @@ func runSwarmStart(cmd *cobra.Command, args []string) error {
if _, exists := s.Swarms[swarmID]; exists {
store = s
foundRig = r
break
}
}
@@ -329,6 +334,73 @@ func runSwarmStart(cmd *cobra.Command, args []string) error {
}
fmt.Printf("%s Swarm %s started\n", style.Bold.Render("✓"), swarmID)
// Spawn sessions for workers with tasks
if len(sw.Workers) > 0 && len(sw.Tasks) > 0 {
fmt.Printf("\nSpawning workers...\n")
if err := spawnSwarmWorkers(foundRig, sw); err != nil {
fmt.Printf("Warning: failed to spawn some workers: %v\n", err)
}
}
return nil
}
// spawnSwarmWorkers spawns sessions for swarm workers with task assignments.
func spawnSwarmWorkers(r *rig.Rig, sw *swarm.Swarm) error {
t := tmux.NewTmux()
sessMgr := session.NewManager(t, r)
polecatGit := git.NewGit(r.Path)
polecatMgr := polecat.NewManager(r, polecatGit)
// Pair workers with tasks (round-robin if more tasks than workers)
workerIdx := 0
for i, task := range sw.Tasks {
if task.State != swarm.TaskPending {
continue
}
if workerIdx >= len(sw.Workers) {
break // No more workers
}
worker := sw.Workers[workerIdx]
workerIdx++
// Assign task to worker in swarm state
sw.Tasks[i].Assignee = worker
sw.Tasks[i].State = swarm.TaskAssigned
// Update polecat state
if err := polecatMgr.AssignIssue(worker, task.IssueID); err != nil {
fmt.Printf(" Warning: couldn't assign %s to %s: %v\n", task.IssueID, worker, err)
continue
}
// Check if already running
running, _ := sessMgr.IsRunning(worker)
if running {
fmt.Printf(" %s already running, injecting task...\n", worker)
} else {
fmt.Printf(" Starting %s...\n", worker)
if err := sessMgr.Start(worker, session.StartOptions{}); err != nil {
fmt.Printf(" Warning: couldn't start %s: %v\n", worker, err)
continue
}
// Wait for Claude to initialize
time.Sleep(5 * time.Second)
}
// Inject work assignment
context := fmt.Sprintf("[SWARM] You are part of swarm %s.\n\nAssigned task: %s\nTitle: %s\n\nWork on this task. When complete, commit and signal DONE.",
sw.ID, task.IssueID, task.Title)
if err := sessMgr.Inject(worker, context); err != nil {
fmt.Printf(" Warning: couldn't inject to %s: %v\n", worker, err)
} else {
fmt.Printf(" %s → %s ✓\n", worker, task.IssueID)
}
}
return nil
}
@@ -533,8 +605,8 @@ func runSwarmLand(cmd *cobra.Command, args []string) error {
// Create manager and land
mgr := swarm.NewManager(foundRig)
// Reload swarm into manager
mgr.Create(sw.EpicID, sw.Workers, sw.TargetBranch)
mgr.UpdateState(sw.ID, sw.State)
_, _ = mgr.Create(sw.EpicID, sw.Workers, sw.TargetBranch)
_ = mgr.UpdateState(sw.ID, sw.State)
fmt.Printf("Landing swarm %s to %s...\n", swarmID, sw.TargetBranch)

193
internal/cmd/theme.go Normal file
View File

@@ -0,0 +1,193 @@
package cmd
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/tmux"
)
var (
themeListFlag bool
themeApplyFlag bool
)
var themeCmd = &cobra.Command{
Use: "theme [name]",
Short: "View or set tmux theme for the current rig",
Long: `Manage tmux status bar themes for Gas Town sessions.
Without arguments, shows the current theme assignment.
With a name argument, sets the theme for this rig.
Examples:
gt theme # Show current theme
gt theme --list # List available themes
gt theme forest # Set theme to 'forest'
gt theme apply # Apply theme to all running sessions in this rig`,
RunE: runTheme,
}
var themeApplyCmd = &cobra.Command{
Use: "apply",
Short: "Apply theme to all running sessions in this rig",
RunE: runThemeApply,
}
func init() {
rootCmd.AddCommand(themeCmd)
themeCmd.AddCommand(themeApplyCmd)
themeCmd.Flags().BoolVarP(&themeListFlag, "list", "l", false, "List available themes")
}
func runTheme(cmd *cobra.Command, args []string) error {
// List mode
if themeListFlag {
fmt.Println("Available themes:")
for _, name := range tmux.ListThemeNames() {
theme := tmux.GetThemeByName(name)
fmt.Printf(" %-10s %s\n", name, theme.Style())
}
// Also show Mayor theme
mayor := tmux.MayorTheme()
fmt.Printf(" %-10s %s (Mayor only)\n", mayor.Name, mayor.Style())
return nil
}
// Determine current rig
rigName := detectCurrentRig()
if rigName == "" {
rigName = "unknown"
}
// Show current theme assignment
if len(args) == 0 {
theme := tmux.AssignTheme(rigName)
fmt.Printf("Rig: %s\n", rigName)
fmt.Printf("Theme: %s (%s)\n", theme.Name, theme.Style())
return nil
}
// Set theme
themeName := args[0]
theme := tmux.GetThemeByName(themeName)
if theme == nil {
return fmt.Errorf("unknown theme: %s (use --list to see available themes)", themeName)
}
// TODO: Save to rig config.json
fmt.Printf("Theme '%s' selected for rig '%s'\n", themeName, rigName)
fmt.Println("Note: Run 'gt theme apply' to apply to running sessions")
fmt.Println("(Persistent config not yet implemented)")
return nil
}
func runThemeApply(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()
// Get all sessions
sessions, err := t.ListSessions()
if err != nil {
return fmt.Errorf("listing sessions: %w", err)
}
// Determine current rig
rigName := detectCurrentRig()
// Apply to matching sessions
applied := 0
for _, session := range sessions {
if !strings.HasPrefix(session, "gt-") {
continue
}
// Determine theme and identity for this session
var theme tmux.Theme
var rig, worker, role string
if session == "gt-mayor" {
theme = tmux.MayorTheme()
worker = "Mayor"
role = "coordinator"
} else {
// Parse session name: gt-<rig>-<worker> or gt-<rig>-crew-<name>
parts := strings.SplitN(session, "-", 3)
if len(parts) < 3 {
continue
}
rig = parts[1]
// Skip if not matching current rig (if we know it)
if rigName != "" && rig != rigName {
continue
}
workerPart := parts[2]
if strings.HasPrefix(workerPart, "crew-") {
worker = strings.TrimPrefix(workerPart, "crew-")
role = "crew"
} else {
worker = workerPart
role = "polecat"
}
theme = tmux.AssignTheme(rig)
}
// Apply theme and status format
if err := t.ApplyTheme(session, theme); err != nil {
fmt.Printf(" %s: failed (%v)\n", session, err)
continue
}
if err := t.SetStatusFormat(session, rig, worker, role); err != nil {
fmt.Printf(" %s: failed to set format (%v)\n", session, err)
continue
}
if err := t.SetDynamicStatus(session); err != nil {
fmt.Printf(" %s: failed to set dynamic status (%v)\n", session, err)
continue
}
fmt.Printf(" %s: applied %s theme\n", session, theme.Name)
applied++
}
if applied == 0 {
fmt.Println("No matching sessions found")
} else {
fmt.Printf("\nApplied theme to %d session(s)\n", applied)
}
return nil
}
// detectCurrentRig determines the rig from environment or cwd.
func detectCurrentRig() string {
// Try environment first
if rig := detectCurrentSession(); rig != "" {
// Extract rig from session name
parts := strings.SplitN(rig, "-", 3)
if len(parts) >= 2 && parts[0] == "gt" {
return parts[1]
}
}
// Try to detect from cwd
cwd, err := findBeadsWorkDir()
if err != nil {
return ""
}
// Extract rig name from path
// Typical paths: /Users/stevey/gt/<rig>/...
parts := strings.Split(cwd, "/")
for i, p := range parts {
if p == "gt" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}

View File

@@ -9,7 +9,7 @@ import (
// Version information - set at build time via ldflags
var (
Version = "0.1.0"
Version = "0.0.1"
BuildTime = "unknown"
GitCommit = "unknown"
)

291
internal/cmd/witness.go Normal file
View File

@@ -0,0 +1,291 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/witness"
"github.com/steveyegge/gastown/internal/workspace"
)
// Witness command flags
var (
witnessForeground bool
witnessStatusJSON bool
)
var witnessCmd = &cobra.Command{
Use: "witness",
Short: "Manage the polecat monitoring agent",
Long: `Manage the Witness monitoring agent for a rig.
The Witness monitors polecats for stuck/idle state, nudges polecats
that seem blocked, and reports status to the mayor.`,
}
var witnessStartCmd = &cobra.Command{
Use: "start <rig>",
Short: "Start the witness",
Long: `Start the Witness for a rig.
Launches the monitoring agent which watches polecats for stuck or idle
states and takes action to keep work flowing.
Examples:
gt witness start gastown
gt witness start gastown --foreground`,
Args: cobra.ExactArgs(1),
RunE: runWitnessStart,
}
var witnessStopCmd = &cobra.Command{
Use: "stop <rig>",
Short: "Stop the witness",
Long: `Stop a running Witness.
Gracefully stops the witness monitoring agent.`,
Args: cobra.ExactArgs(1),
RunE: runWitnessStop,
}
var witnessStatusCmd = &cobra.Command{
Use: "status <rig>",
Short: "Show witness status",
Long: `Show the status of a rig's Witness.
Displays running state, monitored polecats, and statistics.`,
Args: cobra.ExactArgs(1),
RunE: runWitnessStatus,
}
var witnessAttachCmd = &cobra.Command{
Use: "attach <rig>",
Aliases: []string{"at"},
Short: "Attach to witness session",
Long: `Attach to the Witness tmux session for a rig.
Attaches the current terminal to the witness's tmux session.
Detach with Ctrl-B D.
If the witness is not running, this will start it first.`,
Args: cobra.ExactArgs(1),
RunE: runWitnessAttach,
}
func init() {
// Start flags
witnessStartCmd.Flags().BoolVar(&witnessForeground, "foreground", false, "Run in foreground (default: background)")
// Status flags
witnessStatusCmd.Flags().BoolVar(&witnessStatusJSON, "json", false, "Output as JSON")
// Add subcommands
witnessCmd.AddCommand(witnessStartCmd)
witnessCmd.AddCommand(witnessStopCmd)
witnessCmd.AddCommand(witnessStatusCmd)
witnessCmd.AddCommand(witnessAttachCmd)
rootCmd.AddCommand(witnessCmd)
}
// getWitnessManager creates a witness manager for a rig.
func getWitnessManager(rigName string) (*witness.Manager, *rig.Rig, error) {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return nil, nil, fmt.Errorf("not in a Gas Town workspace: %w", err)
}
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(rigName)
if err != nil {
return nil, nil, fmt.Errorf("rig '%s' not found", rigName)
}
mgr := witness.NewManager(r)
return mgr, r, nil
}
func runWitnessStart(cmd *cobra.Command, args []string) error {
rigName := args[0]
mgr, _, err := getWitnessManager(rigName)
if err != nil {
return err
}
fmt.Printf("Starting witness for %s...\n", rigName)
if err := mgr.Start(witnessForeground); err != nil {
if err == witness.ErrAlreadyRunning {
fmt.Printf("%s Witness is already running\n", style.Dim.Render("⚠"))
return nil
}
return fmt.Errorf("starting witness: %w", err)
}
if witnessForeground {
// This will block until stopped
return nil
}
fmt.Printf("%s Witness started for %s\n", style.Bold.Render("✓"), rigName)
fmt.Printf(" %s\n", style.Dim.Render("Use 'gt witness status' to check progress"))
return nil
}
func runWitnessStop(cmd *cobra.Command, args []string) error {
rigName := args[0]
mgr, _, err := getWitnessManager(rigName)
if err != nil {
return err
}
if err := mgr.Stop(); err != nil {
if err == witness.ErrNotRunning {
fmt.Printf("%s Witness is not running\n", style.Dim.Render("⚠"))
return nil
}
return fmt.Errorf("stopping witness: %w", err)
}
fmt.Printf("%s Witness stopped for %s\n", style.Bold.Render("✓"), rigName)
return nil
}
func runWitnessStatus(cmd *cobra.Command, args []string) error {
rigName := args[0]
mgr, _, err := getWitnessManager(rigName)
if err != nil {
return err
}
w, err := mgr.Status()
if err != nil {
return fmt.Errorf("getting status: %w", err)
}
// JSON output
if witnessStatusJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(w)
}
// Human-readable output
fmt.Printf("%s Witness: %s\n\n", style.Bold.Render("👁"), rigName)
stateStr := string(w.State)
switch w.State {
case witness.StateRunning:
stateStr = style.Bold.Render("● running")
case witness.StateStopped:
stateStr = style.Dim.Render("○ stopped")
case witness.StatePaused:
stateStr = style.Dim.Render("⏸ paused")
}
fmt.Printf(" State: %s\n", stateStr)
if w.StartedAt != nil {
fmt.Printf(" Started: %s\n", w.StartedAt.Format("2006-01-02 15:04:05"))
}
if w.LastCheckAt != nil {
fmt.Printf(" Last check: %s\n", w.LastCheckAt.Format("2006-01-02 15:04:05"))
}
// Show monitored polecats
fmt.Printf("\n %s\n", style.Bold.Render("Monitored Polecats:"))
if len(w.MonitoredPolecats) == 0 {
fmt.Printf(" %s\n", style.Dim.Render("(none)"))
} else {
for _, p := range w.MonitoredPolecats {
fmt.Printf(" • %s\n", p)
}
}
fmt.Printf("\n %s\n", style.Bold.Render("Statistics:"))
fmt.Printf(" Checks today: %d\n", w.Stats.TodayChecks)
fmt.Printf(" Nudges today: %d\n", w.Stats.TodayNudges)
fmt.Printf(" Total checks: %d\n", w.Stats.TotalChecks)
fmt.Printf(" Total nudges: %d\n", w.Stats.TotalNudges)
fmt.Printf(" Total escalations: %d\n", w.Stats.TotalEscalations)
return nil
}
// witnessSessionName returns the tmux session name for a rig's witness.
func witnessSessionName(rigName string) string {
return fmt.Sprintf("gt-witness-%s", rigName)
}
func runWitnessAttach(cmd *cobra.Command, args []string) error {
rigName := args[0]
// Verify rig exists
_, r, err := getWitnessManager(rigName)
if err != nil {
return err
}
t := tmux.NewTmux()
sessionName := witnessSessionName(rigName)
// Check if session exists
running, err := t.HasSession(sessionName)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
if !running {
// Start witness session (like Mayor)
fmt.Printf("Starting witness session for %s...\n", rigName)
if err := t.NewSession(sessionName, r.Path); err != nil {
return fmt.Errorf("creating session: %w", err)
}
// Set environment
t.SetEnvironment(sessionName, "GT_ROLE", "witness")
t.SetEnvironment(sessionName, "GT_RIG", rigName)
// Apply theme (same as rig polecats)
theme := tmux.AssignTheme(rigName)
_ = t.ConfigureGasTownSession(sessionName, theme, rigName, "witness", "witness")
// Launch Claude in a respawn loop
loopCmd := `while true; do echo "👁️ Starting Witness for ` + rigName + `..."; claude --dangerously-skip-permissions; echo ""; echo "Witness exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done`
if err := t.SendKeysDelayed(sessionName, loopCmd, 200); err != nil {
return fmt.Errorf("sending command: %w", err)
}
}
// Attach to the session
tmuxPath, err := exec.LookPath("tmux")
if err != nil {
return fmt.Errorf("tmux not found: %w", err)
}
attachCmd := exec.Command(tmuxPath, "attach-session", "-t", sessionName)
attachCmd.Stdin = os.Stdin
attachCmd.Stdout = os.Stdout
attachCmd.Stderr = os.Stderr
return attachCmd.Run()
}