@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/opencode.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { createOpencode } from
|
|
2
|
-
import { toolMap } from
|
|
3
|
-
import { errorMessage, 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,8 +235,8 @@ 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;
|
|
@@ -270,7 +270,7 @@ export function isTransientApiError(error) {
|
|
|
270
270
|
return false;
|
|
271
271
|
}
|
|
272
272
|
const message = errorMessage(error);
|
|
273
|
-
return TRANSIENT_PATTERNS.some(pattern => pattern.test(message));
|
|
273
|
+
return TRANSIENT_PATTERNS.some((pattern) => pattern.test(message));
|
|
274
274
|
}
|
|
275
275
|
/**
|
|
276
276
|
* Run a model call, retrying with bounded backoff on a transient API error. This
|
|
@@ -364,7 +364,7 @@ async function sendSessionPrompt(handle, sessionID, args) {
|
|
|
364
364
|
body: {
|
|
365
365
|
agent: args.agent,
|
|
366
366
|
system: args.system,
|
|
367
|
-
parts: [{ type:
|
|
367
|
+
parts: [{ type: "text", text: args.text }],
|
|
368
368
|
},
|
|
369
369
|
}));
|
|
370
370
|
}
|
|
@@ -410,11 +410,11 @@ async function pollForCompletion(handle, sessionID, opts) {
|
|
|
410
410
|
throw new DeadlineReached(lastCost, lastTokens);
|
|
411
411
|
}
|
|
412
412
|
const recent = messages.slice(opts.fromIndex);
|
|
413
|
-
const assistant = [...recent].reverse().find(message => message.info?.role ===
|
|
413
|
+
const assistant = [...recent].reverse().find((message) => message.info?.role === "assistant");
|
|
414
414
|
if (!assistant) {
|
|
415
415
|
continue;
|
|
416
416
|
}
|
|
417
|
-
if (typeof assistant.info?.cost ===
|
|
417
|
+
if (typeof assistant.info?.cost === "number") {
|
|
418
418
|
lastCost = assistant.info.cost;
|
|
419
419
|
}
|
|
420
420
|
if (assistant.info?.tokens) {
|
|
@@ -423,15 +423,15 @@ async function pollForCompletion(handle, sessionID, opts) {
|
|
|
423
423
|
// Track each distinct tool call once (for the tool-call cap) and, the first
|
|
424
424
|
// time it starts, emit a live line so a long run shows what the agent is doing.
|
|
425
425
|
for (const part of assistant.parts ?? []) {
|
|
426
|
-
if (part?.type !==
|
|
426
|
+
if (part?.type !== "tool") {
|
|
427
427
|
continue;
|
|
428
428
|
}
|
|
429
429
|
const key = part.callID ?? part.id;
|
|
430
430
|
const status = part.state?.status;
|
|
431
|
-
if (key && status && status !==
|
|
431
|
+
if (key && status && status !== "pending" && !opts.reportedTools.has(key)) {
|
|
432
432
|
opts.reportedTools.add(key);
|
|
433
433
|
if (opts.onActivity) {
|
|
434
|
-
const tool = part.tool ??
|
|
434
|
+
const tool = part.tool ?? "tool";
|
|
435
435
|
const title = part.state?.title;
|
|
436
436
|
emit(title ? `${tool}: ${title}` : tool);
|
|
437
437
|
}
|
|
@@ -444,9 +444,9 @@ async function pollForCompletion(handle, sessionID, opts) {
|
|
|
444
444
|
// work is done, so there's nothing to finalize.
|
|
445
445
|
if (assistant.info?.time?.completed != null) {
|
|
446
446
|
const text = (assistant.parts ?? [])
|
|
447
|
-
.filter(part => part?.type ===
|
|
448
|
-
.map(part => part.text)
|
|
449
|
-
.join(
|
|
447
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
448
|
+
.map((part) => part.text)
|
|
449
|
+
.join("\n")
|
|
450
450
|
.trim();
|
|
451
451
|
return {
|
|
452
452
|
text,
|