Merge main, keeping main's manager.go and our FailureType tests
This commit is contained in:
@@ -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
233
internal/cmd/daemon.go
Normal 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
274
internal/cmd/gitinit.go
Normal 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
337
internal/cmd/handoff.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
128
internal/cmd/issue.go
Normal 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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
495
internal/cmd/molecule.go
Normal 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
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
214
internal/cmd/mq_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, " ")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
145
internal/cmd/statusline.go
Normal 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
|
||||
}
|
||||
@@ -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
193
internal/cmd/theme.go
Normal 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 ""
|
||||
}
|
||||
@@ -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
291
internal/cmd/witness.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user