ralph-review 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +123 -16
  2. package/package.json +6 -4
  3. package/src/cli-core.ts +51 -88
  4. package/src/cli-rrr.ts +1 -4
  5. package/src/cli.ts +1 -2
  6. package/src/commands/apply.ts +6 -14
  7. package/src/commands/config-handlers.ts +68 -69
  8. package/src/commands/config-model.ts +147 -125
  9. package/src/commands/doctor.ts +2 -4
  10. package/src/commands/fix.ts +73 -51
  11. package/src/commands/handoff-selection.ts +6 -8
  12. package/src/commands/interactive-deps.ts +18 -0
  13. package/src/commands/log.ts +12 -12
  14. package/src/commands/run.ts +32 -33
  15. package/src/commands/stop.ts +6 -13
  16. package/src/commands/update.ts +2 -4
  17. package/src/lib/agents/claude.ts +4 -16
  18. package/src/lib/agents/core.ts +16 -0
  19. package/src/lib/agents/droid.ts +4 -15
  20. package/src/lib/cli-parser.ts +19 -14
  21. package/src/lib/handoff.ts +16 -7
  22. package/src/lib/logging/session-log.ts +2 -1
  23. package/src/lib/prompts/defaults/review.md +1 -1
  24. package/src/lib/prompts/protocol.ts +2 -1
  25. package/src/lib/review-workflow/findings/artifact.ts +3 -1
  26. package/src/lib/review-workflow/findings/types.ts +1 -1
  27. package/src/lib/review-workflow/remediation/prompt.ts +7 -7
  28. package/src/lib/review-workflow/remediation/run-batch-fix-phase.ts +30 -20
  29. package/src/lib/review-workflow/remediation/run-fix-session.ts +70 -68
  30. package/src/lib/review-workflow/results/finalize-result.ts +20 -3
  31. package/src/lib/review-workflow/run-review-cycle.ts +1 -12
  32. package/src/lib/review-workflow/session-status.ts +13 -0
  33. package/src/lib/review-workflow/shared/framed-json.ts +2 -47
  34. package/src/lib/session/state.ts +50 -38
  35. package/src/lib/structured-output.ts +24 -9
  36. package/src/lib/tui/dashboard/HelpOverlay.tsx +13 -57
  37. package/src/lib/tui/dashboard/StatusBar.tsx +12 -50
  38. package/src/lib/tui/dashboard/StopSessionPickerOverlay.tsx +4 -22
  39. package/src/lib/tui/sessions/detail/DetailPane.tsx +6 -64
  40. package/src/lib/tui/sessions/detail/IdleStateView.tsx +10 -12
  41. package/src/lib/tui/sessions/detail/SessionDetailView.tsx +1 -1
  42. package/src/lib/tui/sessions/detail/session-detail-parts.tsx +66 -87
  43. package/src/lib/tui/sessions/history/SessionListOverlay.tsx +17 -75
  44. package/src/lib/tui/sessions/review-summary-parser.ts +2 -68
  45. package/src/lib/tui/shared/CenteredModal.tsx +44 -0
  46. package/src/lib/tui/shared/KeyboardShortcutsModal.tsx +14 -0
  47. package/src/lib/tui/shared/ShortcutHint.tsx +33 -0
  48. package/src/lib/tui/workspace/Workspace.tsx +6 -91
  49. package/src/lib/tui/workspace/use-workspace-state.ts +44 -37
  50. package/src/lib/types/fix.ts +15 -48
  51. package/src/lib/types/guards.ts +47 -0
  52. package/src/lib/types/review.ts +5 -39
@@ -8,6 +8,7 @@ import {
8
8
  isAgentType,
9
9
  isReasoningLevel,
10
10
  type ReasoningLevel,
11
+ type RetryOverrideConfig,
11
12
  } from "@/lib/types";
12
13
 
13
14
  type ConfigRole = "reviewer" | "fixer";
@@ -116,6 +117,40 @@ function parseInteger(value: string, key: ConfigKey): number {
116
117
  return parsed;
117
118
  }
118
119
 
120
+ function parseBoundedIntegerUpdate(
121
+ key: ParsedScalarConfigUpdate["key"],
122
+ rawValue: string,
123
+ minimum: number
124
+ ): ParsedScalarConfigUpdate {
125
+ const parsed = parseInteger(requireNonNullRawValue(key, rawValue), key);
126
+ if (parsed < minimum) {
127
+ throw new Error(`Value for "${key}" must be greater than or equal to ${minimum}.`);
128
+ }
129
+ return { key, value: parsed } as ParsedScalarConfigUpdate;
130
+ }
131
+
132
+ function requireNumberConfigValue(key: ConfigKey, value: ConfigValue, requirement: string): number {
133
+ if (typeof value === "number") {
134
+ return value;
135
+ }
136
+
137
+ throw new Error(`Value for "${key}" must be an integer ${requirement}.`);
138
+ }
139
+
140
+ function requirePiRoleSettings<T extends AgentSettings | AgentOverrideSettings>(
141
+ current: T | undefined,
142
+ role: ConfigRole,
143
+ command: string
144
+ ): T {
145
+ if (!current || current.agent !== "pi") {
146
+ throw new Error(
147
+ `Cannot set "${role}.agent" to "pi" in a single-key update. Run "${command}" for pi setup.`
148
+ );
149
+ }
150
+
151
+ return current;
152
+ }
153
+
119
154
  function requireNonNullRawValue(key: ConfigKey, rawValue: string): string {
120
155
  if (rawValue === "null") {
121
156
  throw new Error(`Value "null" is not allowed for "${key}".`);
@@ -205,36 +240,20 @@ export function parseConfigUpdate(key: ConfigKey, rawValue: string): ParsedConfi
205
240
  return createRoleReasoningUpdate(key, rawValue);
206
241
 
207
242
  case "maxIterations": {
208
- const parsed = parseInteger(requireNonNullRawValue(key, rawValue), key);
209
- if (parsed <= 0) {
210
- throw new Error(`Value for "${key}" must be greater than 0.`);
211
- }
212
- return { key, value: parsed };
243
+ return parseBoundedIntegerUpdate(key, rawValue, 1);
213
244
  }
214
245
 
215
246
  case "iterationTimeout": {
216
- const parsed = parseInteger(requireNonNullRawValue(key, rawValue), key);
217
- if (parsed <= 0) {
218
- throw new Error(`Value for "${key}" must be greater than 0.`);
219
- }
220
- return { key, value: parsed };
247
+ return parseBoundedIntegerUpdate(key, rawValue, 1);
221
248
  }
222
249
 
223
250
  case "retry.maxRetries": {
224
- const parsed = parseInteger(requireNonNullRawValue(key, rawValue), key);
225
- if (parsed < 0) {
226
- throw new Error(`Value for "${key}" must be greater than or equal to 0.`);
227
- }
228
- return { key, value: parsed };
251
+ return parseBoundedIntegerUpdate(key, rawValue, 0);
229
252
  }
230
253
 
231
254
  case "retry.baseDelayMs":
232
255
  case "retry.maxDelayMs": {
233
- const parsed = parseInteger(requireNonNullRawValue(key, rawValue), key);
234
- if (parsed <= 0) {
235
- throw new Error(`Value for "${key}" must be greater than 0.`);
236
- }
237
- return { key, value: parsed };
256
+ return parseBoundedIntegerUpdate(key, rawValue, 1);
238
257
  }
239
258
 
240
259
  case "defaultReview.type":
@@ -329,18 +348,9 @@ function ensureRoleForMutation(
329
348
  function applyRoleAgentUpdate(config: Config, role: ConfigRole, nextAgent: AgentType): Config {
330
349
  const current = readRoleSettings(role, config);
331
350
  if (nextAgent === "pi") {
332
- if (!current || current.agent !== "pi") {
333
- throw new Error(
334
- `Cannot set "${role}.agent" to "pi" in a single-key update. Run "rr init" for pi setup.`
335
- );
336
- }
351
+ const piSettings = requirePiRoleSettings(current, role, "rr init");
337
352
 
338
- writeRoleSettings(role, config, {
339
- agent: "pi",
340
- provider: current.provider,
341
- model: current.model,
342
- reasoning: current.reasoning,
343
- });
353
+ writeRoleSettings(role, config, piSettings);
344
354
  return config;
345
355
  }
346
356
 
@@ -362,6 +372,86 @@ function applyRoleAgentUpdate(config: Config, role: ConfigRole, nextAgent: Agent
362
372
  return config;
363
373
  }
364
374
 
375
+ function setRequiredRetryValue(
376
+ config: Config,
377
+ field: keyof typeof DEFAULT_RETRY_CONFIG,
378
+ value: number
379
+ ): void {
380
+ config.retry = config.retry ? { ...config.retry } : { ...DEFAULT_RETRY_CONFIG };
381
+ config.retry[field] = value;
382
+ }
383
+
384
+ function setOverrideRetryValue(
385
+ config: ConfigOverride,
386
+ field: keyof RetryOverrideConfig,
387
+ value: number
388
+ ): void {
389
+ config.retry = {
390
+ ...(config.retry && config.retry !== null ? config.retry : {}),
391
+ [field]: value,
392
+ };
393
+ }
394
+
395
+ function applyRoleProviderUpdate(
396
+ settings: AgentSettings | AgentOverrideSettings,
397
+ role: ConfigRole,
398
+ key: RoleConfigKey,
399
+ value: ConfigValue,
400
+ clearNonPiProvider: boolean
401
+ ): void {
402
+ if (value === null) {
403
+ if (settings.agent !== "pi") {
404
+ if (clearNonPiProvider) {
405
+ delete settings.provider;
406
+ }
407
+ return;
408
+ }
409
+ throw new Error(`Cannot unset "${role}.provider" while "${role}.agent" is "pi".`);
410
+ }
411
+
412
+ if (typeof value !== "string") {
413
+ throw new Error(`Value for "${key}" must be a string or null.`);
414
+ }
415
+
416
+ if (settings.agent !== "pi") {
417
+ throw new Error(`"${role}.provider" is only valid when "${role}.agent" is "pi".`);
418
+ }
419
+
420
+ settings.provider = value;
421
+ }
422
+
423
+ function applyRoleModelUpdate(
424
+ settings: AgentSettings | AgentOverrideSettings,
425
+ role: ConfigRole,
426
+ key: RoleConfigKey,
427
+ value: ConfigValue,
428
+ assignNullForNonPi: boolean
429
+ ): void {
430
+ if (settings.agent === "pi") {
431
+ if (value === null || typeof value !== "string") {
432
+ throw new Error(`Cannot unset "${role}.model" while "${role}.agent" is "pi".`);
433
+ }
434
+ settings.model = value;
435
+ return;
436
+ }
437
+
438
+ if (value === null) {
439
+ if (assignNullForNonPi) {
440
+ settings.model = value;
441
+ return;
442
+ }
443
+ delete settings.model;
444
+ return;
445
+ }
446
+
447
+ if (typeof value === "string") {
448
+ settings.model = value;
449
+ return;
450
+ }
451
+
452
+ throw new Error(`Value for "${key}" must be a string or null.`);
453
+ }
454
+
365
455
  export function setConfigValue(config: Config, key: ConfigKey, value: ConfigValue): Config {
366
456
  const next = structuredClone(config) as Config;
367
457
 
@@ -385,49 +475,12 @@ export function setConfigValue(config: Config, key: ConfigKey, value: ConfigValu
385
475
  const settings = ensureRoleForMutation(next, role, field);
386
476
 
387
477
  if (field === "provider") {
388
- if (value === null) {
389
- if (settings.agent !== "pi") {
390
- return next;
391
- }
392
- throw new Error(`Cannot unset "${role}.provider" while "${role}.agent" is "pi".`);
393
- }
394
- if (typeof value !== "string") {
395
- throw new Error(`Value for "${key}" must be a string or null.`);
396
- }
397
- if (settings.agent !== "pi") {
398
- throw new Error(`"${role}.provider" is only valid when "${role}.agent" is "pi".`);
399
- }
400
- settings.provider = value;
478
+ applyRoleProviderUpdate(settings, role, key as RoleConfigKey, value, false);
401
479
  return next;
402
480
  }
403
481
 
404
482
  if (field === "model") {
405
- if (settings.agent === "pi") {
406
- if (value === null || typeof value !== "string") {
407
- throw new Error(`Cannot unset "${role}.model" while "${role}.agent" is "pi".`);
408
- }
409
- settings.model = value;
410
- return next;
411
- }
412
-
413
- if (value === null) {
414
- delete settings.model;
415
- } else if (typeof value === "string") {
416
- settings.model = value;
417
- } else {
418
- throw new Error(`Value for "${key}" must be a string or null.`);
419
- }
420
- return next;
421
- }
422
-
423
- if (settings.agent === "pi") {
424
- if (value === null) {
425
- delete settings.reasoning;
426
- } else if (typeof value === "string" && isReasoningLevel(value)) {
427
- settings.reasoning = value;
428
- } else {
429
- throw new Error(`Value for "${key}" must be one of: low, medium, high, xhigh, max.`);
430
- }
483
+ applyRoleModelUpdate(settings, role, key as RoleConfigKey, value, false);
431
484
  return next;
432
485
  }
433
486
 
@@ -479,25 +532,25 @@ export function setConfigValue(config: Config, key: ConfigKey, value: ConfigValu
479
532
  next.defaultReview = { type: "base", branch: value };
480
533
  return next;
481
534
  case "retry.maxRetries":
482
- next.retry = next.retry ? { ...next.retry } : { ...DEFAULT_RETRY_CONFIG };
483
- if (typeof value !== "number") {
484
- throw new Error(`Value for "${key}" must be an integer greater than or equal to 0.`);
485
- }
486
- next.retry.maxRetries = value;
535
+ setRequiredRetryValue(
536
+ next,
537
+ "maxRetries",
538
+ requireNumberConfigValue(key, value, "greater than or equal to 0")
539
+ );
487
540
  return next;
488
541
  case "retry.baseDelayMs":
489
- next.retry = next.retry ? { ...next.retry } : { ...DEFAULT_RETRY_CONFIG };
490
- if (typeof value !== "number") {
491
- throw new Error(`Value for "${key}" must be an integer greater than 0.`);
492
- }
493
- next.retry.baseDelayMs = value;
542
+ setRequiredRetryValue(
543
+ next,
544
+ "baseDelayMs",
545
+ requireNumberConfigValue(key, value, "greater than 0")
546
+ );
494
547
  return next;
495
548
  case "retry.maxDelayMs":
496
- next.retry = next.retry ? { ...next.retry } : { ...DEFAULT_RETRY_CONFIG };
497
- if (typeof value !== "number") {
498
- throw new Error(`Value for "${key}" must be an integer greater than 0.`);
499
- }
500
- next.retry.maxDelayMs = value;
549
+ setRequiredRetryValue(
550
+ next,
551
+ "maxDelayMs",
552
+ requireNumberConfigValue(key, value, "greater than 0")
553
+ );
501
554
  return next;
502
555
  case "notifications.sound.enabled":
503
556
  if (typeof value !== "boolean") {
@@ -552,17 +605,13 @@ function applyOverrideRoleAgentUpdate(
552
605
  ): ConfigOverride {
553
606
  const current = readOverrideRoleSettings(role, config);
554
607
  if (nextAgent === "pi") {
555
- if (!current || current.agent !== "pi") {
556
- throw new Error(
557
- `Cannot set "${role}.agent" to "pi" in a single-key update. Run "rr init --local" for pi setup.`
558
- );
559
- }
608
+ const piSettings = requirePiRoleSettings(current, role, "rr init --local");
560
609
 
561
610
  writeOverrideRoleSettings(role, config, {
562
611
  agent: "pi",
563
- provider: current.provider,
564
- model: current.model,
565
- reasoning: current.reasoning,
612
+ provider: piSettings.provider,
613
+ model: piSettings.model,
614
+ reasoning: piSettings.reasoning,
566
615
  });
567
616
  return config;
568
617
  }
@@ -600,30 +649,12 @@ export function setConfigOverrideValue(
600
649
  const settings = ensureOverrideRoleForMutation(next, role);
601
650
 
602
651
  if (field === "provider") {
603
- if (value === null) {
604
- if (settings.agent !== "pi") {
605
- delete settings.provider;
606
- return next;
607
- }
608
- throw new Error(`Cannot unset "${role}.provider" while "${role}.agent" is "pi".`);
609
- }
610
- if (settings.agent !== "pi") {
611
- throw new Error(`"${role}.provider" is only valid when "${role}.agent" is "pi".`);
612
- }
613
- settings.provider = value;
652
+ applyRoleProviderUpdate(settings, role, update.key, value, true);
614
653
  return next;
615
654
  }
616
655
 
617
656
  if (field === "model") {
618
- if (settings.agent === "pi") {
619
- if (value === null) {
620
- throw new Error(`Cannot unset "${role}.model" while "${role}.agent" is "pi".`);
621
- }
622
- settings.model = value;
623
- return next;
624
- }
625
-
626
- settings.model = value;
657
+ applyRoleModelUpdate(settings, role, update.key, value, true);
627
658
  return next;
628
659
  }
629
660
 
@@ -657,22 +688,13 @@ export function setConfigOverrideValue(
657
688
  next.defaultReview = { type: "base", branch: update.value };
658
689
  return next;
659
690
  case "retry.maxRetries":
660
- next.retry = {
661
- ...(next.retry && next.retry !== null ? next.retry : {}),
662
- maxRetries: update.value,
663
- };
691
+ setOverrideRetryValue(next, "maxRetries", update.value);
664
692
  return next;
665
693
  case "retry.baseDelayMs":
666
- next.retry = {
667
- ...(next.retry && next.retry !== null ? next.retry : {}),
668
- baseDelayMs: update.value,
669
- };
694
+ setOverrideRetryValue(next, "baseDelayMs", update.value);
670
695
  return next;
671
696
  case "retry.maxDelayMs":
672
- next.retry = {
673
- ...(next.retry && next.retry !== null ? next.retry : {}),
674
- maxDelayMs: update.value,
675
- };
697
+ setOverrideRetryValue(next, "maxDelayMs", update.value);
676
698
  return next;
677
699
  case "notifications.sound.enabled":
678
700
  next.notifications = {
@@ -1,4 +1,5 @@
1
1
  import * as p from "@clack/prompts";
2
+ import type { SpinnerFactory } from "@/cli-io";
2
3
  import { runDiagnostics } from "@/lib/diagnostics";
3
4
  import type { FixResult, RemediationDependencies } from "@/lib/diagnostics/remediation";
4
5
  import { applyFixes as defaultApplyFixes, isFixable } from "@/lib/diagnostics/remediation";
@@ -17,10 +18,7 @@ interface DoctorRuntime {
17
18
  ) => Promise<FixResult[]>;
18
19
  intro: (message: string) => void;
19
20
  note: (message: string, title: string) => void;
20
- spinner: () => {
21
- start: (message: string) => void;
22
- stop: (message: string) => void;
23
- };
21
+ spinner: SpinnerFactory;
24
22
  log: {
25
23
  error: (message: string) => void;
26
24
  warn: (message: string) => void;
@@ -12,7 +12,10 @@ import {
12
12
  } from "@/lib/priority-list";
13
13
  import { loadFindingsArtifactBySessionId } from "@/lib/review-workflow/findings/artifact";
14
14
  import type { FindingId, FindingsArtifact } from "@/lib/review-workflow/findings/types";
15
- import { runFixSession } from "@/lib/review-workflow/remediation/run-fix-session";
15
+ import {
16
+ promptForFixSelection,
17
+ runFixSession,
18
+ } from "@/lib/review-workflow/remediation/run-fix-session";
16
19
  import {
17
20
  createSessionState,
18
21
  HEARTBEAT_INTERVAL_MS,
@@ -67,30 +70,19 @@ export interface FixCommandDeps {
67
70
  exit: (code: number) => void;
68
71
  }
69
72
 
70
- function defaultPromptForSelection(artifact: FindingsArtifact): Promise<FindingId[] | null> {
71
- return p
72
- .multiselect({
73
- message: "Choose findings to fix",
74
- options: artifact.findings.map((finding) => ({
75
- value: finding.id,
76
- label: `${finding.id} [${finding.priority}] ${finding.title}`,
77
- hint: `${finding.filePath}:${finding.startLine}-${finding.endLine}`,
78
- })),
79
- required: false,
80
- })
81
- .then((selection) => {
82
- if (p.isCancel(selection)) {
83
- return null;
84
- }
73
+ interface LoadedFixArtifact {
74
+ parsed: ParsedFixCommandOptions;
75
+ artifact: FindingsArtifact;
76
+ }
85
77
 
86
- return (selection as FindingId[]) ?? [];
87
- });
78
+ interface PreparedFixCommand extends LoadedFixArtifact {
79
+ commandDeps: FixCommandDeps;
88
80
  }
89
81
 
90
82
  const DEFAULT_FIX_COMMAND_DEPS: FixCommandDeps = {
91
83
  loadConfig: loadEffectiveConfig,
92
84
  loadFindingsArtifactBySessionId,
93
- promptForSelection: defaultPromptForSelection,
85
+ promptForSelection: promptForFixSelection,
94
86
  runFixSession,
95
87
  isTTY: () => process.stdout.isTTY === true,
96
88
  isTmuxInstalled,
@@ -332,31 +324,69 @@ export function parseFixCommandOptions(args: string[]): ParsedFixCommandOptions
332
324
  };
333
325
  }
334
326
 
335
- export async function runFix(
336
- args: string[] = [],
337
- deps: Partial<FixCommandDeps> = {}
338
- ): Promise<void> {
339
- const commandDeps = { ...DEFAULT_FIX_COMMAND_DEPS, ...deps };
340
-
327
+ async function loadFixCommandArtifact(
328
+ args: string[],
329
+ commandDeps: FixCommandDeps
330
+ ): Promise<LoadedFixArtifact | null> {
341
331
  let parsed: ParsedFixCommandOptions;
342
332
  try {
343
333
  parsed = parseFixCommandOptions(args);
344
334
  } catch (error) {
345
335
  commandDeps.logError(`${error}`);
346
336
  commandDeps.exit(1);
347
- return;
337
+ return null;
348
338
  }
349
339
 
350
340
  const artifact = await commandDeps.loadFindingsArtifactBySessionId(CONFIG_DIR, parsed.sessionId);
351
341
  if (!artifact) {
352
342
  commandDeps.logError(`Findings artifact not found for session ${parsed.sessionId}`);
353
343
  commandDeps.exit(1);
344
+ return null;
345
+ }
346
+
347
+ return { parsed, artifact };
348
+ }
349
+
350
+ async function prepareFixCommand(
351
+ args: string[],
352
+ deps: Partial<FixCommandDeps>
353
+ ): Promise<PreparedFixCommand | null> {
354
+ const commandDeps = { ...DEFAULT_FIX_COMMAND_DEPS, ...deps };
355
+ const loaded = await loadFixCommandArtifact(args, commandDeps);
356
+ return loaded ? { commandDeps, ...loaded } : null;
357
+ }
358
+
359
+ async function withPreparedFixCommand(
360
+ args: string[],
361
+ deps: Partial<FixCommandDeps>,
362
+ run: (prepared: PreparedFixCommand) => Promise<void>
363
+ ): Promise<void> {
364
+ const prepared = await prepareFixCommand(args, deps);
365
+ if (!prepared) {
354
366
  return;
355
367
  }
356
368
 
357
- if (!(await commandDeps.loadConfig(artifact.projectPath))) {
369
+ await run(prepared);
370
+ }
371
+
372
+ async function loadRequiredFixConfig(
373
+ commandDeps: FixCommandDeps,
374
+ projectPath: string
375
+ ): ReturnType<FixCommandDeps["loadConfig"]> {
376
+ const config = await commandDeps.loadConfig(projectPath);
377
+ if (!config) {
358
378
  commandDeps.logError("Failed to load configuration");
359
379
  commandDeps.exit(1);
380
+ }
381
+ return config;
382
+ }
383
+
384
+ async function runPreparedFix({
385
+ commandDeps,
386
+ parsed,
387
+ artifact,
388
+ }: PreparedFixCommand): Promise<void> {
389
+ if (!(await loadRequiredFixConfig(commandDeps, artifact.projectPath))) {
360
390
  return;
361
391
  }
362
392
 
@@ -423,33 +453,18 @@ export async function runFix(
423
453
  }
424
454
  }
425
455
 
426
- export async function runFixForeground(
427
- args: string[] = [],
428
- deps: Partial<FixCommandDeps> = {}
429
- ): Promise<void> {
430
- const commandDeps = { ...DEFAULT_FIX_COMMAND_DEPS, ...deps };
431
-
432
- let parsed: ParsedFixCommandOptions;
433
- try {
434
- parsed = parseFixCommandOptions(args);
435
- } catch (error) {
436
- commandDeps.logError(`${error}`);
437
- commandDeps.exit(1);
438
- return;
439
- }
440
-
441
- const artifact = await commandDeps.loadFindingsArtifactBySessionId(CONFIG_DIR, parsed.sessionId);
442
- if (!artifact) {
443
- commandDeps.logError(`Findings artifact not found for session ${parsed.sessionId}`);
444
- commandDeps.exit(1);
445
- return;
446
- }
456
+ export function runFix(args: string[] = [], deps: Partial<FixCommandDeps> = {}): Promise<void> {
457
+ return withPreparedFixCommand(args, deps, runPreparedFix);
458
+ }
447
459
 
460
+ async function runPreparedFixForeground({
461
+ commandDeps,
462
+ parsed,
463
+ artifact,
464
+ }: PreparedFixCommand): Promise<void> {
448
465
  const projectPath = commandDeps.env.RR_PROJECT_PATH || artifact.projectPath || commandDeps.cwd();
449
- const config = await commandDeps.loadConfig(projectPath);
466
+ const config = await loadRequiredFixConfig(commandDeps, projectPath);
450
467
  if (!config) {
451
- commandDeps.logError("Failed to load configuration");
452
- commandDeps.exit(1);
453
468
  return;
454
469
  }
455
470
 
@@ -593,3 +608,10 @@ export async function runFixForeground(
593
608
  });
594
609
  }
595
610
  }
611
+
612
+ export function runFixForeground(
613
+ args: string[] = [],
614
+ deps: Partial<FixCommandDeps> = {}
615
+ ): Promise<void> {
616
+ return withPreparedFixCommand(args, deps, runPreparedFixForeground);
617
+ }
@@ -2,6 +2,10 @@ import * as p from "@clack/prompts";
2
2
  import type { PendingHandoffArtifact } from "@/lib/handoff";
3
3
 
4
4
  type HandoffAction = "apply" | "discard";
5
+ type HandoffSelect = (input: {
6
+ message: string;
7
+ options: Array<{ value: string; label: string; hint: string }>;
8
+ }) => Promise<unknown>;
5
9
 
6
10
  interface SelectableHandoff {
7
11
  handoffId: string;
@@ -14,10 +18,7 @@ interface ResolvePendingHandoffSelectionOptions {
14
18
  selector?: string;
15
19
  action: HandoffAction;
16
20
  isTTY: boolean;
17
- select?: (input: {
18
- message: string;
19
- options: Array<{ value: string; label: string; hint: string }>;
20
- }) => Promise<unknown>;
21
+ select?: HandoffSelect;
21
22
  isCancel?: (value: unknown) => boolean;
22
23
  }
23
24
 
@@ -105,10 +106,7 @@ async function resolveHandoffSelection<T extends SelectableHandoff>(options: {
105
106
  action: HandoffAction;
106
107
  isTTY: boolean;
107
108
  multipleHandoffsMessage: string;
108
- select?: (input: {
109
- message: string;
110
- options: Array<{ value: string; label: string; hint: string }>;
111
- }) => Promise<unknown>;
109
+ select?: HandoffSelect;
112
110
  isCancel?: (value: unknown) => boolean;
113
111
  }): Promise<HandoffSelectionResult<T>> {
114
112
  if (options.selector) {
@@ -0,0 +1,18 @@
1
+ import * as p from "@clack/prompts";
2
+ import { getCommandDef } from "@/cli";
3
+
4
+ export interface InteractiveCommandDeps {
5
+ getCommandDef: typeof getCommandDef;
6
+ logError: (message: string) => void;
7
+ exit: (code: number) => void;
8
+ isTTY: () => boolean;
9
+ }
10
+
11
+ export function createInteractiveCommandDeps(): InteractiveCommandDeps {
12
+ return {
13
+ getCommandDef,
14
+ logError: (message) => p.log.error(message),
15
+ exit: (code) => process.exit(code),
16
+ isTTY: () => process.stdout.isTTY === true,
17
+ };
18
+ }
@@ -82,6 +82,16 @@ function isUnknownEmptySession(session: SessionStats): boolean {
82
82
  return session.status === "unknown" && session.iterations === 0;
83
83
  }
84
84
 
85
+ function printNoProjectSessions(projectName: string, json: boolean): void {
86
+ if (json) {
87
+ console.log(JSON.stringify({ project: projectName, sessions: [] }, null, 2));
88
+ return;
89
+ }
90
+
91
+ p.log.info("No review sessions found for current working directory.");
92
+ p.log.message('Start a review with "rr run" first.');
93
+ }
94
+
85
95
  function formatDate(timestamp: number): string {
86
96
  return new Date(timestamp).toLocaleString();
87
97
  }
@@ -412,12 +422,7 @@ export async function runLog(args: string[]): Promise<void> {
412
422
  const projectSessions = await listProjectLogSessions(CONFIG_DIR, currentProjectPath);
413
423
 
414
424
  if (projectSessions.length === 0) {
415
- if (options.json) {
416
- console.log(JSON.stringify({ project: projectName, sessions: [] }, null, 2));
417
- } else {
418
- p.log.info("No review sessions found for current working directory.");
419
- p.log.message('Start a review with "rr run" first.');
420
- }
425
+ printNoProjectSessions(projectName, options.json);
421
426
  return;
422
427
  }
423
428
 
@@ -451,12 +456,7 @@ export async function runLog(args: string[]): Promise<void> {
451
456
  }
452
457
 
453
458
  if (sessionStats.length === 0) {
454
- if (options.json) {
455
- console.log(JSON.stringify({ project: projectName, sessions: [] }, null, 2));
456
- } else {
457
- p.log.info("No review sessions found for current working directory.");
458
- p.log.message('Start a review with "rr run" first.');
459
- }
459
+ printNoProjectSessions(projectName, options.json);
460
460
  return;
461
461
  }
462
462