ralph-review 0.2.2 → 0.2.4

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 (56) hide show
  1. package/README.md +123 -16
  2. package/package.json +7 -5
  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 +35 -20
  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 +43 -0
  13. package/src/commands/list.ts +24 -7
  14. package/src/commands/log.ts +12 -12
  15. package/src/commands/run.ts +32 -33
  16. package/src/commands/status.ts +25 -4
  17. package/src/commands/stop.ts +99 -62
  18. package/src/commands/update.ts +2 -4
  19. package/src/lib/agents/claude.ts +4 -16
  20. package/src/lib/agents/core.ts +16 -0
  21. package/src/lib/agents/droid.ts +4 -15
  22. package/src/lib/agents/models.ts +9 -0
  23. package/src/lib/cli-parser.ts +19 -14
  24. package/src/lib/handoff.ts +16 -7
  25. package/src/lib/logging/session-log.ts +2 -1
  26. package/src/lib/prompts/defaults/review.md +1 -1
  27. package/src/lib/prompts/protocol.ts +2 -1
  28. package/src/lib/review-workflow/findings/artifact.ts +3 -1
  29. package/src/lib/review-workflow/findings/types.ts +1 -1
  30. package/src/lib/review-workflow/remediation/prompt.ts +7 -7
  31. package/src/lib/review-workflow/remediation/run-batch-fix-phase.ts +30 -20
  32. package/src/lib/review-workflow/remediation/run-fix-session.ts +70 -68
  33. package/src/lib/review-workflow/results/finalize-result.ts +20 -3
  34. package/src/lib/review-workflow/run-review-cycle.ts +1 -12
  35. package/src/lib/review-workflow/session-status.ts +13 -0
  36. package/src/lib/review-workflow/shared/framed-json.ts +2 -47
  37. package/src/lib/session/state.ts +50 -38
  38. package/src/lib/structured-output.ts +24 -9
  39. package/src/lib/tui/dashboard/HelpOverlay.tsx +13 -57
  40. package/src/lib/tui/dashboard/ReviewModeOverlay.tsx +2 -2
  41. package/src/lib/tui/dashboard/StatusBar.tsx +12 -50
  42. package/src/lib/tui/dashboard/StopSessionPickerOverlay.tsx +4 -22
  43. package/src/lib/tui/sessions/detail/DetailPane.tsx +6 -64
  44. package/src/lib/tui/sessions/detail/IdleStateView.tsx +10 -12
  45. package/src/lib/tui/sessions/detail/SessionDetailView.tsx +1 -1
  46. package/src/lib/tui/sessions/detail/session-detail-parts.tsx +66 -87
  47. package/src/lib/tui/sessions/history/SessionListOverlay.tsx +17 -75
  48. package/src/lib/tui/sessions/review-summary-parser.ts +2 -68
  49. package/src/lib/tui/shared/CenteredModal.tsx +44 -0
  50. package/src/lib/tui/shared/KeyboardShortcutsModal.tsx +14 -0
  51. package/src/lib/tui/shared/ShortcutHint.tsx +33 -0
  52. package/src/lib/tui/workspace/Workspace.tsx +6 -91
  53. package/src/lib/tui/workspace/use-workspace-state.ts +113 -61
  54. package/src/lib/types/fix.ts +15 -48
  55. package/src/lib/types/guards.ts +47 -0
  56. package/src/lib/types/review.ts +5 -39
@@ -39,6 +39,11 @@ interface ParsedShowArgs extends ParsedScopedArgs {
39
39
  verbose: boolean;
40
40
  }
41
41
 
42
+ type LoadedEffectiveConfig = Awaited<
43
+ ReturnType<ConfigCommandDeps["loadEffectiveConfigWithDiagnostics"]>
44
+ >;
45
+ type ValidEffectiveConfig = LoadedEffectiveConfig & { config: Config };
46
+
42
47
  const SHOW_USAGE = "Usage: rr config show [--local|--global] [--json] [--verbose]";
43
48
 
44
49
  function shellQuote(value: string): string {
@@ -61,61 +66,72 @@ function printValue(value: unknown, print: (value: string) => void): void {
61
66
  print(String(value));
62
67
  }
63
68
 
69
+ async function warnIfEffectiveConfigInvalid(
70
+ deps: ConfigCommandDeps,
71
+ remediation: string
72
+ ): Promise<ValidEffectiveConfig | null> {
73
+ const effective = await deps.loadEffectiveConfigWithDiagnostics(deps.cwd());
74
+ const errors = collectEffectiveConfigValidationErrors(effective);
75
+ if (!effective.config || errors.length > 0) {
76
+ deps.log.warn(
77
+ formatConfigValidationMessage(
78
+ getEffectiveConfigErrorHeader(effective),
79
+ errors.length > 0 ? errors : ["Configuration format is invalid."],
80
+ remediation
81
+ )
82
+ );
83
+ return null;
84
+ }
85
+
86
+ return { ...effective, config: effective.config };
87
+ }
88
+
64
89
  function parseScopedArgs(args: string[], defaultScope: ResolvedReadScope): ParsedScopedArgs {
65
90
  const positional: string[] = [];
66
- let scope = defaultScope;
67
- let sawLocal = false;
68
- let sawGlobal = false;
91
+ const state = { scope: defaultScope, sawLocal: false, sawGlobal: false };
69
92
 
70
93
  for (const arg of args) {
71
- if (arg === "--local") {
72
- if (sawGlobal) {
73
- throw new Error("Cannot use --local and --global together.");
74
- }
75
- sawLocal = true;
76
- scope = "local";
77
- continue;
94
+ if (!parseScopeFlag(arg, state)) {
95
+ positional.push(arg);
78
96
  }
97
+ }
79
98
 
80
- if (arg === "--global") {
81
- if (sawLocal) {
82
- throw new Error("Cannot use --local and --global together.");
83
- }
84
- sawGlobal = true;
85
- scope = "global";
86
- continue;
99
+ return { scope: state.scope, positional };
100
+ }
101
+
102
+ function parseScopeFlag(
103
+ arg: string,
104
+ state: { scope: ResolvedReadScope; sawLocal: boolean; sawGlobal: boolean }
105
+ ): boolean {
106
+ if (arg === "--local") {
107
+ if (state.sawGlobal) {
108
+ throw new Error("Cannot use --local and --global together.");
87
109
  }
110
+ state.sawLocal = true;
111
+ state.scope = "local";
112
+ return true;
113
+ }
88
114
 
89
- positional.push(arg);
115
+ if (arg === "--global") {
116
+ if (state.sawLocal) {
117
+ throw new Error("Cannot use --local and --global together.");
118
+ }
119
+ state.sawGlobal = true;
120
+ state.scope = "global";
121
+ return true;
90
122
  }
91
123
 
92
- return { scope, positional };
124
+ return false;
93
125
  }
94
126
 
95
127
  function parseShowArgs(args: string[]): ParsedShowArgs {
96
128
  const positional: string[] = [];
97
- let scope: ResolvedReadScope = "effective";
98
- let sawLocal = false;
99
- let sawGlobal = false;
129
+ const scopeState = { scope: "effective" as ResolvedReadScope, sawLocal: false, sawGlobal: false };
100
130
  let json = false;
101
131
  let verbose = false;
102
132
 
103
133
  for (const arg of args) {
104
- if (arg === "--local") {
105
- if (sawGlobal) {
106
- throw new Error("Cannot use --local and --global together.");
107
- }
108
- sawLocal = true;
109
- scope = "local";
110
- continue;
111
- }
112
-
113
- if (arg === "--global") {
114
- if (sawLocal) {
115
- throw new Error("Cannot use --local and --global together.");
116
- }
117
- sawGlobal = true;
118
- scope = "global";
134
+ if (parseScopeFlag(arg, scopeState)) {
119
135
  continue;
120
136
  }
121
137
 
@@ -132,7 +148,7 @@ function parseShowArgs(args: string[]): ParsedShowArgs {
132
148
  positional.push(arg);
133
149
  }
134
150
 
135
- return { scope, positional, json, verbose };
151
+ return { scope: scopeState.scope, positional, json, verbose };
136
152
  }
137
153
 
138
154
  export async function runShow(args: string[], deps: ConfigCommandDeps): Promise<void> {
@@ -296,17 +312,10 @@ export async function runSet(args: string[], deps: ConfigCommandDeps): Promise<v
296
312
  await deps.saveConfig(normalized.config);
297
313
  deps.log.success(`Updated "${key}" to ${formatValue(parsedValue)}.`);
298
314
 
299
- const effective = await deps.loadEffectiveConfigWithDiagnostics(deps.cwd());
300
- const effectiveErrors = collectEffectiveConfigValidationErrors(effective);
301
- if (!effective.config || effectiveErrors.length > 0) {
302
- deps.log.warn(
303
- formatConfigValidationMessage(
304
- getEffectiveConfigErrorHeader(effective),
305
- effectiveErrors.length > 0 ? effectiveErrors : ["Configuration format is invalid."],
306
- "Fix the repo-local override or restore compatible global values, then try again."
307
- )
308
- );
309
- }
315
+ await warnIfEffectiveConfigInvalid(
316
+ deps,
317
+ "Fix the repo-local override or restore compatible global values, then try again."
318
+ );
310
319
  }
311
320
 
312
321
  export async function runEdit(args: string[], deps: ConfigCommandDeps): Promise<void> {
@@ -347,16 +356,11 @@ export async function runEdit(args: string[], deps: ConfigCommandDeps): Promise<
347
356
  }
348
357
 
349
358
  if (parsed.scope === "local") {
350
- const effective = await deps.loadEffectiveConfigWithDiagnostics(deps.cwd());
351
- const errors = collectEffectiveConfigValidationErrors(effective);
352
- if (!effective.config || errors.length > 0) {
353
- deps.log.warn(
354
- formatConfigValidationMessage(
355
- getEffectiveConfigErrorHeader(effective),
356
- errors.length > 0 ? errors : ["Configuration format is invalid."],
357
- 'Run "rr init" and choose Repo-local config to regenerate the file, or fix it manually.'
358
- )
359
- );
359
+ const effective = await warnIfEffectiveConfigInvalid(
360
+ deps,
361
+ 'Run "rr init" and choose Repo-local config to regenerate the file, or fix it manually.'
362
+ );
363
+ if (!effective) {
360
364
  return;
361
365
  }
362
366
 
@@ -378,16 +382,11 @@ export async function runEdit(args: string[], deps: ConfigCommandDeps): Promise<
378
382
  return;
379
383
  }
380
384
 
381
- const effective = await deps.loadEffectiveConfigWithDiagnostics(deps.cwd());
382
- const effectiveErrors = collectEffectiveConfigValidationErrors(effective);
383
- if (!effective.config || effectiveErrors.length > 0) {
384
- deps.log.warn(
385
- formatConfigValidationMessage(
386
- getEffectiveConfigErrorHeader(effective),
387
- effectiveErrors.length > 0 ? effectiveErrors : ["Configuration format is invalid."],
388
- "Fix the repo-local override or restore compatible global values, then try again."
389
- )
390
- );
385
+ const effective = await warnIfEffectiveConfigInvalid(
386
+ deps,
387
+ "Fix the repo-local override or restore compatible global values, then try again."
388
+ );
389
+ if (!effective) {
391
390
  return;
392
391
  }
393
392
 
@@ -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;