@expo/code-review-cli 0.9.2 → 0.10.0

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.
@@ -11,9 +11,9 @@ const CLAUDE_MAX_TURNS = 60;
11
11
  /** Fallback per-pass ceiling when a caller passes no maxWaitMs. */
12
12
  const DEFAULT_MAX_WAIT_MS = 8 * 60 * 1000;
13
13
  /**
14
- * A stateless `claude -p` pass emits no incremental tool lines to the caller, so a
15
- * long pass would look hung. Emit a "still working" heartbeat this often (matching
16
- * opencode.ts's HEARTBEAT_MS) to keep the progress signal alive.
14
+ * Emit a "still working" heartbeat after this long with no structured stream
15
+ * activity (matching opencode.ts's HEARTBEAT_MS), so a thinking stretch still has
16
+ * a progress signal between tool calls.
17
17
  */
18
18
  const CLAUDE_HEARTBEAT_MS = 45_000;
19
19
  /**
@@ -215,11 +215,13 @@ export function buildClaudeArgs(opts) {
215
215
  return [
216
216
  "-p",
217
217
  "--output-format",
218
- "json",
218
+ "stream-json",
219
+ "--verbose",
219
220
  "--model",
220
221
  opts.model,
221
222
  "--append-system-prompt",
222
223
  opts.system,
224
+ ...(opts.jsonSchema ? ["--json-schema", JSON.stringify(opts.jsonSchema)] : []),
223
225
  ...(enabled.length > 0 ? ["--allowedTools", ...enabled.map(scope)] : []),
224
226
  "--disallowedTools",
225
227
  ...deniedReadTools,
@@ -232,6 +234,165 @@ export function buildClaudeArgs(opts) {
232
234
  String(opts.maxTurns ?? CLAUDE_MAX_TURNS),
233
235
  ];
234
236
  }
237
+ const CLAUDE_STREAM_MISSING_RESULT = "Claude Code stream ended without a final result event";
238
+ const CLAUDE_RESULT_MISSING_ERROR = "Claude Code returned an error without a message";
239
+ function jsonRecord(value) {
240
+ return value !== null && typeof value === "object" && !Array.isArray(value)
241
+ ? value
242
+ : null;
243
+ }
244
+ /** The final result object from either legacy single JSON or JSONL stream output. */
245
+ function finalClaudeResult(stdout) {
246
+ try {
247
+ const parsed = jsonRecord(JSON.parse(stdout));
248
+ return parsed?.type === "result" ? parsed : null;
249
+ }
250
+ catch {
251
+ let result = null;
252
+ for (const line of stdout.split(/\r?\n/)) {
253
+ if (!line.trim()) {
254
+ continue;
255
+ }
256
+ try {
257
+ const event = jsonRecord(JSON.parse(line));
258
+ if (event?.type === "result") {
259
+ result = event;
260
+ }
261
+ }
262
+ catch {
263
+ // A malformed/non-JSON line cannot be the structured final result.
264
+ }
265
+ }
266
+ return result;
267
+ }
268
+ }
269
+ const MAX_ACTIVITY_DETAIL = 180;
270
+ /** Collapse control/newline injection and bound provider/model-originated log text. */
271
+ function safeActivityDetail(value) {
272
+ if (typeof value !== "string") {
273
+ return undefined;
274
+ }
275
+ const withoutControls = [...value]
276
+ .map((char) => {
277
+ const code = char.charCodeAt(0);
278
+ return code <= 0x1f || (code >= 0x7f && code <= 0x9f) ? " " : char;
279
+ })
280
+ .join("");
281
+ const clean = withoutControls.replace(/\s+/g, " ").trim();
282
+ if (!clean) {
283
+ return undefined;
284
+ }
285
+ return clean.length > MAX_ACTIVITY_DETAIL ? `${clean.slice(0, MAX_ACTIVITY_DETAIL - 1)}…` : clean;
286
+ }
287
+ /** Render an in-tree tool target without exposing attempted host paths. */
288
+ function activityPath(value, cwd) {
289
+ const candidate = safeActivityDetail(value);
290
+ if (!candidate) {
291
+ return undefined;
292
+ }
293
+ const root = path.resolve(cwd);
294
+ const absolute = path.resolve(root, candidate);
295
+ if (absolute !== root && !pathInside(absolute, root)) {
296
+ return undefined;
297
+ }
298
+ const relative = path.relative(root, absolute).replace(/\\/g, "/");
299
+ return relative || ".";
300
+ }
301
+ /**
302
+ * Convert one Claude stream event into safe progress metadata. Raw assistant text,
303
+ * tool results, and grep patterns are deliberately never logged: PR/model content is
304
+ * untrusted and may contain secrets or terminal-control/log-injection payloads.
305
+ */
306
+ // @ref LLP 0003#claude-code-cli-containment [implements] — stream only bounded lifecycle/tool metadata; raw model text and tool results never become progress logs
307
+ export function claudeActivities(eventValue, cwd) {
308
+ const event = jsonRecord(eventValue);
309
+ if (!event) {
310
+ return [];
311
+ }
312
+ if (event.type === "system" && event.subtype === "init") {
313
+ const model = safeActivityDetail(event.model);
314
+ return [{ line: model ? `started ${model}` : "started" }];
315
+ }
316
+ if (event.type === "result") {
317
+ if (event.is_error === true) {
318
+ return [];
319
+ }
320
+ const duration = typeof event.duration_ms === "number"
321
+ ? `${Math.max(0, Math.round(event.duration_ms / 1000))}s`
322
+ : null;
323
+ const turns = typeof event.num_turns === "number" ? `${event.num_turns} turn(s)` : null;
324
+ const detail = [duration, turns].filter(Boolean).join(", ");
325
+ return [{ line: detail ? `completed (${detail})` : "completed" }];
326
+ }
327
+ if (event.type !== "assistant") {
328
+ return [];
329
+ }
330
+ const message = jsonRecord(event.message);
331
+ const content = Array.isArray(message?.content) ? message.content : [];
332
+ const activities = [];
333
+ for (const value of content) {
334
+ const block = jsonRecord(value);
335
+ if (block?.type !== "tool_use" || typeof block.name !== "string") {
336
+ continue;
337
+ }
338
+ const input = jsonRecord(block.input) ?? {};
339
+ const key = typeof block.id === "string" ? block.id : undefined;
340
+ if (block.name === "Read") {
341
+ const target = activityPath(input.file_path, cwd);
342
+ activities.push({ key, line: target ? `Read ${target}` : "Read" });
343
+ }
344
+ else if (block.name === "Grep") {
345
+ const target = activityPath(input.path, cwd);
346
+ activities.push({ key, line: target ? `Grep ${target}` : "Grep" });
347
+ }
348
+ else if (block.name === "Glob") {
349
+ const target = activityPath(input.path, cwd);
350
+ activities.push({ key, line: target && target !== "." ? `Glob ${target}` : "Glob" });
351
+ }
352
+ }
353
+ return activities;
354
+ }
355
+ /** Incremental JSONL decoder for Claude's stream-json stdout. */
356
+ export function createClaudeActivityStream(cwd, onActivity) {
357
+ let buffered = "";
358
+ const reported = new Set();
359
+ const consume = (line) => {
360
+ if (!line.trim()) {
361
+ return;
362
+ }
363
+ try {
364
+ for (const activity of claudeActivities(JSON.parse(line), cwd)) {
365
+ if (activity.key && reported.has(activity.key)) {
366
+ continue;
367
+ }
368
+ if (activity.key) {
369
+ reported.add(activity.key);
370
+ }
371
+ onActivity(activity.line);
372
+ }
373
+ }
374
+ catch {
375
+ // Ignore malformed progress events; final result parsing still reports errors.
376
+ }
377
+ };
378
+ return {
379
+ push(chunk) {
380
+ buffered += chunk;
381
+ for (;;) {
382
+ const newline = buffered.indexOf("\n");
383
+ if (newline < 0) {
384
+ break;
385
+ }
386
+ consume(buffered.slice(0, newline).replace(/\r$/, ""));
387
+ buffered = buffered.slice(newline + 1);
388
+ }
389
+ },
390
+ finish() {
391
+ consume(buffered);
392
+ buffered = "";
393
+ },
394
+ };
395
+ }
235
396
  /**
236
397
  * The model that actually answered, out of the result's modelUsage keys. The CLI
237
398
  * also bills its own internal helper calls there (a haiku entry appears alongside
@@ -248,22 +409,26 @@ export function pickAnsweringModel(requested, modelOutputTokens) {
248
409
  return keys.sort((a, b) => (modelOutputTokens[b] ?? 0) - (modelOutputTokens[a] ?? 0))[0];
249
410
  }
250
411
  /**
251
- * Parse the `--output-format json` result object. Keys off `is_error` / a parse
412
+ * Parse the final result from `--output-format stream-json` JSONL (also accepts the
413
+ * former single JSON object for compatibility/tests). Keys off `is_error` / a parse
252
414
  * failure, NOT `subtype` — `subtype` stays `"success"` on some API errors.
253
415
  */
254
416
  export function parseClaudeResult(stdout) {
255
- let parsed;
256
- try {
257
- parsed = JSON.parse(stdout);
258
- }
259
- catch {
417
+ const parsed = finalClaudeResult(stdout);
418
+ if (!parsed) {
419
+ // The stream may contain assistant text and tool results sourced from the
420
+ // untrusted review tree. Never turn that transcript into an error message or
421
+ // feed it to provider-error classification.
422
+ // @ref LLP 0003#claude-code-cli-containment [constrained-by] — raw JSONL transcript content must never reach logs or error classifiers
260
423
  return {
261
424
  text: "",
262
425
  cost: 0,
263
426
  tokens: {},
264
427
  modelOutputTokens: {},
265
428
  isError: true,
266
- errorText: stdout.trim(),
429
+ errorText: CLAUDE_STREAM_MISSING_RESULT,
430
+ hasStructuredOutput: false,
431
+ structuredOutputFailure: false,
267
432
  };
268
433
  }
269
434
  const usage = (parsed.usage ?? {});
@@ -281,17 +446,41 @@ export function parseClaudeResult(stdout) {
281
446
  for (const [key, value] of Object.entries(modelUsage)) {
282
447
  modelOutputTokens[key] = num(value?.outputTokens) ?? 0;
283
448
  }
284
- const result = typeof parsed.result === "string" ? parsed.result : "";
285
449
  const isError = parsed.is_error === true;
450
+ const hasStructuredOutput = parsed.structured_output !== undefined;
451
+ // `--json-schema` returns a provider-validated object in `structured_output`.
452
+ // Serialize it back through the existing local parser so Zod remains the final
453
+ // trust boundary. Ignore any stale/retracted structured value on an error result;
454
+ // error classification must use only the CLI's explicit final error message.
455
+ const structured = isError || parsed.structured_output === undefined
456
+ ? undefined
457
+ : JSON.stringify(parsed.structured_output);
458
+ const result = structured ?? (typeof parsed.result === "string" ? parsed.result : "");
286
459
  return {
287
460
  text: result,
288
461
  cost: num(parsed.total_cost_usd) ?? 0,
289
462
  tokens,
290
463
  modelOutputTokens,
291
464
  isError,
292
- errorText: isError ? result || stdout.trim() : "",
465
+ // Only the final result event's explicit error text is safe to classify and
466
+ // surface. Falling back to stdout would expose the full JSONL transcript.
467
+ errorText: isError ? result || CLAUDE_RESULT_MISSING_ERROR : "",
468
+ hasStructuredOutput,
469
+ structuredOutputFailure: parsed.subtype === "error_max_structured_output_retries",
293
470
  };
294
471
  }
472
+ /**
473
+ * A provider-side schema failure that spent tokens and may be retried once from a
474
+ * clean process. Carrying the attempt lets the caller retain honest run metrics.
475
+ */
476
+ class ClaudeStructuredOutputError extends Error {
477
+ result;
478
+ constructor(result) {
479
+ super("Claude Code could not produce output matching the required JSON Schema");
480
+ this.result = result;
481
+ this.name = "ClaudeStructuredOutputError";
482
+ }
483
+ }
295
484
  /** Classify a Claude Code failure so the caller can pick backoff vs. hard fail. */
296
485
  export function classifyClaudeError(errorText, apiStatus) {
297
486
  if (apiStatus === 401 || apiStatus === 403) {
@@ -365,6 +554,34 @@ export function claudeTemperatureNote(config, engineOf) {
365
554
  "(for the claude-routed passes)"
366
555
  : null;
367
556
  }
557
+ /**
558
+ * Preserve actionable process diagnostics without copying arbitrary stderr into a
559
+ * public Actions log. A non-empty stream may already contain model/tool content, so
560
+ * stderr is not even classified in that case. With no stream, recognize only fixed
561
+ * CLI/setup categories and never interpolate the matched text.
562
+ */
563
+ // @ref LLP 0003#claude-code-cli-containment [constrained-by] — stderr may reflect untrusted tree content; expose only exit metadata and fixed allowlisted categories
564
+ function claudeExitDiagnostic(result) {
565
+ const exit = `Claude Code exited with code ${result.code}`;
566
+ if (result.stdout.trim() !== "") {
567
+ return exit;
568
+ }
569
+ if (/unknown (?:option|argument)|unrecognized option|unexpected argument/i.test(result.stderr)) {
570
+ return (`${exit}; the CLI rejected its arguments — verify the pinned ` +
571
+ "@anthropic-ai/claude-code version supports the configured flags");
572
+ }
573
+ if (/authentication|oauth|api.?key|unauthorized|\b401\b|\b403\b/i.test(result.stderr)) {
574
+ return (`${exit}; authentication failed before a result was emitted — check ` +
575
+ "`claude auth status` and `ecr doctor`");
576
+ }
577
+ if (/\bENOENT\b|command not found|no such file or directory/i.test(result.stderr)) {
578
+ return `${exit}; the Claude Code executable or one of its required files was not found`;
579
+ }
580
+ if (/\bEACCES\b/i.test(result.stderr)) {
581
+ return `${exit}; the Claude Code executable could not be launched due to permissions`;
582
+ }
583
+ return exit;
584
+ }
368
585
  /**
369
586
  * One prompt → text/cost/tokens/model, as a single `claude -p` subprocess (the
370
587
  * Claude analogue of OpenCode's promptAgent; no sessions/polling).
@@ -380,12 +597,23 @@ export async function runClaudePrompt(handle, args) {
380
597
  // A soft tool-call ceiling doubles as the CLI's per-pass turn bound (the closest
381
598
  // stateless analogue of OpenCode's mid-run tool-call cap).
382
599
  const maxTurns = args.maxToolCalls != null && args.maxToolCalls > 0 ? args.maxToolCalls : undefined;
383
- // A stateless `claude -p` pass streams nothing back, so emit a heartbeat while it
384
- // runs or a long pass looks hung (cleared in finally, whatever the outcome).
600
+ // Stream safe structured activity. The heartbeat fires only after a quiet window,
601
+ // rather than on a fixed cadence that can land immediately after a tool line.
385
602
  const heartbeatStart = Date.now();
603
+ let lastStreamActivityAt = heartbeatStart;
604
+ const emitActivity = (line) => {
605
+ lastStreamActivityAt = Date.now();
606
+ args.onActivity?.(line);
607
+ };
608
+ const activityStream = args.onActivity
609
+ ? createClaudeActivityStream(process.cwd(), emitActivity)
610
+ : undefined;
386
611
  const heartbeat = args.onActivity
387
612
  ? setInterval(() => {
388
- args.onActivity?.(`still working… ${Math.round((Date.now() - heartbeatStart) / 1000)}s elapsed`);
613
+ if (Date.now() - lastStreamActivityAt >= CLAUDE_HEARTBEAT_MS) {
614
+ args.onActivity?.(`still working… ${Math.round((Date.now() - heartbeatStart) / 1000)}s elapsed ` +
615
+ `(no new activity for ${Math.round((Date.now() - lastStreamActivityAt) / 1000)}s)`);
616
+ }
389
617
  }, CLAUDE_HEARTBEAT_MS)
390
618
  : undefined;
391
619
  heartbeat?.unref?.();
@@ -397,15 +625,18 @@ export async function runClaudePrompt(handle, args) {
397
625
  cwd: process.cwd(),
398
626
  tools,
399
627
  maxTurns,
628
+ jsonSchema: args.jsonSchema,
400
629
  }), {
401
630
  input: args.text,
402
631
  env: handle.childEnv,
403
632
  cwd: process.cwd(),
404
633
  timeout: maxWaitMs,
405
634
  check: false,
635
+ onStdout: activityStream?.push,
406
636
  });
407
637
  }
408
638
  finally {
639
+ activityStream?.finish();
409
640
  if (heartbeat) {
410
641
  clearInterval(heartbeat);
411
642
  }
@@ -418,7 +649,7 @@ export async function runClaudePrompt(handle, args) {
418
649
  // A non-timeout signal is a crash (SIGSEGV, OOM SIGKILL, external kill), not a
419
650
  // timeout — surface it as a hard error rather than the subdivide/retry path.
420
651
  if (result.signal) {
421
- throw new Error(`Claude Code was killed by signal ${result.signal}: ${result.stderr.trim() || "(no output)"}`);
652
+ throw new Error(`Claude Code was killed by signal ${result.signal}`);
422
653
  }
423
654
  // Truncated output can't be parsed as JSON; report the cause plainly instead of
424
655
  // letting it fall through as a generic parse failure.
@@ -426,6 +657,28 @@ export async function runClaudePrompt(handle, args) {
426
657
  throw new Error("claude output exceeded the 64MB buffer and was truncated");
427
658
  }
428
659
  const parsed = parseClaudeResult(result.stdout);
660
+ const answered = pickAnsweringModel(configuredModel, parsed.modelOutputTokens);
661
+ const model = answered
662
+ ? claudeModelMatches(configuredModel, answered)
663
+ ? configuredModel
664
+ : `anthropic/${answered}`
665
+ : configuredModel;
666
+ const promptResult = {
667
+ text: parsed.text,
668
+ cost: parsed.cost,
669
+ sessionID: "",
670
+ tokens: parsed.tokens,
671
+ model,
672
+ };
673
+ // A schema-requesting caller must receive the provider-validated object, never a
674
+ // parseable-looking fallback from `result`. Claude repairs schema mismatches in
675
+ // session first; its documented exhaustion result gets one clean-process retry in
676
+ // claudeCodePromptAndParse. A success that omits structured_output is treated the
677
+ // same way because it did not honor the requested provider contract.
678
+ if (args.jsonSchema &&
679
+ (parsed.structuredOutputFailure || (!parsed.isError && !parsed.hasStructuredOutput))) {
680
+ throw new ClaudeStructuredOutputError(promptResult);
681
+ }
429
682
  if (parsed.isError) {
430
683
  const kind = classifyClaudeError(parsed.errorText);
431
684
  if (kind === "rate-limit" || kind === "usage-limit") {
@@ -443,29 +696,26 @@ export async function runClaudePrompt(handle, args) {
443
696
  "`claude setup-token`, set CLAUDE_CODE_OAUTH_TOKEN, and check `claude auth status` / " +
444
697
  "`ecr doctor`.");
445
698
  }
446
- throw new Error(parsed.errorText ||
447
- `claude exited with code ${result.code}: ${result.stderr.trim() || result.stdout.trim() || "(no output)"}`);
699
+ const needsProcessDiagnostic = parsed.errorText === CLAUDE_STREAM_MISSING_RESULT ||
700
+ parsed.errorText === CLAUDE_RESULT_MISSING_ERROR;
701
+ throw new Error(needsProcessDiagnostic
702
+ ? `${parsed.errorText} (${claudeExitDiagnostic(result)})`
703
+ : parsed.errorText);
448
704
  }
449
- const answered = pickAnsweringModel(configuredModel, parsed.modelOutputTokens);
450
- const model = answered
451
- ? claudeModelMatches(configuredModel, answered)
452
- ? configuredModel
453
- : `anthropic/${answered}`
454
- : configuredModel;
455
- return { text: parsed.text, cost: parsed.cost, sessionID: "", tokens: parsed.tokens, model };
705
+ return promptResult;
456
706
  }
457
707
  /**
458
- * The claude analogue of opencode.ts's CORRECTIVE. NOT shared: that one says
459
- * "your previous reply could not be parsed", which is true in OpenCode's
460
- * same-session follow-up but false here each `claude -p` invocation is a fresh
461
- * stateless process with no previous reply to reference.
708
+ * Last-resort correction for a no-schema caller or disagreement between the
709
+ * provider's JSON Schema validator and our local parser. Production parsers pass
710
+ * `--json-schema`, so Claude Code already performs validation-aware repair inside
711
+ * the original session before this fresh-process fallback is needed.
462
712
  */
463
713
  const CLAUDE_CORRECTIVE = "\n\nIMPORTANT: reply with ONLY the single JSON object described above — no prose, " +
464
714
  "no code fences, no partial output.";
465
715
  /**
466
- * Prompt via the Claude Code CLI and parse the reply, mirroring OpenCode's
467
- * promptAndParse: transient retry on the first call, then one corrective re-run
468
- * (a fresh process the diff is inlined, so re-read is a cache hit).
716
+ * Prompt via the Claude Code CLI and parse the provider-validated structured
717
+ * result locally. Transient failures retry first; a remaining local parse failure
718
+ * gets one fresh-process corrective as defense in depth.
469
719
  */
470
720
  export async function claudeCodePromptAndParse(handle, args, parse) {
471
721
  let cost = 0;
@@ -476,20 +726,42 @@ export async function claudeCodePromptAndParse(handle, args, parse) {
476
726
  addTokenUsage(tokens, result.tokens);
477
727
  model = result.model ?? model;
478
728
  };
479
- const first = await withTransientRetry(`Agent "${args.agent}"`, args.onActivity, () => runClaudePrompt(handle, args));
480
- record(first);
729
+ let first;
481
730
  try {
482
- return { value: parse(first.text), cost, truncated: false, tokens, model };
483
- }
484
- catch {
485
- const retry = await runClaudePrompt(handle, { ...args, text: args.text + CLAUDE_CORRECTIVE });
486
- record(retry);
731
+ first = await withTransientRetry(`Agent "${args.agent}"`, args.onActivity, () => runClaudePrompt(handle, args));
732
+ record(first);
487
733
  try {
488
- return { value: parse(retry.text), cost, truncated: false, tokens, model };
734
+ return { value: parse(first.text), cost, truncated: false, tokens, model };
735
+ }
736
+ catch {
737
+ // Provider validation and local Zod validation disagreed. Retry once from a
738
+ // clean process rather than accepting an object the trust boundary rejected.
489
739
  }
490
- catch (finalError) {
491
- throw new Error(`Agent "${args.agent}" did not return parseable JSON after retries: ${finalError instanceof Error ? finalError.message : String(finalError)}`);
740
+ }
741
+ catch (error) {
742
+ if (!(error instanceof ClaudeStructuredOutputError)) {
743
+ throw error;
744
+ }
745
+ record(error.result);
746
+ args.onActivity?.("structured output validation failed — retrying once");
747
+ }
748
+ let retry;
749
+ try {
750
+ retry = await runClaudePrompt(handle, { ...args, text: args.text + CLAUDE_CORRECTIVE });
751
+ }
752
+ catch (error) {
753
+ if (!(error instanceof ClaudeStructuredOutputError)) {
754
+ throw error;
492
755
  }
756
+ record(error.result);
757
+ throw new Error(`Agent "${args.agent}" could not satisfy its required JSON Schema after retries`);
758
+ }
759
+ record(retry);
760
+ try {
761
+ return { value: parse(retry.text), cost, truncated: false, tokens, model };
762
+ }
763
+ catch (finalError) {
764
+ throw new Error(`Agent "${args.agent}" did not return parseable JSON after retries: ${finalError instanceof Error ? finalError.message : String(finalError)}`);
493
765
  }
494
766
  }
495
767
  /**
@@ -9,7 +9,7 @@ import { parseCoordinatorOutput } from "./schema.js";
9
9
  // cap is a backstop. It runs AFTER all passes, so this adds to the worst-case
10
10
  // serial chain — keep it within the CI job timeout (see review.ts / workflows).
11
11
  const COORDINATOR_TIMEOUT_MS = 10 * 60 * 1000;
12
- export async function coordinate(handle, config, metadata, agentFindings, coverageNotes = [], stackManifest) {
12
+ export async function coordinate(handle, config, metadata, agentFindings, coverageNotes = [], stackManifest, onActivity) {
13
13
  const system = buildCoordinatorSystem(config);
14
14
  const text = buildCoordinatorTask(metadata, agentFindings, coverageNotes, stackManifest);
15
15
  const { value, cost, tokens, truncated, model } = await promptAndParse(handle, {
@@ -19,6 +19,7 @@ export async function coordinate(handle, config, metadata, agentFindings, covera
19
19
  title: "review-coordinator",
20
20
  maxWaitMs: COORDINATOR_TIMEOUT_MS,
21
21
  finalizeOnTimeout: true,
22
+ onActivity,
22
23
  }, parseCoordinatorOutput);
23
24
  return { output: value, cost, tokens, truncated, model };
24
25
  }
@@ -161,7 +161,17 @@ function runWithInput(command, args, options, input) {
161
161
  child.stdout.setEncoding("utf8");
162
162
  child.stderr.setEncoding("utf8");
163
163
  child.stdout.on("data", (chunk) => {
164
+ const before = stdout.length;
164
165
  stdout = cap(stdout, chunk);
166
+ const admitted = stdout.length - before;
167
+ if (admitted > 0 && options.onStdout) {
168
+ try {
169
+ options.onStdout(chunk.slice(0, admitted));
170
+ }
171
+ catch {
172
+ // Observability must never break the command whose output it observes.
173
+ }
174
+ }
165
175
  });
166
176
  child.stderr.on("data", (chunk) => {
167
177
  stderr = cap(stderr, chunk);
@@ -799,20 +799,19 @@ export async function withTransientRetry(label, onActivity, fn) {
799
799
  }
800
800
  }
801
801
  /**
802
- * Prompt an agent and parse its reply. On a JSON-parse failure, first retry in
803
- * the SAME session: the model still holds all the file context it read, so the
804
- * corrective is a cache read and a cheap re-emit and better for recall than
805
- * re-investigating from scratch (the usual failure is a truncated/malformed reply
806
- * after a sound investigation). Only if that also fails do we fall back to a fresh
807
- * session as a clean-slate last resort. A timeout is NOT a parse failure:
808
- * promptAgent throws AgentTimeoutError, which propagates so the caller abandons
809
- * the task instead of retrying a non-convergent run.
802
+ * Prompt an agent and parse its reply. Claude-routed production parsers hand their
803
+ * JSON Schema to the CLI, which repairs validation failures in-session before
804
+ * returning. OpenCode retries a JSON-parse failure in the SAME session: the model
805
+ * still holds all the file context it read, so the corrective is a cheap cache-read
806
+ * re-emit with better recall than re-investigating. Only if that also fails does it
807
+ * use a fresh session. A timeout is NOT a parse failure: AgentTimeoutError propagates
808
+ * so the caller abandons the non-convergent task.
810
809
  */
811
810
  export async function promptAndParse(handle, args, parse) {
812
811
  const dispatch = resolveEngineDispatch(handle, args.agent);
813
812
  if (dispatch.engine === CLAUDE_CODE_ENGINE) {
814
813
  const { claudeCodePromptAndParse } = await import("./claude-code.js");
815
- return claudeCodePromptAndParse(dispatch.claudeHandle, args, parse);
814
+ return claudeCodePromptAndParse(dispatch.claudeHandle, { ...args, jsonSchema: parse.jsonSchema }, parse);
816
815
  }
817
816
  let cost = 0;
818
817
  let truncated = false;
@@ -138,7 +138,7 @@ function location(finding, link) {
138
138
  * per-PR dismissals. Findings whose fingerprint appears in `dismissed` render in a
139
139
  * collapsed "Dismissed" section instead of the main list.
140
140
  */
141
- export function renderMarkdown(review, tag, dismissed = [], link, feedback = [], pins = []) {
141
+ export function renderMarkdown(review, tag, dismissed = [], link, feedback = [], pins = [], inputHash) {
142
142
  const dismissedByFp = new Map(dismissed.map((record) => [record.fp, record]));
143
143
  const withFp = review.findings.map((finding) => ({ finding, fp: fingerprintFinding(finding) }));
144
144
  const feedbackByFp = matchedFeedback(feedback, new Set(withFp.map((entry) => entry.fp)));
@@ -181,7 +181,7 @@ export function renderMarkdown(review, tag, dismissed = [], link, feedback = [],
181
181
  // and dismissals, so `/dismiss` can re-render this comment without re-running.
182
182
  const fingerprints = review.findings.map(fingerprintFinding);
183
183
  lines.push("", `<!-- ${tag}:fingerprints=${JSON.stringify(fingerprints)} -->`);
184
- lines.push(`<!-- ${tag}:state=${encodeState(reviewState({ review, dismissed }, feedbackByFp, pins))} -->`);
184
+ lines.push(`<!-- ${tag}:state=${encodeState(reviewState({ review, dismissed, ...(inputHash ? { inputHash } : {}) }, feedbackByFp, pins))} -->`);
185
185
  return lines.join("\n");
186
186
  }
187
187
  /**
@@ -501,7 +501,7 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts, fee
501
501
  // review) so /undismiss can restore them and the Dismissed section persists
502
502
  // across re-renders. The per-scope data (`scopes`) plus a merged v1 `review`
503
503
  // keep both v2 and v1 consumers working.
504
- const stateScopes = rendered.map(({ result, shown, requalified, dropped }) => {
504
+ const stateScopes = rendered.map(({ result, shown, hidden, requalified, requalifiedHidden, dropped }) => {
505
505
  // @ref LLP 0011#suppression-is-never-silent — strip any per-scope `feedback`
506
506
  // a freshly-reviewed scope's ReviewRunResult carries: the top-level feedback
507
507
  // array (reviewState below) is the single source of truth. A stale per-scope
@@ -511,6 +511,13 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts, fee
511
511
  return {
512
512
  scope: result.scope,
513
513
  isDefault: result.isDefault,
514
+ // A truncated state is not the full result and therefore cannot be a cache
515
+ // source. Omit its hash so the next run retries instead of reusing a subset.
516
+ ...(hidden === 0 && requalifiedHidden === 0
517
+ ? result.inputHash
518
+ ? { inputHash: result.inputHash }
519
+ : {}
520
+ : {}),
514
521
  // Requalified findings ride the embedded state (like dismissed ones) so a
515
522
  // re-render (/dismiss) round-trips them and the addressed section persists.
516
523
  // Under truncation they are trimmed exactly like `shown` — state bytes count