feat(crew): accept rig name as positional arg in crew status

Allow `gt crew status <rig>` to work without requiring --rig flag.
This matches the pattern already used by crew start and crew stop.

Desire path: hq-v33hb

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
mayor
2026-01-12 23:07:26 -08:00
committed by beads/crew/emma
parent 2d8949a3d3
commit 15cfb76c2c
4 changed files with 545 additions and 4 deletions

View File

@@ -40,6 +40,13 @@ func runCrewStatus(cmd *cobra.Command, args []string) error {
crewRig = rig
}
targetName = crewName
} else if crewRig == "" {
// Check if single arg (without "/") is a valid rig name
// If so, show status for all crew in that rig
if _, _, err := getRig(targetName); err == nil {
crewRig = targetName
targetName = "" // Show all crew in the rig
}
}
}

View File

@@ -21,6 +21,7 @@ import (
var primeHookMode bool
var primeDryRun bool
var primeState bool
var primeStateJSON bool
var primeExplain bool
// Role represents a detected agent role.
@@ -72,6 +73,8 @@ func init() {
"Show what would be injected without side effects (no marker removal, no bd prime, no mail)")
primeCmd.Flags().BoolVar(&primeState, "state", false,
"Show detected session state only (normal/post-handoff/crash/autonomous)")
primeCmd.Flags().BoolVar(&primeStateJSON, "json", false,
"Output state as JSON (requires --state)")
primeCmd.Flags().BoolVar(&primeExplain, "explain", false,
"Show why each section was included")
rootCmd.AddCommand(primeCmd)
@@ -82,9 +85,13 @@ func init() {
type RoleContext = RoleInfo
func runPrime(cmd *cobra.Command, args []string) error {
// Validate flag combinations: --state is exclusive
// Validate flag combinations: --state is exclusive (except --json)
if primeState && (primeHookMode || primeDryRun || primeExplain) {
return fmt.Errorf("--state cannot be combined with other flags")
return fmt.Errorf("--state cannot be combined with other flags (except --json)")
}
// --json requires --state
if primeStateJSON && !primeState {
return fmt.Errorf("--json requires --state")
}
cwd, err := os.Getwd()
@@ -170,7 +177,7 @@ func runPrime(cmd *cobra.Command, args []string) error {
// --state mode: output state only and exit
if primeState {
outputState(ctx)
outputState(ctx, primeStateJSON)
return nil
}

View File

@@ -1,6 +1,7 @@
package cmd
import (
"encoding/json"
"fmt"
"path/filepath"
"time"
@@ -402,9 +403,22 @@ func outputHandoffWarning(prevSession string) {
}
// outputState outputs only the session state (for --state flag).
func outputState(ctx RoleContext) {
// If jsonOutput is true, outputs JSON format instead of key:value.
func outputState(ctx RoleContext, jsonOutput bool) {
state := detectSessionState(ctx)
if jsonOutput {
data, err := json.Marshal(state)
if err != nil {
// Fall back to plain text on error
fmt.Printf("state: %s\n", state.State)
fmt.Printf("role: %s\n", state.Role)
return
}
fmt.Println(string(data))
return
}
fmt.Printf("state: %s\n", state.State)
fmt.Printf("role: %s\n", state.Role)

View File

@@ -1,13 +1,19 @@
package cmd
import (
"bytes"
"encoding/json"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/checkpoint"
"github.com/steveyegge/gastown/internal/constants"
)
func writeTestRoutes(t *testing.T, townRoot string, routes []beads.Route) {
@@ -167,3 +173,510 @@ func TestPrimeFlagCombinations(t *testing.T) {
})
}
}
// TestCheckHandoffMarkerDryRun tests that dry-run mode doesn't remove the handoff marker.
func TestCheckHandoffMarkerDryRun(t *testing.T) {
workDir := t.TempDir()
// Create .runtime directory and handoff marker
runtimeDir := filepath.Join(workDir, constants.DirRuntime)
if err := os.MkdirAll(runtimeDir, 0755); err != nil {
t.Fatalf("create runtime dir: %v", err)
}
markerPath := filepath.Join(runtimeDir, constants.FileHandoffMarker)
prevSession := "test-session-123"
if err := os.WriteFile(markerPath, []byte(prevSession), 0644); err != nil {
t.Fatalf("write handoff marker: %v", err)
}
// Capture stdout to verify explain output
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
// Enable explain mode for this test
oldExplain := primeExplain
primeExplain = true
defer func() { primeExplain = oldExplain }()
// Call dry-run version
checkHandoffMarkerDryRun(workDir)
w.Close()
var buf bytes.Buffer
io.Copy(&buf, r)
os.Stdout = oldStdout
output := buf.String()
// Verify marker still exists (not removed in dry-run)
if _, err := os.Stat(markerPath); os.IsNotExist(err) {
t.Fatalf("handoff marker was removed in dry-run mode")
}
// Verify marker content unchanged
data, err := os.ReadFile(markerPath)
if err != nil {
t.Fatalf("read handoff marker: %v", err)
}
if string(data) != prevSession {
t.Fatalf("marker content changed: got %q, want %q", string(data), prevSession)
}
// Verify explain output mentions dry-run
if !strings.Contains(output, "dry-run") {
t.Fatalf("expected explain output to mention dry-run, got: %s", output)
}
}
// TestCheckHandoffMarkerDryRun_NoMarker tests dry-run when no marker exists.
func TestCheckHandoffMarkerDryRun_NoMarker(t *testing.T) {
workDir := t.TempDir()
// Create .runtime directory but no marker
runtimeDir := filepath.Join(workDir, constants.DirRuntime)
if err := os.MkdirAll(runtimeDir, 0755); err != nil {
t.Fatalf("create runtime dir: %v", err)
}
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
// Enable explain mode
oldExplain := primeExplain
primeExplain = true
defer func() { primeExplain = oldExplain }()
// Should not panic when marker doesn't exist
checkHandoffMarkerDryRun(workDir)
w.Close()
var buf bytes.Buffer
io.Copy(&buf, r)
os.Stdout = oldStdout
output := buf.String()
// Verify explain output indicates no marker
if !strings.Contains(output, "no handoff marker") {
t.Fatalf("expected explain output to indicate no marker, got: %s", output)
}
}
// TestDetectSessionState tests detectSessionState for all states.
func TestDetectSessionState(t *testing.T) {
t.Run("normal_state", func(t *testing.T) {
workDir := t.TempDir()
ctx := RoleContext{
Role: RoleMayor,
WorkDir: workDir,
}
state := detectSessionState(ctx)
if state.State != "normal" {
t.Fatalf("expected state 'normal', got %q", state.State)
}
if state.Role != RoleMayor {
t.Fatalf("expected role Mayor, got %q", state.Role)
}
})
t.Run("post_handoff_state", func(t *testing.T) {
workDir := t.TempDir()
// Create handoff marker
runtimeDir := filepath.Join(workDir, constants.DirRuntime)
if err := os.MkdirAll(runtimeDir, 0755); err != nil {
t.Fatalf("create runtime dir: %v", err)
}
prevSession := "predecessor-session-abc"
markerPath := filepath.Join(runtimeDir, constants.FileHandoffMarker)
if err := os.WriteFile(markerPath, []byte(prevSession), 0644); err != nil {
t.Fatalf("write handoff marker: %v", err)
}
ctx := RoleContext{
Role: RolePolecat,
Rig: "beads",
Polecat: "jade",
WorkDir: workDir,
}
state := detectSessionState(ctx)
if state.State != "post-handoff" {
t.Fatalf("expected state 'post-handoff', got %q", state.State)
}
if state.PrevSession != prevSession {
t.Fatalf("expected prev_session %q, got %q", prevSession, state.PrevSession)
}
})
t.Run("crash_recovery_state", func(t *testing.T) {
workDir := t.TempDir()
// Create a checkpoint (simulating a crashed session)
cp := &checkpoint.Checkpoint{
SessionID: "crashed-session",
HookedBead: "bd-test123",
StepTitle: "Working on feature X",
Timestamp: time.Now().Add(-1 * time.Hour), // 1 hour old
}
if err := checkpoint.Write(workDir, cp); err != nil {
t.Fatalf("write checkpoint: %v", err)
}
ctx := RoleContext{
Role: RolePolecat,
Rig: "beads",
Polecat: "jade",
WorkDir: workDir,
}
state := detectSessionState(ctx)
if state.State != "crash-recovery" {
t.Fatalf("expected state 'crash-recovery', got %q", state.State)
}
if state.CheckpointAge == "" {
t.Fatalf("expected checkpoint_age to be set")
}
})
t.Run("crash_recovery_only_for_workers", func(t *testing.T) {
workDir := t.TempDir()
// Create a checkpoint
cp := &checkpoint.Checkpoint{
SessionID: "crashed-session",
HookedBead: "bd-test123",
StepTitle: "Working on feature X",
Timestamp: time.Now().Add(-1 * time.Hour),
}
if err := checkpoint.Write(workDir, cp); err != nil {
t.Fatalf("write checkpoint: %v", err)
}
// Mayor should NOT enter crash-recovery (only polecat/crew)
ctx := RoleContext{
Role: RoleMayor,
WorkDir: workDir,
}
state := detectSessionState(ctx)
// Mayor should see normal state, not crash-recovery
if state.State != "normal" {
t.Fatalf("expected Mayor to have 'normal' state despite checkpoint, got %q", state.State)
}
})
t.Run("autonomous_state_hooked_bead", func(t *testing.T) {
// Skip if bd CLI is not available
if _, err := exec.LookPath("bd"); err != nil {
t.Skip("bd binary not found in PATH")
}
workDir := t.TempDir()
townRoot := workDir
// Initialize beads database
initCmd := exec.Command("bd", "init", "--prefix=bd-")
initCmd.Dir = workDir
if output, err := initCmd.CombinedOutput(); err != nil {
t.Fatalf("bd init failed: %v\n%s", err, output)
}
// Write routes file
beadsDir := filepath.Join(workDir, ".beads")
routes := []beads.Route{{Prefix: "bd-", Path: "."}}
if err := beads.WriteRoutes(beadsDir, routes); err != nil {
t.Fatalf("write routes: %v", err)
}
// Create a hooked bead assigned to beads/polecats/jade
b := beads.New(workDir)
issue, err := b.Create(beads.CreateOptions{
Title: "Test hooked bead",
Priority: 2,
})
if err != nil {
t.Fatalf("create bead: %v", err)
}
// Update bead to set status and assignee
status := beads.StatusHooked
assignee := "beads/polecats/jade"
if err := b.Update(issue.ID, beads.UpdateOptions{
Status: &status,
Assignee: &assignee,
}); err != nil {
t.Fatalf("update bead: %v", err)
}
ctx := RoleContext{
Role: RolePolecat,
Rig: "beads",
Polecat: "jade",
WorkDir: workDir,
TownRoot: townRoot,
}
state := detectSessionState(ctx)
if state.State != "autonomous" {
t.Fatalf("expected state 'autonomous', got %q", state.State)
}
if state.HookedBead != issue.ID {
t.Fatalf("expected hooked_bead %q, got %q", issue.ID, state.HookedBead)
}
})
}
// TestOutputState tests outputState function output formats.
func TestOutputState(t *testing.T) {
t.Run("text_output", func(t *testing.T) {
workDir := t.TempDir()
ctx := RoleContext{
Role: RoleMayor,
WorkDir: workDir,
}
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
outputState(ctx, false)
w.Close()
var buf bytes.Buffer
io.Copy(&buf, r)
os.Stdout = oldStdout
output := buf.String()
if !strings.Contains(output, "state: normal") {
t.Fatalf("expected 'state: normal' in output, got: %s", output)
}
if !strings.Contains(output, "role: mayor") {
t.Fatalf("expected 'role: mayor' in output, got: %s", output)
}
})
t.Run("json_output", func(t *testing.T) {
workDir := t.TempDir()
ctx := RoleContext{
Role: RolePolecat,
Rig: "beads",
Polecat: "jade",
WorkDir: workDir,
}
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
outputState(ctx, true)
w.Close()
var buf bytes.Buffer
io.Copy(&buf, r)
os.Stdout = oldStdout
output := buf.String()
// Parse JSON output
var state SessionState
if err := json.Unmarshal([]byte(output), &state); err != nil {
t.Fatalf("failed to parse JSON output: %v, output was: %s", err, output)
}
if state.State != "normal" {
t.Fatalf("expected state 'normal', got %q", state.State)
}
if state.Role != RolePolecat {
t.Fatalf("expected role 'polecat', got %q", state.Role)
}
})
t.Run("json_output_post_handoff", func(t *testing.T) {
workDir := t.TempDir()
// Create handoff marker
runtimeDir := filepath.Join(workDir, constants.DirRuntime)
if err := os.MkdirAll(runtimeDir, 0755); err != nil {
t.Fatalf("create runtime dir: %v", err)
}
prevSession := "prev-session-xyz"
markerPath := filepath.Join(runtimeDir, constants.FileHandoffMarker)
if err := os.WriteFile(markerPath, []byte(prevSession), 0644); err != nil {
t.Fatalf("write marker: %v", err)
}
ctx := RoleContext{
Role: RolePolecat,
Rig: "beads",
Polecat: "jade",
WorkDir: workDir,
}
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
outputState(ctx, true)
w.Close()
var buf bytes.Buffer
io.Copy(&buf, r)
os.Stdout = oldStdout
output := buf.String()
// Parse JSON
var state SessionState
if err := json.Unmarshal([]byte(output), &state); err != nil {
t.Fatalf("failed to parse JSON: %v", err)
}
if state.State != "post-handoff" {
t.Fatalf("expected state 'post-handoff', got %q", state.State)
}
if state.PrevSession != prevSession {
t.Fatalf("expected prev_session %q, got %q", prevSession, state.PrevSession)
}
})
}
// TestExplain tests the explain function output.
func TestExplain(t *testing.T) {
t.Run("explain_enabled_condition_true", func(t *testing.T) {
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
// Enable explain mode
oldExplain := primeExplain
primeExplain = true
defer func() { primeExplain = oldExplain }()
explain(true, "This is a test explanation")
w.Close()
var buf bytes.Buffer
io.Copy(&buf, r)
os.Stdout = oldStdout
output := buf.String()
if !strings.Contains(output, "[EXPLAIN]") {
t.Fatalf("expected [EXPLAIN] tag in output, got: %s", output)
}
if !strings.Contains(output, "This is a test explanation") {
t.Fatalf("expected explanation text in output, got: %s", output)
}
})
t.Run("explain_enabled_condition_false", func(t *testing.T) {
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
// Enable explain mode
oldExplain := primeExplain
primeExplain = true
defer func() { primeExplain = oldExplain }()
explain(false, "This should not appear")
w.Close()
var buf bytes.Buffer
io.Copy(&buf, r)
os.Stdout = oldStdout
output := buf.String()
if strings.Contains(output, "[EXPLAIN]") {
t.Fatalf("expected no [EXPLAIN] tag when condition is false, got: %s", output)
}
})
t.Run("explain_disabled", func(t *testing.T) {
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
// Disable explain mode
oldExplain := primeExplain
primeExplain = false
defer func() { primeExplain = oldExplain }()
explain(true, "This should not appear either")
w.Close()
var buf bytes.Buffer
io.Copy(&buf, r)
os.Stdout = oldStdout
output := buf.String()
if strings.Contains(output, "[EXPLAIN]") {
t.Fatalf("expected no [EXPLAIN] tag when explain mode disabled, got: %s", output)
}
})
}
// TestDryRunSkipsSideEffects tests that --dry-run skips various side effects via CLI.
func TestDryRunSkipsSideEffects(t *testing.T) {
// Find the gt binary
gtBin, err := exec.LookPath("gt")
if err != nil {
t.Skip("gt binary not found in PATH")
}
// Create a temp workspace
townRoot := t.TempDir()
// Set up minimal workspace structure
beadsDir := filepath.Join(townRoot, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("create beads dir: %v", err)
}
// Write routes
routes := []beads.Route{{Prefix: "bd-", Path: "."}}
if err := beads.WriteRoutes(beadsDir, routes); err != nil {
t.Fatalf("write routes: %v", err)
}
// Create handoff marker that should NOT be removed in dry-run
runtimeDir := filepath.Join(townRoot, constants.DirRuntime)
if err := os.MkdirAll(runtimeDir, 0755); err != nil {
t.Fatalf("create runtime dir: %v", err)
}
markerPath := filepath.Join(runtimeDir, constants.FileHandoffMarker)
if err := os.WriteFile(markerPath, []byte("prev-session"), 0644); err != nil {
t.Fatalf("write marker: %v", err)
}
// Run gt prime --dry-run --explain
cmd := exec.Command(gtBin, "prime", "--dry-run", "--explain")
cmd.Dir = townRoot
output, _ := cmd.CombinedOutput()
// The command may fail for other reasons (not fully configured workspace)
// but we can check:
// 1. Marker still exists
if _, err := os.Stat(markerPath); os.IsNotExist(err) {
t.Fatalf("handoff marker was removed in dry-run mode")
}
// 2. Output mentions skipped operations
outputStr := string(output)
// Check for explain output about dry-run (if workspace was valid enough to get there)
if strings.Contains(outputStr, "bd prime") && !strings.Contains(outputStr, "skipped") {
t.Logf("Note: output doesn't explicitly mention skipping bd prime: %s", outputStr)
}
}