@expo/code-review-cli 0.4.0 → 0.5.1

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.
@@ -146,13 +146,72 @@ export function buildReviewerTask(files, allFiles, filtered = []) {
146
146
  * large diff. It sees the whole change set and reports ONLY issues that span
147
147
  * multiple changed files, which per-chunk reviews can't see.
148
148
  */
149
- export function buildCrossCuttingTask(allFiles, agents, filtered = []) {
149
+ /**
150
+ * Total changed lines the cross-file task will inline before it stops and lists the
151
+ * rest as patch paths to read on demand. Sized to stay well inside the model's
152
+ * context on a normal PR while still covering the overwhelming majority of them; a
153
+ * genuinely huge diff degrades to the old read-on-demand behavior for its tail
154
+ * rather than overflowing.
155
+ */
156
+ export const CROSS_CUTTING_INLINE_MAX_LINES = 6000;
157
+ /**
158
+ * Split the changed files into the ones whose diffs are inlined into the cross-file
159
+ * task and the ones left for on-demand reads. Always inlines at least the first file
160
+ * so a single enormous file can't produce an all-deferred prompt. Exported for tests.
161
+ */
162
+ export function splitCrossCuttingInline(allFiles, maxLines = CROSS_CUTTING_INLINE_MAX_LINES) {
163
+ const inlined = [];
164
+ const deferred = [];
165
+ let lines = 0;
166
+ for (const file of allFiles) {
167
+ if (inlined.length > 0 && lines + file.changedLines > maxLines) {
168
+ deferred.push(file);
169
+ continue;
170
+ }
171
+ inlined.push(file);
172
+ lines += file.changedLines;
173
+ }
174
+ return { inlined, deferred };
175
+ }
176
+ export function buildCrossCuttingTask(allFiles, agents, filtered = [],
177
+ /** Set for the no-tools fallback pass, which cannot open anything it isn't shown. */
178
+ opts = {}) {
150
179
  const lenses = agents
151
180
  .map((agent) => `- ${agent.id}: ${agent.description || agent.id}`)
152
181
  .join("\n");
153
- const fileList = allFiles
154
- .map((file) => `- \`${sanitizeUntrusted(file.path)}\` (${file.status ?? "M"}) patch: \`${file.patchPath}\``)
155
- .join("\n");
182
+ // Inline the diffs instead of only naming their patch files. Reading them back was
183
+ // one tool round-trip per changed file BEFORE any tracing could start (13 reads and
184
+ // several minutes on a 14-file PR), spent on content we already have in memory.
185
+ const { inlined, deferred } = splitCrossCuttingInline(allFiles);
186
+ const inlinedDiffs = inlined.map(inlineDiff).join("\n\n");
187
+ // On a diff too large to inline whole, the tail is named either way — a file this
188
+ // pass can't see must never look unchanged. What differs is the instruction: a
189
+ // no-tools pass told to "read their patch files" would be told to do the one thing
190
+ // it can't, so it gets the same "you cannot see these, don't fault them" framing
191
+ // the noise-filtered files get.
192
+ const deferredSection = deferred.length === 0
193
+ ? []
194
+ : opts.noTools
195
+ ? [
196
+ "",
197
+ "This PR changed these files too, but their diffs are NOT shown to you (the",
198
+ "diff is large) and you cannot open them on this pass:",
199
+ deferred
200
+ .map((file) => `- \`${sanitizeUntrusted(file.path)}\` (${file.status ?? "M"})`)
201
+ .join("\n"),
202
+ "",
203
+ "They WERE changed by this PR. Do NOT report that any of them was not updated,",
204
+ "and do not claim an interaction you cannot see in the diffs above.",
205
+ ]
206
+ : [
207
+ "",
208
+ "This PR changed these files too, but their diffs are NOT inlined above (the",
209
+ "diff is large). Read their patch files on demand if a cross-file interaction",
210
+ "points at them:",
211
+ deferred
212
+ .map((file) => `- \`${sanitizeUntrusted(file.path)}\` (${file.status ?? "M"}) — patch: \`${file.patchPath}\``)
213
+ .join("\n"),
214
+ ];
156
215
  return [
157
216
  "This PR changed the files below, and each was already reviewed on its own by",
158
217
  "specialist reviewers covering these concerns:",
@@ -166,7 +225,7 @@ export function buildCrossCuttingTask(allFiles, agents, filtered = []) {
166
225
  "single-file issues.",
167
226
  "",
168
227
  "Stay focused and efficient — you are on a time budget:",
169
- "- Work from the patches of the CHANGED files listed below; that is your scope.",
228
+ "- Work from the diffs of the CHANGED files below; that is your scope.",
170
229
  "- Read additional source ONLY when directly needed to confirm a specific",
171
230
  " cross-file interaction (e.g. open the caller a changed signature affects).",
172
231
  "- Do NOT audit unrelated parts of the repository or read files with no",
@@ -174,8 +233,10 @@ export function buildCrossCuttingTask(allFiles, agents, filtered = []) {
174
233
  "- As soon as you have traced the cross-file interactions, return your answer;",
175
234
  " do not keep exploring for completeness.",
176
235
  "",
177
- "Changed files:",
178
- fileList,
236
+ "Changed files (diffs inlined — you do not need to read these back):",
237
+ "",
238
+ inlinedDiffs,
239
+ ...deferredSection,
179
240
  ...filteredSection(filtered),
180
241
  "",
181
242
  "Return the single JSON object described in your instructions and nothing else.",
@@ -3,7 +3,7 @@ import { prepareAuth } from "./auth.js";
3
3
  import { coordinate } from "./coordinator.js";
4
4
  import { writeRunLog } from "./log.js";
5
5
  import { filterNoise, writePatchWorkspace } from "./noise.js";
6
- import { addTokenUsage, AgentTimeoutError, buildOpencodeConfig, CROSS_CUTTING_AGENT, promptAndParse, startOpencode, } from "./opencode.js";
6
+ import { addTokenUsage, AgentTimeoutError, assertModelsResolvable, buildOpencodeConfig, CROSS_CUTTING_AGENT, promptAndParse, startOpencode, } from "./opencode.js";
7
7
  import { routeAgents } from "./router.js";
8
8
  import { buildCrossCuttingSystem, buildCrossCuttingTask, buildReviewerSystem, buildReviewerTask, NO_TOOLS_INSTRUCTION, } from "./prompts.js";
9
9
  import { fingerprintFinding, parseReviewerOutput } from "./schema.js";
@@ -114,7 +114,19 @@ export async function runReview(source, options) {
114
114
  await auth.cleanup();
115
115
  await restoreCwd();
116
116
  throw new Error(`Failed to start the OpenCode server. Ensure the \`opencode\` CLI is installed and ` +
117
- `model credentials are configured.\n${errorMessage(error)}`);
117
+ `model credentials are configured (\`ecr doctor\` checks both).\n${errorMessage(error)}`);
118
+ }
119
+ // Preflight: a model id the server can't resolve would otherwise fail EVERY pass
120
+ // identically — N indistinguishable coverage gaps, after spending the run's budget
121
+ // discovering the same fixable thing N times. Throw once, up front, naming the fix.
122
+ try {
123
+ await assertModelsResolvable(handle, [...config.agents.map((agent) => agent.model), config.coordinator.model], config.auth);
124
+ }
125
+ catch (error) {
126
+ handle.close();
127
+ await auth.cleanup();
128
+ await restoreCwd();
129
+ throw error;
118
130
  }
119
131
  const agentCosts = {};
120
132
  const tokenTotals = {};
@@ -129,6 +141,23 @@ export async function runReview(source, options) {
129
141
  addTokenUsage(tokenTotals, tokens);
130
142
  addTokenUsage((agentTokens[bucket] ??= {}), tokens);
131
143
  };
144
+ // The provider/model that ACTUALLY answered each pass, and any pass whose model was
145
+ // silently substituted for the configured one. OpenCode does that substitution
146
+ // quietly whenever an agent's model id is empty or unusable, so a run can review with
147
+ // a different model than config.jsonc names and look completely normal — which is
148
+ // exactly what happened for weeks behind an empty REVIEWER_MODEL. Recorded in the run
149
+ // log, reported in the log line, and surfaced as a coverage note when it happens.
150
+ const agentModels = {};
151
+ const substituted = new Set();
152
+ const trackModel = (bucket, configured, actual) => {
153
+ if (!actual) {
154
+ return;
155
+ }
156
+ agentModels[bucket] = actual;
157
+ if (configured && actual !== configured) {
158
+ substituted.add(`${bucket}: configured ${configured}, ran ${actual}`);
159
+ }
160
+ };
132
161
  try {
133
162
  const workspace = await writePatchWorkspace(kept, metadata, runDir);
134
163
  // Resolve which agents run: an explicit list wins; otherwise route (LLM picks
@@ -158,7 +187,6 @@ export async function runReview(source, options) {
158
187
  // These caps must fit inside PASSES_BUDGET_MS (below), which in turn fits inside
159
188
  // the CI job's timeout-minutes.
160
189
  const CHUNK_TIMEOUT_MS = 15 * 60 * 1000;
161
- const CROSS_CUTTING_TIMEOUT_MS = 25 * 60 * 1000;
162
190
  // A subdivided sub-chunk is smaller, so it gets a shorter cap (halved per level,
163
191
  // floored) — enough to converge without letting the recursion balloon.
164
192
  const SUBDIVIDE_MIN_TIMEOUT_MS = 6 * 60 * 1000;
@@ -168,14 +196,50 @@ export async function runReview(source, options) {
168
196
  // Tool-call ceilings — generous for a legitimate pass, low enough to catch
169
197
  // runaway roaming (the root cause of the non-convergent timeouts).
170
198
  const CHUNK_MAX_TOOL_CALLS = 50;
171
- const CROSS_CUTTING_MAX_TOOL_CALLS = 120;
172
199
  // Global ceiling for ALL passes incl. subdivision/fallback waves, sized to
173
200
  // leave room for the coordinator (10m) + verification + overhead inside the CI
174
201
  // job timeout. Past this, a timed-out pass is reported as a gap rather than
175
202
  // broken down further, so total wall-clock stays bounded.
176
- const PASSES_BUDGET_MS = 32 * 60 * 1000;
203
+ const PASSES_BUDGET_MS = 55 * 60 * 1000;
177
204
  const passesBudgetMs = options.passesBudgetMs ?? PASSES_BUDGET_MS;
178
205
  const passesDeadline = started + passesBudgetMs;
206
+ // The cross-file pass is the one pass whose scope cannot be traded for
207
+ // convergence: halving its file set deletes exactly the coverage it exists to
208
+ // provide (see the timeout branch below). So instead of a fixed cap it gets the
209
+ // WHOLE remaining passes window. Chunk passes run concurrently alongside it under
210
+ // their own caps, so a long cross-file pass doesn't starve them — it only extends
211
+ // the run toward the passes deadline, which the job timeout is sized for.
212
+ //
213
+ // Computed here, not as a constant, because the window is what's actually left:
214
+ // filtering, routing and server startup already spent some of it, and `ecr ci`
215
+ // divides the budget across active scopes.
216
+ //
217
+ // The reserve is what keeps its own salvage paths affordable: if it expanded into
218
+ // the entire window, then on a timeout there would be nothing left to run the
219
+ // whole-diff no-tools fallback with, and "elastic budget" would have quietly
220
+ // reintroduced the coverage gap it exists to prevent. Sized for the finalize
221
+ // soft-landing plus one FALLBACK_TIMEOUT_MS pass.
222
+ const CROSS_CUTTING_RESERVE_MS = FALLBACK_TIMEOUT_MS + 4 * 60 * 1000;
223
+ // Floor: never LESS generous than one chunk pass. On a run whose window is already
224
+ // small (many active scopes dividing the budget) this can exceed what's left, but
225
+ // that exposure is exactly what chunk passes already carry — their 15m cap can also
226
+ // outlast a small per-scope slice — and the job timeout keeps a wide margin over
227
+ // the budget for it. Dropping the pass instead would silently cost the coverage no
228
+ // other pass provides.
229
+ const crossCuttingWaitMs = Math.max(CHUNK_TIMEOUT_MS, passesDeadline - Date.now() - CROSS_CUTTING_RESERVE_MS);
230
+ // Tool calls are for TRACING (opening the caller a changed signature affects) —
231
+ // the changed files' diffs are inlined, so they are not spent fetching the diff.
232
+ // Scale with the diff's file count instead of fixing the ceiling: under a large
233
+ // elastic time budget a fixed cap becomes the binding constraint, and the extra
234
+ // time can't be used.
235
+ const CROSS_CUTTING_TOOL_CALLS_PER_FILE = 10;
236
+ const CROSS_CUTTING_MIN_TOOL_CALLS = 120;
237
+ const CROSS_CUTTING_MAX_TOOL_CALLS = 400;
238
+ const crossCuttingMaxToolCalls = Math.min(CROSS_CUTTING_MAX_TOOL_CALLS, Math.max(CROSS_CUTTING_MIN_TOOL_CALLS, CROSS_CUTTING_TOOL_CALLS_PER_FILE * workspace.files.length));
239
+ // Coverage notes for passes that hit their time limit, stalled, failed, or were
240
+ // never started, surfaced in the final review so a cut-short run is never
241
+ // presented as complete.
242
+ const incomplete = [];
179
243
  const tasks = [];
180
244
  for (const agent of selectedAgents) {
181
245
  const system = buildReviewerSystem(config, agent);
@@ -206,8 +270,8 @@ export async function runReview(source, options) {
206
270
  title: "review-xcut",
207
271
  files: workspace.files,
208
272
  coverageLabel: "the cross-file review (issues spanning multiple changed files)",
209
- maxWaitMs: CROSS_CUTTING_TIMEOUT_MS,
210
- maxToolCalls: CROSS_CUTTING_MAX_TOOL_CALLS,
273
+ maxWaitMs: crossCuttingWaitMs,
274
+ maxToolCalls: crossCuttingMaxToolCalls,
211
275
  depth: 0,
212
276
  fallback: false,
213
277
  });
@@ -215,11 +279,19 @@ export async function runReview(source, options) {
215
279
  // Longest-processing-time-first: schedule the long cross-cutting/large chunks
216
280
  // ahead of short ones so they don't dominate the tail of the makespan.
217
281
  tasks.sort((a, b) => b.maxWaitMs - a.maxWaitMs);
282
+ // What each task was CONFIGURED to run on — mirrors buildOpencodeConfig, which
283
+ // gives the cross-file pass the first agent's model. Compared against what actually
284
+ // answered so a silent substitution can't pass unnoticed.
285
+ const taskModel = (task) => task.kind === "cross-cutting"
286
+ ? (selectedAgents[0]?.model ?? config.coordinator.model)
287
+ : (selectedAgents.find((agent) => agent.id === task.bucket)?.model ?? "");
218
288
  // Build the task prompt on demand (so a subdivided task rebuilds over its
219
289
  // smaller file set); a fallback task forbids tools and reviews the inlined diff.
220
290
  const buildTaskText = (task) => {
221
291
  const base = task.kind === "cross-cutting"
222
- ? buildCrossCuttingTask(task.files, selectedAgents, filtered)
292
+ ? buildCrossCuttingTask(task.files, selectedAgents, filtered, {
293
+ noTools: task.fallback,
294
+ })
223
295
  : buildReviewerTask(task.files, workspace.files, filtered);
224
296
  return task.fallback ? `${base}\n\n${NO_TOOLS_INSTRUCTION}` : base;
225
297
  };
@@ -234,9 +306,6 @@ export async function runReview(source, options) {
234
306
  coverageLabel: `the ${humanBucket(parent.bucket)} review of ${filesLabel(files)}`,
235
307
  ...overrides,
236
308
  });
237
- // Coverage notes for passes that hit their time limit or failed, surfaced in
238
- // the final review so a cut-short run is never presented as complete.
239
- const incomplete = [];
240
309
  let completedPasses = 0;
241
310
  let failedPasses = 0;
242
311
  // promptAndParse already retries internally (same-session corrective, then a
@@ -247,7 +316,7 @@ export async function runReview(source, options) {
247
316
  await runGrowableQueue(tasks, config.chunk.concurrency, async (task, enqueue) => {
248
317
  const minutes = Math.round(task.maxWaitMs / 60000);
249
318
  try {
250
- const { value, cost, truncated, tokens } = await promptAndParse(handle, {
319
+ const { value, cost, truncated, tokens, model } = await promptAndParse(handle, {
251
320
  agent: task.bucket,
252
321
  system: task.system,
253
322
  text: buildTaskText(task),
@@ -259,6 +328,7 @@ export async function runReview(source, options) {
259
328
  }, parseReviewerOutput);
260
329
  agentCosts[task.bucket] = (agentCosts[task.bucket] ?? 0) + cost;
261
330
  trackTokens(task.bucket, tokens);
331
+ trackModel(task.bucket, taskModel(task), model);
262
332
  (agentFindings[task.bucket] ??= []).push(...value.findings);
263
333
  completedPasses++;
264
334
  if (truncated) {
@@ -284,13 +354,15 @@ export async function runReview(source, options) {
284
354
  agentCosts[task.bucket] = (agentCosts[task.bucket] ?? 0) + error.cost;
285
355
  trackTokens(task.bucket, error.tokens);
286
356
  const remaining = passesDeadline - Date.now();
287
- // Cross-file analysis needs ≥2 files to be meaningful; a single-file
288
- // reviewer chunk can't be split further.
289
- const minFiles = task.kind === "cross-cutting" ? 2 : 1;
357
+ // Subdividing trades scope for convergence, which is the right trade for a
358
+ // reviewer chunk (each file still gets reviewed) and the WRONG one for the
359
+ // cross-file pass: an interaction between a file in the left half and one in
360
+ // the right half is invisible to both halves, so "splitting" it silently
361
+ // deletes the coverage the pass exists to provide while reporting success.
362
+ // It goes straight to the whole-diff no-tools fallback below instead.
363
+ const canSubdivide = task.kind === "reviewer" && task.files.length > 1;
290
364
  const childCap = Math.max(SUBDIVIDE_MIN_TIMEOUT_MS, Math.floor(task.maxWaitMs / 2));
291
- if (task.files.length > minFiles &&
292
- task.depth < MAX_SUBDIVIDE_DEPTH &&
293
- remaining > childCap) {
365
+ if (canSubdivide && task.depth < MAX_SUBDIVIDE_DEPTH && remaining > childCap) {
294
366
  const mid = Math.ceil(task.files.length / 2);
295
367
  const left = task.files.slice(0, mid);
296
368
  const right = task.files.slice(mid);
@@ -300,9 +372,12 @@ export async function runReview(source, options) {
300
372
  enqueue(childTask(task, right, `↳${right.length}f`, over));
301
373
  return;
302
374
  }
303
- // Can't subdivide further: try a fast no-tools pass over the inlined diff
304
- // (reviewer only cross-file analysis fundamentally needs to read files).
305
- if (task.kind === "reviewer" && !task.fallback && remaining > FALLBACK_TIMEOUT_MS) {
375
+ // Can't (or shouldn't) subdivide: fall back to a fast no-tools pass over the
376
+ // inlined diffs. This works for the cross-file pass too every changed file's
377
+ // diff is inlined in its task, so it can still reason across the whole diff
378
+ // without tools; it just can't open a caller outside the diff. A lighter
379
+ // cross-file review beats the coverage gap it used to report.
380
+ if (!task.fallback && remaining > FALLBACK_TIMEOUT_MS) {
306
381
  progress(` ${task.label}: exceeded ${minutes}m — retrying ${filesLabel(task.files)} with a fast no-tools pass`);
307
382
  enqueue(childTask(task, task.files, "(no-tools fallback)", {
308
383
  fallback: true,
@@ -315,10 +390,24 @@ export async function runReview(source, options) {
315
390
  // never silent. Distinguish WHY so the note doesn't overstate what happened:
316
391
  // we could still have split/fallen back, but the global budget ran out first,
317
392
  // vs. the task was already at its smallest reviewable unit and still failed.
393
+ // A stalled pass is called out separately: it did not run out of time doing
394
+ // work, its model requests went silent, which is an infrastructure symptom
395
+ // and not something a bigger budget or a smaller scope would have fixed.
318
396
  failedPasses++;
319
- const couldStillReduce = (task.files.length > minFiles && task.depth < MAX_SUBDIVIDE_DEPTH) ||
320
- (task.kind === "reviewer" && !task.fallback);
321
- if (couldStillReduce) {
397
+ const couldStillReduce = (canSubdivide && task.depth < MAX_SUBDIVIDE_DEPTH) || !task.fallback;
398
+ if (error.reason === "stall") {
399
+ progress(` ${task.label}: its model requests went silent (stalled) and did not recover — ` +
400
+ `most likely provider rate limiting; reporting a coverage gap`);
401
+ // Name the likely cause. OpenCode retries a 429 internally without surfacing
402
+ // it, so provider throttling reaches us as pure silence — indistinguishable
403
+ // from a wedged connection, and the single most common reason a pass produces
404
+ // nothing at all. Saying "went silent" alone sends people hunting for a bug
405
+ // in the reviewer instead of checking their usage window.
406
+ incomplete.push(`${capitalize(task.coverageLabel)} could not run: its model requests went silent and produced no output, even after being retried. ` +
407
+ `The usual cause is the model provider rate-limiting the account (a subscription credential over its usage window), which reaches this tool as silence rather than an error; ` +
408
+ `those changes were not fully reviewed.`);
409
+ }
410
+ else if (couldStillReduce) {
322
411
  progress(` ${task.label}: exceeded ${minutes}m and the run's time budget is spent — reporting a coverage gap`);
323
412
  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.`);
324
413
  }
@@ -328,6 +417,18 @@ export async function runReview(source, options) {
328
417
  }
329
418
  }
330
419
  });
420
+ // A substituted model means the review did not run on the model this repo
421
+ // configured — the findings may be from a weaker (or free-tier) model entirely.
422
+ // Never silent: it goes to the log, the coverage notes, and the run log.
423
+ if (substituted.size > 0) {
424
+ for (const line of substituted) {
425
+ progress(` ⚠ model substituted — ${line}`);
426
+ }
427
+ incomplete.push(`Some passes did not run on the configured model (${[...substituted].join("; ")}). ` +
428
+ `OpenCode silently falls back to a default model when the configured id is empty or ` +
429
+ `unusable, so these findings may come from a different (possibly much weaker) model ` +
430
+ `than intended — check the agents' \`model\`, \`coordinator.model\`, REVIEWER_MODEL, and the provider credential.`);
431
+ }
331
432
  // Note: routine noise filtering (lockfiles, generated, binary) is expected and
332
433
  // NOT a coverage gap — it stays in the run log (filteredFiles), not the
333
434
  // user-facing coverage note, which is reserved for passes that didn't finish.
@@ -348,9 +449,10 @@ export async function runReview(source, options) {
348
449
  progress("Coordinating findings…");
349
450
  let consolidated;
350
451
  try {
351
- const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes);
452
+ const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, model: coordinatorModel, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes);
352
453
  agentCosts["coordinator"] = cost;
353
454
  trackTokens("coordinator", coordinatorTokens);
455
+ trackModel("coordinator", config.coordinator.model, coordinatorModel);
354
456
  consolidated = applyReviewPolicy(rawOutput, config.policy);
355
457
  if (coordinatorTruncated) {
356
458
  // The coordinator ran out of time and returned partial findings — flag it
@@ -382,6 +484,8 @@ export async function runReview(source, options) {
382
484
  const verification = await verifyFindings(handle, output.findings, process.cwd(), progress);
383
485
  agentCosts["verifier"] = verification.cost;
384
486
  trackTokens("verifier", verification.tokens);
487
+ // Mirrors buildOpencodeConfig, which gives the verifier the first agent's model.
488
+ trackModel("verifier", config.agents[0]?.model ?? config.coordinator.model, verification.model);
385
489
  verifierDropped = verification.dropped;
386
490
  if (verification.dropped.length > 0) {
387
491
  progress(`Verification dropped ${verification.dropped.length} unverified finding(s).`);
@@ -411,14 +515,23 @@ export async function runReview(source, options) {
411
515
  if (removedAfterChecks > 0) {
412
516
  output = { ...output, summary: reconcileSummary(output.summary, output.findings.length) };
413
517
  }
518
+ // Every pass says which model actually answered it — in the job log, the step
519
+ // summary table, and the run log — so a wrong or substituted model is always
520
+ // visible, not just when the substitution warning fires.
521
+ if (Object.keys(agentModels).length > 0) {
522
+ progress(`Models used — ${Object.entries(agentModels)
523
+ .map(([bucket, model]) => `${bucket}: ${model}`)
524
+ .join("; ")}`);
525
+ }
414
526
  progress(formatUsageSummary(tokenTotals, sum(agentCosts)));
415
- await appendStepSummary(renderUsageMarkdown(agentTokens, agentCosts, tokenTotals, sum(agentCosts)));
527
+ await appendStepSummary(renderUsageMarkdown(agentTokens, agentCosts, tokenTotals, sum(agentCosts), agentModels));
416
528
  await safeLog(logPath, {
417
529
  ...baseRecord,
418
530
  agentCosts,
419
531
  totalCost: sum(agentCosts),
420
532
  tokens: tokenTotals,
421
533
  agentTokens,
534
+ agentModels,
422
535
  agentFindings,
423
536
  coverageNotes,
424
537
  verifierDropped,
@@ -650,15 +763,15 @@ export function formatUsageSummary(tokens, totalCost) {
650
763
  * row per pass plus a total, and the prompt-cache hit rate (the share of prompt
651
764
  * tokens served from cache instead of being reprocessed at full price).
652
765
  */
653
- export function renderUsageMarkdown(agentTokens, agentCosts, totals, totalCost) {
654
- const row = (label, tokens, cost) => `| ${label} | ${tokens.input ?? 0} | ${tokens.output ?? 0} | ${tokens.cache?.read ?? 0} | ${tokens.cache?.write ?? 0} | $${cost.toFixed(4)} |`;
766
+ export function renderUsageMarkdown(agentTokens, agentCosts, totals, totalCost, agentModels = {}) {
767
+ const row = (label, model, tokens, cost) => `| ${label} | ${model} | ${tokens.input ?? 0} | ${tokens.output ?? 0} | ${tokens.cache?.read ?? 0} | ${tokens.cache?.write ?? 0} | $${cost.toFixed(4)} |`;
655
768
  const lines = [
656
769
  "### 🤖 AI review — token usage",
657
770
  "",
658
- "| pass | input | output | cache read | cache write | cost |",
659
- "| --- | ---: | ---: | ---: | ---: | ---: |",
660
- ...Object.keys(agentCosts).map((bucket) => row(bucket, agentTokens[bucket] ?? {}, agentCosts[bucket] ?? 0)),
661
- row("**total**", totals, totalCost),
771
+ "| pass | model | input | output | cache read | cache write | cost |",
772
+ "| --- | --- | ---: | ---: | ---: | ---: | ---: |",
773
+ ...Object.keys(agentCosts).map((bucket) => row(bucket, agentModels[bucket] ?? "—", agentTokens[bucket] ?? {}, agentCosts[bucket] ?? 0)),
774
+ row("**total**", "", totals, totalCost),
662
775
  ];
663
776
  const read = totals.cache?.read ?? 0;
664
777
  const uncached = totals.input ?? 0;
@@ -81,6 +81,7 @@ async function evidencePresence(finding, cwd) {
81
81
  export async function verifyFindings(handle, findings, cwd, onProgress) {
82
82
  const dropped = [];
83
83
  let cost = 0;
84
+ let model;
84
85
  const tokens = {};
85
86
  // Phase 1 — deterministic quote-grounding for every finding.
86
87
  const checked = await Promise.all(findings.map(async (finding) => ({ finding, presence: await evidencePresence(finding, cwd) })));
@@ -98,7 +99,7 @@ export async function verifyFindings(handle, findings, cwd, onProgress) {
98
99
  // Phase 2 — LLM verify (parallel). Refuted → drop; verified or errored → keep.
99
100
  await Promise.all(toVerify.map(async ({ finding, presence }, index) => {
100
101
  try {
101
- const { value, cost: verifyCost, tokens: verifyTokens, } = await promptAndParse(handle, {
102
+ const { value, cost: verifyCost, tokens: verifyTokens, model: verifyModel, } = await promptAndParse(handle, {
102
103
  agent: VERIFIER_AGENT,
103
104
  system: buildVerifierSystem(),
104
105
  text: buildVerifierTask(finding, { evidenceUngrounded: presence === "absent" }),
@@ -108,6 +109,7 @@ export async function verifyFindings(handle, findings, cwd, onProgress) {
108
109
  }, parseVerdict);
109
110
  cost += verifyCost;
110
111
  addTokenUsage(tokens, verifyTokens);
112
+ model = verifyModel ?? model;
111
113
  if (value.verified) {
112
114
  verdicts.set(finding, "keep");
113
115
  }
@@ -125,5 +127,5 @@ export async function verifyFindings(handle, findings, cwd, onProgress) {
125
127
  }));
126
128
  // Preserve original order.
127
129
  const kept = findings.filter((finding) => verdicts.get(finding) === "keep");
128
- return { kept, dropped, cost, tokens };
130
+ return { kept, dropped, cost, tokens, model };
129
131
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/code-review-cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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": {
@@ -10,7 +10,8 @@
10
10
  "type": "module",
11
11
  "bin": {
12
12
  "ecr": "build/cli.js",
13
- "expo-code-review": "build/cli.js"
13
+ "expo-code-review": "build/cli.js",
14
+ "code-review-cli": "build/cli.js"
14
15
  },
15
16
  "files": [
16
17
  "build",
@@ -35,8 +36,8 @@
35
36
  "prepublishOnly": "rimraf build && tsc -p tsconfig.build.json"
36
37
  },
37
38
  "dependencies": {
38
- "@opencode-ai/sdk": "^1.18.2",
39
- "opencode-ai": "^1.18.2",
39
+ "@opencode-ai/sdk": "1.18.4",
40
+ "opencode-ai": "1.18.4",
40
41
  "zod": "^4.4.3"
41
42
  },
42
43
  "devDependencies": {
@@ -2,10 +2,10 @@
2
2
  description: Security and secrets. Injection, credential or secret leakage, unsafe shell/child-process use, missing validation at trust boundaries.
3
3
  alwaysRun: true
4
4
  # Security is the highest-stakes agent and benefits most from stronger threat-model
5
- # reasoning, so it runs on Opus even though the other specialists use the default
6
- # model. Scoped to this one agent to limit the extra latency/rate-limit cost;
7
- # subdivide-on-timeout + the per-fetch deadline keep a slow Opus pass from hanging.
8
- model: anthropic/claude-opus-4-8
5
+ # reasoning, so it runs on the pro tier even though the other specialists use the
6
+ # default model. Scoped to this one agent to limit the extra latency/rate-limit cost;
7
+ # subdivide-on-timeout + the per-fetch deadline keep a slow pro pass from hanging.
8
+ model: openai/gpt-5.5-pro
9
9
  ---
10
10
 
11
11
  # Security & secrets
@@ -36,8 +36,10 @@ jobs:
36
36
  startsWith(github.event.comment.body, '/review') &&
37
37
  contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
38
38
  runs-on: ubuntu-latest
39
- # Bound the run so a slow/stalled review fails fast rather than hanging.
40
- timeout-minutes: 50
39
+ # Bound the run so a slow/stalled review fails fast rather than hanging. Keep it
40
+ # above the passes budget (budget.totalPassesMinutes, 55m) + coordinator (10m) +
41
+ # verification + setup, like the auto-review workflow's cap.
42
+ timeout-minutes: 90
41
43
  # A reviewer failure must never fail the PR's checks.
42
44
  continue-on-error: true
43
45
  steps:
@@ -113,7 +115,8 @@ jobs:
113
115
  - name: Guard config tokenEnv (root + routing + all scopes)
114
116
  if: steps.cmd.outputs.run == 'true'
115
117
  env:
116
- ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'ANTHROPIC_OAUTH_API_KEY' }}
118
+ # (Comma-separated set for a multi-credential auth.providers config.)
119
+ ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'OPENAI_API_KEY' }}
117
120
  run: npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr verify-config
118
121
 
119
122
  - name: Run AI review
@@ -123,11 +126,11 @@ jobs:
123
126
  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
124
127
  # Layer-1 auth lock: the CLI refuses to run when the tokenEnv it would honor
125
128
  # differs from this. Keep it in sync with the guard's EXPECTED.
126
- ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'ANTHROPIC_OAUTH_API_KEY' }}
127
- # Claude Pro/Max OAuth token (from `claude setup-token`) — the env var named
128
- # by auth.tokenEnv in config.jsonc. Store it as a repo secret. (For an API
129
- # key instead, set auth.mode "api-key" and pass that key here.)
130
- ANTHROPIC_OAUTH_API_KEY: ${{ secrets.ANTHROPIC_OAUTH_API_KEY }}
129
+ ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'OPENAI_API_KEY' }}
130
+ # OpenAI API key — the env var named by auth.tokenEnv in config.jsonc.
131
+ # Store it as a repo secret; a project-scoped key restricted to model
132
+ # inference (with a spend limit) is all the reviewer needs.
133
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
131
134
  # Optional: override the model for every agent (uses your OpenCode login).
132
135
  REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
133
136
  AGENTS: ${{ steps.cmd.outputs.agents }}
@@ -1,14 +1,14 @@
1
1
  {
2
2
  // Default model for every agent. Override per-agent via frontmatter in the
3
3
  // agent's markdown, or at runtime with the REVIEWER_MODEL env var
4
- // (e.g. REVIEWER_MODEL=openai/gpt-5.4-mini-fast).
5
- "model": "anthropic/claude-sonnet-5",
4
+ // (e.g. REVIEWER_MODEL=openai/gpt-5.4-mini).
5
+ "model": "openai/gpt-5.5",
6
6
 
7
7
  // Agents: every markdown file in agents/ is one reviewer (id = filename).
8
8
  // Add or remove files to change the roster — no list needed here.
9
9
  // shared.md (prepended to every agent + coordinator) and coordinator.md are
10
10
  // reserved filenames. Per-agent overrides go in each file's YAML frontmatter,
11
- // e.g. `---\nmodel: anthropic/claude-opus-4-1\n---`.
11
+ // e.g. `---\nmodel: openai/gpt-5.5-pro\n---`.
12
12
 
13
13
  "policy": {
14
14
  // Phase 1: keep signal high by surfacing only critical/warning.
@@ -41,16 +41,29 @@
41
41
  // HTML marker used to find + update the single PR comment. Keep it stable.
42
42
  "commentTag": "expo-ai-code-reviewer",
43
43
 
44
- // How model credentials are provided. Default: a Claude Pro/Max subscription.
45
- // "oauth": tokenEnv holds a Claude Pro/Max OAuth token (from
46
- // `claude setup-token`); it's injected as a bearer credential.
47
- // "api-key": tokenEnv holds a provider API key (sent as x-api-key). If you
48
- // omit `auth`, OpenCode's own login / ANTHROPIC_API_KEY is used.
49
- // For OpenAI/GPT or another provider, omit `auth` and set REVIEWER_MODEL after an
50
- // `opencode auth login` for that provider.
44
+ // How model credentials are provided. Default: an OpenAI API key.
45
+ // "api-key": tokenEnv names the env var holding a provider API key. In CI,
46
+ // store the key as a repo secret and pass it under that env var
47
+ // (the scaffolded workflow does). If you omit `auth` entirely,
48
+ // OpenCode's own login / ambient provider env vars are used.
49
+ // For Anthropic/Claude, set provider "anthropic", tokenEnv "ANTHROPIC_API_KEY",
50
+ // and an anthropic/... model above. For another provider, omit `auth` and set
51
+ // REVIEWER_MODEL after an `opencode auth login` for that provider.
52
+ //
53
+ // MIXED setup (a ChatGPT/Codex subscription for the default models, plus a
54
+ // metered API key for pro-tier models the subscription doesn't offer): use the
55
+ // per-provider map instead, reference `openai-api/...` models in the frontmatter
56
+ // of the agents that need the pro tier, and set ECR_EXPECTED_TOKEN_ENV in the
57
+ // workflow to the comma-separated set of both env names.
58
+ // "auth": { "providers": {
59
+ // "openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_REFRESH_TOKEN" },
60
+ // "openai-api": { "mode": "api-key", "tokenEnv": "OPENAI_API_KEY", "upstream": "openai" }
61
+ // } }
62
+ // (openai oauth: tokenEnv holds the REFRESH token from an `opencode auth login`
63
+ // ChatGPT sign-in — copy `.openai.refresh` from OpenCode's auth.json.)
51
64
  "auth": {
52
- "mode": "oauth",
53
- "provider": "anthropic",
54
- "tokenEnv": "ANTHROPIC_OAUTH_API_KEY"
65
+ "mode": "api-key",
66
+ "provider": "openai",
67
+ "tokenEnv": "OPENAI_API_KEY"
55
68
  }
56
69
  }
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  # The coordinator makes the final call — de-duping, re-judging severity, and
3
- # deciding — so it runs on Opus: consolidation quality matters more here than the
4
- # small serial-tail latency it adds (no repo tools, so it's a single bounded pass).
3
+ # deciding — so it runs on the pro tier: consolidation quality matters more here
4
+ # than the small serial-tail latency it adds (no repo tools, one bounded pass).
5
5
  # Override with a cheaper model if you'd rather trade decision quality for latency.
6
- model: anthropic/claude-opus-4-8
6
+ model: openai/gpt-5.5-pro
7
7
  ---
8
8
 
9
9
  # Coordinator — consolidation & decision
@@ -18,7 +18,7 @@
18
18
  // Passes budget, split across active scopes (they run sequentially in one `ecr ci`):
19
19
  // keep totalPassesMinutes inside the workflow's timeout-minutes; minScopeMinutes is
20
20
  // the floor below which a scope review isn't worth starting. Defaults shown.
21
- // "budget": { "totalPassesMinutes": 32, "minScopeMinutes": 5 },
21
+ // "budget": { "totalPassesMinutes": 55, "minScopeMinutes": 5 },
22
22
 
23
23
  // Ordered; the LAST matching scope wins per changed file. Keep a '**/*' catch-all first.
24
24
  "scopes": [