co-maintainer 0.5.0 → 0.5.1-beta.2

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 (177) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +5 -3
  3. package/dist/package.json +1 -1
  4. package/dist/src/ai/anthropic.d.ts +55 -0
  5. package/dist/src/ai/anthropic.js +262 -0
  6. package/dist/src/ai/batch.d.ts +2 -2
  7. package/dist/src/ai/batch.js +1 -1
  8. package/dist/src/ai/estimate.d.ts +4 -0
  9. package/dist/src/ai/estimate.js +17 -2
  10. package/dist/src/ai/mermaid_loop.js +7 -4
  11. package/dist/src/ai/openai.d.ts +45 -0
  12. package/dist/src/ai/openai.js +206 -0
  13. package/dist/src/ai/opencode.d.ts +10 -0
  14. package/dist/src/ai/opencode.js +76 -0
  15. package/dist/src/ai/openrouter.d.ts +23 -7
  16. package/dist/src/ai/openrouter.js +39 -23
  17. package/dist/src/ai/pricing.js +1 -1
  18. package/dist/src/ai/provider.d.ts +9 -0
  19. package/dist/src/ai/provider.js +42 -3
  20. package/dist/src/ai/verify.d.ts +9 -1
  21. package/dist/src/ai/verify.js +81 -3
  22. package/dist/src/cli/args.d.ts +1 -2
  23. package/dist/src/cli/args.js +38 -26
  24. package/dist/src/cli/commands/config.d.ts +1 -1
  25. package/dist/src/cli/commands/config.js +10 -4
  26. package/dist/src/cli/commands/probe.js +3 -11
  27. package/dist/src/cli/commands/registry.d.ts +1 -9
  28. package/dist/src/cli/commands/registry.js +75 -10
  29. package/dist/src/cli/commands/review.js +18 -13
  30. package/dist/src/cli/commands/rollback.d.ts +1 -0
  31. package/dist/src/cli/commands/rollback.js +42 -0
  32. package/dist/src/cli/commands/serve.js +2 -3
  33. package/dist/src/cli/commands/set.js +71 -7
  34. package/dist/src/cli/commands/view.js +10 -9
  35. package/dist/src/cli/error.d.ts +4 -4
  36. package/dist/src/cli/error.js +4 -4
  37. package/dist/src/cli/main.d.ts +4 -4
  38. package/dist/src/cli/main.js +13 -8
  39. package/dist/src/cli/prompt.js +1 -1
  40. package/dist/src/cli/review_args.d.ts +9 -3
  41. package/dist/src/cli/review_args.js +28 -5
  42. package/dist/src/cli/review_github.d.ts +31 -0
  43. package/dist/src/cli/review_github.js +128 -0
  44. package/dist/src/cli/review_result.d.ts +27 -16
  45. package/dist/src/cli/review_result.js +7 -7
  46. package/dist/src/config.d.ts +28 -6
  47. package/dist/src/config.js +15 -1
  48. package/dist/src/git/default_branch.d.ts +1 -1
  49. package/dist/src/git/default_branch.js +1 -1
  50. package/dist/src/github/app.js +2 -2
  51. package/dist/src/github/app_manifest.d.ts +1 -7
  52. package/dist/src/github/app_manifest.js +2 -7
  53. package/dist/src/github/client.d.ts +4 -1
  54. package/dist/src/github/client.js +14 -2
  55. package/dist/src/github/gh.js +10 -7
  56. package/dist/src/github/pat.js +4 -4
  57. package/dist/src/github/permissions.d.ts +36 -0
  58. package/dist/src/github/permissions.js +334 -0
  59. package/dist/src/knowledge/facts.js +1 -1
  60. package/dist/src/knowledge/guide.d.ts +3 -3
  61. package/dist/src/knowledge/guide.js +5 -5
  62. package/dist/src/knowledge/probe.js +1 -2
  63. package/dist/src/knowledge/sections.d.ts +3 -3
  64. package/dist/src/knowledge/sections.js +3 -3
  65. package/dist/src/knowledge/skill.d.ts +1 -1
  66. package/dist/src/knowledge/skill.js +4 -4
  67. package/dist/src/knowledge/synthesis.d.ts +1 -1
  68. package/dist/src/knowledge/synthesis.js +5 -5
  69. package/dist/src/knowledge/types.d.ts +1 -1
  70. package/dist/src/local/codegraph_exclude.d.ts +1 -1
  71. package/dist/src/local/codegraph_exclude.js +1 -1
  72. package/dist/src/local/codegraph_prepare.js +1 -1
  73. package/dist/src/local/git_ops.d.ts +1 -1
  74. package/dist/src/local/git_ops.js +1 -1
  75. package/dist/src/local/git_parse.d.ts +2 -2
  76. package/dist/src/local/git_parse.js +2 -2
  77. package/dist/src/local/git_revision.d.ts +1 -1
  78. package/dist/src/local/git_revision.js +1 -1
  79. package/dist/src/local/git_untracked.d.ts +1 -1
  80. package/dist/src/local/git_untracked.js +1 -1
  81. package/dist/src/local/review_local.js +17 -14
  82. package/dist/src/local/review_lock.d.ts +1 -1
  83. package/dist/src/local/review_lock.js +1 -1
  84. package/dist/src/pr/checkout.js +2 -2
  85. package/dist/src/pr/findings.js +1 -1
  86. package/dist/src/pr/findings_json.d.ts +2 -2
  87. package/dist/src/pr/findings_json.js +1 -1
  88. package/dist/src/pr/github_revision.js +1 -1
  89. package/dist/src/pr/review_copy.d.ts +2 -2
  90. package/dist/src/pr/review_copy.js +2 -2
  91. package/dist/src/pr/reviewer.d.ts +6 -0
  92. package/dist/src/pr/reviewer.js +49 -39
  93. package/dist/src/remote/client.d.ts +4 -0
  94. package/dist/src/remote/client.js +84 -23
  95. package/dist/src/remote/fixtures.js +1 -1
  96. package/dist/src/remote/http.d.ts +10 -1
  97. package/dist/src/remote/http.js +15 -3
  98. package/dist/src/remote/schema.d.ts +3 -3
  99. package/dist/src/remote/schema.js +3 -3
  100. package/dist/src/remote/server/body.d.ts +1 -1
  101. package/dist/src/remote/server/body.js +1 -1
  102. package/dist/src/remote/server/byok_keys.d.ts +15 -0
  103. package/dist/src/remote/server/byok_keys.js +40 -0
  104. package/dist/src/remote/server/guides.js +2 -2
  105. package/dist/src/remote/server/routes.js +8 -1
  106. package/dist/src/remote/validate.js +15 -1
  107. package/dist/src/review/blocking.d.ts +2 -2
  108. package/dist/src/review/blocking.js +2 -2
  109. package/dist/src/review/carry_over.d.ts +2 -2
  110. package/dist/src/review/carry_over.js +2 -2
  111. package/dist/src/review/guides.js +1 -1
  112. package/dist/src/server/api/remote_tokens.js +2 -0
  113. package/dist/src/server/api/repos.js +51 -0
  114. package/dist/src/server/api/settings.js +10 -0
  115. package/dist/src/server/errors.d.ts +1 -1
  116. package/dist/src/server/http.js +1 -1
  117. package/dist/src/server/pages/activity.d.ts +2 -0
  118. package/dist/src/server/pages/activity.js +18 -4
  119. package/dist/src/server/pages/add_repo.js +61 -1
  120. package/dist/src/server/pages/analytics.js +18 -8
  121. package/dist/src/server/pages/cost.d.ts +21 -0
  122. package/dist/src/server/pages/cost.js +72 -0
  123. package/dist/src/server/pages/pr.js +5 -4
  124. package/dist/src/server/pages/repo.js +3 -2
  125. package/dist/src/server/pages/repo_prs.js +3 -2
  126. package/dist/src/server/pages/repo_remote.js +3 -2
  127. package/dist/src/server/pages/repo_settings.js +326 -12
  128. package/dist/src/server/pages/router.js +3 -2
  129. package/dist/src/server/pages/settings.js +50 -6
  130. package/dist/src/server/pages/styles.d.ts +1 -1
  131. package/dist/src/server/pages/styles.js +1 -1
  132. package/dist/src/services/credentials.js +8 -11
  133. package/dist/src/services/dashboard.d.ts +22 -9
  134. package/dist/src/services/dashboard.js +16 -2
  135. package/dist/src/services/probe.js +1 -1
  136. package/dist/src/services/remote_review.js +142 -35
  137. package/dist/src/services/remote_review_abort.d.ts +5 -1
  138. package/dist/src/services/remote_review_abort.js +9 -1
  139. package/dist/src/services/review.d.ts +1 -1
  140. package/dist/src/services/review.js +19 -5
  141. package/dist/src/services/review_policy.d.ts +116 -0
  142. package/dist/src/services/review_policy.js +489 -0
  143. package/dist/src/services/setup.d.ts +4 -3
  144. package/dist/src/services/setup.js +18 -16
  145. package/dist/src/services/setup_checklist.js +2 -2
  146. package/dist/src/services/webhook.d.ts +23 -0
  147. package/dist/src/services/webhook.js +141 -34
  148. package/dist/src/store/app_db.d.ts +5 -0
  149. package/dist/src/store/app_db.js +69 -13
  150. package/dist/src/store/backup.d.ts +31 -0
  151. package/dist/src/store/backup.js +158 -0
  152. package/dist/src/store/migrations.d.ts +1 -1
  153. package/dist/src/store/migrations.js +21 -1
  154. package/dist/src/store/remote_review_inputs.d.ts +3 -0
  155. package/dist/src/store/remote_review_inputs.js +3 -3
  156. package/dist/src/store/remote_tokens.d.ts +1 -1
  157. package/dist/src/store/remote_tokens.js +1 -1
  158. package/dist/src/store/repos.d.ts +2 -2
  159. package/dist/src/store/repos.js +1 -1
  160. package/dist/src/store/reviews.d.ts +21 -13
  161. package/dist/src/store/reviews.js +60 -15
  162. package/dist/src/store/rows.d.ts +10 -0
  163. package/dist/src/tools/codegraph.d.ts +4 -4
  164. package/dist/src/tools/codegraph.js +5 -5
  165. package/dist/src/tools/codegraph_exec.d.ts +1 -1
  166. package/dist/src/tools/codegraph_exec.js +1 -1
  167. package/dist/src/types.d.ts +12 -6
  168. package/dist/src/util/cost.d.ts +52 -0
  169. package/dist/src/util/cost.js +70 -0
  170. package/dist/src/util/log.d.ts +1 -1
  171. package/dist/src/util/log.js +2 -2
  172. package/dist/src/util/redact.js +2 -0
  173. package/dist/src/util/run_summary.d.ts +7 -2
  174. package/dist/src/util/run_summary.js +28 -2
  175. package/dist/src/util/time.d.ts +1 -1
  176. package/dist/src/util/time.js +1 -1
  177. package/package.json +1 -1
@@ -2,7 +2,7 @@ import { join } from "node:path";
2
2
  import { normalizePath } from "../review/revision.js";
3
3
  import { lstat, readFile, readLink } from "../util/runtime.js";
4
4
  const NUL_SCAN_BYTES = 8000;
5
- /** Build patch for a new untracked file (plan §10.8). */
5
+ /** Build patch for a new untracked file. */
6
6
  export function patchFromNewFileContent(content) {
7
7
  const scan = content.subarray(0, Math.min(content.length, NUL_SCAN_BYTES));
8
8
  if (scan.includes(0)) {
@@ -1,7 +1,7 @@
1
1
  import { parseArgs } from "../cli/args.js";
2
2
  import { filterReviewConfigArgs, } from "../cli/review_args.js";
3
- import { buildLocalReviewJson, formatHumanLocalReview, resolvedFromFirstReview, reviewExitCodeFromResolved, } from "../cli/review_result.js";
4
- import { emptyAiMetrics, recordAiCost, runInitOrRemake, } from "../services/setup.js";
3
+ import { buildLocalReviewJson, formatHumanLocalReview, humanFindingsFromResolved, resolvedFromFirstReview, reviewExitCodeFromResolved, } from "../cli/review_result.js";
4
+ import { addAiMetrics, emptyAiMetrics, recordAiCost, runInitOrRemake, } from "../services/setup.js";
5
5
  import { aiFor } from "../services/review.js";
6
6
  import { loadGuides } from "../review/guides.js";
7
7
  import { buildCarryPromptSection, classifyCarryItems, guideRebuiltSince, incrementalDiffPaths, parsePreviousVerdicts, resolveCarryOutcomes, } from "../review/carry_over.js";
@@ -11,6 +11,7 @@ import { reviewWorkspaceRevision } from "../pr/reviewer.js";
11
11
  import { runCommand } from "../pr/checkout.js";
12
12
  import { log, startHeartbeat, timed, withCliLogsToStderr, } from "../util/log.js";
13
13
  import { printRunSummary, summaryFromMetrics } from "../util/run_summary.js";
14
+ import { emitGithubOutput } from "../cli/review_github.js";
14
15
  import { setCliInteractive } from "../cli/args.js";
15
16
  import { prepareLocalCodegraph } from "./codegraph_prepare.js";
16
17
  import { canPrompt } from "../tools/codegraph.js";
@@ -19,6 +20,7 @@ import { assertGitQuiet, currentBranch, detectRemoteRepo, gitRoot, headSha, Revi
19
20
  import { EXIT_RUNTIME, exitWith } from "../cli/error.js";
20
21
  import { buildLocalRevision, mergeBase, resolveBaseRef, } from "./git_revision.js";
21
22
  import { clearLocalCarry, localSubjectId, saveLocalCarry, tryLoadLocalCarry, } from "./carry_over_store.js";
23
+ import { costUsage } from "../util/cost.js";
22
24
  function storedFindings(resolved) {
23
25
  return resolved
24
26
  .filter((row) => row.state === "new" || row.state === "open")
@@ -49,7 +51,7 @@ function fail(error, json) {
49
51
  console.error(`Hint: ${error.hint}`);
50
52
  }
51
53
  // Set the exit code and return; `process.exit` here would assert in libuv on
52
- // Windows whenever a fetch pool is open (CORE-11).
54
+ // Windows whenever a fetch pool is open.
53
55
  exitWith(error.exitCode);
54
56
  }
55
57
  function installInterruptCleanup(releaseSync, json) {
@@ -159,7 +161,7 @@ export async function runLocalReview(cli) {
159
161
  let previous = carryLoad.data;
160
162
  // A guide rebuilt after the last review judged its findings under rules
161
163
  // that no longer exist. Rather than let those stale findings mask new
162
- // ones, start fresh automatically (CORE-42 / F03) — the same effect as
164
+ // ones, start fresh automatically — the same effect as
163
165
  // `--fresh`, without making the user remember the flag.
164
166
  if (previous &&
165
167
  guideRebuiltSince(previous.guideBuiltAt, guides.guideBuiltAt)) {
@@ -189,7 +191,7 @@ export async function runLocalReview(cli) {
189
191
  enabled: options.useCodegraph === true,
190
192
  allowInstall: cli.allowToolInstall,
191
193
  // `--json` output must stay machine-readable, so never prompt then;
192
- // `canPrompt` adds the TTY and CI checks (F01).
194
+ // `canPrompt` adds the TTY and CI checks.
193
195
  interactive: !json && canPrompt(),
194
196
  });
195
197
  extras.prepareCodegraphTools = () => Promise.resolve(codegraphPrep.tools);
@@ -199,19 +201,13 @@ export async function runLocalReview(cli) {
199
201
  let response;
200
202
  try {
201
203
  response = await timed("local review AI", options.logTime, () => reviewWorkspaceRevision(revision, options, sha, async (usage) => {
202
- aiMetrics.calls++;
203
- aiMetrics.tokensIn += usage.tokensIn;
204
- aiMetrics.tokensOut += usage.tokensOut;
205
- if (usage.cost === undefined)
206
- aiMetrics.costKnown = false;
207
- else
208
- aiMetrics.cost += usage.cost;
204
+ addAiMetrics(aiMetrics, usage);
209
205
  await recordAiCost(repo, "review_local", usage);
210
206
  }, aiFor(options), (message) => log("review", message), extras));
211
207
  }
212
208
  finally {
213
209
  // Stopping the heartbeat on the error path too, otherwise the interval
214
- // keeps the event loop alive and a post-fetch exit never lands (CORE-11).
210
+ // keeps the event loop alive and a post-fetch exit never lands.
215
211
  stopHeartbeat();
216
212
  await lock?.release();
217
213
  lock = null;
@@ -255,7 +251,7 @@ export async function runLocalReview(cli) {
255
251
  const usage = {
256
252
  tokensIn: aiMetrics.tokensIn,
257
253
  tokensOut: aiMetrics.tokensOut,
258
- costUsd: aiMetrics.costKnown ? aiMetrics.cost : null,
254
+ ...costUsage(aiMetrics),
259
255
  };
260
256
  if (json) {
261
257
  console.log(buildLocalReviewJson({
@@ -273,6 +269,13 @@ export async function runLocalReview(cli) {
273
269
  reviewBlocking: options.reviewBlocking,
274
270
  }));
275
271
  }
272
+ else if (cli.output === "github") {
273
+ emitGithubOutput({
274
+ title: header,
275
+ findings: humanFindingsFromResolved(allResolved, options.reviewBlocking),
276
+ run: summaryFromMetrics(aiMetrics, performance.now() - started),
277
+ });
278
+ }
276
279
  else {
277
280
  console.log("\n" +
278
281
  formatHumanLocalReview(header, revision, response.guideBuiltAt, codegraphState, allResolved, warnings, options.reviewBlocking) +
@@ -3,5 +3,5 @@ export type LocalReviewLock = {
3
3
  /** For signal handlers — must not await before exit. */
4
4
  releaseSync: () => void;
5
5
  };
6
- /** One local review per repo root (plan §13.3). */
6
+ /** One local review per repo root. */
7
7
  export declare function acquireLocalReviewLock(gitRoot: string): Promise<LocalReviewLock>;
@@ -11,7 +11,7 @@ async function lockPath(gitRoot) {
11
11
  await mkdir(dir, { recursive: true });
12
12
  return `${dir}/${hex}.lock`;
13
13
  }
14
- /** One local review per repo root (plan §13.3). */
14
+ /** One local review per repo root. */
15
15
  export async function acquireLocalReviewLock(gitRoot) {
16
16
  const path = await lockPath(gitRoot);
17
17
  let existingPid;
@@ -34,7 +34,7 @@ async function cloneInto(repo, dir, run) {
34
34
  const started = performance.now();
35
35
  // `-c core.longpaths=true` has to be in effect for the clone itself: a deep
36
36
  // source tree can overrun the Windows path limit while the objects are being
37
- // written, and setting the config afterwards is too late to help. CORE-11.
37
+ // written, and setting the config afterwards is too late to help.
38
38
  const result = await run("git", [
39
39
  "-c",
40
40
  "core.longpaths=true",
@@ -45,7 +45,7 @@ async function cloneInto(repo, dir, run) {
45
45
  ]);
46
46
  if (result.code !== 0) {
47
47
  // One actionable line, not the raw multi-line git transcript: the caller
48
- // only needs to know the clone failed and what it costs them. CORE-11.
48
+ // only needs to know the clone failed and what it costs them.
49
49
  const firstLine = result.stderr
50
50
  .split(/\r?\n/)
51
51
  .map((line) => line.trim())
@@ -3,7 +3,7 @@ const FILE_LINE = /(?<path>(?:[\w.-]+\/)*[\w.-]+\.[A-Za-z0-9]+):(?<from>\d+)(?:-
3
3
  * sometimes writes `:` where the prompt asked for the em dash, and sometimes
4
4
  * leaves the symbol off. Both are accepted: an unrecognised heading used to
5
5
  * send every finding down the fallback path, which is what produced the
6
- * truncated and shifted output in F02. The separator may also sit directly
6
+ * truncated and shifted output. The separator may also sit directly
7
7
  * against the path (`\`src/a.ts\`: \`helper()\``), which is how the model
8
8
  * usually writes the colon form. */
9
9
  const NEW_HEADING = /^\[(?<severity>P[0-3])\s*·\s*(?<impact>blocking|non-blocking)\]\s+(?<path>.+?)(?:\s*(?:—|:)\s*(?<symbol>.+))?$/;
@@ -1,6 +1,6 @@
1
- /** Structured review output (CORE-40 / F02).
1
+ /** Structured review output.
2
2
  *
3
- * F02: findings were truncated mid-sentence (`... while \`u`) and split at the
3
+ * Findings were truncated mid-sentence (`... while \`u`) and split at the
4
4
  * wrong offsets, because the model's free-form Markdown was sliced by the
5
5
  * fallback parser. The fix is to have the model return JSON and generate the
6
6
  * Markdown ourselves, so a finding's boundaries can no longer be guessed from
@@ -78,7 +78,7 @@ function asText(value) {
78
78
  }
79
79
  /** Like {@link asText} but keeps the leading indentation. A suggestion is
80
80
  * source lines with their original indentation, so trimming the left edge
81
- * changes the code it proposes (CORE-40). Only the outer blank lines go. */
81
+ * changes the code it proposes. Only the outer blank lines go. */
82
82
  function asSource(value) {
83
83
  return typeof value === "string"
84
84
  ? value.replace(/^\s*\n/, "").replace(/\s+$/, "")
@@ -14,7 +14,7 @@ export function githubFileToRevisionFile(file) {
14
14
  const additions = Number(file.additions ?? 0);
15
15
  const deletions = Number(file.deletions ?? 0);
16
16
  const patch = String(file.patch ?? "");
17
- // GitHub omits large text patches; do not treat that as binary (plan §10.7).
17
+ // GitHub omits large text patches; do not treat that as binary.
18
18
  const binary = false;
19
19
  return {
20
20
  path,
@@ -1,4 +1,4 @@
1
- /** The severity legend and the review-copy rule (CORE-83 / D11).
1
+ /** The severity legend and the review-copy rule.
2
2
  *
3
3
  * Product text avoids the em dash and the semicolon: the docs linter rejects
4
4
  * them, and the finding parser had to accept both a `—` and a `:` heading
@@ -15,6 +15,6 @@ export declare const SEVERITY_LEGEND: ReadonlyArray<{
15
15
  /** The `## Severity` block the review text starts with. The colon keeps it
16
16
  * free of the em dash the old prompt used. */
17
17
  export declare function severitySection(): string;
18
- /** What the model is told about prose (CORE-83). The review body is rendered
18
+ /** What the model is told about prose. The review body is rendered
19
19
  * by our code, not the model, so this only has to keep the fields clean. */
20
20
  export declare const REVIEW_COPY_RULE = "Write prose without the em dash or the semicolon. Use a colon or a comma instead.";
@@ -1,4 +1,4 @@
1
- /** The severity legend and the review-copy rule (CORE-83 / D11).
1
+ /** The severity legend and the review-copy rule.
2
2
  *
3
3
  * Product text avoids the em dash and the semicolon: the docs linter rejects
4
4
  * them, and the finding parser had to accept both a `—` and a `:` heading
@@ -35,6 +35,6 @@ export function severitySection() {
35
35
  const items = SEVERITY_LEGEND.map((row) => `- ${row.level}: ${row.name}: ${row.description}.`);
36
36
  return `## Severity\n\n${items.join("\n")}\n`;
37
37
  }
38
- /** What the model is told about prose (CORE-83). The review body is rendered
38
+ /** What the model is told about prose. The review body is rendered
39
39
  * by our code, not the model, so this only has to keep the fields clean. */
40
40
  export const REVIEW_COPY_RULE = "Write prose without the em dash or the semicolon. Use a colon or a comma instead.";
@@ -14,6 +14,12 @@ export declare const MERMAID_GUIDANCE = "Mermaid selection and minimal syntax:\n
14
14
  * asking for a diagram only invites invented syntax. */
15
15
  export declare const NO_DIAGRAM_RULES = "Do not use Mermaid or any other diagram. Explain with prose only.";
16
16
  export declare function reviewSystemPrompt(diagrams: boolean): string;
17
+ /** The guide block every review sends, kept as the stable head of the system
18
+ * prompt rather than the user prompt so a provider with prompt caching
19
+ * (Anthropic's `cache_control`) reads it instead of re-billing it. Nothing
20
+ * time-varying may enter this string — a single differing byte makes the cache
21
+ * miss. */
22
+ export declare function reviewGuidesBlock(guide: string, detailed: string, codebase: string): string;
17
23
  /** Shared PR and local/remote workspace review instructions: confirm claims
18
24
  * against the indexed graph, not only the diff slice. */
19
25
  export declare const CODEGRAPH_DIFF_VERIFICATION = "Examine the changes line by line, not just file by file. A single file can\ncontain more than one independent defect, and a change that looks fine in\nisolation can be wrong once you trace what calls it or what else it affects.\nWhen a finding depends on behavior outside the changed lines, confirm it with\ncodegraph-node, codegraph-callers, codegraph-callees, codegraph-impact, and\ncodegraph-affected before reporting it. Drop or correct findings that only seem\nplausible from the diff but contradict unchanged callers, callees, or the same\npattern elsewhere in the repo. Use those tools to check blast radius and whether\na test reaches the path. Do not guess coverage or impact from the diff alone\nwhen a tool can answer. A missing regression test is not a substitute for\nidentifying the concrete input or code path that misbehaves when you can.";
@@ -1,4 +1,5 @@
1
- import { OpenRouterProvider } from "../ai/openrouter.js";
1
+ import { createAiProvider } from "../ai/provider.js";
2
+ import { CliError, EXIT_USAGE } from "../cli/error.js";
2
3
  import { completeWithMermaidTools, } from "../ai/mermaid_loop.js";
3
4
  import { loadGuides } from "../review/guides.js";
4
5
  import { computeScope } from "./scope.js";
@@ -7,6 +8,23 @@ import { REVIEW_COPY_RULE, severitySection } from "./review_copy.js";
7
8
  import { needsSummary, READ_FULL_DIFF_TOOL, readFullDiff, summarizeDiff, } from "./diff_summary.js";
8
9
  import { numberPatch } from "./hunks.js";
9
10
  import { FINDINGS_JSON_INSTRUCTIONS, FINDINGS_JSON_SCHEMA, findingsMarkdownFromJson, } from "./findings_json.js";
11
+ /** A model id is never guessed here. Models go stale, and a build already
12
+ * installed cannot fetch a fresher one, so the id to run comes from config
13
+ * or a flag, set at least once by whoever runs it (Murat, 2026-09-28). */
14
+ /** The configured provider for one model. Built only when a call needs it:
15
+ * a provider's constructor requires a real key, which a fake-AI run never has. */
16
+ function reviewProvider(options, model, label) {
17
+ const provider = createAiProvider(options, requiredModel(model, label));
18
+ if (!provider) {
19
+ throw new CliError("missing_provider", "Review needs an AI provider.", "Run co-maintainer set --ai=... --token=...", EXIT_USAGE);
20
+ }
21
+ return provider;
22
+ }
23
+ function requiredModel(model, label) {
24
+ if (model)
25
+ return model;
26
+ throw new CliError("missing_model", `No ${label} model is configured.`, `Run co-maintainer set --${label}-model=..., or pass --${label}-model=... on the command that needs it.`, EXIT_USAGE);
27
+ }
10
28
  const MAX_REVIEW_DIFF_CHARS = 240_000;
11
29
  export const MERMAID_GUIDANCE = `Mermaid selection and minimal syntax:
12
30
  flowchart = decisions, branches, pipelines, and fallback paths;
@@ -59,6 +77,21 @@ export function reviewSystemPrompt(diagrams) {
59
77
  return `${REVIEW_ROLE}
60
78
  ${diagrams ? REVIEW_DIAGRAM_RULES : NO_DIAGRAM_RULES}`;
61
79
  }
80
+ /** The guide block every review sends, kept as the stable head of the system
81
+ * prompt rather than the user prompt so a provider with prompt caching
82
+ * (Anthropic's `cache_control`) reads it instead of re-billing it. Nothing
83
+ * time-varying may enter this string — a single differing byte makes the cache
84
+ * miss. */
85
+ export function reviewGuidesBlock(guide, detailed, codebase) {
86
+ return `REVIEW GUIDE:
87
+ ${guide}
88
+
89
+ DETAILED GUIDE:
90
+ ${detailed}
91
+
92
+ CODEBASE CONVENTIONS:
93
+ ${codebase || "None recorded."}`;
94
+ }
62
95
  /** Shared PR and local/remote workspace review instructions: confirm claims
63
96
  * against the indexed graph, not only the diff slice. */
64
97
  export const CODEGRAPH_DIFF_VERIFICATION = `Examine the changes line by line, not just file by file. A single file can
@@ -78,8 +111,8 @@ function text(value, limit = 20_000) {
78
111
  ? `${result.slice(0, limit)}\n[truncated]`
79
112
  : result;
80
113
  }
81
- /** Turns a model reply into the Markdown every consumer already parses
82
- * (CORE-40 / F02). A reply that is not usable JSON is retried once with an
114
+ /** Turns a model reply into the Markdown every consumer already parses.
115
+ * A reply that is not usable JSON is retried once with an
83
116
  * explicit reminder; if that also fails, the raw text is returned so the
84
117
  * legacy Markdown parser can still read it. This keeps a provider that ignores
85
118
  * `response_format` working, and never drops a review on the floor. */
@@ -219,12 +252,10 @@ export async function reviewPullRequest(client, options, usage, snapshot, ai, pr
219
252
  if (scope) {
220
253
  report(`scope resolved · own=${ownFiles.length} files · upstream=${upstreamFiles.length} files`);
221
254
  }
222
- // Built only if needed: OpenRouterProvider's constructor requires a real
223
- // API key, which a fake-AI test run never has.
255
+ // Built only if needed, see reviewProvider.
224
256
  const filesNeedSummary = ownFiles.some(({ file }) => needsSummary(Number(file.changes ?? 0), String(file.patch ?? "")));
225
257
  const lowProvider = filesNeedSummary
226
- ? (ai ??
227
- new OpenRouterProvider(options.aiToken ?? "", options.lowModel ?? "openai/gpt-oss-120b"))
258
+ ? (ai ?? reviewProvider(options, options.lowModel, "low"))
228
259
  : undefined;
229
260
  const patchByPath = new Map();
230
261
  const [ownSections, codegraphTools] = await Promise.all([
@@ -288,8 +319,7 @@ interaction if the pull request's own change above relies on or conflicts with
288
319
  one of these files, and never mark that finding blocking:
289
320
  ${upstreamListing}${unchangedListing}`;
290
321
  report(`diff prepared · ${ownPatch.length} chars${diffWasTruncated ? " · truncated for model context" : ""}`);
291
- const provider = ai ??
292
- new OpenRouterProvider(options.aiToken ?? "", options.highModel ?? "openai/gpt-5.6-luna");
322
+ const provider = ai ?? reviewProvider(options, options.highModel, "high");
293
323
  const diagrams = provider.supportsTools !== false;
294
324
  const carryBlock = extras?.carryPrompt ? `${extras.carryPrompt}\n` : "";
295
325
  const prompt = `Review this pull request against the repository's review guide and
@@ -324,15 +354,6 @@ code to the nearest numbered line.
324
354
  ${diagrams ? DIAGRAM_PROMPT_RULES : NO_DIAGRAM_RULES}
325
355
  ${FINDINGS_JSON_INSTRUCTIONS}
326
356
 
327
- REVIEW GUIDE:
328
- ${guide}
329
-
330
- DETAILED GUIDE:
331
- ${detailed}
332
-
333
- CODEBASE CONVENTIONS:
334
- ${codebase || "None recorded."}
335
-
336
357
  PULL REQUEST:
337
358
  ${JSON.stringify({
338
359
  number,
@@ -349,17 +370,17 @@ ${diff}`;
349
370
  const matrix = clampImproveMatrix(options.improveMatrix);
350
371
  const request = {
351
372
  job: "review_pull_request",
352
- system: reviewSystemPrompt(diagrams),
373
+ system: `${reviewSystemPrompt(diagrams)}\n\n${reviewGuidesBlock(guide, detailed, codebase)}`,
353
374
  prompt,
354
375
  maxTokens: 24_000 * matrix,
355
376
  reasoningEffort: "high",
356
377
  responseFormat: FINDINGS_JSON_SCHEMA,
357
378
  };
358
- report(`AI request · model=${options.highModel ?? "openrouter default"} · ` +
379
+ report(`AI request · ${options.ai} · model=${options.highModel ?? "unset"} · ` +
359
380
  `prompt=${prompt.length} chars · maxTokens=${request.maxTokens}`);
360
381
  if (options.debug) {
361
382
  console.log(`[debug] review prompt · ${prompt.length} chars · diff=${diff.length} chars`);
362
- console.log(`[debug] openrouter request · model=${options.highModel ?? "openai/gpt-5.6-luna"} · maxTokens=${request.maxTokens}`);
383
+ console.log(`[debug] ${options.ai} request · model=${options.highModel ?? "unset"} · maxTokens=${request.maxTokens}`);
363
384
  }
364
385
  let response = await completeWithMermaidTools(provider, request, 1, extraTools, maxToolRounds);
365
386
  if (usage)
@@ -370,9 +391,9 @@ ${diff}`;
370
391
  console.log(`\n----- INITIAL REVIEW -----\n${response.text}\n`);
371
392
  }
372
393
  if (!response.text.trim()) {
373
- throw new Error("OpenRouter returned an empty review. The reasoning budget may have been exhausted");
394
+ throw new Error(`${options.ai} returned an empty review. The reasoning budget may have been exhausted`);
374
395
  }
375
- // The model returns JSON; we render the Markdown (CORE-40 / F02). A reply
396
+ // The model returns JSON; we render the Markdown. A reply
376
397
  // that is not usable JSON is retried once and then falls back to the legacy
377
398
  // Markdown parser, so a provider that ignores the schema still works.
378
399
  let reviewText = await normalizeReviewResponse(provider, request, response, extraTools, maxToolRounds, usage, report, options);
@@ -402,7 +423,7 @@ ${reviewText}`,
402
423
  report(`AI improvement response · input=${response.tokensIn} tokens · ` +
403
424
  `output=${response.tokensOut} tokens`);
404
425
  if (!response.text.trim()) {
405
- throw new Error(`OpenRouter returned an empty review improvement at pass ${pass - 1}`);
426
+ throw new Error(`${options.ai} returned an empty review improvement at pass ${pass - 1}`);
406
427
  }
407
428
  reviewText = await normalizeReviewResponse(provider, improvementRequest, response, extraTools, maxToolRounds, usage, report, options);
408
429
  if (options.debug) {
@@ -456,8 +477,7 @@ export async function reviewWorkspaceRevision(revision, options, headSha, usage,
456
477
  const changes = Number(file.changes ?? 0);
457
478
  const patch = String(file.patch ?? "");
458
479
  const lowProvider = needsSummary(changes, patch)
459
- ? (ai ??
460
- new OpenRouterProvider(options.aiToken ?? "", options.lowModel ?? "openai/gpt-oss-120b"))
480
+ ? (ai ?? reviewProvider(options, options.lowModel, "low"))
461
481
  : undefined;
462
482
  if (!lowProvider)
463
483
  return `FILE: ${path}\n${filePatch(file)}`;
@@ -491,8 +511,7 @@ export async function reviewWorkspaceRevision(revision, options, headSha, usage,
491
511
  .join("\n")}`
492
512
  : "";
493
513
  const carryBlock = extras?.carryPrompt ? `${extras.carryPrompt}\n` : "";
494
- const provider = ai ??
495
- new OpenRouterProvider(options.aiToken ?? "", options.highModel ?? "openai/gpt-5.6-luna");
514
+ const provider = ai ?? reviewProvider(options, options.highModel, "high");
496
515
  const diagrams = provider.supportsTools !== false;
497
516
  const prompt = `Review these local changes against the repository's review guide and
498
517
  codebase conventions. Find only actionable code-level violations supported by
@@ -504,15 +523,6 @@ ${CODEGRAPH_DIFF_VERIFICATION}
504
523
  ${diagrams ? DIAGRAM_PROMPT_RULES : NO_DIAGRAM_RULES}
505
524
  ${FINDINGS_JSON_INSTRUCTIONS}
506
525
 
507
- REVIEW GUIDE:
508
- ${guide}
509
-
510
- DETAILED GUIDE:
511
- ${detailed}
512
-
513
- CODEBASE CONVENTIONS:
514
- ${codebase || "None recorded."}
515
-
516
526
  WORKSPACE:
517
527
  ${JSON.stringify({
518
528
  branch: revision.baseLabel,
@@ -524,7 +534,7 @@ ${ownDiff}${unchangedListing}`;
524
534
  const matrix = clampImproveMatrix(options.improveMatrix);
525
535
  const request = {
526
536
  job: "review_local",
527
- system: reviewSystemPrompt(diagrams),
537
+ system: `${reviewSystemPrompt(diagrams)}\n\n${reviewGuidesBlock(guide, detailed, codebase)}`,
528
538
  prompt,
529
539
  maxTokens: 24_000 * matrix,
530
540
  reasoningEffort: "high",
@@ -535,7 +545,7 @@ ${ownDiff}${unchangedListing}`;
535
545
  if (usage)
536
546
  await usage(response);
537
547
  if (!response.text.trim()) {
538
- throw new Error("OpenRouter returned an empty review");
548
+ throw new Error(`${options.ai} returned an empty review`);
539
549
  }
540
550
  let reviewText = await normalizeReviewResponse(provider, request, response, extraTools, maxToolRounds, usage, report, options);
541
551
  for (let pass = 2; pass <= matrix; pass++) {
@@ -1,4 +1,8 @@
1
1
  import type { ReviewCliArgs } from "../cli/review_args.ts";
2
+ /** A plain-http, non-local host means the BYOK key travels in the clear.
3
+ * Pulled out as a pure function so the rule is unit-testable
4
+ * without standing up a plain-http fake server. */
5
+ export declare function byokHttpWarning(host: string): string | null;
2
6
  export declare function runRemoteReview(cli: ReviewCliArgs & {
3
7
  mode: "remote";
4
8
  }): Promise<void>;
@@ -1,8 +1,9 @@
1
1
  import { VERSION } from "../version.js";
2
2
  import { CliError, EXIT_USAGE, exitWith } from "../cli/error.js";
3
3
  import { formatHumanReview, humanFindingsFromJson, reviewExitCodeFromJsonFindings, } from "../cli/review_result.js";
4
- import { baseUrl, die, readApiError, remoteFetch } from "./http.js";
4
+ import { baseUrl, die, readApiError, remoteFetch, remoteFromEnv, } from "./http.js";
5
5
  import { readConfig, writeUserConfig } from "../config.js";
6
+ import { getEnv } from "../util/runtime.js";
6
7
  import { prepareLocalCodegraph } from "../local/codegraph_prepare.js";
7
8
  import { canPrompt } from "../tools/codegraph.js";
8
9
  import { runRemoteToolCalls, toolHandlerMap, } from "./client_tools.js";
@@ -11,19 +12,42 @@ import { runCommand } from "../pr/checkout.js";
11
12
  import { assertGitQuiet, currentBranch, detectRemoteRepo, gitRoot, } from "../local/git_ops.js";
12
13
  import { buildLocalRevision, mergeBase, resolveBaseRef, } from "../local/git_revision.js";
13
14
  import { withCliLogsToStderr } from "../util/log.js";
14
- import { printRunSummary, summaryFromMetrics } from "../util/run_summary.js";
15
+ import { printRunSummary } from "../util/run_summary.js";
16
+ import { emitGithubOutput, neutralizeCommands } from "../cli/review_github.js";
17
+ import { costReasonText } from "../util/cost.js";
15
18
  import { setCliInteractive } from "../cli/args.js";
16
19
  import { MIN_SERVER_SCHEMA, REMOTE_CLI_UPGRADE_COMMAND, REMOTE_SCHEMA_VERSION, } from "./schema.js";
20
+ const LOCAL_BYOK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
21
+ /** A plain-http, non-local host means the BYOK key travels in the clear.
22
+ * Pulled out as a pure function so the rule is unit-testable
23
+ * without standing up a plain-http fake server. */
24
+ export function byokHttpWarning(host) {
25
+ const url = new URL(baseUrl(host));
26
+ if (url.protocol !== "http:" || LOCAL_BYOK_HOSTNAMES.has(url.hostname)) {
27
+ return null;
28
+ }
29
+ return (`Warning: sending your AI key over plain http to ${url.hostname}. ` +
30
+ "Anyone on the network path can read it.");
31
+ }
17
32
  export async function runRemoteReview(cli) {
18
33
  const config = readConfig();
19
- // Inline flags win over the saved config for this run only (CORE-25), so a
34
+ // Inline flags win over the saved config for this run only, so a
20
35
  // one-off review does not have to be written to disk first.
21
- const host = cli.remoteHost ?? config.remoteHost;
22
- const token = cli.remoteToken ?? config.remoteToken;
36
+ const fromEnv = remoteFromEnv();
37
+ const host = cli.remoteHost ?? fromEnv.host ?? config.remoteHost;
38
+ const token = cli.remoteToken ?? fromEnv.token ?? config.remoteToken;
23
39
  if (!host || !token) {
24
40
  die("remote_not_configured", "Remote review is not configured.", "co-maintainer config set remote-host https://your-server " +
25
41
  "&& co-maintainer config set remote-token <token> " +
26
- "(or pass --remote-host=... --remote-token=... for one run)");
42
+ "(or pass --remote-host=... --remote-token=... for one run, or set CM_REMOTE_HOST and CM_REMOTE_TOKEN)");
43
+ }
44
+ // The client's own AI key, used only when this run turns BYOK
45
+ // on. The env var wins so a hosted CI runner can supply it without writing
46
+ // config, mirroring how CM_REMOTE_BYOK_POLICY overrides the server side.
47
+ const byokKey = getEnv("CM_REMOTE_BYOK") ?? config.remoteByok;
48
+ const byokEnabled = cli.remoteByok ?? config.remoteByokDefault ?? false;
49
+ if (byokEnabled && !byokKey) {
50
+ die("remote_byok_not_configured", "No BYOK key is set.", "co-maintainer config set remote-byok <key>");
27
51
  }
28
52
  setCliInteractive(!cli.json);
29
53
  await withCliLogsToStderr(async () => {
@@ -76,6 +100,25 @@ export async function runRemoteReview(cli) {
76
100
  if (hs.schemaVersion < MIN_SERVER_SCHEMA) {
77
101
  die("server_too_old", "The dashboard server is too old for this CLI.", undefined, 4);
78
102
  }
103
+ // Never silently fall back to the server's own key. Each of
104
+ // these is a mismatch between what this run wants and what the server
105
+ // will do, so all four are a hard stop.
106
+ const serverSupportsByok = hs.features?.includes("byok") ?? false;
107
+ if (byokEnabled && !serverSupportsByok) {
108
+ die("remote_byok_unsupported", `This server (version ${hs.serverVersion}) does not support your own key.`, "Upgrade the server or run without --remote-byok.");
109
+ }
110
+ if (byokEnabled && hs.byok?.policy === "off") {
111
+ die("remote_byok_rejected", "This server does not accept your own key.");
112
+ }
113
+ if (serverSupportsByok && hs.byok?.policy === "require" && !byokEnabled) {
114
+ die("remote_byok_required", "This server requires your own key.", "co-maintainer review --remote --remote-byok");
115
+ }
116
+ if (byokEnabled && !cli.json) {
117
+ const warning = byokHttpWarning(host);
118
+ if (warning)
119
+ console.error(warning);
120
+ console.error(`Using your own key with the server's provider: ${hs.ai?.provider ?? "unknown"}.`);
121
+ }
79
122
  const hostKey = baseUrl(host);
80
123
  const shown = config.remoteNoticeShownFor ?? [];
81
124
  if (!shown.includes(hostKey) && !cli.json) {
@@ -89,7 +132,7 @@ export async function runRemoteReview(cli) {
89
132
  gitRoot: root,
90
133
  enabled: !cli.disableCodegraph,
91
134
  allowInstall: cli.allowToolInstall,
92
- // `--json` must stay machine-readable; `canPrompt` adds TTY and CI (F01).
135
+ // `--json` must stay machine-readable; `canPrompt` adds TTY and CI.
93
136
  interactive: !cli.json && canPrompt(),
94
137
  });
95
138
  const localTools = toolHandlerMap(codegraphPrep.tools);
@@ -111,6 +154,9 @@ export async function runRemoteReview(cli) {
111
154
  files: built.revision.files,
112
155
  },
113
156
  capabilities: { tools: capabilityTools },
157
+ // Only present when this run turned BYOK on; `byokKey` is
158
+ // known to be set here because of the earlier `byokEnabled` guard.
159
+ ...(byokEnabled ? { byok: { key: byokKey } } : {}),
114
160
  };
115
161
  const serialized = JSON.stringify(submitBody);
116
162
  if (serialized.length > hs.limits.maxBodyBytes) {
@@ -143,14 +189,33 @@ export async function runRemoteReview(cli) {
143
189
  const payload = (await sync.json());
144
190
  for (const line of payload.logs ?? []) {
145
191
  afterLogSeq = Math.max(afterLogSeq, line.seq);
146
- if (!cli.json)
147
- console.error(line.message);
192
+ if (!cli.json) {
193
+ console.error(cli.output === "github"
194
+ ? neutralizeCommands(line.message)
195
+ : line.message);
196
+ }
148
197
  }
149
198
  if (payload.toolCalls?.length) {
150
199
  toolResults = await runRemoteToolCalls(payload.toolCalls, localTools);
151
200
  continue;
152
201
  }
153
202
  if (payload.status === "done" && payload.result) {
203
+ const findings = (payload.result.findings ?? []);
204
+ const usage = payload.result.usage;
205
+ const known = usage !== undefined &&
206
+ usage.costUsd !== null &&
207
+ usage.costUsd !== undefined;
208
+ const run = usage
209
+ ? {
210
+ durationMs: performance.now() - reviewStarted,
211
+ tokensIn: usage.tokensIn ?? 0,
212
+ tokensOut: usage.tokensOut ?? 0,
213
+ costUsd: known ? usage.costUsd : null,
214
+ ...(!known && usage.costNote
215
+ ? { costNote: costReasonText(usage.costNote) }
216
+ : {}),
217
+ }
218
+ : undefined;
154
219
  if (cli.json) {
155
220
  console.log(JSON.stringify({
156
221
  schemaVersion: 1,
@@ -159,9 +224,14 @@ export async function runRemoteReview(cli) {
159
224
  ...payload.result,
160
225
  }));
161
226
  }
227
+ else if (cli.output === "github") {
228
+ emitGithubOutput({
229
+ title: `${repo} · ${branch} (remote)`,
230
+ findings: humanFindingsFromJson(findings),
231
+ run,
232
+ });
233
+ }
162
234
  else {
163
- const findings = (payload.result.findings ??
164
- []);
165
235
  const resultGuide = payload.result.guide;
166
236
  const resultCodegraph = payload.result.codegraph;
167
237
  console.log("\n" +
@@ -172,22 +242,13 @@ export async function runRemoteReview(cli) {
172
242
  findings: humanFindingsFromJson(findings),
173
243
  }) +
174
244
  "\n");
175
- const usage = payload.result.usage;
176
- if (usage) {
177
- printRunSummary(summaryFromMetrics({
178
- calls: 0,
179
- tokensIn: usage.tokensIn ?? 0,
180
- tokensOut: usage.tokensOut ?? 0,
181
- cost: usage.costUsd ?? 0,
182
- costKnown: usage.costUsd !== null && usage.costUsd !== undefined,
183
- }, performance.now() - reviewStarted));
184
- }
245
+ if (run)
246
+ printRunSummary(run);
185
247
  }
186
- const findings = (payload.result.findings ?? []);
187
248
  exitWith(reviewExitCodeFromJsonFindings(findings));
188
249
  // `done` is terminal. Without this return the loop polls `sync` again
189
250
  // and, since the server keeps reporting the same finished job, the
190
- // client reprints the result and re-requests forever (CORE-25).
251
+ // client reprints the result and re-requests forever.
191
252
  return;
192
253
  }
193
254
  if (payload.status === "failed" || payload.status === "canceled") {
@@ -1,4 +1,4 @@
1
- /** Fixture directory hashing for plan §15.3.
1
+ /** Fixture directory hashing.
2
2
  *
3
3
  * The hash exists to catch schema/content drift in the wire fixtures, not
4
4
  * line-ending drift, so `\r\n` is normalized to `\n` before hashing. Without
@@ -1,4 +1,13 @@
1
- /** A usage error the CLI prints with its hint and exit code (CORE-12). */
1
+ /** Where the remote server is, as an environment variable can say it.
2
+ * A CI job has no config file, and a token on the command line
3
+ * ends up in the process list, so `CM_REMOTE_HOST` and `CM_REMOTE_TOKEN` are
4
+ * read here. An empty value counts as unset, so a workflow secret that did not
5
+ * resolve falls through to the "not configured" error instead of a blank host. */
6
+ export declare function remoteFromEnv(): {
7
+ host?: string;
8
+ token?: string;
9
+ };
10
+ /** A usage error the CLI prints with its hint and exit code. */
2
11
  export declare function die(code: string, message: string, hint?: string, exitCode?: number): never;
3
12
  export declare function baseUrl(host: string): string;
4
13
  export declare function remoteFetch(host: string, token: string, path: string, init: RequestInit): Promise<Response>;