@expo/code-review-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -0
  3. package/build/cli.js +54 -0
  4. package/build/commands/ci.js +130 -0
  5. package/build/commands/dismiss.js +97 -0
  6. package/build/commands/doctor.js +81 -0
  7. package/build/commands/init.js +82 -0
  8. package/build/commands/review.js +191 -0
  9. package/build/config/load.js +205 -0
  10. package/build/config/schema.js +65 -0
  11. package/build/core/auth.js +102 -0
  12. package/build/core/coordinator.js +24 -0
  13. package/build/core/diff.js +86 -0
  14. package/build/core/exec.js +61 -0
  15. package/build/core/log.js +10 -0
  16. package/build/core/noise.js +186 -0
  17. package/build/core/opencode.js +412 -0
  18. package/build/core/prompts.js +288 -0
  19. package/build/core/render.js +153 -0
  20. package/build/core/review.js +550 -0
  21. package/build/core/router.js +33 -0
  22. package/build/core/schema.js +107 -0
  23. package/build/core/suppress.js +60 -0
  24. package/build/core/tools.js +16 -0
  25. package/build/core/util.js +11 -0
  26. package/build/core/verify.js +93 -0
  27. package/build/reporters/github.js +166 -0
  28. package/build/reporters/reporter.js +1 -0
  29. package/build/reporters/terminal.js +93 -0
  30. package/build/sources/github-pr.js +36 -0
  31. package/build/sources/local-git.js +107 -0
  32. package/build/sources/source.js +1 -0
  33. package/package.json +43 -0
  34. package/templates/agents/consistency.md +53 -0
  35. package/templates/agents/correctness.md +32 -0
  36. package/templates/agents/security.md +51 -0
  37. package/templates/config.jsonc +44 -0
  38. package/templates/coordinator.md +62 -0
  39. package/templates/shared.md +79 -0
  40. package/templates/workflow.yml +43 -0
@@ -0,0 +1,412 @@
1
+ import { createOpencode } from '@opencode-ai/sdk';
2
+ import { toolMap } from './tools.js';
3
+ import { sleep } from './util.js';
4
+ /** Sum token usage across attempts (for per-task/run totals). */
5
+ export function addTokenUsage(into, from) {
6
+ if (!from) {
7
+ return into;
8
+ }
9
+ into.input = (into.input ?? 0) + (from.input ?? 0);
10
+ into.output = (into.output ?? 0) + (from.output ?? 0);
11
+ into.reasoning = (into.reasoning ?? 0) + (from.reasoning ?? 0);
12
+ into.cache = {
13
+ read: (into.cache?.read ?? 0) + (from.cache?.read ?? 0),
14
+ write: (into.cache?.write ?? 0) + (from.cache?.write ?? 0),
15
+ };
16
+ return into;
17
+ }
18
+ // The coordinator consolidates findings; it needs no repo tools.
19
+ const COORDINATOR_TOOLS = toolMap([]);
20
+ // Agent id for the single combined cross-cutting pass (see review.ts). It MUST be
21
+ // defined here so OpenCode uses this restricted tool set — otherwise the model
22
+ // falls back to a default agent with full tools and crawls the whole repo, which
23
+ // is why the cross-file pass used to wander for its entire time budget.
24
+ export const CROSS_CUTTING_AGENT = 'cross-cutting';
25
+ // Deliberately NO `glob`/`list`: the cross-file pass is given the changed files'
26
+ // patch paths already, and directory crawling is exactly what made it wander into
27
+ // unrelated packages. `read` (open a known file) + `grep` (find a cross-reference
28
+ // among the changed files) are enough to trace interactions.
29
+ const CROSS_CUTTING_TOOLS = toolMap(['read', 'grep']);
30
+ // Verifies a finding by re-reading the actual file (adversarial refute pass). Same
31
+ // restricted tool set — it opens the cited file and checks the claim.
32
+ export const VERIFIER_AGENT = 'verifier';
33
+ const VERIFIER_TOOLS = toolMap(['read', 'grep']);
34
+ /** Build the inline OpenCode config (agents + coordinator) from a repo config. */
35
+ export function buildOpencodeConfig(config) {
36
+ const agent = {};
37
+ for (const reviewer of config.agents) {
38
+ agent[reviewer.id] = {
39
+ description: `${reviewer.id} reviewer`,
40
+ mode: 'all',
41
+ model: reviewer.model,
42
+ temperature: reviewer.temperature,
43
+ prompt: `You are the ${reviewer.id} code reviewer. Follow the user message exactly and return only the requested JSON.`,
44
+ tools: reviewer.tools,
45
+ };
46
+ }
47
+ agent[CROSS_CUTTING_AGENT] = {
48
+ description: 'Cross-file reviewer: issues spanning multiple changed files.',
49
+ mode: 'all',
50
+ // Use the default reviewing model (agents share it unless overridden).
51
+ model: config.agents[0]?.model ?? config.coordinator.model,
52
+ temperature: config.agents[0]?.temperature ?? 0.1,
53
+ prompt: 'You are the cross-file code reviewer. Follow the user message exactly and return only the requested JSON.',
54
+ tools: CROSS_CUTTING_TOOLS,
55
+ };
56
+ agent[VERIFIER_AGENT] = {
57
+ description: 'Verifies a finding against the real file (adversarial refute pass).',
58
+ mode: 'all',
59
+ model: config.agents[0]?.model ?? config.coordinator.model,
60
+ temperature: config.agents[0]?.temperature ?? 0.1,
61
+ prompt: 'You verify code-review findings against the actual source. Follow the user message exactly and return only the requested JSON.',
62
+ tools: VERIFIER_TOOLS,
63
+ };
64
+ agent['coordinator'] = {
65
+ description: 'Consolidates specialist findings into one decision.',
66
+ mode: 'all',
67
+ model: config.coordinator.model,
68
+ temperature: config.coordinator.temperature,
69
+ prompt: 'You are the review coordinator. Follow the user message exactly and return only the requested JSON.',
70
+ tools: COORDINATOR_TOOLS,
71
+ };
72
+ return { $schema: 'https://opencode.ai/config.json', agent };
73
+ }
74
+ /** hey-api style responses come back as { data, error }; unwrap or throw. */
75
+ function unwrap(res) {
76
+ if (res && typeof res === 'object' && ('data' in res || 'error' in res)) {
77
+ if (res.error) {
78
+ throw new Error(typeof res.error === 'string' ? res.error : JSON.stringify(res.error));
79
+ }
80
+ return res.data;
81
+ }
82
+ return res;
83
+ }
84
+ /** Start an in-process OpenCode server with the given inline config. */
85
+ export async function startOpencode(config) {
86
+ const { client, server } = await createOpencode({
87
+ hostname: '127.0.0.1',
88
+ config: config,
89
+ });
90
+ return { client, url: server.url, close: () => server.close() };
91
+ }
92
+ const POLL_INTERVAL_MS = 1000;
93
+ // Emit a "still working" heartbeat if this long passes with no tool activity, so
94
+ // a long model-thinking stretch doesn't look hung in the logs.
95
+ const HEARTBEAT_MS = 45_000;
96
+ // Default per-attempt ceiling. Focused chunk passes finish well under this; the
97
+ // cross-cutting pass is given more (see review.ts). Hitting the cap does NOT mean
98
+ // "retry" — we first interrupt the run and ask the agent to return whatever
99
+ // findings it already has (finalizeOnTimeout), and only fail if that also runs
100
+ // over. Callers must treat AgentTimeoutError as "abandon", never "retry".
101
+ const DEFAULT_MAX_WAIT_MS = 8 * 60 * 1000;
102
+ // Extra budget for the "stop and summarize what you have" finalization prompt.
103
+ const FINALIZE_WAIT_MS = 90 * 1000;
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
+ /**
110
+ * Internal signal that a poll loop passed its deadline. Carries the best-effort
111
+ * cost/tokens of the in-progress (never-completed) assistant message so a
112
+ * timed-out task's spend isn't dropped from the run's metrics.
113
+ */
114
+ class DeadlineReached extends Error {
115
+ cost;
116
+ tokens;
117
+ constructor(cost = 0, tokens) {
118
+ super('deadline reached');
119
+ this.cost = cost;
120
+ this.tokens = tokens;
121
+ }
122
+ }
123
+ const DEADLINE_SENTINEL = Symbol('deadline');
124
+ /**
125
+ * Race a promise against the poll deadline. Without this, a stalled message fetch
126
+ * (a wedged/overloaded OpenCode server) blocks the poll loop past its deadline,
127
+ * because the deadline is only re-checked at the top of the loop — so a single
128
+ * hung fetch could let a task run minutes past its time cap. Returns the sentinel
129
+ * the instant the deadline passes, so the loop enforces the cap even mid-fetch.
130
+ */
131
+ async function raceDeadline(work, deadline) {
132
+ const remaining = deadline - Date.now();
133
+ if (remaining <= 0) {
134
+ return DEADLINE_SENTINEL;
135
+ }
136
+ let timer;
137
+ const timeout = new Promise(resolve => {
138
+ timer = setTimeout(() => resolve(DEADLINE_SENTINEL), remaining);
139
+ });
140
+ try {
141
+ return await Promise.race([work, timeout]);
142
+ }
143
+ finally {
144
+ clearTimeout(timer);
145
+ }
146
+ }
147
+ /**
148
+ * Thrown when an agent exceeds its time budget even after being asked to wrap up.
149
+ * Callers MUST treat this as "abandon this task" — retrying just repeats the same
150
+ * non-convergent run. Carries the cost/tokens burned so the caller can still
151
+ * account for the (abandoned) work.
152
+ */
153
+ export class AgentTimeoutError extends Error {
154
+ cost;
155
+ tokens;
156
+ constructor(agent, minutes, cost = 0, tokens) {
157
+ super(`Agent "${agent}" timed out after ${minutes} minutes (including finalize)`);
158
+ this.name = 'AgentTimeoutError';
159
+ this.cost = cost;
160
+ this.tokens = tokens;
161
+ }
162
+ }
163
+ /**
164
+ * Run a single prompt against the named agent in a fresh session and return the
165
+ * concatenated assistant text.
166
+ *
167
+ * Uses the async prompt + polling rather than the synchronous `session.prompt`:
168
+ * a large diff can keep an agent busy well past undici's 300s headers timeout,
169
+ * which would kill a long-held synchronous request. promptAsync returns
170
+ * immediately and we poll the message list (quick GETs) until the assistant
171
+ * message completes.
172
+ */
173
+ export async function promptAgent(handle, args) {
174
+ const session = unwrap(await handle.client.session.create({ body: { title: args.title } }));
175
+ const reportedTools = new Set();
176
+ await sendSessionPrompt(handle, session.id, {
177
+ agent: args.agent,
178
+ system: args.system,
179
+ text: args.text,
180
+ });
181
+ const maxWaitMs = args.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
182
+ try {
183
+ return await pollForCompletion(handle, session.id, {
184
+ agent: args.agent,
185
+ fromIndex: 0,
186
+ deadline: Date.now() + maxWaitMs,
187
+ onActivity: args.onActivity,
188
+ reportedTools,
189
+ maxToolCalls: args.maxToolCalls,
190
+ });
191
+ }
192
+ catch (error) {
193
+ if (!(error instanceof DeadlineReached)) {
194
+ throw error;
195
+ }
196
+ // Cost/tokens burned during the (never-completed) investigation, so the
197
+ // finalize reply or the abandon path still accounts for them.
198
+ const spentCost = error.cost;
199
+ const spentTokens = error.tokens;
200
+ // Time budget hit. Interrupt the wandering run first.
201
+ await abortQuietly(handle, session.id);
202
+ if (!args.finalizeOnTimeout) {
203
+ throw new AgentTimeoutError(args.agent, Math.round(maxWaitMs / 60000), spentCost, spentTokens);
204
+ }
205
+ // Soft landing: ask the (same, context-carrying) session to return whatever
206
+ // it has now. Only messages after this point count as the answer.
207
+ const baseline = (await fetchMessages(handle, session.id)).length;
208
+ args.onActivity?.('time budget reached — asking for findings so far');
209
+ await sendSessionPrompt(handle, session.id, {
210
+ agent: args.agent,
211
+ system: args.system,
212
+ text: FINALIZE_PROMPT,
213
+ });
214
+ try {
215
+ const result = await pollForCompletion(handle, session.id, {
216
+ agent: args.agent,
217
+ fromIndex: baseline,
218
+ deadline: Date.now() + FINALIZE_WAIT_MS,
219
+ onActivity: args.onActivity,
220
+ reportedTools,
221
+ });
222
+ return {
223
+ ...result,
224
+ cost: result.cost + spentCost,
225
+ tokens: addTokenUsage(addTokenUsage({}, spentTokens), result.tokens),
226
+ truncated: true,
227
+ };
228
+ }
229
+ catch (finalizeError) {
230
+ if (finalizeError instanceof DeadlineReached) {
231
+ await abortQuietly(handle, session.id);
232
+ throw new AgentTimeoutError(args.agent, Math.round((maxWaitMs + FINALIZE_WAIT_MS) / 60000), spentCost + finalizeError.cost, addTokenUsage(addTokenUsage({}, spentTokens), finalizeError.tokens));
233
+ }
234
+ throw finalizeError;
235
+ }
236
+ }
237
+ }
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
+ // Budget for a corrective "re-emit the JSON" reply — no fresh investigation, so
241
+ // it should return almost immediately.
242
+ const CORRECTIVE_WAIT_MS = 2 * 60 * 1000;
243
+ /**
244
+ * Prompt an agent and parse its reply. On a JSON-parse failure, first retry in
245
+ * the SAME session: the model still holds all the file context it read, so the
246
+ * corrective is a cache read and a cheap re-emit — and better for recall than
247
+ * re-investigating from scratch (the usual failure is a truncated/malformed reply
248
+ * after a sound investigation). Only if that also fails do we fall back to a fresh
249
+ * session as a clean-slate last resort. A timeout is NOT a parse failure:
250
+ * promptAgent throws AgentTimeoutError, which propagates so the caller abandons
251
+ * the task instead of retrying a non-convergent run.
252
+ */
253
+ export async function promptAndParse(handle, args, parse) {
254
+ let cost = 0;
255
+ let truncated = false;
256
+ const tokens = {};
257
+ const record = (result) => {
258
+ cost += result.cost;
259
+ truncated = truncated || (result.truncated ?? false);
260
+ addTokenUsage(tokens, result.tokens);
261
+ };
262
+ const first = await promptAgent(handle, args);
263
+ record(first);
264
+ try {
265
+ return { value: parse(first.text), cost, truncated, tokens };
266
+ }
267
+ catch {
268
+ // Same-session corrective retry: send the nudge as a follow-up and wait for
269
+ // the NEW assistant message (past the current message count).
270
+ try {
271
+ const baseline = (await fetchMessages(handle, first.sessionID)).length;
272
+ await sendSessionPrompt(handle, first.sessionID, {
273
+ agent: args.agent,
274
+ system: args.system,
275
+ text: CORRECTIVE,
276
+ });
277
+ const retry = await pollForCompletion(handle, first.sessionID, {
278
+ agent: args.agent,
279
+ fromIndex: baseline,
280
+ deadline: Date.now() + CORRECTIVE_WAIT_MS,
281
+ onActivity: args.onActivity,
282
+ reportedTools: new Set(),
283
+ });
284
+ record(retry);
285
+ return { value: parse(retry.text), cost, truncated, tokens };
286
+ }
287
+ catch {
288
+ // Fresh-session last resort: a clean slate for a genuinely confused run.
289
+ const fresh = await promptAgent(handle, {
290
+ ...args,
291
+ text: args.text + CORRECTIVE,
292
+ finalizeOnTimeout: false,
293
+ });
294
+ record(fresh);
295
+ try {
296
+ return { value: parse(fresh.text), cost, truncated, tokens };
297
+ }
298
+ catch (finalError) {
299
+ throw new Error(`Agent "${args.agent}" did not return parseable JSON after retries: ${finalError instanceof Error ? finalError.message : String(finalError)}`);
300
+ }
301
+ }
302
+ }
303
+ }
304
+ async function fetchMessages(handle, sessionID) {
305
+ return unwrap(await handle.client.session.messages({ path: { id: sessionID } }));
306
+ }
307
+ async function sendSessionPrompt(handle, sessionID, args) {
308
+ unwrap(await handle.client.session.promptAsync({
309
+ path: { id: sessionID },
310
+ body: {
311
+ agent: args.agent,
312
+ system: args.system,
313
+ parts: [{ type: 'text', text: args.text }],
314
+ },
315
+ }));
316
+ }
317
+ async function abortQuietly(handle, sessionID) {
318
+ try {
319
+ await handle.client.session.abort({ path: { id: sessionID } });
320
+ }
321
+ catch {
322
+ // best effort — the session may already be gone
323
+ }
324
+ }
325
+ /**
326
+ * Poll a session for the first assistant message at or after `fromIndex` to
327
+ * complete. `fromIndex` lets a follow-up prompt (finalize, corrective retry)
328
+ * skip the earlier completed message and wait for the NEW reply instead. Throws
329
+ * DeadlineReached once `deadline` passes.
330
+ */
331
+ async function pollForCompletion(handle, sessionID, opts) {
332
+ // Best-effort usage of the in-progress assistant message, so a task that times
333
+ // out before completing still contributes its spend to the run's metrics.
334
+ let lastCost = 0;
335
+ let lastTokens;
336
+ const startedAt = Date.now();
337
+ let lastEmitAt = startedAt;
338
+ const emit = (line) => {
339
+ lastEmitAt = Date.now();
340
+ opts.onActivity?.(line);
341
+ };
342
+ for (;;) {
343
+ if (Date.now() > opts.deadline) {
344
+ throw new DeadlineReached(lastCost, lastTokens);
345
+ }
346
+ await sleep(POLL_INTERVAL_MS);
347
+ // Heartbeat if nothing has been reported for a while (e.g. the model is
348
+ // reasoning without calling tools), so a long pass doesn't look hung.
349
+ if (opts.onActivity && Date.now() - lastEmitAt >= HEARTBEAT_MS) {
350
+ emit(`still working… ${Math.round((Date.now() - startedAt) / 1000)}s elapsed`);
351
+ }
352
+ // Bound the fetch by the deadline: a stalled server can't push the task past
353
+ // its time cap (the overshoot we saw when the server was overloaded).
354
+ const messages = await raceDeadline(fetchMessages(handle, sessionID), opts.deadline);
355
+ if (messages === DEADLINE_SENTINEL) {
356
+ throw new DeadlineReached(lastCost, lastTokens);
357
+ }
358
+ const recent = messages.slice(opts.fromIndex);
359
+ const assistant = [...recent].reverse().find(message => message.info?.role === 'assistant');
360
+ if (!assistant) {
361
+ continue;
362
+ }
363
+ if (typeof assistant.info?.cost === 'number') {
364
+ lastCost = assistant.info.cost;
365
+ }
366
+ if (assistant.info?.tokens) {
367
+ lastTokens = assistant.info.tokens;
368
+ }
369
+ // Track each distinct tool call once (for the tool-call cap) and, the first
370
+ // time it starts, emit a live line so a long run shows what the agent is doing.
371
+ for (const part of assistant.parts ?? []) {
372
+ if (part?.type !== 'tool') {
373
+ continue;
374
+ }
375
+ const key = part.callID ?? part.id;
376
+ const status = part.state?.status;
377
+ if (key && status && status !== 'pending' && !opts.reportedTools.has(key)) {
378
+ opts.reportedTools.add(key);
379
+ if (opts.onActivity) {
380
+ const tool = part.tool ?? 'tool';
381
+ const title = part.state?.title;
382
+ emit(title ? `${tool}: ${title}` : tool);
383
+ }
384
+ }
385
+ }
386
+ if (assistant.info?.error) {
387
+ throw new Error(`Agent "${opts.agent}" returned an error: ${JSON.stringify(assistant.info.error)}`);
388
+ }
389
+ // A completed message ALWAYS wins — return it regardless of tool count; the
390
+ // work is done, so there's nothing to finalize.
391
+ if (assistant.info?.time?.completed != null) {
392
+ const text = (assistant.parts ?? [])
393
+ .filter(part => part?.type === 'text' && typeof part.text === 'string')
394
+ .map(part => part.text)
395
+ .join('\n')
396
+ .trim();
397
+ return {
398
+ text,
399
+ cost: assistant.info?.cost ?? 0,
400
+ sessionID,
401
+ tokens: assistant.info?.tokens,
402
+ };
403
+ }
404
+ // Still in progress: enforce the tool-call cap. An agent that has made this
405
+ // many tool calls without finishing is wandering, not converging — trip the
406
+ // same soft-landing as the wall-clock deadline so it returns what it has.
407
+ if (opts.maxToolCalls != null && opts.reportedTools.size > opts.maxToolCalls) {
408
+ emit(`made ${opts.reportedTools.size} tool calls — wrapping up to stay on budget`);
409
+ throw new DeadlineReached(lastCost, lastTokens);
410
+ }
411
+ }
412
+ }