@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.
- package/README.md +183 -6
- package/build/cli.js +24 -17
- package/build/commands/ci.js +406 -43
- package/build/commands/dismiss.js +16 -16
- package/build/commands/doctor.js +173 -26
- package/build/commands/init.js +244 -34
- package/build/commands/review.js +118 -30
- package/build/commands/verify-config.js +214 -0
- package/build/config/load.js +154 -52
- package/build/config/routing.js +122 -0
- package/build/config/schema.js +116 -12
- package/build/core/auth.js +32 -29
- package/build/core/coordinator.js +5 -5
- package/build/core/diff.js +19 -19
- package/build/core/exec.js +10 -10
- package/build/core/log.js +3 -3
- package/build/core/noise.js +52 -52
- package/build/core/opencode.js +44 -44
- package/build/core/prompts.js +157 -148
- package/build/core/render.js +202 -48
- package/build/core/review.js +147 -85
- package/build/core/router.js +10 -10
- package/build/core/schema.js +26 -12
- package/build/core/step-summary.js +18 -0
- package/build/core/suppress.js +7 -7
- package/build/core/tools.js +9 -9
- package/build/core/util.js +2 -2
- package/build/core/verify.js +25 -25
- package/build/reporters/github.js +103 -51
- package/build/reporters/terminal.js +19 -19
- package/build/sources/github-pr.js +21 -21
- package/build/sources/local-git.js +20 -20
- package/build/sources/source.js +35 -1
- package/package.json +6 -1
- package/templates/agents/security.md +5 -0
- package/templates/command.yml +164 -0
- package/templates/coordinator.md +5 -3
- package/templates/dismiss.yml +110 -0
- package/templates/routing.jsonc +27 -0
- package/templates/scope-config.jsonc +25 -0
- package/templates/shared.md +12 -0
- package/templates/workflow.yml +50 -20
package/build/core/review.js
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
|
-
import path from
|
|
2
|
-
import { prepareAuth } from
|
|
3
|
-
import { coordinate } from
|
|
4
|
-
import { writeRunLog } from
|
|
5
|
-
import { filterNoise, writePatchWorkspace } from
|
|
6
|
-
import { addTokenUsage, AgentTimeoutError, buildOpencodeConfig, CROSS_CUTTING_AGENT, promptAndParse, startOpencode, } from
|
|
7
|
-
import { routeAgents } from
|
|
8
|
-
import { buildCrossCuttingSystem, buildCrossCuttingTask, buildReviewerSystem, buildReviewerTask, NO_TOOLS_INSTRUCTION, } from
|
|
9
|
-
import { fingerprintFinding, parseReviewerOutput } from
|
|
10
|
-
import { sortFindings } from
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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,
|
|
40
|
+
const runsRoot = path.join(config.configDir, ".runs");
|
|
28
41
|
const runDir = path.join(runsRoot, runId);
|
|
29
|
-
const logPath = path.join(runsRoot,
|
|
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
|
-
|
|
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(`${
|
|
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:
|
|
70
|
+
decision: "approve",
|
|
55
71
|
findings: [],
|
|
56
|
-
summary:
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
:
|
|
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(
|
|
125
|
-
`${chunked ?
|
|
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
|
|
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:
|
|
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:
|
|
177
|
-
system: buildCrossCuttingSystem(config
|
|
178
|
-
label:
|
|
179
|
-
title:
|
|
203
|
+
kind: "cross-cutting",
|
|
204
|
+
system: buildCrossCuttingSystem(config),
|
|
205
|
+
label: "cross-file",
|
|
206
|
+
title: "review-xcut",
|
|
180
207
|
files: workspace.files,
|
|
181
|
-
coverageLabel:
|
|
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 ===
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
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 &&
|
|
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 ===
|
|
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,
|
|
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 ===
|
|
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(
|
|
338
|
+
progress("All review passes failed — reporting an incomplete review.");
|
|
310
339
|
output = {
|
|
311
|
-
decision:
|
|
340
|
+
decision: "approve_with_comments",
|
|
312
341
|
findings: [],
|
|
313
|
-
summary:
|
|
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(
|
|
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[
|
|
324
|
-
|
|
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(
|
|
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(
|
|
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 ===
|
|
342
|
-
?
|
|
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(
|
|
381
|
+
progress("Verifying findings…");
|
|
352
382
|
const verification = await verifyFindings(handle, output.findings, process.cwd(), progress);
|
|
353
|
-
agentCosts[
|
|
354
|
-
|
|
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 !==
|
|
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 ===
|
|
429
|
-
?
|
|
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 ===
|
|
452
|
-
?
|
|
489
|
+
const decision = merged.some((finding) => finding.severity === "critical")
|
|
490
|
+
? "request_changes"
|
|
453
491
|
: merged.length > 0
|
|
454
|
-
?
|
|
455
|
-
:
|
|
492
|
+
? "approve_with_comments"
|
|
493
|
+
: "approve";
|
|
456
494
|
return applyReviewPolicy({
|
|
457
495
|
decision,
|
|
458
496
|
findings: merged,
|
|
459
|
-
summary:
|
|
460
|
-
|
|
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
|
|
509
|
+
return "approve";
|
|
472
510
|
}
|
|
473
|
-
if (previous ===
|
|
474
|
-
return
|
|
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
|
|
525
|
+
return "All candidate findings were removed by automated verification and suppression, so no issues remain to report.";
|
|
488
526
|
}
|
|
489
|
-
return (
|
|
490
|
-
|
|
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 =
|
|
518
|
-
|
|
519
|
-
|
|
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(
|
|
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(
|
|
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 {
|
package/build/core/router.js
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
import { promptAndParse } from
|
|
2
|
-
import { buildRouterSystem, buildRouterTask } from
|
|
3
|
-
import { parseRouteOutput } from
|
|
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:
|
|
13
|
+
agent: "coordinator",
|
|
14
14
|
system: buildRouterSystem(),
|
|
15
15
|
text: buildRouterTask(config.agents, files),
|
|
16
|
-
title:
|
|
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
|
}
|
package/build/core/schema.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { createHash } from
|
|
2
|
-
import { z } from
|
|
3
|
-
import { normalizeCode } from
|
|
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 = [
|
|
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 = [
|
|
9
|
-
export const DECISIONS = [
|
|
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 = [
|
|
66
|
-
return createHash(
|
|
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
|
+
}
|