@expo/code-review-cli 0.3.0 → 0.5.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.
Files changed (43) hide show
  1. package/README.md +307 -47
  2. package/build/cli.js +24 -17
  3. package/build/commands/ci.js +410 -43
  4. package/build/commands/dismiss.js +16 -16
  5. package/build/commands/doctor.js +219 -26
  6. package/build/commands/init.js +244 -34
  7. package/build/commands/review.js +118 -30
  8. package/build/commands/verify-config.js +252 -0
  9. package/build/config/load.js +200 -55
  10. package/build/config/routing.js +122 -0
  11. package/build/config/schema.js +153 -19
  12. package/build/core/auth.js +237 -75
  13. package/build/core/coordinator.js +7 -7
  14. package/build/core/diff.js +19 -19
  15. package/build/core/exec.js +10 -10
  16. package/build/core/log.js +3 -3
  17. package/build/core/noise.js +52 -52
  18. package/build/core/opencode.js +495 -95
  19. package/build/core/prompts.js +220 -150
  20. package/build/core/render.js +202 -48
  21. package/build/core/review.js +277 -102
  22. package/build/core/router.js +10 -10
  23. package/build/core/schema.js +26 -12
  24. package/build/core/step-summary.js +18 -0
  25. package/build/core/suppress.js +7 -7
  26. package/build/core/tools.js +9 -9
  27. package/build/core/util.js +2 -2
  28. package/build/core/verify.js +28 -26
  29. package/build/reporters/github.js +103 -51
  30. package/build/reporters/terminal.js +19 -19
  31. package/build/sources/github-pr.js +21 -21
  32. package/build/sources/local-git.js +20 -20
  33. package/build/sources/source.js +35 -1
  34. package/package.json +8 -3
  35. package/templates/agents/security.md +5 -0
  36. package/templates/command.yml +167 -0
  37. package/templates/config.jsonc +26 -13
  38. package/templates/coordinator.md +5 -3
  39. package/templates/dismiss.yml +110 -0
  40. package/templates/routing.jsonc +27 -0
  41. package/templates/scope-config.jsonc +25 -0
  42. package/templates/shared.md +12 -0
  43. package/templates/workflow.yml +61 -26
@@ -1,18 +1,31 @@
1
- import path from 'node:path';
2
- import { prepareAuth } from './auth.js';
3
- import { coordinate } from './coordinator.js';
4
- import { writeRunLog } from './log.js';
5
- import { filterNoise, writePatchWorkspace } from './noise.js';
6
- import { addTokenUsage, AgentTimeoutError, buildOpencodeConfig, CROSS_CUTTING_AGENT, promptAndParse, startOpencode, } from './opencode.js';
7
- import { routeAgents } from './router.js';
8
- import { buildCrossCuttingSystem, buildCrossCuttingTask, buildReviewerSystem, buildReviewerTask, NO_TOOLS_INSTRUCTION, } from './prompts.js';
9
- import { fingerprintFinding, parseReviewerOutput } from './schema.js';
10
- import { sortFindings } from './render.js';
11
- import { errorMessage, sleep } from './util.js';
12
- import { verifyFindings } from './verify.js';
13
- import { applyInlineIgnores } from './suppress.js';
1
+ import path from "node:path";
2
+ import { prepareAuth } from "./auth.js";
3
+ import { coordinate } from "./coordinator.js";
4
+ import { writeRunLog } from "./log.js";
5
+ import { filterNoise, writePatchWorkspace } from "./noise.js";
6
+ import { addTokenUsage, AgentTimeoutError, assertModelsResolvable, buildOpencodeConfig, CROSS_CUTTING_AGENT, promptAndParse, startOpencode, } from "./opencode.js";
7
+ import { routeAgents } from "./router.js";
8
+ import { buildCrossCuttingSystem, buildCrossCuttingTask, buildReviewerSystem, buildReviewerTask, NO_TOOLS_INSTRUCTION, } from "./prompts.js";
9
+ import { fingerprintFinding, parseReviewerOutput } from "./schema.js";
10
+ import { sortFindings } from "./render.js";
11
+ import { appendStepSummary } from "./step-summary.js";
12
+ import { errorMessage, sleep } from "./util.js";
13
+ import { verifyFindings } from "./verify.js";
14
+ import { applyInlineIgnores } from "./suppress.js";
15
+ /**
16
+ * Filter changed files down to an explicit include set (exact-path membership, not
17
+ * globs — scope assignment already happened in resolveScopes). With no include set,
18
+ * returns the input unchanged so the non-routed path is byte-identical.
19
+ */
20
+ export function filterByIncludePaths(files, includePaths) {
21
+ if (!includePaths) {
22
+ return files;
23
+ }
24
+ const included = new Set(includePaths);
25
+ return files.filter((file) => included.has(file.path));
26
+ }
14
27
  function makeRunId() {
15
- return new Date().toISOString().replace(/[:.]/g, '-');
28
+ return new Date().toISOString().replace(/[:.]/g, "-");
16
29
  }
17
30
  /**
18
31
  * The invariant, mode-agnostic review core: filter → spawn each configured agent
@@ -24,9 +37,9 @@ export async function runReview(source, options) {
24
37
  const started = Date.now();
25
38
  const runId = makeRunId();
26
39
  const progress = options.onProgress ?? (() => { });
27
- const runsRoot = path.join(config.configDir, '.runs');
40
+ const runsRoot = path.join(config.configDir, ".runs");
28
41
  const runDir = path.join(runsRoot, runId);
29
- const logPath = path.join(runsRoot, 'reviews.jsonl');
42
+ const logPath = path.join(runsRoot, "reviews.jsonl");
30
43
  // Fail fast on an invalid explicit selection before doing any work. Routing
31
44
  // (if requested) is resolved later, once the server is up.
32
45
  const explicitAgents = options.agents?.length
@@ -36,24 +49,27 @@ export async function runReview(source, options) {
36
49
  source.getMetadata(),
37
50
  source.getChangedFiles(),
38
51
  ]);
39
- const { kept, filtered } = await filterNoise(changedFiles, {
52
+ // Scope isolation: when includePaths is set, this run only ever sees its own
53
+ // scope's files — no scope reviews another team's diff.
54
+ const scopedFiles = filterByIncludePaths(changedFiles, options.includePaths);
55
+ const { kept, filtered } = await filterNoise(scopedFiles, {
40
56
  additionalIgnores: config.noise.additionalIgnores,
41
57
  additionalMarkers: config.noise.additionalMarkers,
42
58
  });
43
- progress(`${changedFiles.length} changed file(s); ${kept.length} to review, ${filtered.length} filtered.`);
59
+ progress(`${scopedFiles.length} changed file(s); ${kept.length} to review, ${filtered.length} filtered.`);
44
60
  const baseRecord = {
45
61
  timestamp: new Date().toISOString(),
46
62
  mode: options.mode,
47
63
  runId,
48
64
  metadata: { baseRef: metadata.baseRef, headRef: metadata.headRef },
49
- reviewedFiles: kept.map(entry => entry.path),
65
+ reviewedFiles: kept.map((entry) => entry.path),
50
66
  filteredFiles: filtered,
51
67
  };
52
68
  if (kept.length === 0) {
53
69
  const output = {
54
- decision: 'approve',
70
+ decision: "approve",
55
71
  findings: [],
56
- summary: 'No reviewable changes after noise filtering.',
72
+ summary: "No reviewable changes after noise filtering.",
57
73
  incomplete: [],
58
74
  };
59
75
  await safeLog(logPath, {
@@ -86,10 +102,10 @@ export async function runReview(source, options) {
86
102
  }
87
103
  };
88
104
  if (readRoot) {
89
- progress('Reviewing the PR-head tree (so reads match the PR, not the checkout).');
105
+ progress("Reviewing the PR-head tree (so reads match the PR, not the checkout).");
90
106
  process.chdir(readRoot.dir);
91
107
  }
92
- progress('Starting OpenCode server…');
108
+ progress("Starting OpenCode server…");
93
109
  let handle = null;
94
110
  try {
95
111
  handle = await startOpencode(buildOpencodeConfig(config));
@@ -98,22 +114,62 @@ export async function runReview(source, options) {
98
114
  await auth.cleanup();
99
115
  await restoreCwd();
100
116
  throw new Error(`Failed to start the OpenCode server. Ensure the \`opencode\` CLI is installed and ` +
101
- `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;
102
130
  }
103
131
  const agentCosts = {};
104
132
  const tokenTotals = {};
133
+ const agentTokens = {};
134
+ // Declared outside the try so the error-path log still carries whatever the
135
+ // reviewers produced before the failure — partial findings are exactly what's
136
+ // needed to debug a run that died mid-way.
137
+ const agentFindings = {};
138
+ // Every model request's usage lands in the run total AND its bucket, so the run
139
+ // log can show cache effectiveness per pass and not just run-wide.
140
+ const trackTokens = (bucket, tokens) => {
141
+ addTokenUsage(tokenTotals, tokens);
142
+ addTokenUsage((agentTokens[bucket] ??= {}), tokens);
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
+ };
105
161
  try {
106
162
  const workspace = await writePatchWorkspace(kept, metadata, runDir);
107
163
  // Resolve which agents run: an explicit list wins; otherwise route (LLM picks
108
164
  // relevant agents + always-run) when asked, else all.
109
165
  let selectedAgents = explicitAgents ?? config.agents;
110
166
  if (!explicitAgents && options.route) {
111
- progress('Routing: selecting relevant agents…');
167
+ progress("Routing: selecting relevant agents…");
112
168
  const routed = await routeAgents(handle, config, workspace.files);
113
169
  selectedAgents = routed.agents;
114
170
  progress(routed.routed
115
- ? `Router selected: ${selectedAgents.map(a => a.id).join(', ')}`
116
- : 'Router unavailable; running all agents.');
171
+ ? `Router selected: ${selectedAgents.map((a) => a.id).join(", ")}`
172
+ : "Router unavailable; running all agents.");
117
173
  }
118
174
  // Split the diff into focused chunks so each reviewer call sees a small file
119
175
  // set (better recall than one giant blob), and run all agent×chunk calls
@@ -121,10 +177,9 @@ export async function runReview(source, options) {
121
177
  const chunks = chunkByLines(workspace.files, config.chunk.maxChangedLines, config.chunk.maxFiles);
122
178
  // Only chunk (and add a cross-cutting pass) when the diff exceeds one chunk.
123
179
  const chunked = chunks.length > 1;
124
- progress(`Running ${selectedAgents.length} reviewer(s) [${selectedAgents.map(a => a.id).join(', ')}] over ${chunks.length} chunk(s)` +
125
- `${chunked ? ' + cross-cutting pass' : ''} ` +
180
+ progress(`Running ${selectedAgents.length} reviewer(s) [${selectedAgents.map((a) => a.id).join(", ")}] over ${chunks.length} chunk(s)` +
181
+ `${chunked ? " + cross-cutting pass" : ""} ` +
126
182
  `(${kept.length} files, concurrency ${config.chunk.concurrency})…`);
127
- const agentFindings = {};
128
183
  for (const agent of selectedAgents) {
129
184
  agentFindings[agent.id] = [];
130
185
  agentCosts[agent.id] = 0;
@@ -132,7 +187,6 @@ export async function runReview(source, options) {
132
187
  // These caps must fit inside PASSES_BUDGET_MS (below), which in turn fits inside
133
188
  // the CI job's timeout-minutes.
134
189
  const CHUNK_TIMEOUT_MS = 15 * 60 * 1000;
135
- const CROSS_CUTTING_TIMEOUT_MS = 25 * 60 * 1000;
136
190
  // A subdivided sub-chunk is smaller, so it gets a shorter cap (halved per level,
137
191
  // floored) — enough to converge without letting the recursion balloon.
138
192
  const SUBDIVIDE_MIN_TIMEOUT_MS = 6 * 60 * 1000;
@@ -142,25 +196,62 @@ export async function runReview(source, options) {
142
196
  // Tool-call ceilings — generous for a legitimate pass, low enough to catch
143
197
  // runaway roaming (the root cause of the non-convergent timeouts).
144
198
  const CHUNK_MAX_TOOL_CALLS = 50;
145
- const CROSS_CUTTING_MAX_TOOL_CALLS = 120;
146
199
  // Global ceiling for ALL passes incl. subdivision/fallback waves, sized to
147
200
  // leave room for the coordinator (10m) + verification + overhead inside the CI
148
201
  // job timeout. Past this, a timed-out pass is reported as a gap rather than
149
202
  // broken down further, so total wall-clock stays bounded.
150
- const PASSES_BUDGET_MS = 32 * 60 * 1000;
151
- const passesDeadline = started + PASSES_BUDGET_MS;
203
+ const PASSES_BUDGET_MS = 55 * 60 * 1000;
204
+ const passesBudgetMs = options.passesBudgetMs ?? PASSES_BUDGET_MS;
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 = [];
152
243
  const tasks = [];
153
244
  for (const agent of selectedAgents) {
154
245
  const system = buildReviewerSystem(config, agent);
155
246
  chunks.forEach((chunk, index) => {
156
247
  tasks.push({
157
248
  bucket: agent.id,
158
- kind: 'reviewer',
249
+ kind: "reviewer",
159
250
  system,
160
251
  label: chunked ? `${agent.id} [${index + 1}/${chunks.length}]` : agent.id,
161
252
  title: `review-${agent.id}-c${index}`,
162
253
  files: chunk,
163
- coverageLabel: `the ${agent.id} review${chunked ? ` (part ${index + 1} of ${chunks.length})` : ''}`,
254
+ coverageLabel: `the ${agent.id} review${chunked ? ` (part ${index + 1} of ${chunks.length})` : ""}`,
164
255
  maxWaitMs: CHUNK_TIMEOUT_MS,
165
256
  maxToolCalls: CHUNK_MAX_TOOL_CALLS,
166
257
  depth: 0,
@@ -173,14 +264,14 @@ export async function runReview(source, options) {
173
264
  if (chunked) {
174
265
  tasks.push({
175
266
  bucket: CROSS_CUTTING_AGENT,
176
- kind: 'cross-cutting',
177
- system: buildCrossCuttingSystem(config, selectedAgents),
178
- label: 'cross-file',
179
- title: 'review-xcut',
267
+ kind: "cross-cutting",
268
+ system: buildCrossCuttingSystem(config),
269
+ label: "cross-file",
270
+ title: "review-xcut",
180
271
  files: workspace.files,
181
- coverageLabel: 'the cross-file review (issues spanning multiple changed files)',
182
- maxWaitMs: CROSS_CUTTING_TIMEOUT_MS,
183
- maxToolCalls: CROSS_CUTTING_MAX_TOOL_CALLS,
272
+ coverageLabel: "the cross-file review (issues spanning multiple changed files)",
273
+ maxWaitMs: crossCuttingWaitMs,
274
+ maxToolCalls: crossCuttingMaxToolCalls,
184
275
  depth: 0,
185
276
  fallback: false,
186
277
  });
@@ -188,18 +279,26 @@ export async function runReview(source, options) {
188
279
  // Longest-processing-time-first: schedule the long cross-cutting/large chunks
189
280
  // ahead of short ones so they don't dominate the tail of the makespan.
190
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 ?? "");
191
288
  // Build the task prompt on demand (so a subdivided task rebuilds over its
192
289
  // smaller file set); a fallback task forbids tools and reviews the inlined diff.
193
290
  const buildTaskText = (task) => {
194
- const base = task.kind === 'cross-cutting'
195
- ? buildCrossCuttingTask(task.files, filtered)
291
+ const base = task.kind === "cross-cutting"
292
+ ? buildCrossCuttingTask(task.files, selectedAgents, filtered, {
293
+ noTools: task.fallback,
294
+ })
196
295
  : buildReviewerTask(task.files, workspace.files, filtered);
197
296
  return task.fallback ? `${base}\n\n${NO_TOOLS_INSTRUCTION}` : base;
198
297
  };
199
298
  const filesLabel = (files) => files.length === 1
200
299
  ? `\`${files[0].path}\``
201
300
  : `${files.length} files (e.g. \`${files[0].path}\`)`;
202
- const humanBucket = (bucket) => bucket === CROSS_CUTTING_AGENT ? 'cross-file' : bucket;
301
+ const humanBucket = (bucket) => bucket === CROSS_CUTTING_AGENT ? "cross-file" : bucket;
203
302
  const childTask = (parent, files, labelSuffix, overrides) => ({
204
303
  ...parent,
205
304
  files,
@@ -207,9 +306,6 @@ export async function runReview(source, options) {
207
306
  coverageLabel: `the ${humanBucket(parent.bucket)} review of ${filesLabel(files)}`,
208
307
  ...overrides,
209
308
  });
210
- // Coverage notes for passes that hit their time limit or failed, surfaced in
211
- // the final review so a cut-short run is never presented as complete.
212
- const incomplete = [];
213
309
  let completedPasses = 0;
214
310
  let failedPasses = 0;
215
311
  // promptAndParse already retries internally (same-session corrective, then a
@@ -220,18 +316,19 @@ export async function runReview(source, options) {
220
316
  await runGrowableQueue(tasks, config.chunk.concurrency, async (task, enqueue) => {
221
317
  const minutes = Math.round(task.maxWaitMs / 60000);
222
318
  try {
223
- const { value, cost, truncated, tokens } = await promptAndParse(handle, {
319
+ const { value, cost, truncated, tokens, model } = await promptAndParse(handle, {
224
320
  agent: task.bucket,
225
321
  system: task.system,
226
322
  text: buildTaskText(task),
227
323
  title: task.title,
228
- onActivity: line => progress(` ${task.label}: ${line}`),
324
+ onActivity: (line) => progress(` ${task.label}: ${line}`),
229
325
  maxWaitMs: task.maxWaitMs,
230
326
  maxToolCalls: task.maxToolCalls,
231
327
  finalizeOnTimeout: true,
232
328
  }, parseReviewerOutput);
233
329
  agentCosts[task.bucket] = (agentCosts[task.bucket] ?? 0) + cost;
234
- addTokenUsage(tokenTotals, tokens);
330
+ trackTokens(task.bucket, tokens);
331
+ trackModel(task.bucket, taskModel(task), model);
235
332
  (agentFindings[task.bucket] ??= []).push(...value.findings);
236
333
  completedPasses++;
237
334
  if (truncated) {
@@ -255,13 +352,17 @@ export async function runReview(source, options) {
255
352
  }
256
353
  // Account for the abandoned investigation's spend regardless of what's next.
257
354
  agentCosts[task.bucket] = (agentCosts[task.bucket] ?? 0) + error.cost;
258
- addTokenUsage(tokenTotals, error.tokens);
355
+ trackTokens(task.bucket, error.tokens);
259
356
  const remaining = passesDeadline - Date.now();
260
- // Cross-file analysis needs ≥2 files to be meaningful; a single-file
261
- // reviewer chunk can't be split further.
262
- 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;
263
364
  const childCap = Math.max(SUBDIVIDE_MIN_TIMEOUT_MS, Math.floor(task.maxWaitMs / 2));
264
- if (task.files.length > minFiles && task.depth < MAX_SUBDIVIDE_DEPTH && remaining > childCap) {
365
+ if (canSubdivide && task.depth < MAX_SUBDIVIDE_DEPTH && remaining > childCap) {
265
366
  const mid = Math.ceil(task.files.length / 2);
266
367
  const left = task.files.slice(0, mid);
267
368
  const right = task.files.slice(mid);
@@ -271,11 +372,14 @@ export async function runReview(source, options) {
271
372
  enqueue(childTask(task, right, `↳${right.length}f`, over));
272
373
  return;
273
374
  }
274
- // Can't subdivide further: try a fast no-tools pass over the inlined diff
275
- // (reviewer only cross-file analysis fundamentally needs to read files).
276
- 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) {
277
381
  progress(` ${task.label}: exceeded ${minutes}m — retrying ${filesLabel(task.files)} with a fast no-tools pass`);
278
- enqueue(childTask(task, task.files, '(no-tools fallback)', {
382
+ enqueue(childTask(task, task.files, "(no-tools fallback)", {
279
383
  fallback: true,
280
384
  maxWaitMs: FALLBACK_TIMEOUT_MS,
281
385
  maxToolCalls: 0,
@@ -286,10 +390,24 @@ export async function runReview(source, options) {
286
390
  // never silent. Distinguish WHY so the note doesn't overstate what happened:
287
391
  // we could still have split/fallen back, but the global budget ran out first,
288
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.
289
396
  failedPasses++;
290
- const couldStillReduce = (task.files.length > minFiles && task.depth < MAX_SUBDIVIDE_DEPTH) ||
291
- (task.kind === 'reviewer' && !task.fallback);
292
- 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) {
293
411
  progress(` ${task.label}: exceeded ${minutes}m and the run's time budget is spent — reporting a coverage gap`);
294
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.`);
295
413
  }
@@ -299,6 +417,18 @@ export async function runReview(source, options) {
299
417
  }
300
418
  }
301
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
+ }
302
432
  // Note: routine noise filtering (lockfiles, generated, binary) is expected and
303
433
  // NOT a coverage gap — it stays in the run log (filteredFiles), not the
304
434
  // user-facing coverage note, which is reserved for passes that didn't finish.
@@ -306,27 +436,28 @@ export async function runReview(source, options) {
306
436
  let output;
307
437
  if (completedPasses === 0) {
308
438
  // Nothing succeeded — do NOT let this render as a clean "approve".
309
- progress('All review passes failed — reporting an incomplete review.');
439
+ progress("All review passes failed — reporting an incomplete review.");
310
440
  output = {
311
- decision: 'approve_with_comments',
441
+ decision: "approve_with_comments",
312
442
  findings: [],
313
- summary: '⚠️ The AI review could not complete: every review pass failed or timed out, ' +
443
+ summary: "⚠️ The AI review could not complete: every review pass failed or timed out, " +
314
444
  'so these changes were effectively NOT reviewed. Treat this as "no review", not "looks good".',
315
445
  incomplete: coverageNotes,
316
446
  };
317
447
  }
318
448
  else {
319
- progress('Coordinating findings…');
449
+ progress("Coordinating findings…");
320
450
  let consolidated;
321
451
  try {
322
- const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes);
323
- agentCosts['coordinator'] = cost;
324
- addTokenUsage(tokenTotals, coordinatorTokens);
452
+ const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, model: coordinatorModel, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes);
453
+ agentCosts["coordinator"] = cost;
454
+ trackTokens("coordinator", coordinatorTokens);
455
+ trackModel("coordinator", config.coordinator.model, coordinatorModel);
325
456
  consolidated = applyReviewPolicy(rawOutput, config.policy);
326
457
  if (coordinatorTruncated) {
327
458
  // The coordinator ran out of time and returned partial findings — flag it
328
459
  // like any other truncated pass so reduced coverage is never silent.
329
- coverageNotes.push('The consolidation step ran out of time and returned partial findings; some findings may have been dropped or not fully de-duplicated.');
460
+ coverageNotes.push("The consolidation step ran out of time and returned partial findings; some findings may have been dropped or not fully de-duplicated.");
330
461
  }
331
462
  }
332
463
  catch (error) {
@@ -335,11 +466,11 @@ export async function runReview(source, options) {
335
466
  // merge so a comment is still posted.
336
467
  progress(`Coordinator failed (${errorMessage(error)}); consolidating findings locally.`);
337
468
  consolidated = fallbackConsolidation(agentFindings, config.policy);
338
- coverageNotes.push('The consolidation step failed, so findings are shown merged but not de-duplicated or re-judged.');
469
+ coverageNotes.push("The consolidation step failed, so findings are shown merged but not de-duplicated or re-judged.");
339
470
  }
340
471
  // A run with any failed/timed-out pass must never present as a clean approve.
341
- const decision = failedPasses > 0 && consolidated.decision === 'approve'
342
- ? 'approve_with_comments'
472
+ const decision = failedPasses > 0 && consolidated.decision === "approve"
473
+ ? "approve_with_comments"
343
474
  : consolidated.decision;
344
475
  output = { ...consolidated, decision, incomplete: [...new Set(coverageNotes)] };
345
476
  }
@@ -347,11 +478,15 @@ export async function runReview(source, options) {
347
478
  // finding against the real file, and adversarially verify criticals. This is
348
479
  // what stops a confident but wrong critical from shipping.
349
480
  const findingCountBeforeChecks = output.findings.length;
481
+ let verifierDropped = [];
350
482
  if (output.findings.length > 0) {
351
- progress('Verifying findings…');
483
+ progress("Verifying findings…");
352
484
  const verification = await verifyFindings(handle, output.findings, process.cwd(), progress);
353
- agentCosts['verifier'] = verification.cost;
354
- addTokenUsage(tokenTotals, verification.tokens);
485
+ agentCosts["verifier"] = verification.cost;
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);
489
+ verifierDropped = verification.dropped;
355
490
  if (verification.dropped.length > 0) {
356
491
  progress(`Verification dropped ${verification.dropped.length} unverified finding(s).`);
357
492
  output = {
@@ -380,12 +515,26 @@ export async function runReview(source, options) {
380
515
  if (removedAfterChecks > 0) {
381
516
  output = { ...output, summary: reconcileSummary(output.summary, output.findings.length) };
382
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
+ }
383
526
  progress(formatUsageSummary(tokenTotals, sum(agentCosts)));
527
+ await appendStepSummary(renderUsageMarkdown(agentTokens, agentCosts, tokenTotals, sum(agentCosts), agentModels));
384
528
  await safeLog(logPath, {
385
529
  ...baseRecord,
386
530
  agentCosts,
387
531
  totalCost: sum(agentCosts),
388
532
  tokens: tokenTotals,
533
+ agentTokens,
534
+ agentModels,
535
+ agentFindings,
536
+ coverageNotes,
537
+ verifierDropped,
389
538
  durationMs: Date.now() - started,
390
539
  decision: output.decision,
391
540
  findingCount: output.findings.length,
@@ -399,6 +548,8 @@ export async function runReview(source, options) {
399
548
  agentCosts,
400
549
  totalCost: sum(agentCosts),
401
550
  tokens: tokenTotals,
551
+ agentTokens,
552
+ agentFindings,
402
553
  durationMs: Date.now() - started,
403
554
  decision: null,
404
555
  findingCount: 0,
@@ -420,13 +571,13 @@ export async function runReview(source, options) {
420
571
  export function applyReviewPolicy(output, policy) {
421
572
  let findings = policy.includeSuggestions
422
573
  ? output.findings
423
- : output.findings.filter(finding => finding.severity !== 'suggestion');
574
+ : output.findings.filter((finding) => finding.severity !== "suggestion");
424
575
  findings = sortFindings(findings);
425
576
  if (policy.maxFindings != null) {
426
577
  findings = findings.slice(0, policy.maxFindings);
427
578
  }
428
- const decision = output.decision === 'approve_with_comments' && findings.length === 0
429
- ? 'approve'
579
+ const decision = output.decision === "approve_with_comments" && findings.length === 0
580
+ ? "approve"
430
581
  : output.decision;
431
582
  return { ...output, findings, decision };
432
583
  }
@@ -448,16 +599,16 @@ function fallbackConsolidation(agentFindings, policy) {
448
599
  }
449
600
  }
450
601
  }
451
- const decision = merged.some(finding => finding.severity === 'critical')
452
- ? 'request_changes'
602
+ const decision = merged.some((finding) => finding.severity === "critical")
603
+ ? "request_changes"
453
604
  : merged.length > 0
454
- ? 'approve_with_comments'
455
- : 'approve';
605
+ ? "approve_with_comments"
606
+ : "approve";
456
607
  return applyReviewPolicy({
457
608
  decision,
458
609
  findings: merged,
459
- summary: 'Consolidation step failed; showing the specialist reviewers’ findings ' +
460
- 'merged and de-duplicated, but not re-judged.',
610
+ summary: "Consolidation step failed; showing the specialist reviewers’ findings " +
611
+ "merged and de-duplicated, but not re-judged.",
461
612
  incomplete: [],
462
613
  }, policy);
463
614
  }
@@ -468,10 +619,10 @@ function fallbackConsolidation(agentFindings, policy) {
468
619
  */
469
620
  export function decisionAfterVerification(previous, kept) {
470
621
  if (kept.length === 0) {
471
- return 'approve';
622
+ return "approve";
472
623
  }
473
- if (previous === 'request_changes' && !kept.some(finding => finding.severity === 'critical')) {
474
- return 'approve_with_comments';
624
+ if (previous === "request_changes" && !kept.some((finding) => finding.severity === "critical")) {
625
+ return "approve_with_comments";
475
626
  }
476
627
  return previous;
477
628
  }
@@ -484,10 +635,10 @@ export function decisionAfterVerification(previous, kept) {
484
635
  */
485
636
  export function reconcileSummary(summary, remaining) {
486
637
  if (remaining === 0) {
487
- return 'All candidate findings were removed by automated verification and suppression, so no issues remain to report.';
638
+ return "All candidate findings were removed by automated verification and suppression, so no issues remain to report.";
488
639
  }
489
- return ('_Note: some findings were removed by automated verification/suppression after ' +
490
- 'this summary was written, so it may mention issues no longer listed below._\n\n' +
640
+ return ("_Note: some findings were removed by automated verification/suppression after " +
641
+ "this summary was written, so it may mention issues no longer listed below._\n\n" +
491
642
  summary);
492
643
  }
493
644
  /** Capitalize the first letter (coverage notes read as sentences). */
@@ -514,19 +665,19 @@ export function isAuthError(error) {
514
665
  new RegExp(`${problem.source}\\b[^.]{0,20}${cred.source}`).test(message) ||
515
666
  new RegExp(`${cred.source}[^.]{0,20}${problem.source}`).test(message));
516
667
  }
517
- const AUTH_FAILURE_NOTE = 'The model provider rejected the request (authentication or permission). Check the ' +
518
- 'configured credential (auth.tokenEnv, or REVIEWER_MODEL for a local run) and re-run — ' +
519
- 'those changes were not reviewed.';
668
+ const AUTH_FAILURE_NOTE = "The model provider rejected the request (authentication or permission). Check the " +
669
+ "configured credential (auth.tokenEnv, or REVIEWER_MODEL for a local run) and re-run — " +
670
+ "those changes were not reviewed.";
520
671
  function selectAgents(all, filter) {
521
672
  if (!filter?.length) {
522
673
  return all;
523
674
  }
524
- const known = new Set(all.map(agent => agent.id));
525
- const unknown = filter.filter(id => !known.has(id));
675
+ const known = new Set(all.map((agent) => agent.id));
676
+ const unknown = filter.filter((id) => !known.has(id));
526
677
  if (unknown.length > 0) {
527
- throw new Error(`Unknown agent(s): ${unknown.join(', ')}. Available: ${all.map(a => a.id).join(', ')}`);
678
+ throw new Error(`Unknown agent(s): ${unknown.join(", ")}. Available: ${all.map((a) => a.id).join(", ")}`);
528
679
  }
529
- return all.filter(agent => filter.includes(agent.id));
680
+ return all.filter((agent) => filter.includes(agent.id));
530
681
  }
531
682
  /**
532
683
  * Greedily pack files into chunks bounded by total changed lines (primary) and
@@ -604,8 +755,32 @@ export function formatUsageSummary(tokens, totalCost) {
604
755
  parts.push(`reasoning ${tokens.reasoning}`);
605
756
  }
606
757
  parts.push(`cache read ${tokens.cache?.read ?? 0}`, `cache write ${tokens.cache?.write ?? 0}`);
607
- const cost = totalCost > 0 ? ` (cost $${totalCost.toFixed(4)})` : '';
608
- return `Token usage — ${parts.join(', ')}${cost}`;
758
+ const cost = totalCost > 0 ? ` (cost $${totalCost.toFixed(4)})` : "";
759
+ return `Token usage — ${parts.join(", ")}${cost}`;
760
+ }
761
+ /**
762
+ * Markdown-table version of the usage summary for the Actions step summary: one
763
+ * row per pass plus a total, and the prompt-cache hit rate (the share of prompt
764
+ * tokens served from cache instead of being reprocessed at full price).
765
+ */
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)} |`;
768
+ const lines = [
769
+ "### 🤖 AI review — token usage",
770
+ "",
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),
775
+ ];
776
+ const read = totals.cache?.read ?? 0;
777
+ const uncached = totals.input ?? 0;
778
+ if (read + uncached > 0) {
779
+ const rate = Math.round((read / (read + uncached)) * 100);
780
+ lines.push("", `Prompt cache hit rate: **${rate}%** (cache read / (cache read + input)). ` +
781
+ 'See "Tokens, cost & prompt caching" in the README for how to read these numbers.');
782
+ }
783
+ return lines.join("\n");
609
784
  }
610
785
  async function safeLog(logPath, record) {
611
786
  try {