@expo/code-review-cli 0.3.0 → 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 (42) hide show
  1. package/README.md +183 -6
  2. package/build/cli.js +24 -17
  3. package/build/commands/ci.js +406 -43
  4. package/build/commands/dismiss.js +16 -16
  5. package/build/commands/doctor.js +173 -26
  6. package/build/commands/init.js +244 -34
  7. package/build/commands/review.js +118 -30
  8. package/build/commands/verify-config.js +214 -0
  9. package/build/config/load.js +154 -52
  10. package/build/config/routing.js +122 -0
  11. package/build/config/schema.js +116 -12
  12. package/build/core/auth.js +32 -29
  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 +44 -44
  19. package/build/core/prompts.js +157 -148
  20. package/build/core/render.js +202 -48
  21. package/build/core/review.js +147 -85
  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/coordinator.md +5 -3
  38. package/templates/dismiss.yml +110 -0
  39. package/templates/routing.jsonc +27 -0
  40. package/templates/scope-config.jsonc +25 -0
  41. package/templates/shared.md +12 -0
  42. package/templates/workflow.yml +50 -20
@@ -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) {
@@ -255,13 +282,15 @@ export async function runReview(source, options) {
255
282
  }
256
283
  // Account for the abandoned investigation's spend regardless of what's next.
257
284
  agentCosts[task.bucket] = (agentCosts[task.bucket] ?? 0) + error.cost;
258
- addTokenUsage(tokenTotals, error.tokens);
285
+ trackTokens(task.bucket, error.tokens);
259
286
  const remaining = passesDeadline - Date.now();
260
287
  // Cross-file analysis needs ≥2 files to be meaningful; a single-file
261
288
  // reviewer chunk can't be split further.
262
- const minFiles = task.kind === 'cross-cutting' ? 2 : 1;
289
+ const minFiles = task.kind === "cross-cutting" ? 2 : 1;
263
290
  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) {
291
+ if (task.files.length > minFiles &&
292
+ task.depth < MAX_SUBDIVIDE_DEPTH &&
293
+ remaining > childCap) {
265
294
  const mid = Math.ceil(task.files.length / 2);
266
295
  const left = task.files.slice(0, mid);
267
296
  const right = task.files.slice(mid);
@@ -273,9 +302,9 @@ export async function runReview(source, options) {
273
302
  }
274
303
  // Can't subdivide further: try a fast no-tools pass over the inlined diff
275
304
  // (reviewer only — cross-file analysis fundamentally needs to read files).
276
- if (task.kind === 'reviewer' && !task.fallback && remaining > FALLBACK_TIMEOUT_MS) {
305
+ if (task.kind === "reviewer" && !task.fallback && remaining > FALLBACK_TIMEOUT_MS) {
277
306
  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)', {
307
+ enqueue(childTask(task, task.files, "(no-tools fallback)", {
279
308
  fallback: true,
280
309
  maxWaitMs: FALLBACK_TIMEOUT_MS,
281
310
  maxToolCalls: 0,
@@ -288,7 +317,7 @@ export async function runReview(source, options) {
288
317
  // vs. the task was already at its smallest reviewable unit and still failed.
289
318
  failedPasses++;
290
319
  const couldStillReduce = (task.files.length > minFiles && task.depth < MAX_SUBDIVIDE_DEPTH) ||
291
- (task.kind === 'reviewer' && !task.fallback);
320
+ (task.kind === "reviewer" && !task.fallback);
292
321
  if (couldStillReduce) {
293
322
  progress(` ${task.label}: exceeded ${minutes}m and the run's time budget is spent — reporting a coverage gap`);
294
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.`);
@@ -306,27 +335,27 @@ export async function runReview(source, options) {
306
335
  let output;
307
336
  if (completedPasses === 0) {
308
337
  // Nothing succeeded — do NOT let this render as a clean "approve".
309
- progress('All review passes failed — reporting an incomplete review.');
338
+ progress("All review passes failed — reporting an incomplete review.");
310
339
  output = {
311
- decision: 'approve_with_comments',
340
+ decision: "approve_with_comments",
312
341
  findings: [],
313
- 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, " +
314
343
  'so these changes were effectively NOT reviewed. Treat this as "no review", not "looks good".',
315
344
  incomplete: coverageNotes,
316
345
  };
317
346
  }
318
347
  else {
319
- progress('Coordinating findings…');
348
+ progress("Coordinating findings…");
320
349
  let consolidated;
321
350
  try {
322
351
  const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes);
323
- agentCosts['coordinator'] = cost;
324
- addTokenUsage(tokenTotals, coordinatorTokens);
352
+ agentCosts["coordinator"] = cost;
353
+ trackTokens("coordinator", coordinatorTokens);
325
354
  consolidated = applyReviewPolicy(rawOutput, config.policy);
326
355
  if (coordinatorTruncated) {
327
356
  // The coordinator ran out of time and returned partial findings — flag it
328
357
  // 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.');
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.");
330
359
  }
331
360
  }
332
361
  catch (error) {
@@ -335,11 +364,11 @@ export async function runReview(source, options) {
335
364
  // merge so a comment is still posted.
336
365
  progress(`Coordinator failed (${errorMessage(error)}); consolidating findings locally.`);
337
366
  consolidated = fallbackConsolidation(agentFindings, config.policy);
338
- 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.");
339
368
  }
340
369
  // 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'
370
+ const decision = failedPasses > 0 && consolidated.decision === "approve"
371
+ ? "approve_with_comments"
343
372
  : consolidated.decision;
344
373
  output = { ...consolidated, decision, incomplete: [...new Set(coverageNotes)] };
345
374
  }
@@ -347,11 +376,13 @@ export async function runReview(source, options) {
347
376
  // finding against the real file, and adversarially verify criticals. This is
348
377
  // what stops a confident but wrong critical from shipping.
349
378
  const findingCountBeforeChecks = output.findings.length;
379
+ let verifierDropped = [];
350
380
  if (output.findings.length > 0) {
351
- progress('Verifying findings…');
381
+ progress("Verifying findings…");
352
382
  const verification = await verifyFindings(handle, output.findings, process.cwd(), progress);
353
- agentCosts['verifier'] = verification.cost;
354
- addTokenUsage(tokenTotals, verification.tokens);
383
+ agentCosts["verifier"] = verification.cost;
384
+ trackTokens("verifier", verification.tokens);
385
+ verifierDropped = verification.dropped;
355
386
  if (verification.dropped.length > 0) {
356
387
  progress(`Verification dropped ${verification.dropped.length} unverified finding(s).`);
357
388
  output = {
@@ -381,11 +412,16 @@ export async function runReview(source, options) {
381
412
  output = { ...output, summary: reconcileSummary(output.summary, output.findings.length) };
382
413
  }
383
414
  progress(formatUsageSummary(tokenTotals, sum(agentCosts)));
415
+ await appendStepSummary(renderUsageMarkdown(agentTokens, agentCosts, tokenTotals, sum(agentCosts)));
384
416
  await safeLog(logPath, {
385
417
  ...baseRecord,
386
418
  agentCosts,
387
419
  totalCost: sum(agentCosts),
388
420
  tokens: tokenTotals,
421
+ agentTokens,
422
+ agentFindings,
423
+ coverageNotes,
424
+ verifierDropped,
389
425
  durationMs: Date.now() - started,
390
426
  decision: output.decision,
391
427
  findingCount: output.findings.length,
@@ -399,6 +435,8 @@ export async function runReview(source, options) {
399
435
  agentCosts,
400
436
  totalCost: sum(agentCosts),
401
437
  tokens: tokenTotals,
438
+ agentTokens,
439
+ agentFindings,
402
440
  durationMs: Date.now() - started,
403
441
  decision: null,
404
442
  findingCount: 0,
@@ -420,13 +458,13 @@ export async function runReview(source, options) {
420
458
  export function applyReviewPolicy(output, policy) {
421
459
  let findings = policy.includeSuggestions
422
460
  ? output.findings
423
- : output.findings.filter(finding => finding.severity !== 'suggestion');
461
+ : output.findings.filter((finding) => finding.severity !== "suggestion");
424
462
  findings = sortFindings(findings);
425
463
  if (policy.maxFindings != null) {
426
464
  findings = findings.slice(0, policy.maxFindings);
427
465
  }
428
- const decision = output.decision === 'approve_with_comments' && findings.length === 0
429
- ? 'approve'
466
+ const decision = output.decision === "approve_with_comments" && findings.length === 0
467
+ ? "approve"
430
468
  : output.decision;
431
469
  return { ...output, findings, decision };
432
470
  }
@@ -448,16 +486,16 @@ function fallbackConsolidation(agentFindings, policy) {
448
486
  }
449
487
  }
450
488
  }
451
- const decision = merged.some(finding => finding.severity === 'critical')
452
- ? 'request_changes'
489
+ const decision = merged.some((finding) => finding.severity === "critical")
490
+ ? "request_changes"
453
491
  : merged.length > 0
454
- ? 'approve_with_comments'
455
- : 'approve';
492
+ ? "approve_with_comments"
493
+ : "approve";
456
494
  return applyReviewPolicy({
457
495
  decision,
458
496
  findings: merged,
459
- summary: 'Consolidation step failed; showing the specialist reviewers’ findings ' +
460
- '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.",
461
499
  incomplete: [],
462
500
  }, policy);
463
501
  }
@@ -468,10 +506,10 @@ function fallbackConsolidation(agentFindings, policy) {
468
506
  */
469
507
  export function decisionAfterVerification(previous, kept) {
470
508
  if (kept.length === 0) {
471
- return 'approve';
509
+ return "approve";
472
510
  }
473
- if (previous === 'request_changes' && !kept.some(finding => finding.severity === 'critical')) {
474
- return 'approve_with_comments';
511
+ if (previous === "request_changes" && !kept.some((finding) => finding.severity === "critical")) {
512
+ return "approve_with_comments";
475
513
  }
476
514
  return previous;
477
515
  }
@@ -484,10 +522,10 @@ export function decisionAfterVerification(previous, kept) {
484
522
  */
485
523
  export function reconcileSummary(summary, remaining) {
486
524
  if (remaining === 0) {
487
- 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.";
488
526
  }
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' +
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" +
491
529
  summary);
492
530
  }
493
531
  /** Capitalize the first letter (coverage notes read as sentences). */
@@ -514,19 +552,19 @@ export function isAuthError(error) {
514
552
  new RegExp(`${problem.source}\\b[^.]{0,20}${cred.source}`).test(message) ||
515
553
  new RegExp(`${cred.source}[^.]{0,20}${problem.source}`).test(message));
516
554
  }
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.';
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.";
520
558
  function selectAgents(all, filter) {
521
559
  if (!filter?.length) {
522
560
  return all;
523
561
  }
524
- const known = new Set(all.map(agent => agent.id));
525
- 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));
526
564
  if (unknown.length > 0) {
527
- 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(", ")}`);
528
566
  }
529
- return all.filter(agent => filter.includes(agent.id));
567
+ return all.filter((agent) => filter.includes(agent.id));
530
568
  }
531
569
  /**
532
570
  * Greedily pack files into chunks bounded by total changed lines (primary) and
@@ -604,8 +642,32 @@ export function formatUsageSummary(tokens, totalCost) {
604
642
  parts.push(`reasoning ${tokens.reasoning}`);
605
643
  }
606
644
  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}`;
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");
609
671
  }
610
672
  async function safeLog(logPath, record) {
611
673
  try {
@@ -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
  }
@@ -0,0 +1,18 @@
1
+ import { appendFile } from "node:fs/promises";
2
+ /**
3
+ * Append a markdown section to the GitHub Actions step summary, so a run's
4
+ * output survives on the workflow-run page after the PR comment is upserted
5
+ * away by the next run. No-op outside Actions (GITHUB_STEP_SUMMARY unset).
6
+ */
7
+ export async function appendStepSummary(markdown) {
8
+ const file = process.env.GITHUB_STEP_SUMMARY;
9
+ if (!file) {
10
+ return;
11
+ }
12
+ try {
13
+ await appendFile(file, `${markdown}\n\n`, "utf8");
14
+ }
15
+ catch {
16
+ // Observability must never break a review.
17
+ }
18
+ }