@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.
Files changed (42) hide show
  1. package/README.md +183 -6
  2. package/build/cli.js +24 -17
  3. package/build/commands/ci.js +406 -43
  4. package/build/commands/dismiss.js +16 -16
  5. package/build/commands/doctor.js +173 -26
  6. package/build/commands/init.js +244 -34
  7. package/build/commands/review.js +118 -30
  8. package/build/commands/verify-config.js +214 -0
  9. package/build/config/load.js +154 -52
  10. package/build/config/routing.js +122 -0
  11. package/build/config/schema.js +116 -12
  12. package/build/core/auth.js +32 -29
  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 +44 -44
  19. package/build/core/prompts.js +157 -148
  20. package/build/core/render.js +202 -48
  21. package/build/core/review.js +147 -85
  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/coordinator.md +5 -3
  38. package/templates/dismiss.yml +110 -0
  39. package/templates/routing.jsonc +27 -0
  40. package/templates/scope-config.jsonc +25 -0
  41. package/templates/shared.md +12 -0
  42. package/templates/workflow.yml +50 -20
@@ -1,6 +1,6 @@
1
- import { createOpencode } from '@opencode-ai/sdk';
2
- import { toolMap } from './tools.js';
3
- import { errorMessage, 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,8 +235,8 @@ 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;
@@ -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: 'text', text: args.text }],
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 === 'assistant');
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 === 'number') {
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 !== 'tool') {
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 !== 'pending' && !opts.reportedTools.has(key)) {
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 ?? '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 === 'text' && typeof part.text === 'string')
448
- .map(part => part.text)
449
- .join('\n')
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,