diff --git a/internal/cmd/crew_status.go b/internal/cmd/crew_status.go index 4d0b0c51..bc58b86c 100644 --- a/internal/cmd/crew_status.go +++ b/internal/cmd/crew_status.go @@ -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 + } } } diff --git a/internal/cmd/prime.go b/internal/cmd/prime.go index 04cb9b69..72162f71 100644 --- a/internal/cmd/prime.go +++ b/internal/cmd/prime.go @@ -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 } diff --git a/internal/cmd/prime_output.go b/internal/cmd/prime_output.go index 3384da14..04ae9e0f 100644 --- a/internal/cmd/prime_output.go +++ b/internal/cmd/prime_output.go @@ -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) diff --git a/internal/cmd/prime_test.go b/internal/cmd/prime_test.go index d6e0b3bb..70361ad7 100644 --- a/internal/cmd/prime_test.go +++ b/internal/cmd/prime_test.go @@ -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) + } +}