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 crewRig = rig
} }
targetName = crewName 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 primeHookMode bool
var primeDryRun bool var primeDryRun bool
var primeState bool var primeState bool
var primeStateJSON bool
var primeExplain bool var primeExplain bool
// Role represents a detected agent role. // 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)") "Show what would be injected without side effects (no marker removal, no bd prime, no mail)")
primeCmd.Flags().BoolVar(&primeState, "state", false, primeCmd.Flags().BoolVar(&primeState, "state", false,
"Show detected session state only (normal/post-handoff/crash/autonomous)") "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, primeCmd.Flags().BoolVar(&primeExplain, "explain", false,
"Show why each section was included") "Show why each section was included")
rootCmd.AddCommand(primeCmd) rootCmd.AddCommand(primeCmd)
@@ -82,9 +85,13 @@ func init() {
type RoleContext = RoleInfo type RoleContext = RoleInfo
func runPrime(cmd *cobra.Command, args []string) error { 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) { 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() cwd, err := os.Getwd()
@@ -170,7 +177,7 @@ func runPrime(cmd *cobra.Command, args []string) error {
// --state mode: output state only and exit // --state mode: output state only and exit
if primeState { if primeState {
outputState(ctx) outputState(ctx, primeStateJSON)
return nil return nil
} }

View File

@@ -1,6 +1,7 @@
package cmd package cmd
import ( import (
"encoding/json"
"fmt" "fmt"
"path/filepath" "path/filepath"
"time" "time"
@@ -402,9 +403,22 @@ func outputHandoffWarning(prevSession string) {
} }
// outputState outputs only the session state (for --state flag). // 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) 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("state: %s\n", state.State)
fmt.Printf("role: %s\n", state.Role) fmt.Printf("role: %s\n", state.Role)

View File

@@ -1,13 +1,19 @@
package cmd package cmd
import ( import (
"bytes"
"encoding/json"
"io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"github.com/steveyegge/gastown/internal/beads" "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) { 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)
}
}