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
@@ -1,5 +1,9 @@
1
- import * as p from "@clack/prompts";
2
- import { getCommandDef } from "@/cli";
1
+ import {
2
+ createInteractiveCommandDeps,
3
+ createPromptDeps,
4
+ type InteractiveCommandDeps,
5
+ type PromptDeps,
6
+ } from "@/commands/interactive-deps";
3
7
  import { parseCommand } from "@/lib/cli-parser";
4
8
  import { listProjectPendingHandoffs } from "@/lib/handoff";
5
9
  import { formatHandoffNote } from "@/lib/handoff-note";
@@ -22,18 +26,46 @@ interface StopOptions {
22
26
  session?: string;
23
27
  }
24
28
 
25
- interface StopDeps {
26
- getCommandDef: typeof getCommandDef;
27
- logError: (message: string) => void;
28
- exit: (code: number) => void;
29
- isTTY: () => boolean;
30
- }
29
+ type StopDeps = InteractiveCommandDeps &
30
+ PromptDeps & {
31
+ cwd: () => string;
32
+ computeSessionStats: typeof computeSessionStats;
33
+ listProjectPendingHandoffs: typeof listProjectPendingHandoffs;
34
+ listAllActiveSessions: typeof listAllActiveSessions;
35
+ listProjectActiveSessions: typeof listProjectActiveSessions;
36
+ removeAllSessionStates: typeof removeAllSessionStates;
37
+ stopActiveSession: typeof stopActiveSession;
38
+ updateSessionState: typeof updateSessionState;
39
+ sendInterrupt: typeof sendInterrupt;
40
+ readSessionState: typeof readSessionState;
41
+ sessionExists: typeof sessionExists;
42
+ killSession: typeof killSession;
43
+ removeSessionState: typeof removeSessionState;
44
+ listRalphSessions: typeof listRalphSessions;
45
+ sleep: (ms: number) => Promise<void>;
46
+ };
31
47
 
32
48
  const DEFAULT_STOP_DEPS: StopDeps = {
33
- getCommandDef,
34
- logError: (message: string) => p.log.error(message),
35
- exit: (code: number) => process.exit(code),
36
- isTTY: () => process.stdout.isTTY === true,
49
+ ...createInteractiveCommandDeps(),
50
+ ...createPromptDeps(),
51
+ cwd: () => process.cwd(),
52
+ computeSessionStats,
53
+ listProjectPendingHandoffs,
54
+ listAllActiveSessions,
55
+ listProjectActiveSessions,
56
+ removeAllSessionStates,
57
+ stopActiveSession,
58
+ updateSessionState,
59
+ sendInterrupt,
60
+ readSessionState,
61
+ sessionExists,
62
+ killSession,
63
+ removeSessionState,
64
+ listRalphSessions,
65
+ sleep: (ms) =>
66
+ new Promise<void>((resolve) => {
67
+ setTimeout(resolve, ms);
68
+ }),
37
69
  };
38
70
 
39
71
  type ResolvedStopHandoff = {
@@ -97,12 +129,13 @@ function createLogSessionFromPath(session: ActiveSession): LogSession | null {
97
129
  }
98
130
 
99
131
  async function resolveStoppedSessionHandoff(
100
- session: ActiveSession
132
+ session: ActiveSession,
133
+ deps: StopDeps
101
134
  ): Promise<ResolvedStopHandoff | null> {
102
135
  const logSession = createLogSessionFromPath(session);
103
136
  if (logSession) {
104
137
  try {
105
- const stats = await computeSessionStats(logSession);
138
+ const stats = await deps.computeSessionStats(logSession);
106
139
  if (
107
140
  (!stats.sessionId || stats.sessionId === session.sessionId) &&
108
141
  isReportedStopHandoffStatus(stats.handoffStatus)
@@ -119,7 +152,7 @@ async function resolveStoppedSessionHandoff(
119
152
  }
120
153
 
121
154
  try {
122
- const pendingHandoffs = await listProjectPendingHandoffs(undefined, session.projectPath);
155
+ const pendingHandoffs = await deps.listProjectPendingHandoffs(undefined, session.projectPath);
123
156
  const matchingHandoffs = pendingHandoffs.filter(
124
157
  (handoff) => handoff.sessionId === session.sessionId
125
158
  );
@@ -160,9 +193,10 @@ function formatProjectScopedCommand(
160
193
 
161
194
  async function resolveStoppedSessionHandoffNote(
162
195
  session: ActiveSession,
163
- currentProjectPath: string
196
+ currentProjectPath: string,
197
+ deps: StopDeps
164
198
  ): Promise<string | null> {
165
- const handoff = await resolveStoppedSessionHandoff(session);
199
+ const handoff = await resolveStoppedSessionHandoff(session, deps);
166
200
  if (!handoff) {
167
201
  return null;
168
202
  }
@@ -188,18 +222,20 @@ async function resolveStoppedSessionHandoffNote(
188
222
 
189
223
  async function stopSessionWithHandoff(
190
224
  session: ActiveSession,
191
- currentProjectPath: string
225
+ currentProjectPath: string,
226
+ deps: StopDeps
192
227
  ): Promise<string | null> {
193
- await stopActiveSession(session, {
194
- updateSessionState,
195
- sendInterrupt,
196
- readSessionState,
197
- sessionExists,
198
- killSession,
199
- removeSessionState,
228
+ await deps.stopActiveSession(session, {
229
+ updateSessionState: deps.updateSessionState,
230
+ sendInterrupt: deps.sendInterrupt,
231
+ readSessionState: deps.readSessionState,
232
+ sessionExists: deps.sessionExists,
233
+ killSession: deps.killSession,
234
+ removeSessionState: deps.removeSessionState,
235
+ sleep: deps.sleep,
200
236
  });
201
237
 
202
- return await resolveStoppedSessionHandoffNote(session, currentProjectPath);
238
+ return await resolveStoppedSessionHandoffNote(session, currentProjectPath, deps);
203
239
  }
204
240
 
205
241
  function findSessionBySelector(
@@ -240,9 +276,10 @@ function findSessionBySelector(
240
276
  }
241
277
 
242
278
  async function chooseProjectSession(
243
- projectSessions: ActiveSession[]
279
+ projectSessions: ActiveSession[],
280
+ deps: StopDeps
244
281
  ): Promise<ActiveSession | null> {
245
- const selection = await p.select({
282
+ const selection = await deps.select({
246
283
  message: "Choose a review session to stop",
247
284
  options: projectSessions.map((session) => ({
248
285
  value: session.sessionId,
@@ -251,38 +288,38 @@ async function chooseProjectSession(
251
288
  })),
252
289
  });
253
290
 
254
- if (p.isCancel(selection)) {
291
+ if (deps.isCancel(selection)) {
255
292
  return null;
256
293
  }
257
294
 
258
295
  return projectSessions.find((session) => session.sessionId === selection) ?? null;
259
296
  }
260
297
 
261
- async function stopSession(session: ActiveSession): Promise<void> {
262
- p.log.step(`Stopping session: ${session.sessionName}`);
263
- const handoffNote = await stopSessionWithHandoff(session, process.cwd());
264
- p.log.success("Review stopped.");
298
+ async function stopSession(session: ActiveSession, deps: StopDeps): Promise<void> {
299
+ deps.logStep(`Stopping session: ${session.sessionName}`);
300
+ const handoffNote = await stopSessionWithHandoff(session, deps.cwd(), deps);
301
+ deps.logSuccess("Review stopped.");
265
302
  if (handoffNote) {
266
- p.log.message(`Handoff:\n${handoffNote}`);
303
+ deps.logMessage(`Handoff:\n${handoffNote}`);
267
304
  }
268
305
  }
269
306
 
270
- async function stopAllSessions(): Promise<void> {
307
+ async function stopAllSessions(deps: StopDeps): Promise<void> {
271
308
  const orphanStopGracePeriod = 1_000;
272
- const currentProjectPath = process.cwd();
273
- const activeSessions = await listAllActiveSessions();
274
- const tmuxSessions = await listRalphSessions();
309
+ const currentProjectPath = deps.cwd();
310
+ const activeSessions = await deps.listAllActiveSessions();
311
+ const tmuxSessions = await deps.listRalphSessions();
275
312
  const sessionNames = [
276
313
  ...new Set([...tmuxSessions, ...activeSessions.map((session) => session.sessionName)]),
277
314
  ];
278
315
 
279
316
  if (sessionNames.length === 0) {
280
- p.log.info("No active review sessions.");
281
- await removeAllSessionStates();
317
+ deps.logInfo("No active review sessions.");
318
+ await deps.removeAllSessionStates();
282
319
  return;
283
320
  }
284
321
 
285
- p.log.step(`Stopping ${sessionNames.length} session(s)...`);
322
+ deps.logStep(`Stopping ${sessionNames.length} session(s)...`);
286
323
 
287
324
  const activeSessionsByName = new Map(
288
325
  activeSessions.map((session) => [session.sessionName, session] as const)
@@ -292,38 +329,36 @@ async function stopAllSessions(): Promise<void> {
292
329
  );
293
330
  const activeStopPromise = Promise.all(
294
331
  activeSessions.map(async (session) => ({
295
- handoffNote: await stopSessionWithHandoff(session, currentProjectPath),
332
+ handoffNote: await stopSessionWithHandoff(session, currentProjectPath, deps),
296
333
  }))
297
334
  );
298
335
 
299
336
  for (const sessionName of orphanSessionNames) {
300
- await sendInterrupt(sessionName);
337
+ await deps.sendInterrupt(sessionName);
301
338
  }
302
339
 
303
340
  const stoppedActiveSessions = await activeStopPromise;
304
341
 
305
342
  if (orphanSessionNames.length > 0) {
306
- await new Promise<void>((resolve) => {
307
- setTimeout(resolve, orphanStopGracePeriod);
308
- });
343
+ await deps.sleep(orphanStopGracePeriod);
309
344
  }
310
345
 
311
346
  for (const sessionName of orphanSessionNames) {
312
- await killSession(sessionName);
347
+ await deps.killSession(sessionName);
313
348
  }
314
349
 
315
350
  for (const sessionName of sessionNames) {
316
- p.log.message(` Stopped: ${sessionName}`);
351
+ deps.logMessage(` Stopped: ${sessionName}`);
317
352
  }
318
353
 
319
354
  for (const stoppedSession of stoppedActiveSessions) {
320
355
  if (stoppedSession.handoffNote) {
321
- p.log.message(`Handoff:\n${stoppedSession.handoffNote}`);
356
+ deps.logMessage(`Handoff:\n${stoppedSession.handoffNote}`);
322
357
  }
323
358
  }
324
359
 
325
- await removeAllSessionStates();
326
- p.log.success(`Stopped ${sessionNames.length} session(s).`);
360
+ await deps.removeAllSessionStates();
361
+ deps.logSuccess(`Stopped ${sessionNames.length} session(s).`);
327
362
  }
328
363
 
329
364
  async function stopCurrentProjectSession(
@@ -332,7 +367,7 @@ async function stopCurrentProjectSession(
332
367
  deps: StopDeps
333
368
  ): Promise<void> {
334
369
  const projectSessions = getCurrentProjectSessions(
335
- await listProjectActiveSessions(undefined, projectPath),
370
+ await deps.listProjectActiveSessions(undefined, projectPath),
336
371
  projectPath
337
372
  );
338
373
 
@@ -344,17 +379,17 @@ async function stopCurrentProjectSession(
344
379
  return;
345
380
  }
346
381
 
347
- await stopSession(match.session);
382
+ await stopSession(match.session, deps);
348
383
  return;
349
384
  }
350
385
 
351
386
  if (projectSessions.length === 0) {
352
- p.log.info("No active review session for current working directory.");
387
+ deps.logInfo("No active review session for current working directory.");
353
388
 
354
- const allSessions = await listAllActiveSessions();
389
+ const allSessions = await deps.listAllActiveSessions();
355
390
  if (allSessions.length > 0) {
356
- p.log.message(`\nThere are ${allSessions.length} other session(s) running.`);
357
- p.log.message(
391
+ deps.logMessage(`\nThere are ${allSessions.length} other session(s) running.`);
392
+ deps.logMessage(
358
393
  'Use "rr stop --all" to stop all running review sessions, or "rr" to see details.'
359
394
  );
360
395
  }
@@ -364,7 +399,7 @@ async function stopCurrentProjectSession(
364
399
  if (projectSessions.length === 1) {
365
400
  const onlySession = projectSessions[0];
366
401
  if (onlySession) {
367
- await stopSession(onlySession);
402
+ await stopSession(onlySession, deps);
368
403
  }
369
404
  return;
370
405
  }
@@ -377,12 +412,12 @@ async function stopCurrentProjectSession(
377
412
  return;
378
413
  }
379
414
 
380
- const selectedSession = await chooseProjectSession(projectSessions);
415
+ const selectedSession = await chooseProjectSession(projectSessions, deps);
381
416
  if (!selectedSession) {
382
417
  return;
383
418
  }
384
419
 
385
- await stopSession(selectedSession);
420
+ await stopSession(selectedSession, deps);
386
421
  }
387
422
 
388
423
  export async function runStop(args: string[], deps: Partial<StopDeps> = {}): Promise<void> {
@@ -406,9 +441,11 @@ export async function runStop(args: string[], deps: Partial<StopDeps> = {}): Pro
406
441
  }
407
442
 
408
443
  if (options.all) {
409
- await stopAllSessions();
444
+ await stopAllSessions(stopDeps);
410
445
  return;
411
446
  }
412
447
 
413
- await stopCurrentProjectSession(process.cwd(), options.session, stopDeps);
448
+ await stopCurrentProjectSession(stopDeps.cwd(), options.session, stopDeps);
414
449
  }
450
+
451
+ export type { StopDeps };
@@ -1,5 +1,6 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { getCommandDef } from "@/cli";
3
+ import type { SpinnerFactory } from "@/cli-io";
3
4
  import { type CommandDef, parseCommand } from "@/lib/cli-parser";
4
5
  import {
5
6
  getDefaultSelfUpdateDependencies,
@@ -16,10 +17,7 @@ interface UpdateRuntime extends SelfUpdateDependencies {
16
17
  getCommandDef: (name: string) => CommandDef | undefined;
17
18
  parseCommand: typeof parseCommand;
18
19
  performSelfUpdate: typeof performSelfUpdate;
19
- spinner: () => {
20
- start: (message: string) => void;
21
- stop: (message: string) => void;
22
- };
20
+ spinner: SpinnerFactory;
23
21
  log: {
24
22
  error: (message: string) => void;
25
23
  info: (message: string) => void;
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { AgentConfig, AgentRole, ReviewOptions } from "@/lib/types";
6
- import { createLineFormatter, parseJsonlEvent } from "./core";
6
+ import { createLineFormatter, extractLastParsedValue, parseJsonlEvent } from "./core";
7
7
  import type {
8
8
  AssistantContentBlock,
9
9
  AssistantEvent,
@@ -128,21 +128,9 @@ export function formatClaudeEventForDisplay(event: ClaudeStreamEvent): string |
128
128
  }
129
129
 
130
130
  export function extractClaudeResult(output: string): string | null {
131
- if (!output.trim()) {
132
- return null;
133
- }
134
-
135
- const lines = output.split("\n");
136
- let lastResult: string | null = null;
137
-
138
- for (const line of lines) {
139
- const event = parseClaudeStreamEvent(line);
140
- if (event && isResultEvent(event)) {
141
- lastResult = event.result;
142
- }
143
- }
144
-
145
- return lastResult;
131
+ return extractLastParsedValue(output, parseClaudeStreamEvent, (event) =>
132
+ isResultEvent(event) ? event.result : null
133
+ );
146
134
  }
147
135
 
148
136
  export const formatClaudeLine = createLineFormatter(
@@ -46,6 +46,22 @@ export function createLineFormatter<T>(
46
46
  };
47
47
  }
48
48
 
49
+ export function extractLastParsedValue<T>(
50
+ output: string,
51
+ parser: (line: string) => T | null,
52
+ selectValue: (event: T) => string | null
53
+ ): string | null {
54
+ if (!output.trim()) {
55
+ return null;
56
+ }
57
+
58
+ return output.split("\n").reduce<string | null>((lastResult, line) => {
59
+ const event = parser(line);
60
+ const value = event ? selectValue(event) : null;
61
+ return value ?? lastResult;
62
+ }, null);
63
+ }
64
+
49
65
  export function stripSystemReminders(text: unknown): string {
50
66
  const normalized = typeof text === "string" ? text : String(text ?? "");
51
67
  return normalized.replace(/<system-reminder>[\s\S]*?<\/system-reminder>\s*/g, "").trim();
@@ -11,6 +11,7 @@ import {
11
11
  import {
12
12
  createLineFormatter,
13
13
  defaultBuildEnv,
14
+ extractLastParsedValue,
14
15
  parseJsonlEvent,
15
16
  stripSystemReminders,
16
17
  } from "./core";
@@ -109,21 +110,9 @@ export function formatDroidEventForDisplay(event: DroidStreamEvent): string | nu
109
110
  }
110
111
 
111
112
  export function extractDroidResult(output: string): string | null {
112
- if (!output.trim()) {
113
- return null;
114
- }
115
-
116
- const lines = output.split("\n");
117
- let lastResult: string | null = null;
118
-
119
- for (const line of lines) {
120
- const event = parseDroidStreamEvent(line);
121
- if (event?.type === "completion") {
122
- lastResult = event.finalText;
123
- }
124
- }
125
-
126
- return lastResult;
113
+ return extractLastParsedValue(output, parseDroidStreamEvent, (event) =>
114
+ event.type === "completion" ? event.finalText : null
115
+ );
127
116
  }
128
117
 
129
118
  export const formatDroidLine = createLineFormatter(
@@ -52,6 +52,15 @@ export function registerDroidReasoningOptions(
52
52
  }
53
53
  }
54
54
 
55
+ export function resetRegisteredReasoningOptions(): void {
56
+ Object.keys(codexReasoningLevelsByModel).forEach((model) => {
57
+ delete codexReasoningLevelsByModel[model];
58
+ });
59
+ Object.keys(droidReasoningLevelsByModel).forEach((model) => {
60
+ delete droidReasoningLevelsByModel[model];
61
+ });
62
+ }
63
+
55
64
  export function getDroidReasoningOptions(model: string): ReasoningLevel[] {
56
65
  const levels = droidReasoningLevelsByModel[model];
57
66
  return levels ? [...levels] : [];
@@ -162,6 +162,23 @@ function consumeOptionValue(
162
162
  throw new Error(`Option --${opt.name} requires a value`);
163
163
  }
164
164
 
165
+ function assignOptionValue(
166
+ values: Record<string, unknown>,
167
+ opt: OptionDef,
168
+ argv: string[],
169
+ currentIndex: number,
170
+ inlineValue?: string
171
+ ): number {
172
+ if (opt.type === "boolean") {
173
+ values[opt.name] = true;
174
+ return currentIndex;
175
+ }
176
+
177
+ const { value, nextIndex } = consumeOptionValue(opt, argv, currentIndex, inlineValue);
178
+ values[opt.name] = parseValue(opt, value);
179
+ return nextIndex;
180
+ }
181
+
165
182
  export function parseCommand<T = Record<string, unknown>>(
166
183
  def: CommandDef,
167
184
  argv: string[]
@@ -216,13 +233,7 @@ export function parseCommand<T = Record<string, unknown>>(
216
233
  throw new CliError(def.name, "unknown_option", arg, validOptions, suggestion);
217
234
  }
218
235
 
219
- if (opt.type === "boolean") {
220
- values[opt.name] = true;
221
- } else {
222
- const { value, nextIndex } = consumeOptionValue(opt, argv, i, inlineValue);
223
- i = nextIndex;
224
- values[opt.name] = parseValue(opt, value);
225
- }
236
+ i = assignOptionValue(values, opt, argv, i, inlineValue);
226
237
 
227
238
  i++;
228
239
  continue;
@@ -249,13 +260,7 @@ export function parseCommand<T = Record<string, unknown>>(
249
260
  throw new CliError(def.name, "unknown_option", `-${alias}`, validOptions);
250
261
  }
251
262
 
252
- if (opt.type === "boolean") {
253
- values[opt.name] = true;
254
- } else {
255
- const { value, nextIndex } = consumeOptionValue(opt, argv, i);
256
- i = nextIndex;
257
- values[opt.name] = parseValue(opt, value);
258
- }
263
+ i = assignOptionValue(values, opt, argv, i);
259
264
 
260
265
  i++;
261
266
  continue;
@@ -386,8 +386,8 @@ export async function listProjectPendingHandoffs(
386
386
  );
387
387
  }
388
388
 
389
- export async function applyPendingHandoff(
390
- storageRoot: string = CONFIG_DIR,
389
+ async function requirePendingHandoff(
390
+ storageRoot: string,
391
391
  projectPath: string,
392
392
  handoffId: string
393
393
  ): Promise<PendingHandoffArtifact> {
@@ -396,7 +396,19 @@ export async function applyPendingHandoff(
396
396
  throw new Error(`Pending review handoff "${handoffId}" was not found.`);
397
397
  }
398
398
 
399
- return await applyPendingHandoffArtifact(storageRoot, artifact, "manual");
399
+ return artifact;
400
+ }
401
+
402
+ export async function applyPendingHandoff(
403
+ storageRoot: string = CONFIG_DIR,
404
+ projectPath: string,
405
+ handoffId: string
406
+ ): Promise<PendingHandoffArtifact> {
407
+ return await applyPendingHandoffArtifact(
408
+ storageRoot,
409
+ await requirePendingHandoff(storageRoot, projectPath, handoffId),
410
+ "manual"
411
+ );
400
412
  }
401
413
 
402
414
  export async function discardPendingHandoff(
@@ -404,10 +416,7 @@ export async function discardPendingHandoff(
404
416
  projectPath: string,
405
417
  handoffId: string
406
418
  ): Promise<PendingHandoffArtifact> {
407
- const artifact = await readPendingHandoff(storageRoot, projectPath, handoffId);
408
- if (!artifact) {
409
- throw new Error(`Pending review handoff "${handoffId}" was not found.`);
410
- }
419
+ const artifact = await requirePendingHandoff(storageRoot, projectPath, handoffId);
411
420
 
412
421
  if (artifact.state === "apply-conflicted") {
413
422
  throw new Error(
@@ -517,6 +517,7 @@ function applyEntryToSummary(
517
517
  const unresolvedFindings = entry.fixResults.filter(
518
518
  (result) => result.status === "unresolved"
519
519
  ).length;
520
+ const skippedFindings = entry.fixResults.filter((result) => result.status === "skipped").length;
520
521
  const failed = entry.error !== undefined;
521
522
  const interrupted = entry.error?.message.toLowerCase().includes("interrupt") === true;
522
523
 
@@ -528,7 +529,7 @@ function applyEntryToSummary(
528
529
  next.totalResolvedSelectedFindings = resolvedFindings;
529
530
  next.totalUnresolvedSelectedFindings = unresolvedFindings;
530
531
  next.totalFixes = resolvedFindings;
531
- next.totalSkipped = unresolvedFindings;
532
+ next.totalSkipped = skippedFindings + unresolvedFindings;
532
533
 
533
534
  if (entry.duration !== undefined) {
534
535
  next.totalDuration = (summary.totalDuration ?? 0) + entry.duration;
@@ -89,6 +89,6 @@ Use this strict framing protocol:
89
89
 
90
90
  * **Do not** wrap the JSON in markdown fences or extra prose.
91
91
  * The code_location field is required and must include absolute_file_path and line_range.
92
- * Line ranges must be as short as possible for interpreting the issue (avoid ranges over 5–10 lines; pick the most suitable subrange).
92
+ * Line ranges must use integer `start` and `end` values with `end >= start`, and must be as short as possible for interpreting the issue (avoid ranges over 5–10 lines; pick the most suitable subrange).
93
93
  * The code_location should overlap with the diff.
94
94
  * Do not generate a PR fix.
@@ -11,7 +11,8 @@ export function createReviewerStructuredOutputInstructions(): string {
11
11
  - ${REVIEW_SUMMARY_START_TOKEN}
12
12
  - ${REVIEW_SUMMARY_END_TOKEN}
13
13
  - Do not include markdown fences.
14
- - Do not include any text before the start token or after the end token.`;
14
+ - Do not include any text before the start token or after the end token.
15
+ - For each finding code_location.line_range, start and end MUST be integers and end MUST be greater than or equal to start.`;
15
16
  }
16
17
 
17
18
  export function createFixerStructuredOutputInstructions(): string {
@@ -79,7 +79,9 @@ function isFixResultArray(value: unknown): value is FindingFixResult[] {
79
79
 
80
80
  return (
81
81
  isFindingId(entry.findingId) &&
82
- (entry.status === "resolved" || entry.status === "unresolved") &&
82
+ (entry.status === "resolved" ||
83
+ entry.status === "skipped" ||
84
+ entry.status === "unresolved") &&
83
85
  typeof entry.summary === "string"
84
86
  );
85
87
  });
@@ -19,7 +19,7 @@ export interface StoredFinding {
19
19
 
20
20
  export interface FindingFixResult {
21
21
  findingId: FindingId;
22
- status: "resolved" | "unresolved";
22
+ status: "resolved" | "skipped" | "unresolved";
23
23
  summary: string;
24
24
  }
25
25
 
@@ -213,7 +213,7 @@ ${formatSelectedFindings(options.selectedFindings)}
213
213
 
214
214
  ## Required workflow
215
215
  1. Verify each selected finding independently against the real code.
216
- 2. Decide resolved vs unresolved for each finding before making edits.
216
+ 2. Decide resolved vs skipped vs unresolved for each finding before making edits.
217
217
  3. Apply fixes only for findings you can prove.
218
218
  4. Keep edits as local and minimal as possible.
219
219
  5. Return one result entry for every selected finding ID.
@@ -231,8 +231,8 @@ ${FIX_SUMMARY_START_TOKEN}
231
231
  "decision": "<NO_CHANGES_NEEDED | APPLY_SELECTIVELY | APPLY_MOST>",
232
232
  "results": {
233
233
  "F001": {
234
- "status": "<resolved | unresolved>",
235
- "summary": "<what changed or why the finding remains unresolved>"
234
+ "status": "<resolved | skipped | unresolved>",
235
+ "summary": "<what changed or why the finding was skipped or remains unresolved>"
236
236
  }
237
237
  }
238
238
  }
@@ -240,11 +240,11 @@ ${FIX_SUMMARY_END_TOKEN}
240
240
 
241
241
  JSON rules:
242
242
  - Use the selected finding IDs as the object keys under \`results\`.
243
- - Return \`resolved\` only when the selected finding is fully addressed or already satisfied by the current code.
244
- - Return \`unresolved\` when the finding still needs follow-up, including when you skip it for lack of proof or cannot safely remediate it.
243
+ - Return \`resolved\` only when the selected finding is verified and fixed; intentional edits may be included in a handoff.
244
+ - Return \`skipped\` only when the selected finding is not actionable or not proven and does not require a code change.
245
+ - Return \`unresolved\` when the finding is likely actionable or you attempted a fix but cannot safely complete and verify it.
245
246
  - You must return one result entry for every selected finding ID.
246
247
  - Do not include any finding that was not selected.
247
- - Use \`resolved\` only when you verified the issue and applied a real code change.
248
- - Use \`unresolved\` when the finding was unproven, out of scope, or did not require a safe change.
248
+ - Missing result entries will be treated as \`unresolved\`.
249
249
  - The delimited JSON block must be the final output.`;
250
250
  }