@expo/code-review-cli 0.2.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +183 -6
- package/build/cli.js +24 -17
- package/build/commands/ci.js +427 -28
- package/build/commands/dismiss.js +16 -16
- package/build/commands/doctor.js +172 -32
- package/build/commands/init.js +244 -34
- package/build/commands/review.js +124 -30
- package/build/commands/verify-config.js +214 -0
- package/build/config/load.js +155 -52
- package/build/config/routing.js +122 -0
- package/build/config/schema.js +127 -8
- package/build/core/auth.js +101 -38
- 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 +98 -44
- package/build/core/prompts.js +157 -148
- package/build/core/render.js +202 -48
- package/build/core/review.js +187 -81
- 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/config.jsonc +10 -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 +58 -23
package/build/core/opencode.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { createOpencode } from
|
|
2
|
-
import { toolMap } from
|
|
3
|
-
import { sleep } from
|
|
1
|
+
import { createOpencode } from "@opencode-ai/sdk";
|
|
2
|
+
import { toolMap } from "./tools.js";
|
|
3
|
+
import { errorMessage, sleep } from "./util.js";
|
|
4
4
|
/** Sum token usage across attempts (for per-task/run totals). */
|
|
5
5
|
export function addTokenUsage(into, from) {
|
|
6
6
|
if (!from) {
|
|
@@ -21,23 +21,23 @@ const COORDINATOR_TOOLS = toolMap([]);
|
|
|
21
21
|
// defined here so OpenCode uses this restricted tool set — otherwise the model
|
|
22
22
|
// falls back to a default agent with full tools and crawls the whole repo, which
|
|
23
23
|
// is why the cross-file pass used to wander for its entire time budget.
|
|
24
|
-
export const CROSS_CUTTING_AGENT =
|
|
24
|
+
export const CROSS_CUTTING_AGENT = "cross-cutting";
|
|
25
25
|
// Deliberately NO `glob`/`list`: the cross-file pass is given the changed files'
|
|
26
26
|
// patch paths already, and directory crawling is exactly what made it wander into
|
|
27
27
|
// unrelated packages. `read` (open a known file) + `grep` (find a cross-reference
|
|
28
28
|
// among the changed files) are enough to trace interactions.
|
|
29
|
-
const CROSS_CUTTING_TOOLS = toolMap([
|
|
29
|
+
const CROSS_CUTTING_TOOLS = toolMap(["read", "grep"]);
|
|
30
30
|
// Verifies a finding by re-reading the actual file (adversarial refute pass). Same
|
|
31
31
|
// restricted tool set — it opens the cited file and checks the claim.
|
|
32
|
-
export const VERIFIER_AGENT =
|
|
33
|
-
const VERIFIER_TOOLS = toolMap([
|
|
32
|
+
export const VERIFIER_AGENT = "verifier";
|
|
33
|
+
const VERIFIER_TOOLS = toolMap(["read", "grep"]);
|
|
34
34
|
/** Build the inline OpenCode config (agents + coordinator) from a repo config. */
|
|
35
35
|
export function buildOpencodeConfig(config) {
|
|
36
36
|
const agent = {};
|
|
37
37
|
for (const reviewer of config.agents) {
|
|
38
38
|
agent[reviewer.id] = {
|
|
39
39
|
description: `${reviewer.id} reviewer`,
|
|
40
|
-
mode:
|
|
40
|
+
mode: "all",
|
|
41
41
|
model: reviewer.model,
|
|
42
42
|
temperature: reviewer.temperature,
|
|
43
43
|
prompt: `You are the ${reviewer.id} code reviewer. Follow the user message exactly and return only the requested JSON.`,
|
|
@@ -45,37 +45,37 @@ export function buildOpencodeConfig(config) {
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
agent[CROSS_CUTTING_AGENT] = {
|
|
48
|
-
description:
|
|
49
|
-
mode:
|
|
48
|
+
description: "Cross-file reviewer: issues spanning multiple changed files.",
|
|
49
|
+
mode: "all",
|
|
50
50
|
// Use the default reviewing model (agents share it unless overridden).
|
|
51
51
|
model: config.agents[0]?.model ?? config.coordinator.model,
|
|
52
52
|
temperature: config.agents[0]?.temperature ?? 0.1,
|
|
53
|
-
prompt:
|
|
53
|
+
prompt: "You are the cross-file code reviewer. Follow the user message exactly and return only the requested JSON.",
|
|
54
54
|
tools: CROSS_CUTTING_TOOLS,
|
|
55
55
|
};
|
|
56
56
|
agent[VERIFIER_AGENT] = {
|
|
57
|
-
description:
|
|
58
|
-
mode:
|
|
57
|
+
description: "Verifies a finding against the real file (adversarial refute pass).",
|
|
58
|
+
mode: "all",
|
|
59
59
|
model: config.agents[0]?.model ?? config.coordinator.model,
|
|
60
60
|
temperature: config.agents[0]?.temperature ?? 0.1,
|
|
61
|
-
prompt:
|
|
61
|
+
prompt: "You verify code-review findings against the actual source. Follow the user message exactly and return only the requested JSON.",
|
|
62
62
|
tools: VERIFIER_TOOLS,
|
|
63
63
|
};
|
|
64
|
-
agent[
|
|
65
|
-
description:
|
|
66
|
-
mode:
|
|
64
|
+
agent["coordinator"] = {
|
|
65
|
+
description: "Consolidates specialist findings into one decision.",
|
|
66
|
+
mode: "all",
|
|
67
67
|
model: config.coordinator.model,
|
|
68
68
|
temperature: config.coordinator.temperature,
|
|
69
|
-
prompt:
|
|
69
|
+
prompt: "You are the review coordinator. Follow the user message exactly and return only the requested JSON.",
|
|
70
70
|
tools: COORDINATOR_TOOLS,
|
|
71
71
|
};
|
|
72
|
-
return { $schema:
|
|
72
|
+
return { $schema: "https://opencode.ai/config.json", agent };
|
|
73
73
|
}
|
|
74
74
|
/** hey-api style responses come back as { data, error }; unwrap or throw. */
|
|
75
75
|
function unwrap(res) {
|
|
76
|
-
if (res && typeof res ===
|
|
76
|
+
if (res && typeof res === "object" && ("data" in res || "error" in res)) {
|
|
77
77
|
if (res.error) {
|
|
78
|
-
throw new Error(typeof res.error ===
|
|
78
|
+
throw new Error(typeof res.error === "string" ? res.error : JSON.stringify(res.error));
|
|
79
79
|
}
|
|
80
80
|
return res.data;
|
|
81
81
|
}
|
|
@@ -84,7 +84,7 @@ function unwrap(res) {
|
|
|
84
84
|
/** Start an in-process OpenCode server with the given inline config. */
|
|
85
85
|
export async function startOpencode(config) {
|
|
86
86
|
const { client, server } = await createOpencode({
|
|
87
|
-
hostname:
|
|
87
|
+
hostname: "127.0.0.1",
|
|
88
88
|
config: config,
|
|
89
89
|
});
|
|
90
90
|
return { client, url: server.url, close: () => server.close() };
|
|
@@ -101,11 +101,11 @@ const HEARTBEAT_MS = 45_000;
|
|
|
101
101
|
const DEFAULT_MAX_WAIT_MS = 8 * 60 * 1000;
|
|
102
102
|
// Extra budget for the "stop and summarize what you have" finalization prompt.
|
|
103
103
|
const FINALIZE_WAIT_MS = 90 * 1000;
|
|
104
|
-
const FINALIZE_PROMPT =
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
104
|
+
const FINALIZE_PROMPT = "You have reached your time budget. STOP investigating now — do NOT read, grep, " +
|
|
105
|
+
"glob, list, or open any more files, and do not call any tools. Based ONLY on " +
|
|
106
|
+
"what you have already examined, reply with the single JSON object exactly as " +
|
|
107
|
+
"specified in your instructions, containing whatever findings you are already " +
|
|
108
|
+
"confident about. If you have nothing solid, return an empty findings array.";
|
|
109
109
|
/**
|
|
110
110
|
* Internal signal that a poll loop passed its deadline. Carries the best-effort
|
|
111
111
|
* cost/tokens of the in-progress (never-completed) assistant message so a
|
|
@@ -115,12 +115,12 @@ class DeadlineReached extends Error {
|
|
|
115
115
|
cost;
|
|
116
116
|
tokens;
|
|
117
117
|
constructor(cost = 0, tokens) {
|
|
118
|
-
super(
|
|
118
|
+
super("deadline reached");
|
|
119
119
|
this.cost = cost;
|
|
120
120
|
this.tokens = tokens;
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
|
-
const DEADLINE_SENTINEL = Symbol(
|
|
123
|
+
const DEADLINE_SENTINEL = Symbol("deadline");
|
|
124
124
|
/**
|
|
125
125
|
* Race a promise against the poll deadline. Without this, a stalled message fetch
|
|
126
126
|
* (a wedged/overloaded OpenCode server) blocks the poll loop past its deadline,
|
|
@@ -134,7 +134,7 @@ async function raceDeadline(work, deadline) {
|
|
|
134
134
|
return DEADLINE_SENTINEL;
|
|
135
135
|
}
|
|
136
136
|
let timer;
|
|
137
|
-
const timeout = new Promise(resolve => {
|
|
137
|
+
const timeout = new Promise((resolve) => {
|
|
138
138
|
timer = setTimeout(() => resolve(DEADLINE_SENTINEL), remaining);
|
|
139
139
|
});
|
|
140
140
|
try {
|
|
@@ -155,7 +155,7 @@ export class AgentTimeoutError extends Error {
|
|
|
155
155
|
tokens;
|
|
156
156
|
constructor(agent, minutes, cost = 0, tokens) {
|
|
157
157
|
super(`Agent "${agent}" timed out after ${minutes} minutes (including finalize)`);
|
|
158
|
-
this.name =
|
|
158
|
+
this.name = "AgentTimeoutError";
|
|
159
159
|
this.cost = cost;
|
|
160
160
|
this.tokens = tokens;
|
|
161
161
|
}
|
|
@@ -205,7 +205,7 @@ export async function promptAgent(handle, args) {
|
|
|
205
205
|
// Soft landing: ask the (same, context-carrying) session to return whatever
|
|
206
206
|
// it has now. Only messages after this point count as the answer.
|
|
207
207
|
const baseline = (await fetchMessages(handle, session.id)).length;
|
|
208
|
-
args.onActivity?.(
|
|
208
|
+
args.onActivity?.("time budget reached — asking for findings so far");
|
|
209
209
|
await sendSessionPrompt(handle, session.id, {
|
|
210
210
|
agent: args.agent,
|
|
211
211
|
system: args.system,
|
|
@@ -235,11 +235,65 @@ export async function promptAgent(handle, args) {
|
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
237
|
}
|
|
238
|
-
const CORRECTIVE =
|
|
239
|
-
|
|
238
|
+
const CORRECTIVE = "\n\nIMPORTANT: your previous reply could not be parsed. Reply with ONLY the single " +
|
|
239
|
+
"JSON object described above — no prose, no code fences, no partial output.";
|
|
240
240
|
// Budget for a corrective "re-emit the JSON" reply — no fresh investigation, so
|
|
241
241
|
// it should return almost immediately.
|
|
242
242
|
const CORRECTIVE_WAIT_MS = 2 * 60 * 1000;
|
|
243
|
+
/** Backoff (ms) before the 2nd and 3rd attempt of a transient-failing model call. */
|
|
244
|
+
const TRANSIENT_BACKOFF_MS = [2_000, 8_000];
|
|
245
|
+
/**
|
|
246
|
+
* A transient, retryable API failure — a one-off rate-limit (429), server error
|
|
247
|
+
* (5xx), or network blip — as opposed to a timeout (which means "abandon", see
|
|
248
|
+
* AgentTimeoutError) or a JSON-parse failure (handled by the corrective re-emit in
|
|
249
|
+
* promptAndParse). We match on the error text because the OpenCode SDK surfaces
|
|
250
|
+
* these as plain Errors; an AgentTimeoutError is never transient.
|
|
251
|
+
*/
|
|
252
|
+
const TRANSIENT_PATTERNS = [
|
|
253
|
+
/\b429\b/,
|
|
254
|
+
/\b50[0-9]\b/,
|
|
255
|
+
/rate.?limit/i,
|
|
256
|
+
/overloaded/i,
|
|
257
|
+
/too many requests/i,
|
|
258
|
+
/temporarily unavailable/i,
|
|
259
|
+
/ETIMEDOUT/i,
|
|
260
|
+
/ECONNRESET/i,
|
|
261
|
+
/ECONNREFUSED/i,
|
|
262
|
+
/ENOTFOUND/i,
|
|
263
|
+
/EAI_AGAIN/i,
|
|
264
|
+
/socket hang ?up/i,
|
|
265
|
+
/network error/i,
|
|
266
|
+
/fetch failed/i,
|
|
267
|
+
];
|
|
268
|
+
export function isTransientApiError(error) {
|
|
269
|
+
if (error instanceof AgentTimeoutError) {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
const message = errorMessage(error);
|
|
273
|
+
return TRANSIENT_PATTERNS.some((pattern) => pattern.test(message));
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Run a model call, retrying with bounded backoff on a transient API error. This
|
|
277
|
+
* is deliberately separate from the timeout path (abandon, never retry) and the
|
|
278
|
+
* parse-failure path (corrective re-emit): a one-off 429/5xx/network error used to
|
|
279
|
+
* drop the whole pass with no retry, reported as a coverage gap. Non-transient
|
|
280
|
+
* errors (incl. AgentTimeoutError) propagate immediately.
|
|
281
|
+
*/
|
|
282
|
+
async function withTransientRetry(label, onActivity, fn) {
|
|
283
|
+
for (let attempt = 0;; attempt++) {
|
|
284
|
+
try {
|
|
285
|
+
return await fn();
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
const waitMs = TRANSIENT_BACKOFF_MS[attempt];
|
|
289
|
+
if (waitMs === undefined || !isTransientApiError(error)) {
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
onActivity?.(`${label}: transient API error (${errorMessage(error)}); retry ${attempt + 1}/${TRANSIENT_BACKOFF_MS.length} in ${Math.round(waitMs / 1000)}s`);
|
|
293
|
+
await sleep(waitMs);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
243
297
|
/**
|
|
244
298
|
* Prompt an agent and parse its reply. On a JSON-parse failure, first retry in
|
|
245
299
|
* the SAME session: the model still holds all the file context it read, so the
|
|
@@ -259,7 +313,7 @@ export async function promptAndParse(handle, args, parse) {
|
|
|
259
313
|
truncated = truncated || (result.truncated ?? false);
|
|
260
314
|
addTokenUsage(tokens, result.tokens);
|
|
261
315
|
};
|
|
262
|
-
const first = await promptAgent(handle, args);
|
|
316
|
+
const first = await withTransientRetry(`Agent "${args.agent}"`, args.onActivity, () => promptAgent(handle, args));
|
|
263
317
|
record(first);
|
|
264
318
|
try {
|
|
265
319
|
return { value: parse(first.text), cost, truncated, tokens };
|
|
@@ -310,7 +364,7 @@ async function sendSessionPrompt(handle, sessionID, args) {
|
|
|
310
364
|
body: {
|
|
311
365
|
agent: args.agent,
|
|
312
366
|
system: args.system,
|
|
313
|
-
parts: [{ type:
|
|
367
|
+
parts: [{ type: "text", text: args.text }],
|
|
314
368
|
},
|
|
315
369
|
}));
|
|
316
370
|
}
|
|
@@ -356,11 +410,11 @@ async function pollForCompletion(handle, sessionID, opts) {
|
|
|
356
410
|
throw new DeadlineReached(lastCost, lastTokens);
|
|
357
411
|
}
|
|
358
412
|
const recent = messages.slice(opts.fromIndex);
|
|
359
|
-
const assistant = [...recent].reverse().find(message => message.info?.role ===
|
|
413
|
+
const assistant = [...recent].reverse().find((message) => message.info?.role === "assistant");
|
|
360
414
|
if (!assistant) {
|
|
361
415
|
continue;
|
|
362
416
|
}
|
|
363
|
-
if (typeof assistant.info?.cost ===
|
|
417
|
+
if (typeof assistant.info?.cost === "number") {
|
|
364
418
|
lastCost = assistant.info.cost;
|
|
365
419
|
}
|
|
366
420
|
if (assistant.info?.tokens) {
|
|
@@ -369,15 +423,15 @@ async function pollForCompletion(handle, sessionID, opts) {
|
|
|
369
423
|
// Track each distinct tool call once (for the tool-call cap) and, the first
|
|
370
424
|
// time it starts, emit a live line so a long run shows what the agent is doing.
|
|
371
425
|
for (const part of assistant.parts ?? []) {
|
|
372
|
-
if (part?.type !==
|
|
426
|
+
if (part?.type !== "tool") {
|
|
373
427
|
continue;
|
|
374
428
|
}
|
|
375
429
|
const key = part.callID ?? part.id;
|
|
376
430
|
const status = part.state?.status;
|
|
377
|
-
if (key && status && status !==
|
|
431
|
+
if (key && status && status !== "pending" && !opts.reportedTools.has(key)) {
|
|
378
432
|
opts.reportedTools.add(key);
|
|
379
433
|
if (opts.onActivity) {
|
|
380
|
-
const tool = part.tool ??
|
|
434
|
+
const tool = part.tool ?? "tool";
|
|
381
435
|
const title = part.state?.title;
|
|
382
436
|
emit(title ? `${tool}: ${title}` : tool);
|
|
383
437
|
}
|
|
@@ -390,9 +444,9 @@ async function pollForCompletion(handle, sessionID, opts) {
|
|
|
390
444
|
// work is done, so there's nothing to finalize.
|
|
391
445
|
if (assistant.info?.time?.completed != null) {
|
|
392
446
|
const text = (assistant.parts ?? [])
|
|
393
|
-
.filter(part => part?.type ===
|
|
394
|
-
.map(part => part.text)
|
|
395
|
-
.join(
|
|
447
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
448
|
+
.map((part) => part.text)
|
|
449
|
+
.join("\n")
|
|
396
450
|
.trim();
|
|
397
451
|
return {
|
|
398
452
|
text,
|