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
@@ -5,7 +5,7 @@ import { parseCommand } from "@/lib/cli-parser";
5
5
  import { loadEffectiveConfig } from "@/lib/config";
6
6
  import { collectIssueItems, runDiagnostics } from "@/lib/diagnostics";
7
7
  import { getTmuxInstallHint } from "@/lib/diagnostics/tmux-install";
8
- import type { DiagnosticsReport } from "@/lib/diagnostics/types";
8
+ import type { DiagnosticItem, DiagnosticsReport } from "@/lib/diagnostics/types";
9
9
  import { type CycleResult, runReviewCycle } from "@/lib/engine";
10
10
  import { formatReviewType } from "@/lib/format";
11
11
  import { formatHandoffNote } from "@/lib/handoff-note";
@@ -19,6 +19,7 @@ import {
19
19
  } from "@/lib/priority-list";
20
20
  import { runFixSession } from "@/lib/review-workflow/remediation/run-fix-session";
21
21
  import type { FixSessionResult } from "@/lib/review-workflow/remediation/types";
22
+ import { mapSessionStatusToFinalStatus } from "@/lib/review-workflow/session-status";
22
23
  import {
23
24
  createSessionId,
24
25
  createSessionState,
@@ -108,20 +109,6 @@ export function formatRunAgentsNote(config: Config, reviewOptions: ReviewOptions
108
109
  return lines.join("\n");
109
110
  }
110
111
 
111
- function mapSessionStatusToFinalStatus(
112
- status: FixSessionResult["sessionStatus"]
113
- ): CycleResult["finalStatus"] {
114
- if (status === "failed") {
115
- return "failed";
116
- }
117
-
118
- if (status === "interrupted") {
119
- return "interrupted";
120
- }
121
-
122
- return "completed";
123
- }
124
-
125
112
  function hasAutoFixPriorityMatches(result: CycleResult, priorities: Priority[]): boolean {
126
113
  return (
127
114
  result.artifact?.findings.some((finding) => priorities.includes(finding.priority)) ?? false
@@ -257,6 +244,32 @@ export interface RunRuntimeOverrides
257
244
  timer?: Partial<RunRuntime["timer"]>;
258
245
  }
259
246
 
247
+ function createRunCommandContext(overrides: RunRuntimeOverrides): {
248
+ runtime: RunRuntime;
249
+ projectPath: string;
250
+ } {
251
+ const runtime = createRunRuntime(overrides);
252
+ return {
253
+ runtime,
254
+ projectPath: runtime.process.env.RR_PROJECT_PATH || runtime.process.cwd(),
255
+ };
256
+ }
257
+
258
+ function logPreflightItems(
259
+ runtime: RunRuntime,
260
+ items: DiagnosticItem[],
261
+ heading: string,
262
+ severity: "error" | "warn"
263
+ ): void {
264
+ runtime.prompt.log[severity](heading);
265
+ for (const item of items) {
266
+ runtime.prompt.log.message(` ${item.summary}`);
267
+ item.remediation.forEach((remediation) => {
268
+ runtime.prompt.log.message(` -> ${remediation}`);
269
+ });
270
+ }
271
+ }
272
+
260
273
  export function createRunRuntime(overrides: RunRuntimeOverrides = {}): RunRuntime {
261
274
  const defaults: RunRuntime = {
262
275
  prompt: {
@@ -449,8 +462,7 @@ export async function runForeground(
449
462
  args: string[] = [],
450
463
  overrides: RunRuntimeOverrides = {}
451
464
  ): Promise<void> {
452
- const runtime = createRunRuntime(overrides);
453
- const projectPath = runtime.process.env.RR_PROJECT_PATH || runtime.process.cwd();
465
+ const { runtime, projectPath } = createRunCommandContext(overrides);
454
466
  const config = await runtime.loadConfig(projectPath);
455
467
  if (!config) {
456
468
  runtime.prompt.log.error("Failed to load config");
@@ -714,8 +726,7 @@ export async function startReview(
714
726
  args: string[],
715
727
  overrides: RunRuntimeOverrides = {}
716
728
  ): Promise<void> {
717
- const runtime = createRunRuntime(overrides);
718
- const projectPath = runtime.process.env.RR_PROJECT_PATH || runtime.process.cwd();
729
+ const { runtime, projectPath } = createRunCommandContext(overrides);
719
730
  // Parse options using command definition
720
731
  const runDef = runtime.getCommandDef("run");
721
732
  if (!runDef) {
@@ -856,25 +867,13 @@ export async function startReview(
856
867
  const warnings = issues.filter((item) => item.severity === "warning");
857
868
 
858
869
  if (errors.length > 0) {
859
- runtime.prompt.log.error("Cannot run review:");
860
- for (const item of errors) {
861
- runtime.prompt.log.message(` ${item.summary}`);
862
- item.remediation.forEach((remediation) => {
863
- runtime.prompt.log.message(` -> ${remediation}`);
864
- });
865
- }
870
+ logPreflightItems(runtime, errors, "Cannot run review:", "error");
866
871
  runtime.process.exit(1);
867
872
  return;
868
873
  }
869
874
 
870
875
  if (warnings.length > 0) {
871
- runtime.prompt.log.warn("Preflight warnings:");
872
- for (const item of warnings) {
873
- runtime.prompt.log.message(` ${item.summary}`);
874
- item.remediation.forEach((remediation) => {
875
- runtime.prompt.log.message(` -> ${remediation}`);
876
- });
877
- }
876
+ logPreflightItems(runtime, warnings, "Preflight warnings:", "warn");
878
877
  }
879
878
 
880
879
  const config = diagnostics.config ?? (await runtime.loadConfig(projectPath));
@@ -1,5 +1,8 @@
1
1
  import * as p from "@clack/prompts";
2
- import { getCommandDef } from "@/cli";
2
+ import {
3
+ createInteractiveCommandDeps,
4
+ type InteractiveCommandDeps,
5
+ } from "@/commands/interactive-deps";
3
6
  import { parseCommand } from "@/lib/cli-parser";
4
7
  import { listProjectPendingHandoffs } from "@/lib/handoff";
5
8
  import { formatHandoffNote } from "@/lib/handoff-note";
@@ -22,19 +25,9 @@ interface StopOptions {
22
25
  session?: string;
23
26
  }
24
27
 
25
- interface StopDeps {
26
- getCommandDef: typeof getCommandDef;
27
- logError: (message: string) => void;
28
- exit: (code: number) => void;
29
- isTTY: () => boolean;
30
- }
28
+ type StopDeps = InteractiveCommandDeps;
31
29
 
32
- 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,
37
- };
30
+ const DEFAULT_STOP_DEPS = createInteractiveCommandDeps();
38
31
 
39
32
  type ResolvedStopHandoff = {
40
33
  handoffStatus: Extract<HandoffStatus, "applied-auto" | "pending-apply" | "apply-conflicted">;
@@ -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(
@@ -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
  }
@@ -19,7 +19,7 @@ import type { Config } from "@/lib/types";
19
19
  import type { FixDecision } from "@/lib/types/domain";
20
20
 
21
21
  interface BatchFixerResultEntry {
22
- status: "resolved" | "unresolved";
22
+ status: "resolved" | "skipped" | "unresolved";
23
23
  summary: string;
24
24
  }
25
25
 
@@ -66,7 +66,9 @@ function isBatchFixerResultEntry(value: unknown): value is BatchFixerResultEntry
66
66
 
67
67
  const candidate = value as Record<string, unknown>;
68
68
  return (
69
- (candidate.status === "resolved" || candidate.status === "unresolved") &&
69
+ (candidate.status === "resolved" ||
70
+ candidate.status === "skipped" ||
71
+ candidate.status === "unresolved") &&
70
72
  typeof candidate.summary === "string"
71
73
  );
72
74
  }
@@ -124,6 +126,30 @@ function toFixResults(
124
126
  });
125
127
  }
126
128
 
129
+ async function appendBatchFixLog(
130
+ deps: RunBatchFixPhaseDependencies,
131
+ options: RunBatchFixPhaseOptions,
132
+ startedAt: number,
133
+ fixResults: FindingFixResult[],
134
+ error?: unknown
135
+ ): Promise<void> {
136
+ await deps.appendLog(options.artifact.logPath, {
137
+ type: "batch_fix",
138
+ timestamp: Date.now(),
139
+ duration: Date.now() - startedAt,
140
+ selectedFindingIds: options.selection.selectedFindingIds,
141
+ fixResults,
142
+ ...(error === undefined
143
+ ? {}
144
+ : {
145
+ error: {
146
+ phase: "fixer" as const,
147
+ message: error instanceof Error ? error.message : String(error),
148
+ },
149
+ }),
150
+ });
151
+ }
152
+
127
153
  export async function runBatchFixPhase(
128
154
  options: RunBatchFixPhaseOptions,
129
155
  deps: RunBatchFixPhaseDependencies = DEFAULT_RUN_BATCH_FIX_PHASE_DEPENDENCIES
@@ -168,13 +194,7 @@ export async function runBatchFixPhase(
168
194
  const fixResults = toFixResults(options.selection.selectedFindingIds, parsed);
169
195
 
170
196
  deps.discardCheckpoint(options.worktree.worktreeProjectPath, checkpoint);
171
- await deps.appendLog(options.artifact.logPath, {
172
- type: "batch_fix",
173
- timestamp: Date.now(),
174
- duration: Date.now() - startedAt,
175
- selectedFindingIds: options.selection.selectedFindingIds,
176
- fixResults,
177
- });
197
+ await appendBatchFixLog(deps, options, startedAt, fixResults);
178
198
 
179
199
  return {
180
200
  phase: "batch-fix",
@@ -183,17 +203,7 @@ export async function runBatchFixPhase(
183
203
  };
184
204
  } catch (error) {
185
205
  deps.rollbackToCheckpoint(options.worktree.worktreeProjectPath, checkpoint);
186
- await deps.appendLog(options.artifact.logPath, {
187
- type: "batch_fix",
188
- timestamp: Date.now(),
189
- duration: Date.now() - startedAt,
190
- selectedFindingIds: options.selection.selectedFindingIds,
191
- fixResults: [],
192
- error: {
193
- phase: "fixer",
194
- message: error instanceof Error ? error.message : String(error),
195
- },
196
- });
206
+ await appendBatchFixLog(deps, options, startedAt, [], error);
197
207
  throw error;
198
208
  }
199
209
  }