@expo/code-review-cli 0.1.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 (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -0
  3. package/build/cli.js +54 -0
  4. package/build/commands/ci.js +130 -0
  5. package/build/commands/dismiss.js +97 -0
  6. package/build/commands/doctor.js +81 -0
  7. package/build/commands/init.js +82 -0
  8. package/build/commands/review.js +191 -0
  9. package/build/config/load.js +205 -0
  10. package/build/config/schema.js +65 -0
  11. package/build/core/auth.js +102 -0
  12. package/build/core/coordinator.js +24 -0
  13. package/build/core/diff.js +86 -0
  14. package/build/core/exec.js +61 -0
  15. package/build/core/log.js +10 -0
  16. package/build/core/noise.js +186 -0
  17. package/build/core/opencode.js +412 -0
  18. package/build/core/prompts.js +288 -0
  19. package/build/core/render.js +153 -0
  20. package/build/core/review.js +550 -0
  21. package/build/core/router.js +33 -0
  22. package/build/core/schema.js +107 -0
  23. package/build/core/suppress.js +60 -0
  24. package/build/core/tools.js +16 -0
  25. package/build/core/util.js +11 -0
  26. package/build/core/verify.js +93 -0
  27. package/build/reporters/github.js +166 -0
  28. package/build/reporters/reporter.js +1 -0
  29. package/build/reporters/terminal.js +93 -0
  30. package/build/sources/github-pr.js +36 -0
  31. package/build/sources/local-git.js +107 -0
  32. package/build/sources/source.js +1 -0
  33. package/package.json +43 -0
  34. package/templates/agents/consistency.md +53 -0
  35. package/templates/agents/correctness.md +32 -0
  36. package/templates/agents/security.md +51 -0
  37. package/templates/config.jsonc +44 -0
  38. package/templates/coordinator.md +62 -0
  39. package/templates/shared.md +79 -0
  40. package/templates/workflow.yml +43 -0
@@ -0,0 +1,550 @@
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';
14
+ function makeRunId() {
15
+ return new Date().toISOString().replace(/[:.]/g, '-');
16
+ }
17
+ /**
18
+ * The invariant, mode-agnostic review core: filter → spawn each configured agent
19
+ * → coordinate → apply policy. Returns a CoordinatorOutput; the CLI commands are
20
+ * thin wrappers that supply a Source and render the result.
21
+ */
22
+ export async function runReview(source, options) {
23
+ const { config } = options;
24
+ const started = Date.now();
25
+ const runId = makeRunId();
26
+ const progress = options.onProgress ?? (() => { });
27
+ const runsRoot = path.join(config.configDir, '.runs');
28
+ const runDir = path.join(runsRoot, runId);
29
+ const logPath = path.join(runsRoot, 'reviews.jsonl');
30
+ // Fail fast on an invalid explicit selection before doing any work. Routing
31
+ // (if requested) is resolved later, once the server is up.
32
+ const explicitAgents = options.agents?.length
33
+ ? selectAgents(config.agents, options.agents)
34
+ : null;
35
+ const [metadata, changedFiles] = await Promise.all([
36
+ source.getMetadata(),
37
+ source.getChangedFiles(),
38
+ ]);
39
+ const { kept, filtered } = await filterNoise(changedFiles, {
40
+ additionalIgnores: config.noise.additionalIgnores,
41
+ additionalMarkers: config.noise.additionalMarkers,
42
+ });
43
+ progress(`${changedFiles.length} changed file(s); ${kept.length} to review, ${filtered.length} filtered.`);
44
+ const baseRecord = {
45
+ timestamp: new Date().toISOString(),
46
+ mode: options.mode,
47
+ runId,
48
+ metadata: { baseRef: metadata.baseRef, headRef: metadata.headRef },
49
+ reviewedFiles: kept.map(entry => entry.path),
50
+ filteredFiles: filtered,
51
+ };
52
+ if (kept.length === 0) {
53
+ const output = {
54
+ decision: 'approve',
55
+ findings: [],
56
+ summary: 'No reviewable changes after noise filtering.',
57
+ incomplete: [],
58
+ };
59
+ await safeLog(logPath, {
60
+ ...baseRecord,
61
+ agentCosts: {},
62
+ totalCost: 0,
63
+ durationMs: Date.now() - started,
64
+ decision: output.decision,
65
+ findingCount: 0,
66
+ summary: output.summary,
67
+ });
68
+ return output;
69
+ }
70
+ const auth = await prepareAuth(config);
71
+ progress('Starting OpenCode server…');
72
+ let handle = null;
73
+ try {
74
+ handle = await startOpencode(buildOpencodeConfig(config));
75
+ }
76
+ catch (error) {
77
+ await auth.cleanup();
78
+ throw new Error(`Failed to start the OpenCode server. Ensure the \`opencode\` CLI is installed and ` +
79
+ `model credentials are configured.\n${errorMessage(error)}`);
80
+ }
81
+ const agentCosts = {};
82
+ const tokenTotals = {};
83
+ try {
84
+ const workspace = await writePatchWorkspace(kept, metadata, runDir);
85
+ // Resolve which agents run: an explicit list wins; otherwise route (LLM picks
86
+ // relevant agents + always-run) when asked, else all.
87
+ let selectedAgents = explicitAgents ?? config.agents;
88
+ if (!explicitAgents && options.route) {
89
+ progress('Routing: selecting relevant agents…');
90
+ const routed = await routeAgents(handle, config, workspace.files);
91
+ selectedAgents = routed.agents;
92
+ progress(routed.routed
93
+ ? `Router selected: ${selectedAgents.map(a => a.id).join(', ')}`
94
+ : 'Router unavailable; running all agents.');
95
+ }
96
+ // Split the diff into focused chunks so each reviewer call sees a small file
97
+ // set (better recall than one giant blob), and run all agent×chunk calls
98
+ // concurrently up to a cap.
99
+ const chunks = chunkByLines(workspace.files, config.chunk.maxChangedLines, config.chunk.maxFiles);
100
+ // Only chunk (and add a cross-cutting pass) when the diff exceeds one chunk.
101
+ const chunked = chunks.length > 1;
102
+ progress(`Running ${selectedAgents.length} reviewer(s) [${selectedAgents.map(a => a.id).join(', ')}] over ${chunks.length} chunk(s)` +
103
+ `${chunked ? ' + cross-cutting pass' : ''} ` +
104
+ `(${kept.length} files, concurrency ${config.chunk.concurrency})…`);
105
+ const agentFindings = {};
106
+ for (const agent of selectedAgents) {
107
+ agentFindings[agent.id] = [];
108
+ agentCosts[agent.id] = 0;
109
+ }
110
+ // These caps must fit inside PASSES_BUDGET_MS (below), which in turn fits inside
111
+ // the CI job's timeout-minutes.
112
+ const CHUNK_TIMEOUT_MS = 15 * 60 * 1000;
113
+ const CROSS_CUTTING_TIMEOUT_MS = 25 * 60 * 1000;
114
+ // A subdivided sub-chunk is smaller, so it gets a shorter cap (halved per level,
115
+ // floored) — enough to converge without letting the recursion balloon.
116
+ const SUBDIVIDE_MIN_TIMEOUT_MS = 6 * 60 * 1000;
117
+ const MAX_SUBDIVIDE_DEPTH = 6;
118
+ // The no-tools fallback reviews an inlined diff with no exploration, so it's fast.
119
+ const FALLBACK_TIMEOUT_MS = 4 * 60 * 1000;
120
+ // Tool-call ceilings — generous for a legitimate pass, low enough to catch
121
+ // runaway roaming (the root cause of the non-convergent timeouts).
122
+ const CHUNK_MAX_TOOL_CALLS = 50;
123
+ const CROSS_CUTTING_MAX_TOOL_CALLS = 120;
124
+ // Global ceiling for ALL passes incl. subdivision/fallback waves, sized to
125
+ // leave room for the coordinator (10m) + verification + overhead inside the CI
126
+ // job timeout. Past this, a timed-out pass is reported as a gap rather than
127
+ // broken down further, so total wall-clock stays bounded.
128
+ const PASSES_BUDGET_MS = 32 * 60 * 1000;
129
+ const passesDeadline = started + PASSES_BUDGET_MS;
130
+ const tasks = [];
131
+ for (const agent of selectedAgents) {
132
+ const system = buildReviewerSystem(config, agent);
133
+ chunks.forEach((chunk, index) => {
134
+ tasks.push({
135
+ bucket: agent.id,
136
+ kind: 'reviewer',
137
+ system,
138
+ label: chunked ? `${agent.id} [${index + 1}/${chunks.length}]` : agent.id,
139
+ title: `review-${agent.id}-c${index}`,
140
+ files: chunk,
141
+ coverageLabel: `the ${agent.id} review${chunked ? ` (part ${index + 1} of ${chunks.length})` : ''}`,
142
+ maxWaitMs: CHUNK_TIMEOUT_MS,
143
+ maxToolCalls: CHUNK_MAX_TOOL_CALLS,
144
+ depth: 0,
145
+ fallback: false,
146
+ });
147
+ });
148
+ }
149
+ // On a large diff, ONE combined pass (not one per agent) looks for issues that
150
+ // span multiple changed files, covering every agent's concern at once.
151
+ if (chunked) {
152
+ tasks.push({
153
+ bucket: CROSS_CUTTING_AGENT,
154
+ kind: 'cross-cutting',
155
+ system: buildCrossCuttingSystem(config, selectedAgents),
156
+ label: 'cross-file',
157
+ title: 'review-xcut',
158
+ files: workspace.files,
159
+ coverageLabel: 'the cross-file review (issues spanning multiple changed files)',
160
+ maxWaitMs: CROSS_CUTTING_TIMEOUT_MS,
161
+ maxToolCalls: CROSS_CUTTING_MAX_TOOL_CALLS,
162
+ depth: 0,
163
+ fallback: false,
164
+ });
165
+ }
166
+ // Longest-processing-time-first: schedule the long cross-cutting/large chunks
167
+ // ahead of short ones so they don't dominate the tail of the makespan.
168
+ tasks.sort((a, b) => b.maxWaitMs - a.maxWaitMs);
169
+ // Build the task prompt on demand (so a subdivided task rebuilds over its
170
+ // smaller file set); a fallback task forbids tools and reviews the inlined diff.
171
+ const buildTaskText = (task) => {
172
+ const base = task.kind === 'cross-cutting'
173
+ ? buildCrossCuttingTask(task.files, filtered)
174
+ : buildReviewerTask(task.files, workspace.files, filtered);
175
+ return task.fallback ? `${base}\n\n${NO_TOOLS_INSTRUCTION}` : base;
176
+ };
177
+ const filesLabel = (files) => files.length === 1
178
+ ? `\`${files[0].path}\``
179
+ : `${files.length} files (e.g. \`${files[0].path}\`)`;
180
+ const humanBucket = (bucket) => bucket === CROSS_CUTTING_AGENT ? 'cross-file' : bucket;
181
+ const childTask = (parent, files, labelSuffix, overrides) => ({
182
+ ...parent,
183
+ files,
184
+ label: `${parent.label} ${labelSuffix}`,
185
+ coverageLabel: `the ${humanBucket(parent.bucket)} review of ${filesLabel(files)}`,
186
+ ...overrides,
187
+ });
188
+ // Coverage notes for passes that hit their time limit or failed, surfaced in
189
+ // the final review so a cut-short run is never presented as complete.
190
+ const incomplete = [];
191
+ let completedPasses = 0;
192
+ let failedPasses = 0;
193
+ // promptAndParse already retries internally (same-session corrective, then a
194
+ // bounded fresh session). We do NOT wrap it in another retry loop. On a genuine
195
+ // TIMEOUT, instead of dropping the work we break it into units that converge:
196
+ // subdivide the chunk, then a fast no-tools pass, and only report a coverage gap
197
+ // when even that can't finish inside the budget — so dropped work is never silent.
198
+ await runGrowableQueue(tasks, config.chunk.concurrency, async (task, enqueue) => {
199
+ const minutes = Math.round(task.maxWaitMs / 60000);
200
+ try {
201
+ const { value, cost, truncated, tokens } = await promptAndParse(handle, {
202
+ agent: task.bucket,
203
+ system: task.system,
204
+ text: buildTaskText(task),
205
+ title: task.title,
206
+ onActivity: line => progress(` ${task.label}: ${line}`),
207
+ maxWaitMs: task.maxWaitMs,
208
+ maxToolCalls: task.maxToolCalls,
209
+ finalizeOnTimeout: true,
210
+ }, parseReviewerOutput);
211
+ agentCosts[task.bucket] = (agentCosts[task.bucket] ?? 0) + cost;
212
+ addTokenUsage(tokenTotals, tokens);
213
+ (agentFindings[task.bucket] ??= []).push(...value.findings);
214
+ completedPasses++;
215
+ if (truncated) {
216
+ progress(` ${task.label}: hit its budget — returned partial findings`);
217
+ incomplete.push(`${capitalize(task.coverageLabel)} ran out of time; its findings may be incomplete.`);
218
+ }
219
+ return;
220
+ }
221
+ catch (error) {
222
+ // Non-timeout errors are genuine failures — record and move on.
223
+ if (!(error instanceof AgentTimeoutError)) {
224
+ failedPasses++;
225
+ progress(` ${task.label}: FAILED (${errorMessage(error)})`);
226
+ incomplete.push(`${capitalize(task.coverageLabel)} failed to run; those changes were not reviewed.`);
227
+ return;
228
+ }
229
+ // Account for the abandoned investigation's spend regardless of what's next.
230
+ agentCosts[task.bucket] = (agentCosts[task.bucket] ?? 0) + error.cost;
231
+ addTokenUsage(tokenTotals, error.tokens);
232
+ const remaining = passesDeadline - Date.now();
233
+ // Cross-file analysis needs ≥2 files to be meaningful; a single-file
234
+ // reviewer chunk can't be split further.
235
+ const minFiles = task.kind === 'cross-cutting' ? 2 : 1;
236
+ const childCap = Math.max(SUBDIVIDE_MIN_TIMEOUT_MS, Math.floor(task.maxWaitMs / 2));
237
+ if (task.files.length > minFiles && task.depth < MAX_SUBDIVIDE_DEPTH && remaining > childCap) {
238
+ const mid = Math.ceil(task.files.length / 2);
239
+ const left = task.files.slice(0, mid);
240
+ const right = task.files.slice(mid);
241
+ progress(` ${task.label}: exceeded ${minutes}m — splitting into 2 smaller passes (${left.length} + ${right.length} files)`);
242
+ const over = { depth: task.depth + 1, maxWaitMs: childCap };
243
+ enqueue(childTask(task, left, `↳${left.length}f`, over));
244
+ enqueue(childTask(task, right, `↳${right.length}f`, over));
245
+ return;
246
+ }
247
+ // Can't subdivide further: try a fast no-tools pass over the inlined diff
248
+ // (reviewer only — cross-file analysis fundamentally needs to read files).
249
+ if (task.kind === 'reviewer' && !task.fallback && remaining > FALLBACK_TIMEOUT_MS) {
250
+ progress(` ${task.label}: exceeded ${minutes}m — retrying ${filesLabel(task.files)} with a fast no-tools pass`);
251
+ enqueue(childTask(task, task.files, '(no-tools fallback)', {
252
+ fallback: true,
253
+ maxWaitMs: FALLBACK_TIMEOUT_MS,
254
+ maxToolCalls: 0,
255
+ }));
256
+ return;
257
+ }
258
+ // Genuine, reported gap — the only way work is ever left unreviewed, and
259
+ // never silent. Distinguish WHY so the note doesn't overstate what happened:
260
+ // we could still have split/fallen back, but the global budget ran out first,
261
+ // vs. the task was already at its smallest reviewable unit and still failed.
262
+ failedPasses++;
263
+ const couldStillReduce = (task.files.length > minFiles && task.depth < MAX_SUBDIVIDE_DEPTH) ||
264
+ (task.kind === 'reviewer' && !task.fallback);
265
+ if (couldStillReduce) {
266
+ progress(` ${task.label}: exceeded ${minutes}m and the run's time budget is spent — reporting a coverage gap`);
267
+ 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.`);
268
+ }
269
+ else {
270
+ progress(` ${task.label}: exceeded ${minutes}m even at its smallest reviewable unit — reporting a coverage gap`);
271
+ incomplete.push(`${capitalize(task.coverageLabel)} exceeded its time budget even after being reduced to its smallest reviewable unit; those changes were not fully reviewed.`);
272
+ }
273
+ }
274
+ });
275
+ // Note: routine noise filtering (lockfiles, generated, binary) is expected and
276
+ // NOT a coverage gap — it stays in the run log (filteredFiles), not the
277
+ // user-facing coverage note, which is reserved for passes that didn't finish.
278
+ const coverageNotes = [...new Set(incomplete)];
279
+ let output;
280
+ if (completedPasses === 0) {
281
+ // Nothing succeeded — do NOT let this render as a clean "approve".
282
+ progress('All review passes failed — reporting an incomplete review.');
283
+ output = {
284
+ decision: 'approve_with_comments',
285
+ findings: [],
286
+ summary: '⚠️ The AI review could not complete: every review pass failed or timed out, ' +
287
+ 'so these changes were effectively NOT reviewed. Treat this as "no review", not "looks good".',
288
+ incomplete: coverageNotes,
289
+ };
290
+ }
291
+ else {
292
+ progress('Coordinating findings…');
293
+ let consolidated;
294
+ try {
295
+ const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes);
296
+ agentCosts['coordinator'] = cost;
297
+ addTokenUsage(tokenTotals, coordinatorTokens);
298
+ consolidated = applyReviewPolicy(rawOutput, config.policy);
299
+ if (coordinatorTruncated) {
300
+ // The coordinator ran out of time and returned partial findings — flag it
301
+ // like any other truncated pass so reduced coverage is never silent.
302
+ coverageNotes.push('The consolidation step ran out of time and returned partial findings; some findings may have been dropped or not fully de-duplicated.');
303
+ }
304
+ }
305
+ catch (error) {
306
+ // The coordinator is the last step; if it fails we must not throw away all
307
+ // the findings the agents already produced. Fall back to a deterministic
308
+ // merge so a comment is still posted.
309
+ progress(`Coordinator failed (${errorMessage(error)}); consolidating findings locally.`);
310
+ consolidated = fallbackConsolidation(agentFindings, config.policy);
311
+ coverageNotes.push('The consolidation step failed, so findings are shown merged but not de-duplicated or re-judged.');
312
+ }
313
+ // A run with any failed/timed-out pass must never present as a clean approve.
314
+ const decision = failedPasses > 0 && consolidated.decision === 'approve'
315
+ ? 'approve_with_comments'
316
+ : consolidated.decision;
317
+ output = { ...consolidated, decision, incomplete: [...new Set(coverageNotes)] };
318
+ }
319
+ // Guard against hallucinated findings before surfacing: quote-ground every
320
+ // finding against the real file, and adversarially verify criticals. This is
321
+ // what stops a confident but wrong critical from shipping.
322
+ const findingCountBeforeChecks = output.findings.length;
323
+ if (output.findings.length > 0) {
324
+ progress('Verifying findings…');
325
+ const verification = await verifyFindings(handle, output.findings, process.cwd(), progress);
326
+ agentCosts['verifier'] = verification.cost;
327
+ addTokenUsage(tokenTotals, verification.tokens);
328
+ if (verification.dropped.length > 0) {
329
+ progress(`Verification dropped ${verification.dropped.length} unverified finding(s).`);
330
+ output = {
331
+ ...output,
332
+ findings: verification.kept,
333
+ decision: decisionAfterVerification(output.decision, verification.kept),
334
+ };
335
+ }
336
+ }
337
+ // Inline `expo-code-review-ignore` directives suppress non-critical findings.
338
+ if (output.findings.length > 0) {
339
+ const { kept, suppressed } = await applyInlineIgnores(output.findings, process.cwd(), progress);
340
+ if (suppressed.length > 0) {
341
+ progress(`Suppressed ${suppressed.length} finding(s) via inline directives.`);
342
+ output = {
343
+ ...output,
344
+ findings: kept,
345
+ decision: decisionAfterVerification(output.decision, kept),
346
+ };
347
+ }
348
+ }
349
+ // The coordinator's summary was written against the pre-check finding set, so if
350
+ // verification/suppression removed anything it can now reference issues that are
351
+ // no longer listed. Reconcile the summary so it never contradicts the findings.
352
+ const removedAfterChecks = findingCountBeforeChecks - output.findings.length;
353
+ if (removedAfterChecks > 0) {
354
+ output = { ...output, summary: reconcileSummary(output.summary, output.findings.length) };
355
+ }
356
+ await safeLog(logPath, {
357
+ ...baseRecord,
358
+ agentCosts,
359
+ totalCost: sum(agentCosts),
360
+ tokens: tokenTotals,
361
+ durationMs: Date.now() - started,
362
+ decision: output.decision,
363
+ findingCount: output.findings.length,
364
+ summary: output.summary,
365
+ });
366
+ return output;
367
+ }
368
+ catch (error) {
369
+ await safeLog(logPath, {
370
+ ...baseRecord,
371
+ agentCosts,
372
+ totalCost: sum(agentCosts),
373
+ tokens: tokenTotals,
374
+ durationMs: Date.now() - started,
375
+ decision: null,
376
+ findingCount: 0,
377
+ summary: null,
378
+ error: errorMessage(error),
379
+ });
380
+ throw error;
381
+ }
382
+ finally {
383
+ handle?.close();
384
+ await auth.cleanup();
385
+ }
386
+ }
387
+ /**
388
+ * Policy backstop: drop suggestions unless opted in, cap by count (most severe
389
+ * first), and downgrade approve_with_comments to approve when nothing remains.
390
+ */
391
+ export function applyReviewPolicy(output, policy) {
392
+ let findings = policy.includeSuggestions
393
+ ? output.findings
394
+ : output.findings.filter(finding => finding.severity !== 'suggestion');
395
+ findings = sortFindings(findings);
396
+ if (policy.maxFindings != null) {
397
+ findings = findings.slice(0, policy.maxFindings);
398
+ }
399
+ const decision = output.decision === 'approve_with_comments' && findings.length === 0
400
+ ? 'approve'
401
+ : output.decision;
402
+ return { ...output, findings, decision };
403
+ }
404
+ /**
405
+ * Deterministic consolidation used when the coordinator step itself fails, so a
406
+ * coordinator hiccup never discards the findings the agents already produced.
407
+ * Merges + de-dupes (by fingerprint), applies the same policy, and picks a
408
+ * conservative decision (never a clean approve when there are findings).
409
+ */
410
+ function fallbackConsolidation(agentFindings, policy) {
411
+ const seen = new Set();
412
+ const merged = [];
413
+ for (const findings of Object.values(agentFindings)) {
414
+ for (const finding of findings) {
415
+ const key = fingerprintFinding(finding);
416
+ if (!seen.has(key)) {
417
+ seen.add(key);
418
+ merged.push(finding);
419
+ }
420
+ }
421
+ }
422
+ const decision = merged.some(finding => finding.severity === 'critical')
423
+ ? 'request_changes'
424
+ : merged.length > 0
425
+ ? 'approve_with_comments'
426
+ : 'approve';
427
+ return applyReviewPolicy({
428
+ decision,
429
+ findings: merged,
430
+ summary: 'Consolidation step failed; showing the specialist reviewers’ findings ' +
431
+ 'merged and de-duplicated, but not re-judged.',
432
+ incomplete: [],
433
+ }, policy);
434
+ }
435
+ /**
436
+ * Re-derive the decision after verification dropped findings: nothing left → approve;
437
+ * a `request_changes` with no criticals remaining → soften to approve_with_comments;
438
+ * otherwise keep the coordinator's decision.
439
+ */
440
+ export function decisionAfterVerification(previous, kept) {
441
+ if (kept.length === 0) {
442
+ return 'approve';
443
+ }
444
+ if (previous === 'request_changes' && !kept.some(finding => finding.severity === 'critical')) {
445
+ return 'approve_with_comments';
446
+ }
447
+ return previous;
448
+ }
449
+ /**
450
+ * The coordinator writes its summary before findings are verified/suppressed, so a
451
+ * post-coordination drop can leave the summary referencing issues no longer shown.
452
+ * Reconcile without a second LLM call: if everything was removed, replace it;
453
+ * otherwise prepend a short honest caveat so the prose can't be read as
454
+ * contradicting the (accurate) findings list below it.
455
+ */
456
+ export function reconcileSummary(summary, remaining) {
457
+ if (remaining === 0) {
458
+ return 'All candidate findings were removed by automated verification and suppression, so no issues remain to report.';
459
+ }
460
+ return ('_Note: some findings were removed by automated verification/suppression after ' +
461
+ 'this summary was written, so it may mention issues no longer listed below._\n\n' +
462
+ summary);
463
+ }
464
+ /** Capitalize the first letter (coverage notes read as sentences). */
465
+ function capitalize(text) {
466
+ return text.length > 0 ? text[0].toUpperCase() + text.slice(1) : text;
467
+ }
468
+ function selectAgents(all, filter) {
469
+ if (!filter?.length) {
470
+ return all;
471
+ }
472
+ const known = new Set(all.map(agent => agent.id));
473
+ const unknown = filter.filter(id => !known.has(id));
474
+ if (unknown.length > 0) {
475
+ throw new Error(`Unknown agent(s): ${unknown.join(', ')}. Available: ${all.map(a => a.id).join(', ')}`);
476
+ }
477
+ return all.filter(agent => filter.includes(agent.id));
478
+ }
479
+ /**
480
+ * Greedily pack files into chunks bounded by total changed lines (primary) and
481
+ * file count (secondary guard). A single file larger than maxChangedLines becomes
482
+ * its own chunk (a file is never split).
483
+ */
484
+ export function chunkByLines(files, maxChangedLines, maxFiles) {
485
+ const chunks = [];
486
+ let current = [];
487
+ let lines = 0;
488
+ for (const file of files) {
489
+ const wouldOverflow = lines + file.changedLines > maxChangedLines;
490
+ if (current.length > 0 && (wouldOverflow || current.length >= maxFiles)) {
491
+ chunks.push(current);
492
+ current = [];
493
+ lines = 0;
494
+ }
495
+ current.push(file);
496
+ lines += file.changedLines;
497
+ }
498
+ if (current.length > 0) {
499
+ chunks.push(current);
500
+ }
501
+ return chunks;
502
+ }
503
+ const QUEUE_IDLE_POLL_MS = 100;
504
+ /**
505
+ * Run tasks with at most `limit` in flight, from a queue that workers may GROW
506
+ * while running: a timed-out chunk enqueues smaller sub-tasks, which free workers
507
+ * then pick up. Workers stay alive until the queue is empty AND no worker is still
508
+ * running (a running worker might yet enqueue more), so dynamically-added work is
509
+ * never lost. `fn` receives the item and an `enqueue` callback.
510
+ */
511
+ export async function runGrowableQueue(initial, limit, fn) {
512
+ const queue = [...initial];
513
+ let active = 0;
514
+ const enqueue = (next) => {
515
+ queue.push(next);
516
+ };
517
+ const worker = async () => {
518
+ for (;;) {
519
+ const item = queue.shift();
520
+ if (item === undefined) {
521
+ // Nothing queued: done only once no other worker is still running (which
522
+ // could enqueue more); otherwise wait briefly and re-check.
523
+ if (active === 0) {
524
+ return;
525
+ }
526
+ await sleep(QUEUE_IDLE_POLL_MS);
527
+ continue;
528
+ }
529
+ active++;
530
+ try {
531
+ await fn(item, enqueue);
532
+ }
533
+ finally {
534
+ active--;
535
+ }
536
+ }
537
+ };
538
+ await Promise.all(Array.from({ length: Math.max(1, limit) }, () => worker()));
539
+ }
540
+ function sum(costs) {
541
+ return Object.values(costs).reduce((total, value) => total + value, 0);
542
+ }
543
+ async function safeLog(logPath, record) {
544
+ try {
545
+ await writeRunLog(logPath, record);
546
+ }
547
+ catch {
548
+ // Logging must never break a review.
549
+ }
550
+ }
@@ -0,0 +1,33 @@
1
+ import { promptAndParse } from './opencode.js';
2
+ import { buildRouterSystem, buildRouterTask } from './prompts.js';
3
+ import { parseRouteOutput } from './schema.js';
4
+ /**
5
+ * Ask the model which agents are relevant to the changed files. Agents marked
6
+ * `alwaysRun` are unioned in regardless. Falls back to ALL agents if the router
7
+ * returns nothing usable or errors — a review must never run with zero agents.
8
+ */
9
+ export async function routeAgents(handle, config, files) {
10
+ const always = config.agents.filter(agent => agent.alwaysRun);
11
+ try {
12
+ const { value } = await promptAndParse(handle, {
13
+ agent: 'coordinator',
14
+ system: buildRouterSystem(),
15
+ text: buildRouterTask(config.agents, files),
16
+ title: 'route',
17
+ }, parseRouteOutput);
18
+ const byId = new Map(config.agents.map(agent => [agent.id, agent]));
19
+ const picked = value.agents
20
+ .map(id => byId.get(id))
21
+ .filter((agent) => Boolean(agent));
22
+ const chosenIds = new Set([...picked, ...always].map(agent => agent.id));
23
+ // Preserve config order and dedupe.
24
+ const chosen = config.agents.filter(agent => chosenIds.has(agent.id));
25
+ if (chosen.length === 0) {
26
+ return { agents: config.agents, routed: false };
27
+ }
28
+ return { agents: chosen, routed: true };
29
+ }
30
+ catch {
31
+ return { agents: config.agents, routed: false };
32
+ }
33
+ }