@expo/code-review-cli 0.9.2 → 0.11.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.
@@ -0,0 +1,108 @@
1
+ // @ref LLP 0005#review-result-cache [implements] — automated CI reuses only a complete result whose full review input hashes identically
2
+ import { createHash } from "node:crypto";
3
+ import { createReadStream } from "node:fs";
4
+ import { lstat, readlink } from "node:fs/promises";
5
+ import { createRequire } from "node:module";
6
+ import path from "node:path";
7
+ import { pathInside } from "./exec.js";
8
+ const packageVersion = createRequire(import.meta.url)("../../package.json")
9
+ .version;
10
+ /**
11
+ * Version the cache-key contract independently of the embedded comment-state shape.
12
+ * Bump this whenever review inputs gain a new source that is not represented below.
13
+ */
14
+ const REVIEW_INPUT_HASH_VERSION = 1;
15
+ /** Canonical JSON: object-key order never turns the same input into a cache miss. */
16
+ function canonicalJson(value) {
17
+ if (value === null || typeof value !== "object") {
18
+ return JSON.stringify(value) ?? "null";
19
+ }
20
+ if (Array.isArray(value)) {
21
+ return `[${value.map(canonicalJson).join(",")}]`;
22
+ }
23
+ const record = value;
24
+ return `{${Object.keys(record)
25
+ .filter((key) => record[key] !== undefined)
26
+ .sort()
27
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
28
+ .join(",")}}`;
29
+ }
30
+ /**
31
+ * Hash every input that can change the code-review result while deliberately
32
+ * excluding checkout-local paths and commit OIDs. A restack changes OIDs; when the
33
+ * scoped changed-file contents, normalized patch, prompts/config and PR prose are
34
+ * byte-identical, that is the cache hit this key is meant to recognize.
35
+ */
36
+ function normalizedPatch(patch, binary) {
37
+ return (patch
38
+ .split("\n")
39
+ // Blob ids are volatile across a restack. A binary patch has no textual content
40
+ // to hash, so retain its index line as the only content identity available.
41
+ .filter((line) => binary || !line.startsWith("index "))
42
+ // Unrelated lines landing earlier in the file move hunk coordinates without
43
+ // changing the code under review. Preserve any trailing function heading.
44
+ .map((line) => line.replace(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@(.*)$/, "@@ @@$1"))
45
+ .join("\n"));
46
+ }
47
+ async function fileContentIdentity(root, file) {
48
+ const abs = path.resolve(root, file.path);
49
+ if (!pathInside(abs, root)) {
50
+ throw new Error(`review cache path escapes the PR tree: ${file.path}`);
51
+ }
52
+ let stat;
53
+ try {
54
+ stat = await lstat(abs);
55
+ }
56
+ catch (error) {
57
+ if (error.code === "ENOENT") {
58
+ return { kind: "missing" };
59
+ }
60
+ throw error;
61
+ }
62
+ if (stat.isSymbolicLink()) {
63
+ // Never follow a PR-controlled symlink out of the materialized tree.
64
+ return { kind: "symlink", target: await readlink(abs), mode: stat.mode & 0o777 };
65
+ }
66
+ if (!stat.isFile()) {
67
+ return { kind: "other", mode: stat.mode & 0o777 };
68
+ }
69
+ const hash = createHash("sha256");
70
+ for await (const chunk of createReadStream(abs)) {
71
+ hash.update(chunk);
72
+ }
73
+ return { kind: "file", digest: hash.digest("hex"), mode: stat.mode & 0o777 };
74
+ }
75
+ export async function reviewInputHash(options) {
76
+ const { configDir: _configDir, ...portableConfig } = options.config;
77
+ const sortedFiles = [...options.files].sort((a, b) => a.path.localeCompare(b.path) || (a.status ?? "").localeCompare(b.status ?? ""));
78
+ const files = [];
79
+ // Sequential reads avoid opening one descriptor per changed file on a very wide PR.
80
+ for (const file of sortedFiles) {
81
+ files.push({
82
+ path: file.path,
83
+ patch: normalizedPatch(file.patch, file.binary === true),
84
+ status: file.status ?? "",
85
+ binary: file.binary === true,
86
+ content: await fileContentIdentity(options.readRoot, file),
87
+ });
88
+ }
89
+ const input = {
90
+ version: REVIEW_INPUT_HASH_VERSION,
91
+ engineVersion: packageVersion,
92
+ files,
93
+ config: portableConfig,
94
+ metadata: options.metadata,
95
+ agents: options.agents ? [...options.agents].sort() : null,
96
+ route: options.route === true,
97
+ contextText: options.contextText ?? null,
98
+ };
99
+ return createHash("sha256").update(canonicalJson(input)).digest("hex");
100
+ }
101
+ /** Partial/failed reviews must be retried, never made durable by a cache hit. */
102
+ export function reviewCanBeReused(review) {
103
+ return review.couldNotComplete !== true && review.incomplete.length === 0;
104
+ }
105
+ /** Exact hash match plus the completeness guard used by every CI comment shape. */
106
+ export function reviewMatchesInput(review, storedHash, currentHash) {
107
+ return (storedHash === currentHash && /^[a-f0-9]{64}$/.test(storedHash) && reviewCanBeReused(review));
108
+ }
@@ -8,7 +8,7 @@ import { addTokenUsage, AgentTimeoutError, assertModelsResolvable, buildOpencode
8
8
  import { buildEngineMap, claudeTemperatureNote, claudeTokenCredential, startClaudeCode, } from "./claude-code.js";
9
9
  import { routeAgents } from "./router.js";
10
10
  import { buildCrossCuttingSystem, buildCrossCuttingTask, buildReviewerSystem, buildReviewerTask, NO_TOOLS_INSTRUCTION, } from "./prompts.js";
11
- import { fingerprintFinding, isOverallRiskHandoff, parseReviewerOutput } from "./schema.js";
11
+ import { fingerprintFinding, isOverallRiskHandoff, parseReviewerOutput, REVIEW_TRACE_AGENT_LIMIT, REVIEW_TRACE_BYTES_LIMIT, REVIEW_TRACE_CHECKED_LIMIT, REVIEW_TRACE_UNCERTAINTY_LIMIT, } from "./schema.js";
12
12
  import { adjudicateFeedback } from "./adjudicate.js";
13
13
  import { buildManifestMembership, manifestKey, normalizeManifestPath } from "./stack.js";
14
14
  import { confirmStackRequalifications, patchConfirmer } from "./stack-confirm.js";
@@ -70,6 +70,12 @@ export function effectiveConcurrency(config, env = process.env) {
70
70
  }
71
71
  return 6;
72
72
  }
73
+ /** Prefix live model activity with its stable agent bucket and optional pass label. */
74
+ export function formatAgentActivity(agent, label, line) {
75
+ const pass = label === agent ? "" : label.startsWith(`${agent} `) ? label.slice(agent.length).trim() : label;
76
+ const activity = line.replace(/[\r\n]+/g, " ").trim();
77
+ return ` [${agent}] ${pass ? `${pass}: ` : ""}${activity}`;
78
+ }
73
79
  export async function runReview(source, options) {
74
80
  const { config } = options;
75
81
  const started = Date.now();
@@ -283,6 +289,10 @@ export async function runReview(source, options) {
283
289
  // reviewers produced before the failure — partial findings are exactly what's
284
290
  // needed to debug a run that died mid-way.
285
291
  const agentFindings = {};
292
+ // Bounded, conclusion-only diagnostics for machine consumers of the hidden
293
+ // comment state. These are deliberately separate from findings: they never reach
294
+ // the coordinator, verification, policy, or decision paths.
295
+ const agentTrace = {};
286
296
  // First reviewer (by scheduling order) that produced each fingerprint, so a finding's
287
297
  // originating agent can be carried through the coordinator's merge/rewrite by matching
288
298
  // on fingerprint. Kept separate from agentFindings so the coordinator prompt and the
@@ -324,7 +334,7 @@ export async function runReview(source, options) {
324
334
  let selectedAgents = explicitAgents ?? config.agents;
325
335
  if (!explicitAgents && options.route) {
326
336
  progress("Routing: selecting relevant agents…");
327
- const routed = await routeAgents(handle, config, workspace.files);
337
+ const routed = await routeAgents(handle, config, workspace.files, (line) => progress(formatAgentActivity("router", "router", line)));
328
338
  selectedAgents = routed.agents;
329
339
  progress(routed.routed
330
340
  ? `Router selected: ${selectedAgents.map((a) => a.id).join(", ")}`
@@ -467,6 +477,7 @@ export async function runReview(source, options) {
467
477
  });
468
478
  let completedPasses = 0;
469
479
  let failedPasses = 0;
480
+ const taskProgress = (task, line) => progress(formatAgentActivity(task.bucket, task.label, line));
470
481
  // promptAndParse already retries internally (same-session corrective, then a
471
482
  // bounded fresh session). We do NOT wrap it in another retry loop. On a genuine
472
483
  // TIMEOUT, instead of dropping the work we break it into units that converge:
@@ -480,7 +491,7 @@ export async function runReview(source, options) {
480
491
  system: task.system,
481
492
  text: buildTaskText(task),
482
493
  title: task.title,
483
- onActivity: (line) => progress(` ${task.label}: ${line}`),
494
+ onActivity: (line) => taskProgress(task, line),
484
495
  maxWaitMs: task.maxWaitMs,
485
496
  maxToolCalls: task.maxToolCalls,
486
497
  finalizeOnTimeout: true,
@@ -489,6 +500,9 @@ export async function runReview(source, options) {
489
500
  trackTokens(task.bucket, tokens);
490
501
  trackModel(task.bucket, taskModel(task), model);
491
502
  (agentFindings[task.bucket] ??= []).push(...value.findings);
503
+ if (value.trace) {
504
+ mergeTraceNotes(agentTrace, task.bucket, value.trace);
505
+ }
492
506
  for (const finding of value.findings) {
493
507
  const fp = fingerprintFinding(finding);
494
508
  if (!agentByFp.has(fp)) {
@@ -497,7 +511,7 @@ export async function runReview(source, options) {
497
511
  }
498
512
  completedPasses++;
499
513
  if (truncated) {
500
- progress(` ${task.label}: hit its budget — returned partial findings`);
514
+ taskProgress(task, "hit its budget — returned partial findings");
501
515
  incomplete.push(`${capitalize(task.coverageLabel)} ran out of time; its findings may be incomplete.`);
502
516
  }
503
517
  return;
@@ -506,7 +520,7 @@ export async function runReview(source, options) {
506
520
  // Non-timeout errors are genuine failures — record and move on.
507
521
  if (!(error instanceof AgentTimeoutError)) {
508
522
  failedPasses++;
509
- progress(` ${task.label}: FAILED (${errorMessage(error)})`);
523
+ taskProgress(task, `FAILED (${errorMessage(error)})`);
510
524
  // An auth/permission failure hits every pass identically; push one shared,
511
525
  // actionable note (deduped into a single coverage line) instead of N generic
512
526
  // per-pass failures that bury the real, fixable cause.
@@ -531,7 +545,7 @@ export async function runReview(source, options) {
531
545
  const mid = Math.ceil(task.files.length / 2);
532
546
  const left = task.files.slice(0, mid);
533
547
  const right = task.files.slice(mid);
534
- progress(` ${task.label}: exceeded ${minutes}m — splitting into 2 smaller passes (${left.length} + ${right.length} files)`);
548
+ taskProgress(task, `exceeded ${minutes}m — splitting into 2 smaller passes (${left.length} + ${right.length} files)`);
535
549
  const over = { depth: task.depth + 1, maxWaitMs: childCap };
536
550
  enqueue(childTask(task, left, `↳${left.length}f`, over));
537
551
  enqueue(childTask(task, right, `↳${right.length}f`, over));
@@ -543,7 +557,7 @@ export async function runReview(source, options) {
543
557
  // without tools; it just can't open a caller outside the diff. A lighter
544
558
  // cross-file review beats the coverage gap it used to report.
545
559
  if (!task.fallback && remaining > FALLBACK_TIMEOUT_MS) {
546
- progress(` ${task.label}: exceeded ${minutes}m — retrying ${filesLabel(task.files)} with a fast no-tools pass`);
560
+ taskProgress(task, `exceeded ${minutes}m — retrying ${filesLabel(task.files)} with a fast no-tools pass`);
547
561
  enqueue(childTask(task, task.files, "(no-tools fallback)", {
548
562
  fallback: true,
549
563
  maxWaitMs: FALLBACK_TIMEOUT_MS,
@@ -561,7 +575,7 @@ export async function runReview(source, options) {
561
575
  failedPasses++;
562
576
  const couldStillReduce = (canSubdivide && task.depth < MAX_SUBDIVIDE_DEPTH) || !task.fallback;
563
577
  if (error.reason === "stall") {
564
- progress(` ${task.label}: its model requests went silent (stalled) and did not recover — ` +
578
+ taskProgress(task, `its model requests went silent (stalled) and did not recover — ` +
565
579
  `most likely provider rate limiting; reporting a coverage gap`);
566
580
  // Name the likely cause. OpenCode retries a 429 internally without surfacing
567
581
  // it, so provider throttling reaches us as pure silence — indistinguishable
@@ -573,11 +587,11 @@ export async function runReview(source, options) {
573
587
  `those changes were not fully reviewed.`);
574
588
  }
575
589
  else if (couldStillReduce) {
576
- progress(` ${task.label}: exceeded ${minutes}m and the run's time budget is spent — reporting a coverage gap`);
590
+ taskProgress(task, `exceeded ${minutes}m and the run's time budget is spent — reporting a coverage gap`);
577
591
  incomplete.push(`${capitalize(task.coverageLabel)} timed out and the overall review budget was exhausted before it could be broken down further; those changes were not fully reviewed.`);
578
592
  }
579
593
  else {
580
- progress(` ${task.label}: exceeded ${minutes}m even at its smallest reviewable unit — reporting a coverage gap`);
594
+ taskProgress(task, `exceeded ${minutes}m even at its smallest reviewable unit — reporting a coverage gap`);
581
595
  incomplete.push(`${capitalize(task.coverageLabel)} exceeded its time budget even after being reduced to its smallest reviewable unit; those changes were not fully reviewed.`);
582
596
  }
583
597
  }
@@ -634,7 +648,7 @@ export async function runReview(source, options) {
634
648
  progress("Coordinating findings…");
635
649
  let consolidated;
636
650
  try {
637
- const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, model: coordinatorModel, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes, stackManifest);
651
+ const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, model: coordinatorModel, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes, stackManifest, (line) => progress(formatAgentActivity("coordinator", "coordinator", line)));
638
652
  agentCosts["coordinator"] = cost;
639
653
  trackTokens("coordinator", coordinatorTokens);
640
654
  trackModel("coordinator", config.coordinator.model, coordinatorModel);
@@ -709,7 +723,7 @@ export async function runReview(source, options) {
709
723
  stackManifest &&
710
724
  grounded.some((finding) => finding.requalifiedBy)) {
711
725
  progress("Confirming stacked-PR requalifications against their patches…");
712
- const confirmation = await confirmStackRequalifications(grounded, options.stackConfirm.maxConfirmations, patchConfirmer(handle, source), progress);
726
+ const confirmation = await confirmStackRequalifications(grounded, options.stackConfirm.maxConfirmations, patchConfirmer(handle, source, (line) => progress(formatAgentActivity(STACK_VERIFIER_AGENT, STACK_VERIFIER_AGENT, line))), progress);
713
727
  grounded = confirmation.findings;
714
728
  requalificationStrips.push(...confirmation.strippedFindings);
715
729
  agentCosts[STACK_VERIFIER_AGENT] = confirmation.cost;
@@ -854,6 +868,7 @@ export async function runReview(source, options) {
854
868
  }
855
869
  progress(formatUsageSummary(tokenTotals, sum(agentCosts)));
856
870
  await appendStepSummary(renderUsageMarkdown(agentTokens, agentCosts, tokenTotals, sum(agentCosts), agentModels));
871
+ const reviewTrace = buildReviewTrace(agentTrace);
857
872
  await safeLog(logPath, {
858
873
  ...baseRecord,
859
874
  agentCosts,
@@ -862,6 +877,7 @@ export async function runReview(source, options) {
862
877
  agentTokens,
863
878
  agentModels,
864
879
  agentFindings,
880
+ reviewTrace,
865
881
  coverageNotes,
866
882
  verifierDropped,
867
883
  requalificationStrips,
@@ -878,7 +894,14 @@ export async function runReview(source, options) {
878
894
  });
879
895
  // Engine-owned: overwrite whatever the coordinator may have emitted under this key,
880
896
  // so setup advice is always the checker's, never model text.
881
- const reviewed = { ...output, setupNotes };
897
+ // `CoordinatorOutputSchema` knows the engine field so cached/embedded reviews can
898
+ // parse it, but the coordinator must never author it. Strip any model-supplied
899
+ // value and attach only the trace assembled from reviewer pass outputs.
900
+ const outputWithTrace = attachReviewTrace(output, reviewTrace);
901
+ const reviewed = {
902
+ ...outputWithTrace,
903
+ setupNotes,
904
+ };
882
905
  return feedbackRecords ? { ...reviewed, feedback: feedbackRecords } : reviewed;
883
906
  }
884
907
  catch (error) {
@@ -889,6 +912,7 @@ export async function runReview(source, options) {
889
912
  tokens: tokenTotals,
890
913
  agentTokens,
891
914
  agentFindings,
915
+ reviewTrace: buildReviewTrace(agentTrace),
892
916
  durationMs: Date.now() - started,
893
917
  decision: null,
894
918
  findingCount: 0,
@@ -903,6 +927,50 @@ export async function runReview(source, options) {
903
927
  await restoreCwd();
904
928
  }
905
929
  }
930
+ export function mergeTraceNotes(target, agent, notes) {
931
+ const current = target[agent] ?? { checked: [], uncertainties: [] };
932
+ const checked = [...new Set([...current.checked, ...notes.checked])].slice(0, REVIEW_TRACE_CHECKED_LIMIT);
933
+ const uncertainties = [...new Set([...current.uncertainties, ...notes.uncertainties])].slice(0, REVIEW_TRACE_UNCERTAINTY_LIMIT);
934
+ if (checked.length > 0 || uncertainties.length > 0) {
935
+ target[agent] = { checked, uncertainties };
936
+ }
937
+ }
938
+ export function buildReviewTrace(agents) {
939
+ // Sorting makes the cap deterministic even though concurrent passes finish in a
940
+ // nondeterministic order. The byte ceiling protects GitHub's ~65k comment limit;
941
+ // the trace shares that body with visible findings and their durable state.
942
+ const entries = Object.entries(agents).sort(([left], [right]) => left.localeCompare(right));
943
+ if (entries.length === 0) {
944
+ return undefined;
945
+ }
946
+ const kept = entries.slice(0, REVIEW_TRACE_AGENT_LIMIT);
947
+ let truncatedAgents = entries.length - kept.length;
948
+ for (;;) {
949
+ const trace = {
950
+ version: 1,
951
+ trust: "unverified-model-diagnostics",
952
+ agents: Object.fromEntries(kept),
953
+ ...(truncatedAgents > 0 ? { truncatedAgents } : {}),
954
+ };
955
+ if (Buffer.byteLength(JSON.stringify(trace), "utf8") <= REVIEW_TRACE_BYTES_LIMIT) {
956
+ return trace;
957
+ }
958
+ if (kept.length === 0) {
959
+ return undefined;
960
+ }
961
+ kept.pop();
962
+ truncatedAgents++;
963
+ }
964
+ }
965
+ /**
966
+ * Replace any coordinator-authored trace with the engine-assembled value. The
967
+ * coordinator reads untrusted PR data, so its output can never populate this hidden
968
+ * machine-consumer channel even when it emits a locally schema-valid object.
969
+ */
970
+ export function attachReviewTrace(output, reviewTrace) {
971
+ const { reviewTrace: _coordinatorTrace, ...withoutTrace } = output;
972
+ return { ...withoutTrace, ...(reviewTrace ? { reviewTrace } : {}) };
973
+ }
906
974
  /**
907
975
  * Policy backstop: strip the internal risk handoff, drop suggestions unless
908
976
  * opted in, cap by count (most severe first), and downgrade
@@ -6,7 +6,7 @@ import { parseRouteOutput } from "./schema.js";
6
6
  * `alwaysRun` are unioned in regardless. Falls back to ALL agents if the router
7
7
  * returns nothing usable or errors — a review must never run with zero agents.
8
8
  */
9
- export async function routeAgents(handle, config, files) {
9
+ export async function routeAgents(handle, config, files, onActivity) {
10
10
  const always = config.agents.filter((agent) => agent.alwaysRun);
11
11
  try {
12
12
  const { value } = await promptAndParse(handle, {
@@ -14,6 +14,7 @@ export async function routeAgents(handle, config, files) {
14
14
  system: buildRouterSystem(),
15
15
  text: buildRouterTask(config.agents, files),
16
16
  title: "route",
17
+ onActivity,
17
18
  }, parseRouteOutput);
18
19
  const byId = new Map(config.agents.map((agent) => [agent.id, agent]));
19
20
  const picked = value.agents
@@ -66,9 +66,6 @@ export const VerdictSchema = z.object({
66
66
  verified: z.boolean(),
67
67
  reason: z.string().default(""),
68
68
  });
69
- export function parseVerdict(text) {
70
- return VerdictSchema.parse(extractJsonObject(text));
71
- }
72
69
  /**
73
70
  * A stack verifier's verdict on whether a later stacked PR's patch actually
74
71
  * addresses an absence-style finding (v2 patch confirmation). Fails toward
@@ -79,27 +76,72 @@ export const StackVerdictSchema = z.object({
79
76
  addressed: z.boolean(),
80
77
  reason: z.string().default(""),
81
78
  });
82
- export function parseStackVerdict(text) {
83
- return StackVerdictSchema.parse(extractJsonObject(text));
84
- }
85
79
  // @ref LLP 0011#attribution-and-identity [implements] — `agent` is engine-populated, so the model-facing schema drops it at the parse boundary instead of trusting call sites to strip it
86
80
  /**
87
81
  * The finding shape a MODEL may emit: `FindingSchema` minus the engine-only `agent`.
88
82
  * Both model outputs parse through this, so an `agent` a reviewer pass or the
89
83
  * coordinator invented is dropped where model JSON becomes typed data — the engine's own
90
- * fingerprint lookup is then the only thing that can set it. The transform re-widens the
91
- * result to `Finding` so downstream code (which does set `agent`) needs no change.
84
+ * fingerprint lookup is then the only thing that can set it. This schema deliberately
85
+ * has no Zod transform: the Claude runtime converts it to JSON Schema so the provider
86
+ * can enforce the same contract before the local parse boundary checks it again.
87
+ */
88
+ const ModelFindingSchema = FindingSchema.omit({ agent: true });
89
+ /**
90
+ * Bounded, non-finding diagnostics a reviewer may leave for machine consumers.
91
+ * These notes explain what a clean pass actually checked without exposing a raw
92
+ * transcript or chain-of-thought. They remain unverified model output, so the
93
+ * engine labels the assembled trace with an explicit trust classification.
94
+ */
95
+ export const REVIEW_TRACE_AGENT_LIMIT = 12;
96
+ export const REVIEW_TRACE_CHECKED_LIMIT = 3;
97
+ export const REVIEW_TRACE_UNCERTAINTY_LIMIT = 2;
98
+ export const REVIEW_TRACE_NOTE_LIMIT = 240;
99
+ export const REVIEW_TRACE_BYTES_LIMIT = 6_000;
100
+ export const ReviewerTraceNotesSchema = z.object({
101
+ checked: z
102
+ .array(z.string().min(1).max(REVIEW_TRACE_NOTE_LIMIT))
103
+ .max(REVIEW_TRACE_CHECKED_LIMIT)
104
+ .default([]),
105
+ uncertainties: z
106
+ .array(z.string().min(1).max(REVIEW_TRACE_NOTE_LIMIT))
107
+ .max(REVIEW_TRACE_UNCERTAINTY_LIMIT)
108
+ .default([]),
109
+ });
110
+ /** Provider-facing shape each sub-reviewer is asked to emit. */
111
+ const ReviewerModelOutputSchema = z.object({
112
+ findings: z.array(ModelFindingSchema).default([]),
113
+ trace: ReviewerTraceNotesSchema.optional(),
114
+ });
115
+ /**
116
+ * Local trust boundary for reviewer output. Findings stay strict, while diagnostics
117
+ * fail soft: a malformed optional trace must never discard otherwise valid findings
118
+ * or turn a clean pass into a coverage gap.
92
119
  */
93
- const ModelFindingSchema = FindingSchema.omit({ agent: true }).transform((finding) => finding);
94
- /** Shape each sub-reviewer must emit. */
95
- export const ReviewerOutputSchema = z.object({
120
+ export const ReviewerOutputSchema = z
121
+ .object({
96
122
  findings: z.array(ModelFindingSchema).default([]),
123
+ trace: z.unknown().optional(),
124
+ })
125
+ .transform((output) => {
126
+ const trace = ReviewerTraceNotesSchema.safeParse(output.trace);
127
+ return {
128
+ findings: output.findings,
129
+ ...(trace.success ? { trace: trace.data } : {}),
130
+ };
131
+ });
132
+ export const ReviewTraceSchema = z.object({
133
+ version: z.literal(1),
134
+ trust: z.literal("unverified-model-diagnostics"),
135
+ agents: z.record(z.string(), ReviewerTraceNotesSchema),
136
+ truncatedAgents: z.number().int().nonnegative().optional(),
97
137
  });
98
138
  /** Mode-agnostic coordinator result; each Reporter decides how to render it. */
99
- export const CoordinatorOutputSchema = z.object({
139
+ const CoordinatorModelOutputSchema = z.object({
100
140
  decision: z.enum(DECISIONS),
101
141
  findings: z.array(ModelFindingSchema).default([]),
102
142
  summary: z.string(),
143
+ });
144
+ export const CoordinatorOutputSchema = CoordinatorModelOutputSchema.extend({
103
145
  /**
104
146
  * Human-readable notes about reduced coverage (e.g. a review pass that hit its
105
147
  * time limit and returned partial findings, or was skipped). Populated by the
@@ -125,6 +167,14 @@ export const CoordinatorOutputSchema = z.object({
125
167
  // Optional (like couldNotComplete) so every internal CoordinatorOutput literal stays
126
168
  // valid without restating an engine-owned field.
127
169
  setupNotes: z.array(z.string()).optional(),
170
+ /**
171
+ * Machine-readable reviewer diagnostics embedded in the hidden PR-comment state.
172
+ * Engine-owned and excluded from the coordinator's provider-side schema. It is not
173
+ * rendered as prose and must never affect the decision or finding set.
174
+ */
175
+ // Fail soft here too: the coordinator cannot author this engine field, and a
176
+ // malformed injected value must not fail consolidation before the engine strips it.
177
+ reviewTrace: ReviewTraceSchema.optional().catch(undefined),
128
178
  });
129
179
  /** How an author's reply to a finding held up against the source. */
130
180
  export const FEEDBACK_VERDICTS = ["accepted", "refuted", "unclear"];
@@ -271,9 +321,6 @@ export const AdjudicationSchema = z.object({
271
321
  verdict: z.enum(FEEDBACK_VERDICTS),
272
322
  reason: z.enum(FEEDBACK_REASONS).default("other"),
273
323
  });
274
- export function parseAdjudication(text) {
275
- return AdjudicationSchema.parse(extractJsonObject(text));
276
- }
277
324
  /** Minimum normalized evidence length to key a fingerprint on the code (below
278
325
  * this we fall back to the title). */
279
326
  const MIN_FP_EVIDENCE_LEN = 12;
@@ -344,12 +391,20 @@ export function extractJsonObject(text) {
344
391
  export const RouteOutputSchema = z.object({
345
392
  agents: z.array(z.string()).default([]),
346
393
  });
347
- export function parseRouteOutput(text) {
348
- return RouteOutputSchema.parse(extractJsonObject(text));
349
- }
350
- export function parseReviewerOutput(text) {
351
- return ReviewerOutputSchema.parse(extractJsonObject(text));
352
- }
353
- export function parseCoordinatorOutput(text) {
354
- return CoordinatorOutputSchema.parse(extractJsonObject(text));
394
+ /**
395
+ * Bind local Zod validation to the draft-07 JSON Schema Claude Code consumes.
396
+ * Local parsing remains authoritative; provider-side validation is a reliability
397
+ * layer that repairs malformed output before it reaches this trust boundary.
398
+ */
399
+ // @ref LLP 0003#retry-taxonomy [implements] — Claude receives the same contract as the local parser and repairs mismatches in-session
400
+ function structuredParser(parseSchema, outputSchema = parseSchema) {
401
+ const parser = ((text) => parseSchema.parse(extractJsonObject(text)));
402
+ parser.jsonSchema = z.toJSONSchema(outputSchema, { target: "draft-7" });
403
+ return parser;
355
404
  }
405
+ export const parseVerdict = structuredParser(VerdictSchema);
406
+ export const parseStackVerdict = structuredParser(StackVerdictSchema);
407
+ export const parseAdjudication = structuredParser(AdjudicationSchema);
408
+ export const parseRouteOutput = structuredParser(RouteOutputSchema);
409
+ export const parseReviewerOutput = structuredParser(ReviewerOutputSchema, ReviewerModelOutputSchema);
410
+ export const parseCoordinatorOutput = structuredParser(CoordinatorOutputSchema, CoordinatorModelOutputSchema);
@@ -103,7 +103,7 @@ export async function confirmStackRequalifications(findings, maxConfirmations, c
103
103
  * it into the no-tools stack verifier, and require `addressed: true`. The patch is
104
104
  * inlined, never materialized — there is no disk read and no tool use at all.
105
105
  */
106
- export function patchConfirmer(handle, source) {
106
+ export function patchConfirmer(handle, source, onActivity) {
107
107
  // Per-run patch memo: distinct findings citing the same (prNumber, file) each get
108
108
  // their own verdict but share one gh fetch. The source fails open to null (never
109
109
  // rejects), so memoizing the promise is safe.
@@ -131,6 +131,7 @@ export function patchConfirmer(handle, source) {
131
131
  // A timeout here throws AgentTimeoutError, which the caller catches and STRIPS
132
132
  // (fail toward blocking) — so no finalize salvage, unlike the main verifier.
133
133
  finalizeOnTimeout: false,
134
+ onActivity: (line) => onActivity?.(`PR #${prNumber}: ${line}`),
134
135
  }, parseStackVerdict);
135
136
  return { addressed: value.addressed === true, cost, tokens, model };
136
137
  };
@@ -123,6 +123,7 @@ export async function verifyFindings(handle, findings, cwd, onProgress) {
123
123
  title: `verify-${index}`,
124
124
  maxWaitMs: VERIFY_TIMEOUT_MS,
125
125
  finalizeOnTimeout: true,
126
+ onActivity: (line) => onProgress?.(` [verifier] #${index + 1}: ${line}`),
126
127
  }, parseVerdict);
127
128
  cost += verifyCost;
128
129
  addTokenUsage(tokens, verifyTokens);
@@ -314,7 +314,7 @@ export class GitHubReporter {
314
314
  * (annotate mode). Either way the feedback path fails soft — it never blocks the
315
315
  * comment from being posted.
316
316
  */
317
- async report(review, feedback) {
317
+ async report(review, feedback, inputHash) {
318
318
  // Carry forward any per-PR dismissals recorded in the existing comment so they
319
319
  // survive re-reviews (a dismissed finding stays in the collapsed section).
320
320
  const existing = await this.findExistingComment();
@@ -332,7 +332,7 @@ export class GitHubReporter {
332
332
  ? applyPins(feedback, pinsIn)
333
333
  : await this.computeFeedback(withFp, priorRecords, pinsIn);
334
334
  const link = await this.linkContextAsync();
335
- await this.upsertComment(renderMarkdown(review, this.options.commentTag, dismissed, link, records, pins));
335
+ await this.upsertComment(renderMarkdown(review, this.options.commentTag, dismissed, link, records, pins, inputHash));
336
336
  }
337
337
  /** Post/update the aggregate multi-scope comment (comment:'single' mode). */
338
338
  async reportAggregate(results, unmatchedFiles, feedback) {
@@ -599,7 +599,7 @@ export class GitHubReporter {
599
599
  const link = await this.linkContextAsync();
600
600
  const body = isAggregate
601
601
  ? renderAggregateMarkdown(state.scopes, this.options.commentTag, next.dismissed, link, undefined, next.feedback, next.pins)
602
- : renderMarkdown(state.review, this.options.commentTag, next.dismissed, link, next.feedback, next.pins);
602
+ : renderMarkdown(state.review, this.options.commentTag, next.dismissed, link, next.feedback, next.pins, state.inputHash);
603
603
  await this.patchComment(existing.id, body);
604
604
  return {
605
605
  dismissedCount: next.dismissed.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/code-review-cli",
3
- "version": "0.9.2",
3
+ "version": "0.11.0",
4
4
  "description": "Generic, config-driven AI code reviewer engine. Repos supply their agents via .expo-code-review/.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -3,11 +3,11 @@
3
3
  description: Security and secrets. Injection, credential or secret leakage, unsafe shell/child-process use, missing validation at trust boundaries.
4
4
  alwaysRun: true
5
5
  # Security is the highest-stakes agent and benefits most from stronger threat-model
6
- # reasoning, so it runs on the pro tier even though the other specialists use the
6
+ # reasoning, so it runs on the Opus tier even though the other specialists use the
7
7
  # default model. Scoped to this one agent to limit the extra latency/rate-limit cost;
8
- # subdivide-on-timeout + the per-fetch deadline keep a slow pro pass from hanging.
8
+ # subdivide-on-timeout + the per-fetch deadline keep a slow Opus pass from hanging.
9
9
  # @ref LLP 0009#config-and-prompt-templates [implements]
10
- model: openai/gpt-5.5-pro
10
+ model: anthropic/claude-opus-5
11
11
  ---
12
12
 
13
13
  # Security & secrets
@@ -81,6 +81,11 @@ jobs:
81
81
  # disable setup-node's auto package-manager cache.
82
82
  package-manager-cache: false
83
83
 
84
+ # The anthropic/… models run through the Claude Code CLI (claude-code
85
+ # engine). Pinned exactly — see workflow.yml (expo-code-review.yml).
86
+ - name: Install Claude Code CLI
87
+ run: npm install -g @anthropic-ai/claude-code@2.1.212
88
+
84
89
  # SECURITY: the base-ref checkout includes every .expo-code-review/ config,
85
90
  # whose auth.tokenEnv names the forwarded model credential. `ecr verify-config`
86
91
  # sweeps every config and refuses unless tokenEnv appears exactly once, in a
@@ -89,7 +94,7 @@ jobs:
89
94
  # @ref LLP 0009#guard-step-ordering-and-job-budgets [implements] — same $ECR_VERSION feeds guard and review
90
95
  - name: Guard config tokenEnv (root + routing + all scopes)
91
96
  env:
92
- ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'OPENAI_API_KEY' }}
97
+ ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CLAUDE_CODE_REVIEW_SHARED_API_TOKEN' }}
93
98
  run: npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr verify-config
94
99
 
95
100
  # Running via `issue_comment` makes this a manual /review, which the CLI detects
@@ -101,10 +106,11 @@ jobs:
101
106
  env:
102
107
  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
103
108
  # Layer-1 auth lock: keep in sync with the guard's EXPECTED.
104
- ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'OPENAI_API_KEY' }}
105
- # OpenAI API key — the env var named by auth.tokenEnv in config.jsonc.
106
- OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
107
- # Optional: override the model for every agent (uses your OpenCode login).
109
+ ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'CLAUDE_CODE_REVIEW_SHARED_API_TOKEN' }}
110
+ # Anthropic review credential — the env var named by auth.tokenEnv in
111
+ # config.jsonc (see workflow.yml for the accepted token shapes).
112
+ CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: ${{ secrets.CLAUDE_CODE_REVIEW_SHARED_API_TOKEN }}
113
+ # Optional: override the model for every agent.
108
114
  REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
109
115
  run: |
110
116
  # Build the flags as a bash array so the path never word-splits.