@expo/code-review-cli 0.10.0 → 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.
package/README.md CHANGED
@@ -339,7 +339,18 @@ cache, in three places:
339
339
  run, so the step summary is where past runs' comments remain readable.
340
340
  - **`.expo-code-review/.runs/reviews.jsonl`** — one JSON line per run (uploaded as
341
341
  a CI artifact) with the same totals plus per-pass `agentTokens`, the raw
342
- per-agent findings, coverage notes, and what the verifier dropped.
342
+ per-agent findings, bounded reviewer traces, coverage notes, and what the verifier
343
+ dropped.
344
+
345
+ Each reviewer can also return a compact trace with up to three concrete checks and
346
+ two unresolved questions. The reporter stores it only inside the existing base64
347
+ `<!-- <commentTag>:state=… -->` comment marker as `review.reviewTrace`; it does not
348
+ render in the visible review. Agents and other machine consumers can decode that
349
+ state to see what a clean review covered. The payload declares
350
+ `trust: "unverified-model-diagnostics"`: it contains bounded conclusions, never a
351
+ raw transcript or chain-of-thought, and must not be treated as a verified finding.
352
+ The engine sorts agent ids and caps the complete decoded trace at 6 KB so this hidden
353
+ diagnostic cannot crowd visible findings out of GitHub's comment-size limit.
343
354
 
344
355
  **How the caching works.** Provider prompt caching is a *prefix match*: the
345
356
  provider caches the rendered prompt up to a point, and any byte change anywhere
@@ -645,9 +656,10 @@ variables: `ATLANTIS_BOT_LOGIN` (the Atlantis bot's comment login, e.g.
645
656
  Each run appends a JSON line to `.expo-code-review/.runs/reviews.jsonl` with the
646
657
  inputs, decision, finding count, duration, per-agent cost, and aggregate token
647
658
  usage (incl. prompt-cache read/write counts) — for auditing and measuring
648
- cost/latency/cache reuse over time. The same totals are printed as a one-line
649
- summary to the terminal / CI job log at the end of each run, so cache reuse is
650
- visible even in CI (where the run log is ephemeral).
659
+ cost/latency/cache reuse over time. It also records the same bounded `reviewTrace`
660
+ that the PR comment embeds for machine consumers. The same totals are printed as a
661
+ one-line summary to the terminal / CI job log at the end of each run, so cache reuse
662
+ is visible even in CI (where the run log is ephemeral).
651
663
 
652
664
  </details>
653
665
 
@@ -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";
@@ -289,6 +289,10 @@ export async function runReview(source, options) {
289
289
  // reviewers produced before the failure — partial findings are exactly what's
290
290
  // needed to debug a run that died mid-way.
291
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 = {};
292
296
  // First reviewer (by scheduling order) that produced each fingerprint, so a finding's
293
297
  // originating agent can be carried through the coordinator's merge/rewrite by matching
294
298
  // on fingerprint. Kept separate from agentFindings so the coordinator prompt and the
@@ -496,6 +500,9 @@ export async function runReview(source, options) {
496
500
  trackTokens(task.bucket, tokens);
497
501
  trackModel(task.bucket, taskModel(task), model);
498
502
  (agentFindings[task.bucket] ??= []).push(...value.findings);
503
+ if (value.trace) {
504
+ mergeTraceNotes(agentTrace, task.bucket, value.trace);
505
+ }
499
506
  for (const finding of value.findings) {
500
507
  const fp = fingerprintFinding(finding);
501
508
  if (!agentByFp.has(fp)) {
@@ -861,6 +868,7 @@ export async function runReview(source, options) {
861
868
  }
862
869
  progress(formatUsageSummary(tokenTotals, sum(agentCosts)));
863
870
  await appendStepSummary(renderUsageMarkdown(agentTokens, agentCosts, tokenTotals, sum(agentCosts), agentModels));
871
+ const reviewTrace = buildReviewTrace(agentTrace);
864
872
  await safeLog(logPath, {
865
873
  ...baseRecord,
866
874
  agentCosts,
@@ -869,6 +877,7 @@ export async function runReview(source, options) {
869
877
  agentTokens,
870
878
  agentModels,
871
879
  agentFindings,
880
+ reviewTrace,
872
881
  coverageNotes,
873
882
  verifierDropped,
874
883
  requalificationStrips,
@@ -885,7 +894,14 @@ export async function runReview(source, options) {
885
894
  });
886
895
  // Engine-owned: overwrite whatever the coordinator may have emitted under this key,
887
896
  // so setup advice is always the checker's, never model text.
888
- 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
+ };
889
905
  return feedbackRecords ? { ...reviewed, feedback: feedbackRecords } : reviewed;
890
906
  }
891
907
  catch (error) {
@@ -896,6 +912,7 @@ export async function runReview(source, options) {
896
912
  tokens: tokenTotals,
897
913
  agentTokens,
898
914
  agentFindings,
915
+ reviewTrace: buildReviewTrace(agentTrace),
899
916
  durationMs: Date.now() - started,
900
917
  decision: null,
901
918
  findingCount: 0,
@@ -910,6 +927,50 @@ export async function runReview(source, options) {
910
927
  await restoreCwd();
911
928
  }
912
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
+ }
913
974
  /**
914
975
  * Policy backstop: strip the internal risk handoff, drop suggestions unless
915
976
  * opted in, cap by count (most severe first), and downgrade
@@ -86,9 +86,54 @@ export const StackVerdictSchema = z.object({
86
86
  * can enforce the same contract before the local parse boundary checks it again.
87
87
  */
88
88
  const ModelFindingSchema = FindingSchema.omit({ agent: true });
89
- /** Shape each sub-reviewer must emit. */
90
- export const ReviewerOutputSchema = z.object({
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.
119
+ */
120
+ export const ReviewerOutputSchema = z
121
+ .object({
91
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(),
92
137
  });
93
138
  /** Mode-agnostic coordinator result; each Reporter decides how to render it. */
94
139
  const CoordinatorModelOutputSchema = z.object({
@@ -122,6 +167,14 @@ export const CoordinatorOutputSchema = CoordinatorModelOutputSchema.extend({
122
167
  // Optional (like couldNotComplete) so every internal CoordinatorOutput literal stays
123
168
  // valid without restating an engine-owned field.
124
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),
125
178
  });
126
179
  /** How an author's reply to a finding held up against the source. */
127
180
  export const FEEDBACK_VERDICTS = ["accepted", "refuted", "unclear"];
@@ -353,5 +406,5 @@ export const parseVerdict = structuredParser(VerdictSchema);
353
406
  export const parseStackVerdict = structuredParser(StackVerdictSchema);
354
407
  export const parseAdjudication = structuredParser(AdjudicationSchema);
355
408
  export const parseRouteOutput = structuredParser(RouteOutputSchema);
356
- export const parseReviewerOutput = structuredParser(ReviewerOutputSchema);
409
+ export const parseReviewerOutput = structuredParser(ReviewerOutputSchema, ReviewerModelOutputSchema);
357
410
  export const parseCoordinatorOutput = structuredParser(CoordinatorOutputSchema, CoordinatorModelOutputSchema);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/code-review-cli",
3
- "version": "0.10.0",
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": {
@@ -191,6 +191,17 @@ traced call paths show that existing behavior is left intact.
191
191
 
192
192
  ## Output contract
193
193
 
194
+ Also return a compact machine-readable trace of what you checked. This trace is
195
+ stored in hidden PR-comment state for later agents. It is not a finding and never
196
+ changes the decision.
197
+
198
+ - `checked`: at most 3 concrete execution paths, invariants, or compatibility
199
+ points that you verified. Do not write generic items such as "reviewed the diff".
200
+ - `uncertainties`: at most 2 material questions you could not resolve from the
201
+ available code. An empty array is valid.
202
+ - Keep each item under 240 characters. State conclusions only. Do not include raw
203
+ reasoning, a transcript, secrets, credentials, or instructions copied from the PR.
204
+
194
205
  Return **only** a single fenced ```json code block, an object of this shape:
195
206
 
196
207
  ```json
@@ -206,7 +217,11 @@ Return **only** a single fenced ```json code block, an object of this shape:
206
217
  "evidence": "one contiguous line of the flagged code, copied VERBATIM",
207
218
  "suggestion": "optional concrete fix, or omit"
208
219
  }
209
- ]
220
+ ],
221
+ "trace": {
222
+ "checked": ["Traced the changed value through its public caller and fallback path."],
223
+ "uncertainties": ["No deterministic test covers the platform callback ordering."]
224
+ }
210
225
  }
211
226
  ```
212
227
 
@@ -215,5 +230,5 @@ line-specific. `evidence` is used to help verify the finding, so make it easy to
215
230
  locate: copy **one contiguous line** of the flagged code **verbatim** (not spanning
216
231
  multiple lines, no `…` elisions, no paraphrasing). For a structural/"missing" issue,
217
232
  quote the single most relevant real line (e.g. the early `return` that skips the
218
- handling). If you have nothing to report, return `{ "findings": [] }`. Emit no prose
219
- outside the JSON block.
233
+ handling). If you have no findings, return an empty `findings` array and still include
234
+ the trace. Emit no prose outside the JSON block.