@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.
Files changed (43) hide show
  1. package/README.md +183 -6
  2. package/build/cli.js +24 -17
  3. package/build/commands/ci.js +427 -28
  4. package/build/commands/dismiss.js +16 -16
  5. package/build/commands/doctor.js +172 -32
  6. package/build/commands/init.js +244 -34
  7. package/build/commands/review.js +124 -30
  8. package/build/commands/verify-config.js +214 -0
  9. package/build/config/load.js +155 -52
  10. package/build/config/routing.js +122 -0
  11. package/build/config/schema.js +127 -8
  12. package/build/core/auth.js +101 -38
  13. package/build/core/coordinator.js +5 -5
  14. package/build/core/diff.js +19 -19
  15. package/build/core/exec.js +10 -10
  16. package/build/core/log.js +3 -3
  17. package/build/core/noise.js +52 -52
  18. package/build/core/opencode.js +98 -44
  19. package/build/core/prompts.js +157 -148
  20. package/build/core/render.js +202 -48
  21. package/build/core/review.js +187 -81
  22. package/build/core/router.js +10 -10
  23. package/build/core/schema.js +26 -12
  24. package/build/core/step-summary.js +18 -0
  25. package/build/core/suppress.js +7 -7
  26. package/build/core/tools.js +9 -9
  27. package/build/core/util.js +2 -2
  28. package/build/core/verify.js +25 -25
  29. package/build/reporters/github.js +103 -51
  30. package/build/reporters/terminal.js +19 -19
  31. package/build/sources/github-pr.js +21 -21
  32. package/build/sources/local-git.js +20 -20
  33. package/build/sources/source.js +35 -1
  34. package/package.json +6 -1
  35. package/templates/agents/security.md +5 -0
  36. package/templates/command.yml +164 -0
  37. package/templates/config.jsonc +10 -0
  38. package/templates/coordinator.md +5 -3
  39. package/templates/dismiss.yml +110 -0
  40. package/templates/routing.jsonc +27 -0
  41. package/templates/scope-config.jsonc +25 -0
  42. package/templates/shared.md +12 -0
  43. package/templates/workflow.yml +58 -23
@@ -1,6 +1,6 @@
1
- import { createOpencode } from '@opencode-ai/sdk';
2
- import { toolMap } from './tools.js';
3
- import { sleep } from './util.js';
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 = 'cross-cutting';
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(['read', 'grep']);
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 = 'verifier';
33
- const VERIFIER_TOOLS = toolMap(['read', 'grep']);
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: 'all',
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: 'Cross-file reviewer: issues spanning multiple changed files.',
49
- mode: 'all',
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: 'You are the cross-file code reviewer. Follow the user message exactly and return only the requested JSON.',
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: 'Verifies a finding against the real file (adversarial refute pass).',
58
- mode: 'all',
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: 'You verify code-review findings against the actual source. Follow the user message exactly and return only the requested JSON.',
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['coordinator'] = {
65
- description: 'Consolidates specialist findings into one decision.',
66
- mode: 'all',
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: 'You are the review coordinator. Follow the user message exactly and return only the requested JSON.',
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: 'https://opencode.ai/config.json', agent };
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 === 'object' && ('data' in res || 'error' in 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 === 'string' ? res.error : JSON.stringify(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: '127.0.0.1',
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 = '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.';
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('deadline reached');
118
+ super("deadline reached");
119
119
  this.cost = cost;
120
120
  this.tokens = tokens;
121
121
  }
122
122
  }
123
- const DEADLINE_SENTINEL = Symbol('deadline');
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 = 'AgentTimeoutError';
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?.('time budget reached — asking for findings so far');
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 = '\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.';
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: 'text', text: args.text }],
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 === 'assistant');
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 === 'number') {
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 !== 'tool') {
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 !== 'pending' && !opts.reportedTools.has(key)) {
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 ?? '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 === 'text' && typeof part.text === 'string')
394
- .map(part => part.text)
395
- .join('\n')
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,