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
@@ -1,4 +1,4 @@
1
- /** Pre-flight checks for `config set` (CORE-22, F35).
1
+ /** Pre-flight checks for `config set`.
2
2
  *
3
3
  * A wrong API key or an unknown model used to be accepted by `set` and only
4
4
  * surfaced during a review, after the pull request had been fetched and
@@ -7,6 +7,8 @@
7
7
  * user's mistake, so it warns and saves instead of refusing. */
8
8
  import { CliError, EXIT_USAGE } from "../cli/error.js";
9
9
  import { getEnv } from "../util/runtime.js";
10
+ import { openAiBase } from "./openai.js";
11
+ import { anthropicBase, ANTHROPIC_VERSION } from "./anthropic.js";
10
12
  /** The chat endpoint the provider uses, with `/chat/completions` removed.
11
13
  * `CM_OPENROUTER_URL` points at a local fake in tests, so the base follows it
12
14
  * and the verification hits the same server the review would. */
@@ -15,11 +17,11 @@ export function openRouterBase() {
15
17
  "https://openrouter.ai/api/v1/chat/completions";
16
18
  return endpoint.replace(/\/chat\/completions\/?$/, "");
17
19
  }
18
- async function getJson(url, apiKey) {
20
+ async function getJson(url, apiKey, headers) {
19
21
  let response;
20
22
  try {
21
23
  response = await fetch(url, {
22
- headers: { Authorization: `Bearer ${apiKey}` },
24
+ headers: headers ?? { Authorization: `Bearer ${apiKey}` },
23
25
  });
24
26
  }
25
27
  catch (error) {
@@ -76,3 +78,79 @@ export async function verifyOpenRouter(apiKey, model) {
76
78
  }
77
79
  return { status: "ok" };
78
80
  }
81
+ /** Same shape as {@link verifyOpenRouter}: `/models` both authenticates the
82
+ * key and, when `model` is given, confirms OpenAI actually has it. */
83
+ export async function verifyOpenAi(apiKey, model) {
84
+ const base = openAiBase();
85
+ const models = await getJson(`${base}/models`, apiKey);
86
+ if ("unreachable" in models) {
87
+ return { status: "unreachable", reason: models.unreachable };
88
+ }
89
+ if (models.status === 401) {
90
+ return {
91
+ status: "rejected",
92
+ error: new CliError("openai_unauthorized", "OpenAI rejected the API key.", "Check the key at https://platform.openai.com/api-keys and pass it again.", EXIT_USAGE),
93
+ };
94
+ }
95
+ if (models.status >= 400) {
96
+ return {
97
+ status: "unreachable",
98
+ reason: `OpenAI returned ${models.status}`,
99
+ };
100
+ }
101
+ if (!model)
102
+ return { status: "ok" };
103
+ const rows = models.body?.data;
104
+ if (!Array.isArray(rows)) {
105
+ return { status: "unreachable", reason: "OpenAI returned no model list" };
106
+ }
107
+ if (!rows.some((row) => row?.id === model)) {
108
+ return {
109
+ status: "rejected",
110
+ error: new CliError("openai_unknown_model", `OpenAI does not know the model ${model}.`, "Pick one at https://platform.openai.com/docs/models and set it with co-maintainer set --high-model=...", EXIT_USAGE),
111
+ };
112
+ }
113
+ return { status: "ok" };
114
+ }
115
+ /** Anthropic authenticates the key and lists models at `/models`, but with its
116
+ * own headers (`x-api-key` + `anthropic-version`) instead of a Bearer token.
117
+ * Without a model the call only proves the key; with one it also confirms
118
+ * Anthropic serves it. */
119
+ export async function verifyAnthropic(apiKey, model) {
120
+ const base = anthropicBase();
121
+ const models = await getJson(`${base}/models`, apiKey, {
122
+ "x-api-key": apiKey,
123
+ "anthropic-version": ANTHROPIC_VERSION,
124
+ });
125
+ if ("unreachable" in models) {
126
+ return { status: "unreachable", reason: models.unreachable };
127
+ }
128
+ if (models.status === 401 || models.status === 403) {
129
+ return {
130
+ status: "rejected",
131
+ error: new CliError("anthropic_unauthorized", "Anthropic rejected the API key.", "Check the key at https://console.anthropic.com/settings/keys and pass it again.", EXIT_USAGE),
132
+ };
133
+ }
134
+ if (models.status >= 400) {
135
+ return {
136
+ status: "unreachable",
137
+ reason: `Anthropic returned ${models.status}`,
138
+ };
139
+ }
140
+ if (!model)
141
+ return { status: "ok" };
142
+ const rows = models.body?.data;
143
+ if (!Array.isArray(rows)) {
144
+ return {
145
+ status: "unreachable",
146
+ reason: "Anthropic returned no model list",
147
+ };
148
+ }
149
+ if (!rows.some((row) => row?.id === model)) {
150
+ return {
151
+ status: "rejected",
152
+ error: new CliError("anthropic_unknown_model", `Anthropic does not know the model ${model}.`, "Pick one at https://docs.anthropic.com/en/docs/about-claude/models and set it with co-maintainer set --high-model=...", EXIT_USAGE),
153
+ };
154
+ }
155
+ return { status: "ok" };
156
+ }
@@ -1,5 +1,4 @@
1
1
  import type { Options } from "../types.ts";
2
- export declare const defaultLowModel = "openai/gpt-oss-120b";
3
- /** Plan §8.7 — `--json` must not prompt. */
2
+ /** `--json` must not prompt. */
4
3
  export declare function setCliInteractive(value: boolean): void;
5
4
  export declare function parseArgs(args: string[]): Promise<Options>;
@@ -2,6 +2,7 @@ import { prepareConfig } from "../config.js";
2
2
  import { getEnv } from "../util/runtime.js";
3
3
  import { askLine } from "./prompt.js";
4
4
  import { die } from "./error.js";
5
+ import { AI_PROVIDERS, providerKeyEnv, rejectRetiredProvider, } from "../ai/provider.js";
5
6
  import { detectRemoteRepo } from "../local/git_ops.js";
6
7
  import { reviewBlockingFrom } from "../review/blocking.js";
7
8
  import { renderCommandHelp, renderGlobalHelp, unknownCommandMessage, unknownOptionMessage, } from "./commands/registry.js";
@@ -13,10 +14,8 @@ function numberOption(value, name) {
13
14
  return number;
14
15
  }
15
16
  const commands = ["probe", "init", "sync", "remake", "review"];
16
- export const defaultLowModel = "openai/gpt-oss-120b";
17
- const defaultHighModel = "openai/gpt-5.6-luna";
18
17
  let cliInteractive = true;
19
- /** Plan §8.7 — `--json` must not prompt. */
18
+ /** `--json` must not prompt. */
20
19
  export function setCliInteractive(value) {
21
20
  cliInteractive = value;
22
21
  }
@@ -24,10 +23,10 @@ async function ask(label, fallback, required = false) {
24
23
  if (!cliInteractive) {
25
24
  if (fallback !== undefined && fallback !== "")
26
25
  return fallback;
27
- die(`Missing ${label}; pass it as a CLI option when running without an interactive terminal`);
26
+ die(`Missing ${label}. Pass it as a CLI option when running without an interactive terminal`);
28
27
  }
29
28
  if (process.stdin.isTTY !== true) {
30
- die(`Missing ${label}; pass it as a CLI option when running without an interactive terminal`);
29
+ die(`Missing ${label}. Pass it as a CLI option when running without an interactive terminal`);
31
30
  }
32
31
  const value = await askLine(label, fallback);
33
32
  if (required && !value)
@@ -57,7 +56,7 @@ export async function parseArgs(args) {
57
56
  process.exit(0);
58
57
  }
59
58
  // `probe` (and only probe, for now) may omit the repo and let the current
60
- // directory's git remote name it (CORE-24).
59
+ // directory's git remote name it.
61
60
  let repoName = repo;
62
61
  if ((!repoName || repoName.startsWith("-")) && command === "probe") {
63
62
  repoName = await detectRemoteRepo(process.cwd());
@@ -168,9 +167,10 @@ export async function parseArgs(args) {
168
167
  }
169
168
  const explicitAi = rest.some((arg) => arg.startsWith("--ai="));
170
169
  const configuredAiRaw = env("CO_MAINTAINER_AI") ?? repoConfig.ai ?? config.ai;
170
+ rejectRetiredProvider(configuredAiRaw);
171
171
  if (configuredAiRaw &&
172
- !["none", "openrouter", "hetzner"].includes(configuredAiRaw)) {
173
- die("CO_MAINTAINER_AI/config.ai must be none, openrouter, or hetzner");
172
+ !AI_PROVIDERS.includes(configuredAiRaw)) {
173
+ die(`CO_MAINTAINER_AI/config.ai must be one of: ${AI_PROVIDERS.join(", ")}`);
174
174
  }
175
175
  const configuredAi = configuredAiRaw;
176
176
  const configuredAuthRaw = env("CO_MAINTAINER_AUTH") ?? repoConfig.auth ?? config.auth;
@@ -187,46 +187,58 @@ export async function parseArgs(args) {
187
187
  die("--auth=pat requires a GitHub token. Pass --github-pat=..., set GITHUB_TOKEN/GH_TOKEN, " +
188
188
  "or run: co-maintainer set --github-pat=...");
189
189
  }
190
- let ai = choice("ai", ["none", "openrouter", "hetzner"], configuredAi ?? "none");
190
+ rejectRetiredProvider(text("ai"));
191
+ let ai = choice("ai", AI_PROVIDERS, configuredAi ?? "none");
191
192
  if (command === "review") {
192
- if (explicitAi &&
193
- choice("ai", ["none", "openrouter", "hetzner"], "none") !== "openrouter") {
194
- die("review supports OpenRouter only");
193
+ // Review was pinned to OpenRouter only so Hetzner never wrote one. Hetzner
194
+ // is gone, so review follows the configured provider. With none set it
195
+ // stays on OpenRouter, which every review before this one used.
196
+ if (ai === "none") {
197
+ if (explicitAi) {
198
+ die(`review needs an AI provider. Pass --ai=${AI_PROVIDERS.filter((p) => p !== "none").join("|")}`);
199
+ }
200
+ ai = "openrouter";
195
201
  }
196
- ai = "openrouter";
197
202
  }
198
203
  else if (!explicitAi && !configuredAi && command !== "probe") {
199
- const selected = await ask("AI provider (openrouter|hetzner)", "openrouter");
200
- if (!["openrouter", "hetzner"].includes(selected)) {
201
- die("AI provider must be openrouter or hetzner");
204
+ const selected = await ask(`AI provider (${AI_PROVIDERS.join("|")})`, "openrouter");
205
+ rejectRetiredProvider(selected);
206
+ if (!AI_PROVIDERS.includes(selected)) {
207
+ die(`AI provider must be one of: ${AI_PROVIDERS.join(", ")}`);
202
208
  }
203
209
  ai = selected;
204
210
  }
205
211
  let aiToken = text("token") ??
206
212
  env("CO_MAINTAINER_TOKEN") ??
207
- (ai === "openrouter" ? env("OPENROUTER_API_KEY") : undefined) ??
208
- (ai === "hetzner" ? env("HETZNER_API_KEY") : undefined) ??
213
+ providerKeyEnv(ai, env) ??
209
214
  config.token;
215
+ // The OPENROUTER_ names would hand an OpenRouter model id to another
216
+ // provider, so they only count when OpenRouter is the one running.
217
+ const openRouterModelEnv = (name) => ai === "openrouter" ? env(name) : undefined;
210
218
  let lowModel = text("low-model") ??
211
- env("OPENROUTER_LOW_MODEL") ??
212
- env("HETZNER_LOW_MODEL") ??
219
+ openRouterModelEnv("OPENROUTER_LOW_MODEL") ??
213
220
  env("LOW_MODEL") ??
214
221
  repoConfig.lowModel ??
215
222
  config.lowModel;
216
223
  let highModel = text("high-model") ??
217
- env("OPENROUTER_HIGH_MODEL") ??
218
- env("HETZNER_HIGH_MODEL") ??
224
+ openRouterModelEnv("OPENROUTER_HIGH_MODEL") ??
219
225
  env("HIGH_MODEL") ??
220
226
  repoConfig.highModel ??
221
227
  config.highModel;
222
228
  if (command === "review") {
223
- aiToken ??= await ask("openrouter API key", undefined, true);
224
- highModel ??= await ask("high model", defaultHighModel, true);
229
+ aiToken ??= await ask(`${ai} API key`, undefined, true);
230
+ // Review summarizes large files with the low model, so it needs one just
231
+ // as init and sync do. Without a default to fall back on, ask here too.
232
+ lowModel ??= await ask("low model", undefined, true);
233
+ highModel ??= await ask("high model", undefined, true);
225
234
  }
226
235
  else if (ai !== "none" && command !== "probe") {
236
+ // No house pick for any provider: models go stale and a shipped build
237
+ // cannot fetch a fresher one, so the model to use is the user's call,
238
+ // set once in config or passed every time (Murat, 2026-09-28).
227
239
  aiToken ??= await ask(`${ai} API key`, undefined, true);
228
- lowModel ??= await ask("low model", defaultLowModel, true);
229
- highModel ??= await ask("high model", defaultHighModel, true);
240
+ lowModel ??= await ask("low model", undefined, true);
241
+ highModel ??= await ask("high model", undefined, true);
230
242
  }
231
243
  if (command !== "review" &&
232
244
  ai !== "none" &&
@@ -37,7 +37,7 @@ export declare function resolveField(name: string): string;
37
37
  * `defaults` are nested maps, not settings, so they are not listed.
38
38
  * `satisfies` makes this the type's own vocabulary: a name that is not a
39
39
  * `UserConfig` key is a compile error rather than a silently undefined read. */
40
- export declare const KNOWN_FIELDS: readonly ["auth", "ai", "lowModel", "highModel", "token", "githubPat", "githubAppId", "githubAppPrivateKey", "githubAppPrivateKeyPath", "githubWebhookSecret", "webhookUrl", "githubOAuthClientId", "githubOAuthClientSecret", "githubOAuthAllowedUser", "passwordAuthDisabled", "githubAuthEnabled", "dashboardPasswordHash", "maxConcurrentJobs", "reviewBlocking", "remoteHost", "remoteToken", "remoteNoticeShownFor", "remoteSyncTimeoutSeconds", "maxConcurrentRemoteReviewsPerToken", "remoteToolOutputMaxChars"];
40
+ export declare const KNOWN_FIELDS: readonly ["auth", "ai", "lowModel", "highModel", "token", "githubPat", "githubAppId", "githubAppPrivateKey", "githubAppPrivateKeyPath", "githubWebhookSecret", "webhookUrl", "githubOAuthClientId", "githubOAuthClientSecret", "githubOAuthAllowedUser", "passwordAuthDisabled", "githubAuthEnabled", "dashboardPasswordHash", "maxConcurrentJobs", "reviewBlocking", "remoteHost", "remoteToken", "remoteNoticeShownFor", "remoteSyncTimeoutSeconds", "maxConcurrentRemoteReviewsPerToken", "remoteToolOutputMaxChars", "remoteByok", "remoteByokDefault", "reviewPolicy"];
41
41
  /** `co-maintainer config <sub> ...`. `set` is handled by runSet, which this
42
42
  * command delegates to so there is one implementation of the write rules. */
43
43
  export declare function runConfig(args: string[]): Promise<void>;
@@ -1,5 +1,5 @@
1
1
  /** The `config` command: read and edit the user config without opening the
2
- * file (CORE-22, F12). `set` is `config set` under its own name, so both share
2
+ * file. `set` is `config set` under its own name, so both share
3
3
  * the mapping and masking here.
4
4
  *
5
5
  * The key list is derived from `UserConfig` at runtime where possible: a
@@ -18,6 +18,7 @@ export const SECRET_FIELDS = new Set([
18
18
  "githubOAuthClientSecret",
19
19
  "remoteToken",
20
20
  "dashboardPasswordHash",
21
+ "remoteByok",
21
22
  ]);
22
23
  /** `lowModel` -> `low-model`. The inverse of {@link configKey}. */
23
24
  export function flagName(field) {
@@ -38,13 +39,15 @@ export function maskSecret(value) {
38
39
  /** The env var that supplies each field, mirroring args.ts. Only the fields
39
40
  * that actually read env are listed. */
40
41
  export const ENV_BY_FIELD = {
41
- token: ["CO_MAINTAINER_TOKEN", "OPENROUTER_API_KEY", "HETZNER_API_KEY"],
42
+ token: ["CO_MAINTAINER_TOKEN", "OPENROUTER_API_KEY", "OPENCODE_API_KEY"],
42
43
  githubPat: ["GITHUB_TOKEN", "GH_TOKEN"],
43
44
  ai: ["CO_MAINTAINER_AI"],
44
45
  auth: ["CO_MAINTAINER_AUTH"],
45
- lowModel: ["OPENROUTER_LOW_MODEL", "HETZNER_LOW_MODEL", "LOW_MODEL"],
46
- highModel: ["OPENROUTER_HIGH_MODEL", "HETZNER_HIGH_MODEL", "HIGH_MODEL"],
46
+ lowModel: ["OPENROUTER_LOW_MODEL", "LOW_MODEL"],
47
+ highModel: ["OPENROUTER_HIGH_MODEL", "HIGH_MODEL"],
47
48
  webhookUrl: ["CM_WEBHOOK_URL"],
49
+ remoteHost: ["CM_REMOTE_HOST"],
50
+ remoteToken: ["CM_REMOTE_TOKEN"],
48
51
  reviewBlocking: ["CO_MAINTAINER_REVIEW_BLOCKING"],
49
52
  };
50
53
  /** Renders one field for people. A secret is masked; the password hash, which
@@ -151,6 +154,9 @@ export const KNOWN_FIELDS = [
151
154
  "remoteSyncTimeoutSeconds",
152
155
  "maxConcurrentRemoteReviewsPerToken",
153
156
  "remoteToolOutputMaxChars",
157
+ "remoteByok",
158
+ "remoteByokDefault",
159
+ "reviewPolicy",
154
160
  ];
155
161
  /** `co-maintainer config <sub> ...`. `set` is handled by runSet, which this
156
162
  * command delegates to so there is one implementation of the write rules. */
@@ -1,7 +1,7 @@
1
1
  import { clientFor, runInitOrRemake } from "../../services/setup.js";
2
2
  import { probePlan } from "../../services/probe.js";
3
3
  import { log, startHeartbeat } from "../../util/log.js";
4
- import { estimateInit, readJobHistory } from "../../ai/estimate.js";
4
+ import { estimateCostLine, estimateInit, readJobHistory, } from "../../ai/estimate.js";
5
5
  import { loadPrices } from "../../ai/pricing.js";
6
6
  import { cacheSet } from "../../store/cache_db.js";
7
7
  export async function runProbe(options) {
@@ -25,7 +25,7 @@ export async function runProbe(options) {
25
25
  for (const reason of analysis.reasons)
26
26
  console.log(` - ${reason}`);
27
27
  // The estimate is separate from the recommendation: the flags say what to
28
- // read, this says what that costs in time and dollars (CORE-24). Prices are
28
+ // read, this says what that costs in time and dollars. Prices are
29
29
  // only fetched when an OpenRouter model would actually be billed, so a
30
30
  // plain `--ai=none` probe makes no network call.
31
31
  const needsPrices = options.ai === "openrouter" &&
@@ -50,15 +50,7 @@ export async function runProbe(options) {
50
50
  console.log(` AI jobs: ${estimate.extract} extract + ${estimate.synth} synth`);
51
51
  console.log(` tokens: ${range(estimate.tokensIn)} in · ${range(estimate.tokensOut)} out`);
52
52
  console.log(` time: ${secondsRange(estimate.seconds)}s`);
53
- if (estimate.usd) {
54
- console.log(` cost: $${estimate.usd[0].toFixed(4)}-$${estimate.usd[1].toFixed(4)}`);
55
- }
56
- else if (needsPrices && prices.source === "unavailable") {
57
- console.log(" cost: unknown (could not read OpenRouter prices)");
58
- }
59
- else {
60
- console.log(" cost: set --ai=openrouter with both models to estimate dollars");
61
- }
53
+ console.log(` cost: ${estimateCostLine(estimate, options.ai, needsPrices && prices.source === "unavailable")}`);
62
54
  console.log(` basis: ${estimate.basis === "history"
63
55
  ? "this repository's recorded jobs"
64
56
  : "the cm-dx-lab calibration"}; an estimate, not a bill`);
@@ -1,11 +1,3 @@
1
- /** The command registry (CORE-20).
2
- *
3
- * One place that knows every command, its aliases, its usage lines, its flags
4
- * and its examples. Everything the user reads as help is rendered from here,
5
- * so a flag cannot drift out of the help and a command cannot be documented
6
- * twice with different wording. It is also the source CORE-81 will export to
7
- * the command reference, which is why it is data rather than template strings.
8
- */
9
1
  export type FlagSpec = {
10
2
  /** Without the leading dashes, e.g. `include-codebase`. */
11
3
  name: string;
@@ -57,6 +49,6 @@ export declare function unknownOptionMessage(input: string, command?: string): s
57
49
  export declare function renderGlobalHelp(): string;
58
50
  /** One command's help: usage, flags grouped, examples, docs link. */
59
51
  export declare function renderCommandHelp(name: string): string | undefined;
60
- /** The registry as markdown, so CORE-81 can build the command reference from
52
+ /** The registry as markdown, so the command reference can be built from
61
53
  * the same data the runtime help uses. */
62
54
  export declare function registryToMarkdown(): string;
@@ -1,11 +1,12 @@
1
- /** The command registry (CORE-20).
1
+ /** The command registry.
2
2
  *
3
3
  * One place that knows every command, its aliases, its usage lines, its flags
4
4
  * and its examples. Everything the user reads as help is rendered from here,
5
5
  * so a flag cannot drift out of the help and a command cannot be documented
6
- * twice with different wording. It is also the source CORE-81 will export to
7
- * the command reference, which is why it is data rather than template strings.
6
+ * twice with different wording. It is also the source the command reference is
7
+ * generated from, which is why it is data rather than template strings.
8
8
  */
9
+ import { AI_PROVIDERS } from "../../ai/provider.js";
9
10
  const OUTPUT_FLAGS = {
10
11
  title: "Output and diagnostics",
11
12
  flags: [
@@ -17,12 +18,24 @@ const OUTPUT_FLAGS = {
17
18
  { name: "log-time", description: "Print how long each phase took." },
18
19
  ],
19
20
  };
21
+ /** `review` alone can print for a CI run. */
22
+ const REVIEW_OUTPUT_FLAGS = {
23
+ title: OUTPUT_FLAGS.title,
24
+ flags: [
25
+ ...OUTPUT_FLAGS.flags,
26
+ {
27
+ name: "output",
28
+ value: "github",
29
+ description: "Print GitHub workflow annotations and write the job summary instead of prose. Cannot be used with --json.",
30
+ },
31
+ ],
32
+ };
20
33
  const AI_FLAGS = {
21
34
  title: "AI",
22
35
  flags: [
23
36
  {
24
37
  name: "ai",
25
- value: "none|openrouter|hetzner",
38
+ value: AI_PROVIDERS.join("|"),
26
39
  default: "none",
27
40
  description: "Which provider writes the review.",
28
41
  },
@@ -35,13 +48,11 @@ const AI_FLAGS = {
35
48
  {
36
49
  name: "low-model",
37
50
  value: "ID",
38
- default: "openai/gpt-oss-120b",
39
51
  description: "Model for extraction work.",
40
52
  },
41
53
  {
42
54
  name: "high-model",
43
55
  value: "ID",
44
- default: "openai/gpt-5.6-luna",
45
56
  description: "Model for the review itself.",
46
57
  },
47
58
  ],
@@ -210,7 +221,7 @@ export const COMMANDS = [
210
221
  {
211
222
  name: "sync",
212
223
  // `remake` is the 0.4.13 spelling. It still runs the same handler but is
213
- // not advertised, so nothing the user reads says "remake" (CORE-21).
224
+ // not advertised, so nothing the user reads says "remake".
214
225
  aliases: ["remake"],
215
226
  summary: "Rebuild the review guides for a repository that already has them.",
216
227
  usage: ["co-maintainer sync owner/repo [options]"],
@@ -317,6 +328,14 @@ export const COMMANDS = [
317
328
  value: "TOKEN",
318
329
  description: "Override the configured remote token for this run (needs --remote).",
319
330
  },
331
+ {
332
+ name: "remote-byok",
333
+ description: "Send your own AI key with a remote review (needs --remote).",
334
+ },
335
+ {
336
+ name: "no-remote-byok",
337
+ description: "Do not send your own AI key, even if remote-byok-default is on.",
338
+ },
320
339
  {
321
340
  name: "sync-before-review",
322
341
  description: "Rebuild the guides before reviewing.",
@@ -338,13 +357,15 @@ export const COMMANDS = [
338
357
  },
339
358
  AI_FLAGS,
340
359
  GITHUB_FLAGS,
341
- OUTPUT_FLAGS,
360
+ REVIEW_OUTPUT_FLAGS,
342
361
  ],
343
362
  examples: [
344
363
  "co-maintainer review owner/repo 42 --json",
345
364
  "co-maintainer review --json --disable-codegraph",
346
365
  "co-maintainer review --remote --json",
347
366
  "co-maintainer review --remote --remote-host=https://review.example.com --remote-token=cmr_...",
367
+ "co-maintainer review --remote --remote-byok --json",
368
+ "co-maintainer review --remote --output=github",
348
369
  ],
349
370
  },
350
371
  {
@@ -469,6 +490,33 @@ export const COMMANDS = [
469
490
  value: "TOKEN",
470
491
  description: "The remote review token.",
471
492
  },
493
+ {
494
+ name: "remote-byok-policy",
495
+ value: "off|allow|require",
496
+ default: "off",
497
+ description: "Whether a remote client may send its own AI key.",
498
+ },
499
+ {
500
+ name: "remote-byok",
501
+ value: "KEY",
502
+ description: "Your own AI key to send with a remote review.",
503
+ },
504
+ {
505
+ name: "remote-byok-default",
506
+ value: "on|off",
507
+ default: "off",
508
+ description: "Send remote-byok on every --remote review by default.",
509
+ },
510
+ {
511
+ name: "review-policy",
512
+ value: "everyone|trusted-auto|on-request-only",
513
+ description: "The review policy new repositories start with.",
514
+ },
515
+ {
516
+ name: "review-policy-file",
517
+ value: "PATH",
518
+ description: "Read a custom review policy from a JSON file and use it as that default.",
519
+ },
472
520
  {
473
521
  name: "review-blocking",
474
522
  value: "model|severity",
@@ -492,7 +540,7 @@ export const COMMANDS = [
492
540
  },
493
541
  {
494
542
  name: "no-verify",
495
- description: "Do not check the key and model against OpenRouter.",
543
+ description: "Do not check the key and model against the provider.",
496
544
  },
497
545
  {
498
546
  name: "unset",
@@ -555,6 +603,23 @@ export const COMMANDS = [
555
603
  ],
556
604
  examples: ["co-maintainer serve --port=5000"],
557
605
  },
606
+ {
607
+ name: "rollback",
608
+ summary: "Go back to the version before the last upgrade, restoring its data.",
609
+ usage: ["co-maintainer rollback [--yes]"],
610
+ groups: [
611
+ {
612
+ title: "Rollback",
613
+ flags: [
614
+ {
615
+ name: "yes",
616
+ description: "Skip the confirmation. Needed when there is no terminal.",
617
+ },
618
+ ],
619
+ },
620
+ ],
621
+ examples: ["co-maintainer rollback", "co-maintainer rollback --yes"],
622
+ },
558
623
  {
559
624
  name: "version",
560
625
  summary: "Print the version.",
@@ -692,7 +757,7 @@ export function renderCommandHelp(name) {
692
757
  function cell(text) {
693
758
  return text.replace(/\|/g, "\\|");
694
759
  }
695
- /** The registry as markdown, so CORE-81 can build the command reference from
760
+ /** The registry as markdown, so the command reference can be built from
696
761
  * the same data the runtime help uses. */
697
762
  export function registryToMarkdown() {
698
763
  const lines = [];
@@ -1,6 +1,8 @@
1
1
  import { parseReviewArgs } from "../review_args.js";
2
2
  import { setCliInteractive } from "../args.js";
3
- import { emptyAiMetrics, recordAiCost } from "../../services/setup.js";
3
+ import { addAiMetrics, emptyAiMetrics, recordAiCost, } from "../../services/setup.js";
4
+ import { costUsage } from "../../util/cost.js";
5
+ import { costLabel } from "../../util/run_summary.js";
4
6
  import { reviewPullRequest } from "../../pr/reviewer.js";
5
7
  import { GhClient } from "../../github/gh.js";
6
8
  import { log, startHeartbeat, timed, withCliLogsToStderr, } from "../../util/log.js";
@@ -11,6 +13,7 @@ import { buildPrReviewJson, resolvedFromFirstReview, reviewExitCodeFromResolved,
11
13
  import { parseFindings } from "../../pr/findings.js";
12
14
  import { exitWith } from "../error.js";
13
15
  import { printRunSummary, summaryFromMetrics } from "../../util/run_summary.js";
16
+ import { emitGithubOutput } from "../review_github.js";
14
17
  import { formatHumanReview, humanFindingsFromResolved, } from "../review_result.js";
15
18
  export async function runReviewFromCli(args) {
16
19
  const parsed = await parseReviewArgs(args);
@@ -27,6 +30,7 @@ export async function runReviewFromCli(args) {
27
30
  export async function runReview(options) {
28
31
  await runReviewPr(options, {
29
32
  json: false,
33
+ output: undefined,
30
34
  disableCodegraph: false,
31
35
  allowToolInstall: false,
32
36
  remakeBeforeReview: false,
@@ -44,24 +48,18 @@ async function runReviewPr(options, cli) {
44
48
  }
45
49
  log("review", `reading PR #${options.prNumber} in ${options.repo} via gh`);
46
50
  const result = await timed("review GitHub collection and AI", options.logTime, () => reviewPullRequest(new GhClient(options.debug), options, async (response) => {
47
- aiMetrics.calls++;
48
- aiMetrics.tokensIn += response.tokensIn;
49
- aiMetrics.tokensOut += response.tokensOut;
50
- if (response.cost === undefined)
51
- aiMetrics.costKnown = false;
52
- else
53
- aiMetrics.cost += response.cost;
51
+ addAiMetrics(aiMetrics, response);
54
52
  await recordAiCost(options.repo, "review_pull_request", response);
55
53
  }));
56
54
  const durationMs = Math.round(performance.now() - operationStarted);
57
55
  const usage = {
58
56
  tokensIn: aiMetrics.tokensIn,
59
57
  tokensOut: aiMetrics.tokensOut,
60
- costUsd: aiMetrics.costKnown ? aiMetrics.cost : null,
58
+ ...costUsage(aiMetrics),
61
59
  };
62
60
  // The engine reports the codegraph state it actually reached, instead of
63
61
  // the old `useCodegraph ? "used" : "disabled"` that claimed "used" while
64
- // stderr said codegraph was missing (CORE-43 / F23).
62
+ // stderr said codegraph was missing.
65
63
  const codegraphState = result.codegraphState;
66
64
  const resolved = resolvedFromFirstReview(parseFindings(result.text), new Map());
67
65
  if (cli.json) {
@@ -77,8 +75,15 @@ async function runReviewPr(options, cli) {
77
75
  reviewBlocking: options.reviewBlocking,
78
76
  }));
79
77
  }
78
+ else if (cli.output === "github") {
79
+ emitGithubOutput({
80
+ title: `${options.repo} · PR #${options.prNumber}`,
81
+ findings: humanFindingsFromResolved(resolved, options.reviewBlocking),
82
+ run: summaryFromMetrics(aiMetrics, performance.now() - operationStarted),
83
+ });
84
+ }
80
85
  else {
81
- // One presentation layer for local, PR and remote reviews (CORE-43).
86
+ // One presentation layer for local, PR and remote reviews.
82
87
  console.log("\n" +
83
88
  formatHumanReview({
84
89
  title: `co-maintainer review · ${options.repo} · PR #${options.prNumber}`,
@@ -89,7 +94,7 @@ async function runReviewPr(options, cli) {
89
94
  "\n");
90
95
  printRunSummary(summaryFromMetrics(aiMetrics, performance.now() - operationStarted));
91
96
  if (options.logTime) {
92
- log("time", `AI total · calls=${aiMetrics.calls} · input=${aiMetrics.tokensIn} tokens · output=${aiMetrics.tokensOut} tokens · cost=${aiMetrics.costKnown ? aiMetrics.cost.toFixed(4) : "unknown"}`);
97
+ log("time", `AI total · calls=${aiMetrics.calls} · input=${aiMetrics.tokensIn} tokens · output=${aiMetrics.tokensOut} tokens · cost=${costLabel(aiMetrics)}`);
93
98
  console.error(`[time] total review · ${((performance.now() - operationStarted) / 1000).toFixed(2)}s`);
94
99
  }
95
100
  }
@@ -100,7 +105,7 @@ async function runReviewPr(options, cli) {
100
105
  finally {
101
106
  // A heartbeat left running on the error path keeps the event loop alive
102
107
  // forever, so the process would never drain and the exit code would never
103
- // be applied. CORE-11.
108
+ // be applied.
104
109
  stopHeartbeat();
105
110
  }
106
111
  });
@@ -0,0 +1 @@
1
+ export declare function runRollback(args: string[]): Promise<void>;