@expo/code-review-cli 0.2.3 → 0.4.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 +183 -6
  2. package/build/cli.js +24 -17
  3. package/build/commands/ci.js +427 -28
  4. package/build/commands/dismiss.js +16 -16
  5. package/build/commands/doctor.js +172 -32
  6. package/build/commands/init.js +244 -34
  7. package/build/commands/review.js +124 -30
  8. package/build/commands/verify-config.js +214 -0
  9. package/build/config/load.js +155 -52
  10. package/build/config/routing.js +122 -0
  11. package/build/config/schema.js +127 -8
  12. package/build/core/auth.js +101 -38
  13. package/build/core/coordinator.js +5 -5
  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 +98 -44
  19. package/build/core/prompts.js +157 -148
  20. package/build/core/render.js +202 -48
  21. package/build/core/review.js +187 -81
  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 +25 -25
  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 +6 -1
  35. package/templates/agents/security.md +5 -0
  36. package/templates/command.yml +164 -0
  37. package/templates/config.jsonc +10 -0
  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 +58 -23
@@ -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, 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));
@@ -102,18 +118,29 @@ export async function runReview(source, options) {
102
118
  }
103
119
  const agentCosts = {};
104
120
  const tokenTotals = {};
121
+ const agentTokens = {};
122
+ // Declared outside the try so the error-path log still carries whatever the
123
+ // reviewers produced before the failure — partial findings are exactly what's
124
+ // needed to debug a run that died mid-way.
125
+ const agentFindings = {};
126
+ // Every model request's usage lands in the run total AND its bucket, so the run
127
+ // log can show cache effectiveness per pass and not just run-wide.
128
+ const trackTokens = (bucket, tokens) => {
129
+ addTokenUsage(tokenTotals, tokens);
130
+ addTokenUsage((agentTokens[bucket] ??= {}), tokens);
131
+ };
105
132
  try {
106
133
  const workspace = await writePatchWorkspace(kept, metadata, runDir);
107
134
  // Resolve which agents run: an explicit list wins; otherwise route (LLM picks
108
135
  // relevant agents + always-run) when asked, else all.
109
136
  let selectedAgents = explicitAgents ?? config.agents;
110
137
  if (!explicitAgents && options.route) {
111
- progress('Routing: selecting relevant agents…');
138
+ progress("Routing: selecting relevant agents…");
112
139
  const routed = await routeAgents(handle, config, workspace.files);
113
140
  selectedAgents = routed.agents;
114
141
  progress(routed.routed
115
- ? `Router selected: ${selectedAgents.map(a => a.id).join(', ')}`
116
- : 'Router unavailable; running all agents.');
142
+ ? `Router selected: ${selectedAgents.map((a) => a.id).join(", ")}`
143
+ : "Router unavailable; running all agents.");
117
144
  }
118
145
  // Split the diff into focused chunks so each reviewer call sees a small file
119
146
  // set (better recall than one giant blob), and run all agent×chunk calls
@@ -121,10 +148,9 @@ export async function runReview(source, options) {
121
148
  const chunks = chunkByLines(workspace.files, config.chunk.maxChangedLines, config.chunk.maxFiles);
122
149
  // Only chunk (and add a cross-cutting pass) when the diff exceeds one chunk.
123
150
  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' : ''} ` +
151
+ progress(`Running ${selectedAgents.length} reviewer(s) [${selectedAgents.map((a) => a.id).join(", ")}] over ${chunks.length} chunk(s)` +
152
+ `${chunked ? " + cross-cutting pass" : ""} ` +
126
153
  `(${kept.length} files, concurrency ${config.chunk.concurrency})…`);
127
- const agentFindings = {};
128
154
  for (const agent of selectedAgents) {
129
155
  agentFindings[agent.id] = [];
130
156
  agentCosts[agent.id] = 0;
@@ -148,19 +174,20 @@ export async function runReview(source, options) {
148
174
  // job timeout. Past this, a timed-out pass is reported as a gap rather than
149
175
  // broken down further, so total wall-clock stays bounded.
150
176
  const PASSES_BUDGET_MS = 32 * 60 * 1000;
151
- const passesDeadline = started + PASSES_BUDGET_MS;
177
+ const passesBudgetMs = options.passesBudgetMs ?? PASSES_BUDGET_MS;
178
+ const passesDeadline = started + passesBudgetMs;
152
179
  const tasks = [];
153
180
  for (const agent of selectedAgents) {
154
181
  const system = buildReviewerSystem(config, agent);
155
182
  chunks.forEach((chunk, index) => {
156
183
  tasks.push({
157
184
  bucket: agent.id,
158
- kind: 'reviewer',
185
+ kind: "reviewer",
159
186
  system,
160
187
  label: chunked ? `${agent.id} [${index + 1}/${chunks.length}]` : agent.id,
161
188
  title: `review-${agent.id}-c${index}`,
162
189
  files: chunk,
163
- coverageLabel: `the ${agent.id} review${chunked ? ` (part ${index + 1} of ${chunks.length})` : ''}`,
190
+ coverageLabel: `the ${agent.id} review${chunked ? ` (part ${index + 1} of ${chunks.length})` : ""}`,
164
191
  maxWaitMs: CHUNK_TIMEOUT_MS,
165
192
  maxToolCalls: CHUNK_MAX_TOOL_CALLS,
166
193
  depth: 0,
@@ -173,12 +200,12 @@ export async function runReview(source, options) {
173
200
  if (chunked) {
174
201
  tasks.push({
175
202
  bucket: CROSS_CUTTING_AGENT,
176
- kind: 'cross-cutting',
177
- system: buildCrossCuttingSystem(config, selectedAgents),
178
- label: 'cross-file',
179
- title: 'review-xcut',
203
+ kind: "cross-cutting",
204
+ system: buildCrossCuttingSystem(config),
205
+ label: "cross-file",
206
+ title: "review-xcut",
180
207
  files: workspace.files,
181
- coverageLabel: 'the cross-file review (issues spanning multiple changed files)',
208
+ coverageLabel: "the cross-file review (issues spanning multiple changed files)",
182
209
  maxWaitMs: CROSS_CUTTING_TIMEOUT_MS,
183
210
  maxToolCalls: CROSS_CUTTING_MAX_TOOL_CALLS,
184
211
  depth: 0,
@@ -191,15 +218,15 @@ export async function runReview(source, options) {
191
218
  // Build the task prompt on demand (so a subdivided task rebuilds over its
192
219
  // smaller file set); a fallback task forbids tools and reviews the inlined diff.
193
220
  const buildTaskText = (task) => {
194
- const base = task.kind === 'cross-cutting'
195
- ? buildCrossCuttingTask(task.files, filtered)
221
+ const base = task.kind === "cross-cutting"
222
+ ? buildCrossCuttingTask(task.files, selectedAgents, filtered)
196
223
  : buildReviewerTask(task.files, workspace.files, filtered);
197
224
  return task.fallback ? `${base}\n\n${NO_TOOLS_INSTRUCTION}` : base;
198
225
  };
199
226
  const filesLabel = (files) => files.length === 1
200
227
  ? `\`${files[0].path}\``
201
228
  : `${files.length} files (e.g. \`${files[0].path}\`)`;
202
- const humanBucket = (bucket) => bucket === CROSS_CUTTING_AGENT ? 'cross-file' : bucket;
229
+ const humanBucket = (bucket) => bucket === CROSS_CUTTING_AGENT ? "cross-file" : bucket;
203
230
  const childTask = (parent, files, labelSuffix, overrides) => ({
204
231
  ...parent,
205
232
  files,
@@ -225,13 +252,13 @@ export async function runReview(source, options) {
225
252
  system: task.system,
226
253
  text: buildTaskText(task),
227
254
  title: task.title,
228
- onActivity: line => progress(` ${task.label}: ${line}`),
255
+ onActivity: (line) => progress(` ${task.label}: ${line}`),
229
256
  maxWaitMs: task.maxWaitMs,
230
257
  maxToolCalls: task.maxToolCalls,
231
258
  finalizeOnTimeout: true,
232
259
  }, parseReviewerOutput);
233
260
  agentCosts[task.bucket] = (agentCosts[task.bucket] ?? 0) + cost;
234
- addTokenUsage(tokenTotals, tokens);
261
+ trackTokens(task.bucket, tokens);
235
262
  (agentFindings[task.bucket] ??= []).push(...value.findings);
236
263
  completedPasses++;
237
264
  if (truncated) {
@@ -245,18 +272,25 @@ export async function runReview(source, options) {
245
272
  if (!(error instanceof AgentTimeoutError)) {
246
273
  failedPasses++;
247
274
  progress(` ${task.label}: FAILED (${errorMessage(error)})`);
248
- incomplete.push(`${capitalize(task.coverageLabel)} failed to run; those changes were not reviewed.`);
275
+ // An auth/permission failure hits every pass identically; push one shared,
276
+ // actionable note (deduped into a single coverage line) instead of N generic
277
+ // per-pass failures that bury the real, fixable cause.
278
+ incomplete.push(isAuthError(error)
279
+ ? AUTH_FAILURE_NOTE
280
+ : `${capitalize(task.coverageLabel)} failed to run; those changes were not reviewed.`);
249
281
  return;
250
282
  }
251
283
  // Account for the abandoned investigation's spend regardless of what's next.
252
284
  agentCosts[task.bucket] = (agentCosts[task.bucket] ?? 0) + error.cost;
253
- addTokenUsage(tokenTotals, error.tokens);
285
+ trackTokens(task.bucket, error.tokens);
254
286
  const remaining = passesDeadline - Date.now();
255
287
  // Cross-file analysis needs ≥2 files to be meaningful; a single-file
256
288
  // reviewer chunk can't be split further.
257
- const minFiles = task.kind === 'cross-cutting' ? 2 : 1;
289
+ const minFiles = task.kind === "cross-cutting" ? 2 : 1;
258
290
  const childCap = Math.max(SUBDIVIDE_MIN_TIMEOUT_MS, Math.floor(task.maxWaitMs / 2));
259
- if (task.files.length > minFiles && task.depth < MAX_SUBDIVIDE_DEPTH && remaining > childCap) {
291
+ if (task.files.length > minFiles &&
292
+ task.depth < MAX_SUBDIVIDE_DEPTH &&
293
+ remaining > childCap) {
260
294
  const mid = Math.ceil(task.files.length / 2);
261
295
  const left = task.files.slice(0, mid);
262
296
  const right = task.files.slice(mid);
@@ -268,9 +302,9 @@ export async function runReview(source, options) {
268
302
  }
269
303
  // Can't subdivide further: try a fast no-tools pass over the inlined diff
270
304
  // (reviewer only — cross-file analysis fundamentally needs to read files).
271
- if (task.kind === 'reviewer' && !task.fallback && remaining > FALLBACK_TIMEOUT_MS) {
305
+ if (task.kind === "reviewer" && !task.fallback && remaining > FALLBACK_TIMEOUT_MS) {
272
306
  progress(` ${task.label}: exceeded ${minutes}m — retrying ${filesLabel(task.files)} with a fast no-tools pass`);
273
- enqueue(childTask(task, task.files, '(no-tools fallback)', {
307
+ enqueue(childTask(task, task.files, "(no-tools fallback)", {
274
308
  fallback: true,
275
309
  maxWaitMs: FALLBACK_TIMEOUT_MS,
276
310
  maxToolCalls: 0,
@@ -283,7 +317,7 @@ export async function runReview(source, options) {
283
317
  // vs. the task was already at its smallest reviewable unit and still failed.
284
318
  failedPasses++;
285
319
  const couldStillReduce = (task.files.length > minFiles && task.depth < MAX_SUBDIVIDE_DEPTH) ||
286
- (task.kind === 'reviewer' && !task.fallback);
320
+ (task.kind === "reviewer" && !task.fallback);
287
321
  if (couldStillReduce) {
288
322
  progress(` ${task.label}: exceeded ${minutes}m and the run's time budget is spent — reporting a coverage gap`);
289
323
  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.`);
@@ -301,27 +335,27 @@ export async function runReview(source, options) {
301
335
  let output;
302
336
  if (completedPasses === 0) {
303
337
  // Nothing succeeded — do NOT let this render as a clean "approve".
304
- progress('All review passes failed — reporting an incomplete review.');
338
+ progress("All review passes failed — reporting an incomplete review.");
305
339
  output = {
306
- decision: 'approve_with_comments',
340
+ decision: "approve_with_comments",
307
341
  findings: [],
308
- summary: '⚠️ The AI review could not complete: every review pass failed or timed out, ' +
342
+ summary: "⚠️ The AI review could not complete: every review pass failed or timed out, " +
309
343
  'so these changes were effectively NOT reviewed. Treat this as "no review", not "looks good".',
310
344
  incomplete: coverageNotes,
311
345
  };
312
346
  }
313
347
  else {
314
- progress('Coordinating findings…');
348
+ progress("Coordinating findings…");
315
349
  let consolidated;
316
350
  try {
317
351
  const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes);
318
- agentCosts['coordinator'] = cost;
319
- addTokenUsage(tokenTotals, coordinatorTokens);
352
+ agentCosts["coordinator"] = cost;
353
+ trackTokens("coordinator", coordinatorTokens);
320
354
  consolidated = applyReviewPolicy(rawOutput, config.policy);
321
355
  if (coordinatorTruncated) {
322
356
  // The coordinator ran out of time and returned partial findings — flag it
323
357
  // like any other truncated pass so reduced coverage is never silent.
324
- coverageNotes.push('The consolidation step ran out of time and returned partial findings; some findings may have been dropped or not fully de-duplicated.');
358
+ coverageNotes.push("The consolidation step ran out of time and returned partial findings; some findings may have been dropped or not fully de-duplicated.");
325
359
  }
326
360
  }
327
361
  catch (error) {
@@ -330,11 +364,11 @@ export async function runReview(source, options) {
330
364
  // merge so a comment is still posted.
331
365
  progress(`Coordinator failed (${errorMessage(error)}); consolidating findings locally.`);
332
366
  consolidated = fallbackConsolidation(agentFindings, config.policy);
333
- coverageNotes.push('The consolidation step failed, so findings are shown merged but not de-duplicated or re-judged.');
367
+ coverageNotes.push("The consolidation step failed, so findings are shown merged but not de-duplicated or re-judged.");
334
368
  }
335
369
  // A run with any failed/timed-out pass must never present as a clean approve.
336
- const decision = failedPasses > 0 && consolidated.decision === 'approve'
337
- ? 'approve_with_comments'
370
+ const decision = failedPasses > 0 && consolidated.decision === "approve"
371
+ ? "approve_with_comments"
338
372
  : consolidated.decision;
339
373
  output = { ...consolidated, decision, incomplete: [...new Set(coverageNotes)] };
340
374
  }
@@ -342,11 +376,13 @@ export async function runReview(source, options) {
342
376
  // finding against the real file, and adversarially verify criticals. This is
343
377
  // what stops a confident but wrong critical from shipping.
344
378
  const findingCountBeforeChecks = output.findings.length;
379
+ let verifierDropped = [];
345
380
  if (output.findings.length > 0) {
346
- progress('Verifying findings…');
381
+ progress("Verifying findings…");
347
382
  const verification = await verifyFindings(handle, output.findings, process.cwd(), progress);
348
- agentCosts['verifier'] = verification.cost;
349
- addTokenUsage(tokenTotals, verification.tokens);
383
+ agentCosts["verifier"] = verification.cost;
384
+ trackTokens("verifier", verification.tokens);
385
+ verifierDropped = verification.dropped;
350
386
  if (verification.dropped.length > 0) {
351
387
  progress(`Verification dropped ${verification.dropped.length} unverified finding(s).`);
352
388
  output = {
@@ -375,11 +411,17 @@ export async function runReview(source, options) {
375
411
  if (removedAfterChecks > 0) {
376
412
  output = { ...output, summary: reconcileSummary(output.summary, output.findings.length) };
377
413
  }
414
+ progress(formatUsageSummary(tokenTotals, sum(agentCosts)));
415
+ await appendStepSummary(renderUsageMarkdown(agentTokens, agentCosts, tokenTotals, sum(agentCosts)));
378
416
  await safeLog(logPath, {
379
417
  ...baseRecord,
380
418
  agentCosts,
381
419
  totalCost: sum(agentCosts),
382
420
  tokens: tokenTotals,
421
+ agentTokens,
422
+ agentFindings,
423
+ coverageNotes,
424
+ verifierDropped,
383
425
  durationMs: Date.now() - started,
384
426
  decision: output.decision,
385
427
  findingCount: output.findings.length,
@@ -393,6 +435,8 @@ export async function runReview(source, options) {
393
435
  agentCosts,
394
436
  totalCost: sum(agentCosts),
395
437
  tokens: tokenTotals,
438
+ agentTokens,
439
+ agentFindings,
396
440
  durationMs: Date.now() - started,
397
441
  decision: null,
398
442
  findingCount: 0,
@@ -414,13 +458,13 @@ export async function runReview(source, options) {
414
458
  export function applyReviewPolicy(output, policy) {
415
459
  let findings = policy.includeSuggestions
416
460
  ? output.findings
417
- : output.findings.filter(finding => finding.severity !== 'suggestion');
461
+ : output.findings.filter((finding) => finding.severity !== "suggestion");
418
462
  findings = sortFindings(findings);
419
463
  if (policy.maxFindings != null) {
420
464
  findings = findings.slice(0, policy.maxFindings);
421
465
  }
422
- const decision = output.decision === 'approve_with_comments' && findings.length === 0
423
- ? 'approve'
466
+ const decision = output.decision === "approve_with_comments" && findings.length === 0
467
+ ? "approve"
424
468
  : output.decision;
425
469
  return { ...output, findings, decision };
426
470
  }
@@ -442,16 +486,16 @@ function fallbackConsolidation(agentFindings, policy) {
442
486
  }
443
487
  }
444
488
  }
445
- const decision = merged.some(finding => finding.severity === 'critical')
446
- ? 'request_changes'
489
+ const decision = merged.some((finding) => finding.severity === "critical")
490
+ ? "request_changes"
447
491
  : merged.length > 0
448
- ? 'approve_with_comments'
449
- : 'approve';
492
+ ? "approve_with_comments"
493
+ : "approve";
450
494
  return applyReviewPolicy({
451
495
  decision,
452
496
  findings: merged,
453
- summary: 'Consolidation step failed; showing the specialist reviewers’ findings ' +
454
- 'merged and de-duplicated, but not re-judged.',
497
+ summary: "Consolidation step failed; showing the specialist reviewers’ findings " +
498
+ "merged and de-duplicated, but not re-judged.",
455
499
  incomplete: [],
456
500
  }, policy);
457
501
  }
@@ -462,10 +506,10 @@ function fallbackConsolidation(agentFindings, policy) {
462
506
  */
463
507
  export function decisionAfterVerification(previous, kept) {
464
508
  if (kept.length === 0) {
465
- return 'approve';
509
+ return "approve";
466
510
  }
467
- if (previous === 'request_changes' && !kept.some(finding => finding.severity === 'critical')) {
468
- return 'approve_with_comments';
511
+ if (previous === "request_changes" && !kept.some((finding) => finding.severity === "critical")) {
512
+ return "approve_with_comments";
469
513
  }
470
514
  return previous;
471
515
  }
@@ -478,26 +522,49 @@ export function decisionAfterVerification(previous, kept) {
478
522
  */
479
523
  export function reconcileSummary(summary, remaining) {
480
524
  if (remaining === 0) {
481
- return 'All candidate findings were removed by automated verification and suppression, so no issues remain to report.';
525
+ return "All candidate findings were removed by automated verification and suppression, so no issues remain to report.";
482
526
  }
483
- return ('_Note: some findings were removed by automated verification/suppression after ' +
484
- 'this summary was written, so it may mention issues no longer listed below._\n\n' +
527
+ return ("_Note: some findings were removed by automated verification/suppression after " +
528
+ "this summary was written, so it may mention issues no longer listed below._\n\n" +
485
529
  summary);
486
530
  }
487
531
  /** Capitalize the first letter (coverage notes read as sentences). */
488
532
  function capitalize(text) {
489
533
  return text.length > 0 ? text[0].toUpperCase() + text.slice(1) : text;
490
534
  }
535
+ /**
536
+ * An authentication/authorization failure from the model provider (401/403, a
537
+ * rejected/expired/missing credential) — distinct from a transient blip or a real
538
+ * code finding. Every pass hits the same wall, so the caller collapses it into one
539
+ * actionable coverage note instead of N generic "failed to run" lines.
540
+ */
541
+ export function isAuthError(error) {
542
+ const message = errorMessage(error).toLowerCase();
543
+ const cred = /(api.?key|token|credential)/;
544
+ const problem = /(invalid|expired|revoked|missing|rejected|no)/;
545
+ return (/\b401\b|\b403\b/.test(message) ||
546
+ /unauthor/.test(message) ||
547
+ /\bforbidden\b/.test(message) ||
548
+ /authentication/.test(message) ||
549
+ /permission denied/.test(message) ||
550
+ /invalid x-api-key/.test(message) ||
551
+ // a credential noun and a problem word near each other, in either order
552
+ new RegExp(`${problem.source}\\b[^.]{0,20}${cred.source}`).test(message) ||
553
+ new RegExp(`${cred.source}[^.]{0,20}${problem.source}`).test(message));
554
+ }
555
+ const AUTH_FAILURE_NOTE = "The model provider rejected the request (authentication or permission). Check the " +
556
+ "configured credential (auth.tokenEnv, or REVIEWER_MODEL for a local run) and re-run — " +
557
+ "those changes were not reviewed.";
491
558
  function selectAgents(all, filter) {
492
559
  if (!filter?.length) {
493
560
  return all;
494
561
  }
495
- const known = new Set(all.map(agent => agent.id));
496
- const unknown = filter.filter(id => !known.has(id));
562
+ const known = new Set(all.map((agent) => agent.id));
563
+ const unknown = filter.filter((id) => !known.has(id));
497
564
  if (unknown.length > 0) {
498
- throw new Error(`Unknown agent(s): ${unknown.join(', ')}. Available: ${all.map(a => a.id).join(', ')}`);
565
+ throw new Error(`Unknown agent(s): ${unknown.join(", ")}. Available: ${all.map((a) => a.id).join(", ")}`);
499
566
  }
500
- return all.filter(agent => filter.includes(agent.id));
567
+ return all.filter((agent) => filter.includes(agent.id));
501
568
  }
502
569
  /**
503
570
  * Greedily pack files into chunks bounded by total changed lines (primary) and
@@ -563,6 +630,45 @@ export async function runGrowableQueue(initial, limit, fn) {
563
630
  function sum(costs) {
564
631
  return Object.values(costs).reduce((total, value) => total + value, 0);
565
632
  }
633
+ /**
634
+ * One-line usage summary for the run. Emitted via progress so it lands in the CI
635
+ * job log (and the local terminal) — `.runs/reviews.jsonl` is ephemeral in CI, so
636
+ * this is the only place the token/cache totals are visible after a CI run, which
637
+ * is how prompt-cache effectiveness gets confirmed there.
638
+ */
639
+ export function formatUsageSummary(tokens, totalCost) {
640
+ const parts = [`input ${tokens.input ?? 0}`, `output ${tokens.output ?? 0}`];
641
+ if (tokens.reasoning) {
642
+ parts.push(`reasoning ${tokens.reasoning}`);
643
+ }
644
+ parts.push(`cache read ${tokens.cache?.read ?? 0}`, `cache write ${tokens.cache?.write ?? 0}`);
645
+ const cost = totalCost > 0 ? ` (cost $${totalCost.toFixed(4)})` : "";
646
+ return `Token usage — ${parts.join(", ")}${cost}`;
647
+ }
648
+ /**
649
+ * Markdown-table version of the usage summary for the Actions step summary: one
650
+ * row per pass plus a total, and the prompt-cache hit rate (the share of prompt
651
+ * tokens served from cache instead of being reprocessed at full price).
652
+ */
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)} |`;
655
+ const lines = [
656
+ "### 🤖 AI review — token usage",
657
+ "",
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),
662
+ ];
663
+ const read = totals.cache?.read ?? 0;
664
+ const uncached = totals.input ?? 0;
665
+ if (read + uncached > 0) {
666
+ const rate = Math.round((read / (read + uncached)) * 100);
667
+ lines.push("", `Prompt cache hit rate: **${rate}%** (cache read / (cache read + input)). ` +
668
+ 'See "Tokens, cost & prompt caching" in the README for how to read these numbers.');
669
+ }
670
+ return lines.join("\n");
671
+ }
566
672
  async function safeLog(logPath, record) {
567
673
  try {
568
674
  await writeRunLog(logPath, record);
@@ -1,27 +1,27 @@
1
- import { promptAndParse } from './opencode.js';
2
- import { buildRouterSystem, buildRouterTask } from './prompts.js';
3
- import { parseRouteOutput } from './schema.js';
1
+ import { promptAndParse } from "./opencode.js";
2
+ import { buildRouterSystem, buildRouterTask } from "./prompts.js";
3
+ import { parseRouteOutput } from "./schema.js";
4
4
  /**
5
5
  * Ask the model which agents are relevant to the changed files. Agents marked
6
6
  * `alwaysRun` are unioned in regardless. Falls back to ALL agents if the router
7
7
  * returns nothing usable or errors — a review must never run with zero agents.
8
8
  */
9
9
  export async function routeAgents(handle, config, files) {
10
- const always = config.agents.filter(agent => agent.alwaysRun);
10
+ const always = config.agents.filter((agent) => agent.alwaysRun);
11
11
  try {
12
12
  const { value } = await promptAndParse(handle, {
13
- agent: 'coordinator',
13
+ agent: "coordinator",
14
14
  system: buildRouterSystem(),
15
15
  text: buildRouterTask(config.agents, files),
16
- title: 'route',
16
+ title: "route",
17
17
  }, parseRouteOutput);
18
- const byId = new Map(config.agents.map(agent => [agent.id, agent]));
18
+ const byId = new Map(config.agents.map((agent) => [agent.id, agent]));
19
19
  const picked = value.agents
20
- .map(id => byId.get(id))
20
+ .map((id) => byId.get(id))
21
21
  .filter((agent) => Boolean(agent));
22
- const chosenIds = new Set([...picked, ...always].map(agent => agent.id));
22
+ const chosenIds = new Set([...picked, ...always].map((agent) => agent.id));
23
23
  // Preserve config order and dedupe.
24
- const chosen = config.agents.filter(agent => chosenIds.has(agent.id));
24
+ const chosen = config.agents.filter((agent) => chosenIds.has(agent.id));
25
25
  if (chosen.length === 0) {
26
26
  return { agents: config.agents, routed: false };
27
27
  }
@@ -1,12 +1,12 @@
1
- import { createHash } from 'node:crypto';
2
- import { z } from 'zod';
3
- import { normalizeCode } from './util.js';
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { normalizeCode } from "./util.js";
4
4
  /** Severity levels, ordered most→least severe for sorting/rendering. */
5
- export const SEVERITIES = ['critical', 'warning', 'suggestion'];
5
+ export const SEVERITIES = ["critical", "warning", "suggestion"];
6
6
  /** Sort rank for severities (0 = most severe). Single source of truth. */
7
7
  export const SEVERITY_RANK = { critical: 0, warning: 1, suggestion: 2 };
8
- export const CATEGORIES = ['correctness', 'quality', 'security', 'secrets'];
9
- export const DECISIONS = ['approve', 'approve_with_comments', 'request_changes'];
8
+ export const CATEGORIES = ["correctness", "quality", "security", "secrets"];
9
+ export const DECISIONS = ["approve", "approve_with_comments", "request_changes"];
10
10
  export const FindingSchema = z.object({
11
11
  severity: z.enum(SEVERITIES),
12
12
  category: z.enum(CATEGORIES),
@@ -25,7 +25,7 @@ export const FindingSchema = z.object({
25
25
  /** A verifier's verdict on whether a finding is real (adversarial refute pass). */
26
26
  export const VerdictSchema = z.object({
27
27
  verified: z.boolean(),
28
- reason: z.string().default(''),
28
+ reason: z.string().default(""),
29
29
  });
30
30
  export function parseVerdict(text) {
31
31
  return VerdictSchema.parse(extractJsonObject(text));
@@ -60,10 +60,24 @@ const MIN_FP_EVIDENCE_LEN = 12;
60
60
  * there's too little evidence to key on.
61
61
  */
62
62
  export function fingerprintFinding(finding) {
63
- const evidence = normalizeCode(finding.evidence ?? '');
63
+ const evidence = normalizeCode(finding.evidence ?? "");
64
64
  const key = evidence.length >= MIN_FP_EVIDENCE_LEN ? evidence : normalizeCode(finding.title);
65
- const normalized = ['v2', finding.file, finding.category, key].join('|');
66
- return createHash('sha1').update(normalized).digest('hex').slice(0, 12);
65
+ const normalized = ["v2", finding.file, finding.category, key].join("|");
66
+ return createHash("sha1").update(normalized).digest("hex").slice(0, 12);
67
+ }
68
+ /**
69
+ * Namespace a finding's fingerprint by scope so cross-scope dismissals never
70
+ * collide. The DEFAULT scope (config '.') passes `null` and keeps the plain
71
+ * fingerprintFinding value, so pre-routing dismissal state carries over unchanged
72
+ * (risk 9). Non-default scopes hash into the same hex alphabet the dismiss command
73
+ * sanitizes to (dismiss.ts strips /[^a-f0-9]/), at the same length.
74
+ */
75
+ export function scopedFingerprint(scopeName, finding) {
76
+ const fp = fingerprintFinding(finding);
77
+ if (!scopeName) {
78
+ return fp;
79
+ }
80
+ return createHash("sha1").update(`scope|${scopeName}|${fp}`).digest("hex").slice(0, fp.length);
67
81
  }
68
82
  /**
69
83
  * Extract the JSON payload from an LLM response. Prefers the last fenced
@@ -76,8 +90,8 @@ export function extractJsonObject(text) {
76
90
  if (fenceMatches.length > 0) {
77
91
  candidates.push(fenceMatches[fenceMatches.length - 1][1].trim());
78
92
  }
79
- const firstBrace = text.indexOf('{');
80
- const lastBrace = text.lastIndexOf('}');
93
+ const firstBrace = text.indexOf("{");
94
+ const lastBrace = text.lastIndexOf("}");
81
95
  if (firstBrace !== -1 && lastBrace > firstBrace) {
82
96
  candidates.push(text.slice(firstBrace, lastBrace + 1));
83
97
  }