@expo/code-review-cli 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -50
- package/build/commands/ci.js +10 -6
- package/build/commands/doctor.js +57 -11
- package/build/commands/verify-config.js +65 -27
- package/build/config/load.js +50 -7
- package/build/config/schema.js +46 -16
- package/build/core/auth.js +209 -50
- package/build/core/coordinator.js +2 -2
- package/build/core/opencode.js +453 -53
- package/build/core/prompts.js +68 -7
- package/build/core/review.js +145 -32
- package/build/core/verify.js +4 -2
- package/package.json +3 -3
- package/templates/agents/security.md +4 -4
- package/templates/command.yml +11 -8
- package/templates/config.jsonc +26 -13
- package/templates/coordinator.md +3 -3
- package/templates/routing.jsonc +1 -1
- package/templates/workflow.yml +13 -8
package/build/core/prompts.js
CHANGED
|
@@ -146,13 +146,72 @@ export function buildReviewerTask(files, allFiles, filtered = []) {
|
|
|
146
146
|
* large diff. It sees the whole change set and reports ONLY issues that span
|
|
147
147
|
* multiple changed files, which per-chunk reviews can't see.
|
|
148
148
|
*/
|
|
149
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Total changed lines the cross-file task will inline before it stops and lists the
|
|
151
|
+
* rest as patch paths to read on demand. Sized to stay well inside the model's
|
|
152
|
+
* context on a normal PR while still covering the overwhelming majority of them; a
|
|
153
|
+
* genuinely huge diff degrades to the old read-on-demand behavior for its tail
|
|
154
|
+
* rather than overflowing.
|
|
155
|
+
*/
|
|
156
|
+
export const CROSS_CUTTING_INLINE_MAX_LINES = 6000;
|
|
157
|
+
/**
|
|
158
|
+
* Split the changed files into the ones whose diffs are inlined into the cross-file
|
|
159
|
+
* task and the ones left for on-demand reads. Always inlines at least the first file
|
|
160
|
+
* so a single enormous file can't produce an all-deferred prompt. Exported for tests.
|
|
161
|
+
*/
|
|
162
|
+
export function splitCrossCuttingInline(allFiles, maxLines = CROSS_CUTTING_INLINE_MAX_LINES) {
|
|
163
|
+
const inlined = [];
|
|
164
|
+
const deferred = [];
|
|
165
|
+
let lines = 0;
|
|
166
|
+
for (const file of allFiles) {
|
|
167
|
+
if (inlined.length > 0 && lines + file.changedLines > maxLines) {
|
|
168
|
+
deferred.push(file);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
inlined.push(file);
|
|
172
|
+
lines += file.changedLines;
|
|
173
|
+
}
|
|
174
|
+
return { inlined, deferred };
|
|
175
|
+
}
|
|
176
|
+
export function buildCrossCuttingTask(allFiles, agents, filtered = [],
|
|
177
|
+
/** Set for the no-tools fallback pass, which cannot open anything it isn't shown. */
|
|
178
|
+
opts = {}) {
|
|
150
179
|
const lenses = agents
|
|
151
180
|
.map((agent) => `- ${agent.id}: ${agent.description || agent.id}`)
|
|
152
181
|
.join("\n");
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
182
|
+
// Inline the diffs instead of only naming their patch files. Reading them back was
|
|
183
|
+
// one tool round-trip per changed file BEFORE any tracing could start (13 reads and
|
|
184
|
+
// several minutes on a 14-file PR), spent on content we already have in memory.
|
|
185
|
+
const { inlined, deferred } = splitCrossCuttingInline(allFiles);
|
|
186
|
+
const inlinedDiffs = inlined.map(inlineDiff).join("\n\n");
|
|
187
|
+
// On a diff too large to inline whole, the tail is named either way — a file this
|
|
188
|
+
// pass can't see must never look unchanged. What differs is the instruction: a
|
|
189
|
+
// no-tools pass told to "read their patch files" would be told to do the one thing
|
|
190
|
+
// it can't, so it gets the same "you cannot see these, don't fault them" framing
|
|
191
|
+
// the noise-filtered files get.
|
|
192
|
+
const deferredSection = deferred.length === 0
|
|
193
|
+
? []
|
|
194
|
+
: opts.noTools
|
|
195
|
+
? [
|
|
196
|
+
"",
|
|
197
|
+
"This PR changed these files too, but their diffs are NOT shown to you (the",
|
|
198
|
+
"diff is large) and you cannot open them on this pass:",
|
|
199
|
+
deferred
|
|
200
|
+
.map((file) => `- \`${sanitizeUntrusted(file.path)}\` (${file.status ?? "M"})`)
|
|
201
|
+
.join("\n"),
|
|
202
|
+
"",
|
|
203
|
+
"They WERE changed by this PR. Do NOT report that any of them was not updated,",
|
|
204
|
+
"and do not claim an interaction you cannot see in the diffs above.",
|
|
205
|
+
]
|
|
206
|
+
: [
|
|
207
|
+
"",
|
|
208
|
+
"This PR changed these files too, but their diffs are NOT inlined above (the",
|
|
209
|
+
"diff is large). Read their patch files on demand if a cross-file interaction",
|
|
210
|
+
"points at them:",
|
|
211
|
+
deferred
|
|
212
|
+
.map((file) => `- \`${sanitizeUntrusted(file.path)}\` (${file.status ?? "M"}) — patch: \`${file.patchPath}\``)
|
|
213
|
+
.join("\n"),
|
|
214
|
+
];
|
|
156
215
|
return [
|
|
157
216
|
"This PR changed the files below, and each was already reviewed on its own by",
|
|
158
217
|
"specialist reviewers covering these concerns:",
|
|
@@ -166,7 +225,7 @@ export function buildCrossCuttingTask(allFiles, agents, filtered = []) {
|
|
|
166
225
|
"single-file issues.",
|
|
167
226
|
"",
|
|
168
227
|
"Stay focused and efficient — you are on a time budget:",
|
|
169
|
-
"- Work from the
|
|
228
|
+
"- Work from the diffs of the CHANGED files below; that is your scope.",
|
|
170
229
|
"- Read additional source ONLY when directly needed to confirm a specific",
|
|
171
230
|
" cross-file interaction (e.g. open the caller a changed signature affects).",
|
|
172
231
|
"- Do NOT audit unrelated parts of the repository or read files with no",
|
|
@@ -174,8 +233,10 @@ export function buildCrossCuttingTask(allFiles, agents, filtered = []) {
|
|
|
174
233
|
"- As soon as you have traced the cross-file interactions, return your answer;",
|
|
175
234
|
" do not keep exploring for completeness.",
|
|
176
235
|
"",
|
|
177
|
-
"Changed files:",
|
|
178
|
-
|
|
236
|
+
"Changed files (diffs inlined — you do not need to read these back):",
|
|
237
|
+
"",
|
|
238
|
+
inlinedDiffs,
|
|
239
|
+
...deferredSection,
|
|
179
240
|
...filteredSection(filtered),
|
|
180
241
|
"",
|
|
181
242
|
"Return the single JSON object described in your instructions and nothing else.",
|
package/build/core/review.js
CHANGED
|
@@ -3,7 +3,7 @@ import { prepareAuth } from "./auth.js";
|
|
|
3
3
|
import { coordinate } from "./coordinator.js";
|
|
4
4
|
import { writeRunLog } from "./log.js";
|
|
5
5
|
import { filterNoise, writePatchWorkspace } from "./noise.js";
|
|
6
|
-
import { addTokenUsage, AgentTimeoutError, buildOpencodeConfig, CROSS_CUTTING_AGENT, promptAndParse, startOpencode, } from "./opencode.js";
|
|
6
|
+
import { addTokenUsage, AgentTimeoutError, assertModelsResolvable, buildOpencodeConfig, CROSS_CUTTING_AGENT, promptAndParse, startOpencode, } from "./opencode.js";
|
|
7
7
|
import { routeAgents } from "./router.js";
|
|
8
8
|
import { buildCrossCuttingSystem, buildCrossCuttingTask, buildReviewerSystem, buildReviewerTask, NO_TOOLS_INSTRUCTION, } from "./prompts.js";
|
|
9
9
|
import { fingerprintFinding, parseReviewerOutput } from "./schema.js";
|
|
@@ -114,7 +114,19 @@ export async function runReview(source, options) {
|
|
|
114
114
|
await auth.cleanup();
|
|
115
115
|
await restoreCwd();
|
|
116
116
|
throw new Error(`Failed to start the OpenCode server. Ensure the \`opencode\` CLI is installed and ` +
|
|
117
|
-
`model credentials are configured.\n${errorMessage(error)}`);
|
|
117
|
+
`model credentials are configured (\`ecr doctor\` checks both).\n${errorMessage(error)}`);
|
|
118
|
+
}
|
|
119
|
+
// Preflight: a model id the server can't resolve would otherwise fail EVERY pass
|
|
120
|
+
// identically — N indistinguishable coverage gaps, after spending the run's budget
|
|
121
|
+
// discovering the same fixable thing N times. Throw once, up front, naming the fix.
|
|
122
|
+
try {
|
|
123
|
+
await assertModelsResolvable(handle, [...config.agents.map((agent) => agent.model), config.coordinator.model], config.auth);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
handle.close();
|
|
127
|
+
await auth.cleanup();
|
|
128
|
+
await restoreCwd();
|
|
129
|
+
throw error;
|
|
118
130
|
}
|
|
119
131
|
const agentCosts = {};
|
|
120
132
|
const tokenTotals = {};
|
|
@@ -129,6 +141,23 @@ export async function runReview(source, options) {
|
|
|
129
141
|
addTokenUsage(tokenTotals, tokens);
|
|
130
142
|
addTokenUsage((agentTokens[bucket] ??= {}), tokens);
|
|
131
143
|
};
|
|
144
|
+
// The provider/model that ACTUALLY answered each pass, and any pass whose model was
|
|
145
|
+
// silently substituted for the configured one. OpenCode does that substitution
|
|
146
|
+
// quietly whenever an agent's model id is empty or unusable, so a run can review with
|
|
147
|
+
// a different model than config.jsonc names and look completely normal — which is
|
|
148
|
+
// exactly what happened for weeks behind an empty REVIEWER_MODEL. Recorded in the run
|
|
149
|
+
// log, reported in the log line, and surfaced as a coverage note when it happens.
|
|
150
|
+
const agentModels = {};
|
|
151
|
+
const substituted = new Set();
|
|
152
|
+
const trackModel = (bucket, configured, actual) => {
|
|
153
|
+
if (!actual) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
agentModels[bucket] = actual;
|
|
157
|
+
if (configured && actual !== configured) {
|
|
158
|
+
substituted.add(`${bucket}: configured ${configured}, ran ${actual}`);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
132
161
|
try {
|
|
133
162
|
const workspace = await writePatchWorkspace(kept, metadata, runDir);
|
|
134
163
|
// Resolve which agents run: an explicit list wins; otherwise route (LLM picks
|
|
@@ -158,7 +187,6 @@ export async function runReview(source, options) {
|
|
|
158
187
|
// These caps must fit inside PASSES_BUDGET_MS (below), which in turn fits inside
|
|
159
188
|
// the CI job's timeout-minutes.
|
|
160
189
|
const CHUNK_TIMEOUT_MS = 15 * 60 * 1000;
|
|
161
|
-
const CROSS_CUTTING_TIMEOUT_MS = 25 * 60 * 1000;
|
|
162
190
|
// A subdivided sub-chunk is smaller, so it gets a shorter cap (halved per level,
|
|
163
191
|
// floored) — enough to converge without letting the recursion balloon.
|
|
164
192
|
const SUBDIVIDE_MIN_TIMEOUT_MS = 6 * 60 * 1000;
|
|
@@ -168,14 +196,50 @@ export async function runReview(source, options) {
|
|
|
168
196
|
// Tool-call ceilings — generous for a legitimate pass, low enough to catch
|
|
169
197
|
// runaway roaming (the root cause of the non-convergent timeouts).
|
|
170
198
|
const CHUNK_MAX_TOOL_CALLS = 50;
|
|
171
|
-
const CROSS_CUTTING_MAX_TOOL_CALLS = 120;
|
|
172
199
|
// Global ceiling for ALL passes incl. subdivision/fallback waves, sized to
|
|
173
200
|
// leave room for the coordinator (10m) + verification + overhead inside the CI
|
|
174
201
|
// job timeout. Past this, a timed-out pass is reported as a gap rather than
|
|
175
202
|
// broken down further, so total wall-clock stays bounded.
|
|
176
|
-
const PASSES_BUDGET_MS =
|
|
203
|
+
const PASSES_BUDGET_MS = 55 * 60 * 1000;
|
|
177
204
|
const passesBudgetMs = options.passesBudgetMs ?? PASSES_BUDGET_MS;
|
|
178
205
|
const passesDeadline = started + passesBudgetMs;
|
|
206
|
+
// The cross-file pass is the one pass whose scope cannot be traded for
|
|
207
|
+
// convergence: halving its file set deletes exactly the coverage it exists to
|
|
208
|
+
// provide (see the timeout branch below). So instead of a fixed cap it gets the
|
|
209
|
+
// WHOLE remaining passes window. Chunk passes run concurrently alongside it under
|
|
210
|
+
// their own caps, so a long cross-file pass doesn't starve them — it only extends
|
|
211
|
+
// the run toward the passes deadline, which the job timeout is sized for.
|
|
212
|
+
//
|
|
213
|
+
// Computed here, not as a constant, because the window is what's actually left:
|
|
214
|
+
// filtering, routing and server startup already spent some of it, and `ecr ci`
|
|
215
|
+
// divides the budget across active scopes.
|
|
216
|
+
//
|
|
217
|
+
// The reserve is what keeps its own salvage paths affordable: if it expanded into
|
|
218
|
+
// the entire window, then on a timeout there would be nothing left to run the
|
|
219
|
+
// whole-diff no-tools fallback with, and "elastic budget" would have quietly
|
|
220
|
+
// reintroduced the coverage gap it exists to prevent. Sized for the finalize
|
|
221
|
+
// soft-landing plus one FALLBACK_TIMEOUT_MS pass.
|
|
222
|
+
const CROSS_CUTTING_RESERVE_MS = FALLBACK_TIMEOUT_MS + 4 * 60 * 1000;
|
|
223
|
+
// Floor: never LESS generous than one chunk pass. On a run whose window is already
|
|
224
|
+
// small (many active scopes dividing the budget) this can exceed what's left, but
|
|
225
|
+
// that exposure is exactly what chunk passes already carry — their 15m cap can also
|
|
226
|
+
// outlast a small per-scope slice — and the job timeout keeps a wide margin over
|
|
227
|
+
// the budget for it. Dropping the pass instead would silently cost the coverage no
|
|
228
|
+
// other pass provides.
|
|
229
|
+
const crossCuttingWaitMs = Math.max(CHUNK_TIMEOUT_MS, passesDeadline - Date.now() - CROSS_CUTTING_RESERVE_MS);
|
|
230
|
+
// Tool calls are for TRACING (opening the caller a changed signature affects) —
|
|
231
|
+
// the changed files' diffs are inlined, so they are not spent fetching the diff.
|
|
232
|
+
// Scale with the diff's file count instead of fixing the ceiling: under a large
|
|
233
|
+
// elastic time budget a fixed cap becomes the binding constraint, and the extra
|
|
234
|
+
// time can't be used.
|
|
235
|
+
const CROSS_CUTTING_TOOL_CALLS_PER_FILE = 10;
|
|
236
|
+
const CROSS_CUTTING_MIN_TOOL_CALLS = 120;
|
|
237
|
+
const CROSS_CUTTING_MAX_TOOL_CALLS = 400;
|
|
238
|
+
const crossCuttingMaxToolCalls = Math.min(CROSS_CUTTING_MAX_TOOL_CALLS, Math.max(CROSS_CUTTING_MIN_TOOL_CALLS, CROSS_CUTTING_TOOL_CALLS_PER_FILE * workspace.files.length));
|
|
239
|
+
// Coverage notes for passes that hit their time limit, stalled, failed, or were
|
|
240
|
+
// never started, surfaced in the final review so a cut-short run is never
|
|
241
|
+
// presented as complete.
|
|
242
|
+
const incomplete = [];
|
|
179
243
|
const tasks = [];
|
|
180
244
|
for (const agent of selectedAgents) {
|
|
181
245
|
const system = buildReviewerSystem(config, agent);
|
|
@@ -206,8 +270,8 @@ export async function runReview(source, options) {
|
|
|
206
270
|
title: "review-xcut",
|
|
207
271
|
files: workspace.files,
|
|
208
272
|
coverageLabel: "the cross-file review (issues spanning multiple changed files)",
|
|
209
|
-
maxWaitMs:
|
|
210
|
-
maxToolCalls:
|
|
273
|
+
maxWaitMs: crossCuttingWaitMs,
|
|
274
|
+
maxToolCalls: crossCuttingMaxToolCalls,
|
|
211
275
|
depth: 0,
|
|
212
276
|
fallback: false,
|
|
213
277
|
});
|
|
@@ -215,11 +279,19 @@ export async function runReview(source, options) {
|
|
|
215
279
|
// Longest-processing-time-first: schedule the long cross-cutting/large chunks
|
|
216
280
|
// ahead of short ones so they don't dominate the tail of the makespan.
|
|
217
281
|
tasks.sort((a, b) => b.maxWaitMs - a.maxWaitMs);
|
|
282
|
+
// What each task was CONFIGURED to run on — mirrors buildOpencodeConfig, which
|
|
283
|
+
// gives the cross-file pass the first agent's model. Compared against what actually
|
|
284
|
+
// answered so a silent substitution can't pass unnoticed.
|
|
285
|
+
const taskModel = (task) => task.kind === "cross-cutting"
|
|
286
|
+
? (selectedAgents[0]?.model ?? config.coordinator.model)
|
|
287
|
+
: (selectedAgents.find((agent) => agent.id === task.bucket)?.model ?? "");
|
|
218
288
|
// Build the task prompt on demand (so a subdivided task rebuilds over its
|
|
219
289
|
// smaller file set); a fallback task forbids tools and reviews the inlined diff.
|
|
220
290
|
const buildTaskText = (task) => {
|
|
221
291
|
const base = task.kind === "cross-cutting"
|
|
222
|
-
? buildCrossCuttingTask(task.files, selectedAgents, filtered
|
|
292
|
+
? buildCrossCuttingTask(task.files, selectedAgents, filtered, {
|
|
293
|
+
noTools: task.fallback,
|
|
294
|
+
})
|
|
223
295
|
: buildReviewerTask(task.files, workspace.files, filtered);
|
|
224
296
|
return task.fallback ? `${base}\n\n${NO_TOOLS_INSTRUCTION}` : base;
|
|
225
297
|
};
|
|
@@ -234,9 +306,6 @@ export async function runReview(source, options) {
|
|
|
234
306
|
coverageLabel: `the ${humanBucket(parent.bucket)} review of ${filesLabel(files)}`,
|
|
235
307
|
...overrides,
|
|
236
308
|
});
|
|
237
|
-
// Coverage notes for passes that hit their time limit or failed, surfaced in
|
|
238
|
-
// the final review so a cut-short run is never presented as complete.
|
|
239
|
-
const incomplete = [];
|
|
240
309
|
let completedPasses = 0;
|
|
241
310
|
let failedPasses = 0;
|
|
242
311
|
// promptAndParse already retries internally (same-session corrective, then a
|
|
@@ -247,7 +316,7 @@ export async function runReview(source, options) {
|
|
|
247
316
|
await runGrowableQueue(tasks, config.chunk.concurrency, async (task, enqueue) => {
|
|
248
317
|
const minutes = Math.round(task.maxWaitMs / 60000);
|
|
249
318
|
try {
|
|
250
|
-
const { value, cost, truncated, tokens } = await promptAndParse(handle, {
|
|
319
|
+
const { value, cost, truncated, tokens, model } = await promptAndParse(handle, {
|
|
251
320
|
agent: task.bucket,
|
|
252
321
|
system: task.system,
|
|
253
322
|
text: buildTaskText(task),
|
|
@@ -259,6 +328,7 @@ export async function runReview(source, options) {
|
|
|
259
328
|
}, parseReviewerOutput);
|
|
260
329
|
agentCosts[task.bucket] = (agentCosts[task.bucket] ?? 0) + cost;
|
|
261
330
|
trackTokens(task.bucket, tokens);
|
|
331
|
+
trackModel(task.bucket, taskModel(task), model);
|
|
262
332
|
(agentFindings[task.bucket] ??= []).push(...value.findings);
|
|
263
333
|
completedPasses++;
|
|
264
334
|
if (truncated) {
|
|
@@ -284,13 +354,15 @@ export async function runReview(source, options) {
|
|
|
284
354
|
agentCosts[task.bucket] = (agentCosts[task.bucket] ?? 0) + error.cost;
|
|
285
355
|
trackTokens(task.bucket, error.tokens);
|
|
286
356
|
const remaining = passesDeadline - Date.now();
|
|
287
|
-
//
|
|
288
|
-
// reviewer chunk
|
|
289
|
-
|
|
357
|
+
// Subdividing trades scope for convergence, which is the right trade for a
|
|
358
|
+
// reviewer chunk (each file still gets reviewed) and the WRONG one for the
|
|
359
|
+
// cross-file pass: an interaction between a file in the left half and one in
|
|
360
|
+
// the right half is invisible to both halves, so "splitting" it silently
|
|
361
|
+
// deletes the coverage the pass exists to provide while reporting success.
|
|
362
|
+
// It goes straight to the whole-diff no-tools fallback below instead.
|
|
363
|
+
const canSubdivide = task.kind === "reviewer" && task.files.length > 1;
|
|
290
364
|
const childCap = Math.max(SUBDIVIDE_MIN_TIMEOUT_MS, Math.floor(task.maxWaitMs / 2));
|
|
291
|
-
if (task.
|
|
292
|
-
task.depth < MAX_SUBDIVIDE_DEPTH &&
|
|
293
|
-
remaining > childCap) {
|
|
365
|
+
if (canSubdivide && task.depth < MAX_SUBDIVIDE_DEPTH && remaining > childCap) {
|
|
294
366
|
const mid = Math.ceil(task.files.length / 2);
|
|
295
367
|
const left = task.files.slice(0, mid);
|
|
296
368
|
const right = task.files.slice(mid);
|
|
@@ -300,9 +372,12 @@ export async function runReview(source, options) {
|
|
|
300
372
|
enqueue(childTask(task, right, `↳${right.length}f`, over));
|
|
301
373
|
return;
|
|
302
374
|
}
|
|
303
|
-
// Can't subdivide
|
|
304
|
-
//
|
|
305
|
-
|
|
375
|
+
// Can't (or shouldn't) subdivide: fall back to a fast no-tools pass over the
|
|
376
|
+
// inlined diffs. This works for the cross-file pass too — every changed file's
|
|
377
|
+
// diff is inlined in its task, so it can still reason across the whole diff
|
|
378
|
+
// without tools; it just can't open a caller outside the diff. A lighter
|
|
379
|
+
// cross-file review beats the coverage gap it used to report.
|
|
380
|
+
if (!task.fallback && remaining > FALLBACK_TIMEOUT_MS) {
|
|
306
381
|
progress(` ${task.label}: exceeded ${minutes}m — retrying ${filesLabel(task.files)} with a fast no-tools pass`);
|
|
307
382
|
enqueue(childTask(task, task.files, "(no-tools fallback)", {
|
|
308
383
|
fallback: true,
|
|
@@ -315,10 +390,24 @@ export async function runReview(source, options) {
|
|
|
315
390
|
// never silent. Distinguish WHY so the note doesn't overstate what happened:
|
|
316
391
|
// we could still have split/fallen back, but the global budget ran out first,
|
|
317
392
|
// vs. the task was already at its smallest reviewable unit and still failed.
|
|
393
|
+
// A stalled pass is called out separately: it did not run out of time doing
|
|
394
|
+
// work, its model requests went silent, which is an infrastructure symptom
|
|
395
|
+
// and not something a bigger budget or a smaller scope would have fixed.
|
|
318
396
|
failedPasses++;
|
|
319
|
-
const couldStillReduce = (
|
|
320
|
-
|
|
321
|
-
|
|
397
|
+
const couldStillReduce = (canSubdivide && task.depth < MAX_SUBDIVIDE_DEPTH) || !task.fallback;
|
|
398
|
+
if (error.reason === "stall") {
|
|
399
|
+
progress(` ${task.label}: its model requests went silent (stalled) and did not recover — ` +
|
|
400
|
+
`most likely provider rate limiting; reporting a coverage gap`);
|
|
401
|
+
// Name the likely cause. OpenCode retries a 429 internally without surfacing
|
|
402
|
+
// it, so provider throttling reaches us as pure silence — indistinguishable
|
|
403
|
+
// from a wedged connection, and the single most common reason a pass produces
|
|
404
|
+
// nothing at all. Saying "went silent" alone sends people hunting for a bug
|
|
405
|
+
// in the reviewer instead of checking their usage window.
|
|
406
|
+
incomplete.push(`${capitalize(task.coverageLabel)} could not run: its model requests went silent and produced no output, even after being retried. ` +
|
|
407
|
+
`The usual cause is the model provider rate-limiting the account (a subscription credential over its usage window), which reaches this tool as silence rather than an error; ` +
|
|
408
|
+
`those changes were not fully reviewed.`);
|
|
409
|
+
}
|
|
410
|
+
else if (couldStillReduce) {
|
|
322
411
|
progress(` ${task.label}: exceeded ${minutes}m and the run's time budget is spent — reporting a coverage gap`);
|
|
323
412
|
incomplete.push(`${capitalize(task.coverageLabel)} timed out and the overall review budget was exhausted before it could be broken down further; those changes were not fully reviewed.`);
|
|
324
413
|
}
|
|
@@ -328,6 +417,18 @@ export async function runReview(source, options) {
|
|
|
328
417
|
}
|
|
329
418
|
}
|
|
330
419
|
});
|
|
420
|
+
// A substituted model means the review did not run on the model this repo
|
|
421
|
+
// configured — the findings may be from a weaker (or free-tier) model entirely.
|
|
422
|
+
// Never silent: it goes to the log, the coverage notes, and the run log.
|
|
423
|
+
if (substituted.size > 0) {
|
|
424
|
+
for (const line of substituted) {
|
|
425
|
+
progress(` ⚠ model substituted — ${line}`);
|
|
426
|
+
}
|
|
427
|
+
incomplete.push(`Some passes did not run on the configured model (${[...substituted].join("; ")}). ` +
|
|
428
|
+
`OpenCode silently falls back to a default model when the configured id is empty or ` +
|
|
429
|
+
`unusable, so these findings may come from a different (possibly much weaker) model ` +
|
|
430
|
+
`than intended — check the agents' \`model\`, \`coordinator.model\`, REVIEWER_MODEL, and the provider credential.`);
|
|
431
|
+
}
|
|
331
432
|
// Note: routine noise filtering (lockfiles, generated, binary) is expected and
|
|
332
433
|
// NOT a coverage gap — it stays in the run log (filteredFiles), not the
|
|
333
434
|
// user-facing coverage note, which is reserved for passes that didn't finish.
|
|
@@ -348,9 +449,10 @@ export async function runReview(source, options) {
|
|
|
348
449
|
progress("Coordinating findings…");
|
|
349
450
|
let consolidated;
|
|
350
451
|
try {
|
|
351
|
-
const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes);
|
|
452
|
+
const { output: rawOutput, cost, tokens: coordinatorTokens, truncated: coordinatorTruncated, model: coordinatorModel, } = await coordinate(handle, config, metadata, agentFindings, coverageNotes);
|
|
352
453
|
agentCosts["coordinator"] = cost;
|
|
353
454
|
trackTokens("coordinator", coordinatorTokens);
|
|
455
|
+
trackModel("coordinator", config.coordinator.model, coordinatorModel);
|
|
354
456
|
consolidated = applyReviewPolicy(rawOutput, config.policy);
|
|
355
457
|
if (coordinatorTruncated) {
|
|
356
458
|
// The coordinator ran out of time and returned partial findings — flag it
|
|
@@ -382,6 +484,8 @@ export async function runReview(source, options) {
|
|
|
382
484
|
const verification = await verifyFindings(handle, output.findings, process.cwd(), progress);
|
|
383
485
|
agentCosts["verifier"] = verification.cost;
|
|
384
486
|
trackTokens("verifier", verification.tokens);
|
|
487
|
+
// Mirrors buildOpencodeConfig, which gives the verifier the first agent's model.
|
|
488
|
+
trackModel("verifier", config.agents[0]?.model ?? config.coordinator.model, verification.model);
|
|
385
489
|
verifierDropped = verification.dropped;
|
|
386
490
|
if (verification.dropped.length > 0) {
|
|
387
491
|
progress(`Verification dropped ${verification.dropped.length} unverified finding(s).`);
|
|
@@ -411,14 +515,23 @@ export async function runReview(source, options) {
|
|
|
411
515
|
if (removedAfterChecks > 0) {
|
|
412
516
|
output = { ...output, summary: reconcileSummary(output.summary, output.findings.length) };
|
|
413
517
|
}
|
|
518
|
+
// Every pass says which model actually answered it — in the job log, the step
|
|
519
|
+
// summary table, and the run log — so a wrong or substituted model is always
|
|
520
|
+
// visible, not just when the substitution warning fires.
|
|
521
|
+
if (Object.keys(agentModels).length > 0) {
|
|
522
|
+
progress(`Models used — ${Object.entries(agentModels)
|
|
523
|
+
.map(([bucket, model]) => `${bucket}: ${model}`)
|
|
524
|
+
.join("; ")}`);
|
|
525
|
+
}
|
|
414
526
|
progress(formatUsageSummary(tokenTotals, sum(agentCosts)));
|
|
415
|
-
await appendStepSummary(renderUsageMarkdown(agentTokens, agentCosts, tokenTotals, sum(agentCosts)));
|
|
527
|
+
await appendStepSummary(renderUsageMarkdown(agentTokens, agentCosts, tokenTotals, sum(agentCosts), agentModels));
|
|
416
528
|
await safeLog(logPath, {
|
|
417
529
|
...baseRecord,
|
|
418
530
|
agentCosts,
|
|
419
531
|
totalCost: sum(agentCosts),
|
|
420
532
|
tokens: tokenTotals,
|
|
421
533
|
agentTokens,
|
|
534
|
+
agentModels,
|
|
422
535
|
agentFindings,
|
|
423
536
|
coverageNotes,
|
|
424
537
|
verifierDropped,
|
|
@@ -650,15 +763,15 @@ export function formatUsageSummary(tokens, totalCost) {
|
|
|
650
763
|
* row per pass plus a total, and the prompt-cache hit rate (the share of prompt
|
|
651
764
|
* tokens served from cache instead of being reprocessed at full price).
|
|
652
765
|
*/
|
|
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)} |`;
|
|
766
|
+
export function renderUsageMarkdown(agentTokens, agentCosts, totals, totalCost, agentModels = {}) {
|
|
767
|
+
const row = (label, model, tokens, cost) => `| ${label} | ${model} | ${tokens.input ?? 0} | ${tokens.output ?? 0} | ${tokens.cache?.read ?? 0} | ${tokens.cache?.write ?? 0} | $${cost.toFixed(4)} |`;
|
|
655
768
|
const lines = [
|
|
656
769
|
"### 🤖 AI review — token usage",
|
|
657
770
|
"",
|
|
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),
|
|
771
|
+
"| pass | model | input | output | cache read | cache write | cost |",
|
|
772
|
+
"| --- | --- | ---: | ---: | ---: | ---: | ---: |",
|
|
773
|
+
...Object.keys(agentCosts).map((bucket) => row(bucket, agentModels[bucket] ?? "—", agentTokens[bucket] ?? {}, agentCosts[bucket] ?? 0)),
|
|
774
|
+
row("**total**", "", totals, totalCost),
|
|
662
775
|
];
|
|
663
776
|
const read = totals.cache?.read ?? 0;
|
|
664
777
|
const uncached = totals.input ?? 0;
|
package/build/core/verify.js
CHANGED
|
@@ -81,6 +81,7 @@ async function evidencePresence(finding, cwd) {
|
|
|
81
81
|
export async function verifyFindings(handle, findings, cwd, onProgress) {
|
|
82
82
|
const dropped = [];
|
|
83
83
|
let cost = 0;
|
|
84
|
+
let model;
|
|
84
85
|
const tokens = {};
|
|
85
86
|
// Phase 1 — deterministic quote-grounding for every finding.
|
|
86
87
|
const checked = await Promise.all(findings.map(async (finding) => ({ finding, presence: await evidencePresence(finding, cwd) })));
|
|
@@ -98,7 +99,7 @@ export async function verifyFindings(handle, findings, cwd, onProgress) {
|
|
|
98
99
|
// Phase 2 — LLM verify (parallel). Refuted → drop; verified or errored → keep.
|
|
99
100
|
await Promise.all(toVerify.map(async ({ finding, presence }, index) => {
|
|
100
101
|
try {
|
|
101
|
-
const { value, cost: verifyCost, tokens: verifyTokens, } = await promptAndParse(handle, {
|
|
102
|
+
const { value, cost: verifyCost, tokens: verifyTokens, model: verifyModel, } = await promptAndParse(handle, {
|
|
102
103
|
agent: VERIFIER_AGENT,
|
|
103
104
|
system: buildVerifierSystem(),
|
|
104
105
|
text: buildVerifierTask(finding, { evidenceUngrounded: presence === "absent" }),
|
|
@@ -108,6 +109,7 @@ export async function verifyFindings(handle, findings, cwd, onProgress) {
|
|
|
108
109
|
}, parseVerdict);
|
|
109
110
|
cost += verifyCost;
|
|
110
111
|
addTokenUsage(tokens, verifyTokens);
|
|
112
|
+
model = verifyModel ?? model;
|
|
111
113
|
if (value.verified) {
|
|
112
114
|
verdicts.set(finding, "keep");
|
|
113
115
|
}
|
|
@@ -125,5 +127,5 @@ export async function verifyFindings(handle, findings, cwd, onProgress) {
|
|
|
125
127
|
}));
|
|
126
128
|
// Preserve original order.
|
|
127
129
|
const kept = findings.filter((finding) => verdicts.get(finding) === "keep");
|
|
128
|
-
return { kept, dropped, cost, tokens };
|
|
130
|
+
return { kept, dropped, cost, tokens, model };
|
|
129
131
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@expo/code-review-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Generic, config-driven AI code reviewer engine. Repos supply their agents via .expo-code-review/.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"prepublishOnly": "rimraf build && tsc -p tsconfig.build.json"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@opencode-ai/sdk": "
|
|
39
|
-
"opencode-ai": "
|
|
38
|
+
"@opencode-ai/sdk": "1.18.4",
|
|
39
|
+
"opencode-ai": "1.18.4",
|
|
40
40
|
"zod": "^4.4.3"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
description: Security and secrets. Injection, credential or secret leakage, unsafe shell/child-process use, missing validation at trust boundaries.
|
|
3
3
|
alwaysRun: true
|
|
4
4
|
# Security is the highest-stakes agent and benefits most from stronger threat-model
|
|
5
|
-
# reasoning, so it runs on
|
|
6
|
-
# model. Scoped to this one agent to limit the extra latency/rate-limit cost;
|
|
7
|
-
# subdivide-on-timeout + the per-fetch deadline keep a slow
|
|
8
|
-
model:
|
|
5
|
+
# reasoning, so it runs on the pro tier even though the other specialists use the
|
|
6
|
+
# default model. Scoped to this one agent to limit the extra latency/rate-limit cost;
|
|
7
|
+
# subdivide-on-timeout + the per-fetch deadline keep a slow pro pass from hanging.
|
|
8
|
+
model: openai/gpt-5.5-pro
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
# Security & secrets
|
package/templates/command.yml
CHANGED
|
@@ -36,8 +36,10 @@ jobs:
|
|
|
36
36
|
startsWith(github.event.comment.body, '/review') &&
|
|
37
37
|
contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
|
|
38
38
|
runs-on: ubuntu-latest
|
|
39
|
-
# Bound the run so a slow/stalled review fails fast rather than hanging.
|
|
40
|
-
|
|
39
|
+
# Bound the run so a slow/stalled review fails fast rather than hanging. Keep it
|
|
40
|
+
# above the passes budget (budget.totalPassesMinutes, 55m) + coordinator (10m) +
|
|
41
|
+
# verification + setup, like the auto-review workflow's cap.
|
|
42
|
+
timeout-minutes: 90
|
|
41
43
|
# A reviewer failure must never fail the PR's checks.
|
|
42
44
|
continue-on-error: true
|
|
43
45
|
steps:
|
|
@@ -113,7 +115,8 @@ jobs:
|
|
|
113
115
|
- name: Guard config tokenEnv (root + routing + all scopes)
|
|
114
116
|
if: steps.cmd.outputs.run == 'true'
|
|
115
117
|
env:
|
|
116
|
-
|
|
118
|
+
# (Comma-separated set for a multi-credential auth.providers config.)
|
|
119
|
+
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'OPENAI_API_KEY' }}
|
|
117
120
|
run: npx --yes -p "@expo/code-review-cli@$ECR_VERSION" ecr verify-config
|
|
118
121
|
|
|
119
122
|
- name: Run AI review
|
|
@@ -123,11 +126,11 @@ jobs:
|
|
|
123
126
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
124
127
|
# Layer-1 auth lock: the CLI refuses to run when the tokenEnv it would honor
|
|
125
128
|
# differs from this. Keep it in sync with the guard's EXPECTED.
|
|
126
|
-
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || '
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
|
|
129
|
+
ECR_EXPECTED_TOKEN_ENV: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'OPENAI_API_KEY' }}
|
|
130
|
+
# OpenAI API key — the env var named by auth.tokenEnv in config.jsonc.
|
|
131
|
+
# Store it as a repo secret; a project-scoped key restricted to model
|
|
132
|
+
# inference (with a spend limit) is all the reviewer needs.
|
|
133
|
+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
131
134
|
# Optional: override the model for every agent (uses your OpenCode login).
|
|
132
135
|
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
|
|
133
136
|
AGENTS: ${{ steps.cmd.outputs.agents }}
|
package/templates/config.jsonc
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
// Default model for every agent. Override per-agent via frontmatter in the
|
|
3
3
|
// agent's markdown, or at runtime with the REVIEWER_MODEL env var
|
|
4
|
-
// (e.g. REVIEWER_MODEL=openai/gpt-5.4-mini
|
|
5
|
-
"model": "
|
|
4
|
+
// (e.g. REVIEWER_MODEL=openai/gpt-5.4-mini).
|
|
5
|
+
"model": "openai/gpt-5.5",
|
|
6
6
|
|
|
7
7
|
// Agents: every markdown file in agents/ is one reviewer (id = filename).
|
|
8
8
|
// Add or remove files to change the roster — no list needed here.
|
|
9
9
|
// shared.md (prepended to every agent + coordinator) and coordinator.md are
|
|
10
10
|
// reserved filenames. Per-agent overrides go in each file's YAML frontmatter,
|
|
11
|
-
// e.g. `---\nmodel:
|
|
11
|
+
// e.g. `---\nmodel: openai/gpt-5.5-pro\n---`.
|
|
12
12
|
|
|
13
13
|
"policy": {
|
|
14
14
|
// Phase 1: keep signal high by surfacing only critical/warning.
|
|
@@ -41,16 +41,29 @@
|
|
|
41
41
|
// HTML marker used to find + update the single PR comment. Keep it stable.
|
|
42
42
|
"commentTag": "expo-ai-code-reviewer",
|
|
43
43
|
|
|
44
|
-
// How model credentials are provided. Default:
|
|
45
|
-
// "
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// For
|
|
50
|
-
//
|
|
44
|
+
// How model credentials are provided. Default: an OpenAI API key.
|
|
45
|
+
// "api-key": tokenEnv names the env var holding a provider API key. In CI,
|
|
46
|
+
// store the key as a repo secret and pass it under that env var
|
|
47
|
+
// (the scaffolded workflow does). If you omit `auth` entirely,
|
|
48
|
+
// OpenCode's own login / ambient provider env vars are used.
|
|
49
|
+
// For Anthropic/Claude, set provider "anthropic", tokenEnv "ANTHROPIC_API_KEY",
|
|
50
|
+
// and an anthropic/... model above. For another provider, omit `auth` and set
|
|
51
|
+
// REVIEWER_MODEL after an `opencode auth login` for that provider.
|
|
52
|
+
//
|
|
53
|
+
// MIXED setup (a ChatGPT/Codex subscription for the default models, plus a
|
|
54
|
+
// metered API key for pro-tier models the subscription doesn't offer): use the
|
|
55
|
+
// per-provider map instead, reference `openai-api/...` models in the frontmatter
|
|
56
|
+
// of the agents that need the pro tier, and set ECR_EXPECTED_TOKEN_ENV in the
|
|
57
|
+
// workflow to the comma-separated set of both env names.
|
|
58
|
+
// "auth": { "providers": {
|
|
59
|
+
// "openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_REFRESH_TOKEN" },
|
|
60
|
+
// "openai-api": { "mode": "api-key", "tokenEnv": "OPENAI_API_KEY", "upstream": "openai" }
|
|
61
|
+
// } }
|
|
62
|
+
// (openai oauth: tokenEnv holds the REFRESH token from an `opencode auth login`
|
|
63
|
+
// ChatGPT sign-in — copy `.openai.refresh` from OpenCode's auth.json.)
|
|
51
64
|
"auth": {
|
|
52
|
-
"mode": "
|
|
53
|
-
"provider": "
|
|
54
|
-
"tokenEnv": "
|
|
65
|
+
"mode": "api-key",
|
|
66
|
+
"provider": "openai",
|
|
67
|
+
"tokenEnv": "OPENAI_API_KEY"
|
|
55
68
|
}
|
|
56
69
|
}
|
package/templates/coordinator.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
# The coordinator makes the final call — de-duping, re-judging severity, and
|
|
3
|
-
# deciding — so it runs on
|
|
4
|
-
# small serial-tail latency it adds (no repo tools,
|
|
3
|
+
# deciding — so it runs on the pro tier: consolidation quality matters more here
|
|
4
|
+
# than the small serial-tail latency it adds (no repo tools, one bounded pass).
|
|
5
5
|
# Override with a cheaper model if you'd rather trade decision quality for latency.
|
|
6
|
-
model:
|
|
6
|
+
model: openai/gpt-5.5-pro
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# Coordinator — consolidation & decision
|
package/templates/routing.jsonc
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// Passes budget, split across active scopes (they run sequentially in one `ecr ci`):
|
|
19
19
|
// keep totalPassesMinutes inside the workflow's timeout-minutes; minScopeMinutes is
|
|
20
20
|
// the floor below which a scope review isn't worth starting. Defaults shown.
|
|
21
|
-
// "budget": { "totalPassesMinutes":
|
|
21
|
+
// "budget": { "totalPassesMinutes": 55, "minScopeMinutes": 5 },
|
|
22
22
|
|
|
23
23
|
// Ordered; the LAST matching scope wins per changed file. Keep a '**/*' catch-all first.
|
|
24
24
|
"scopes": [
|