co-maintainer 0.4.13 → 0.5.0-beta.1

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 (140) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +57 -38
  3. package/dist/main.js +2 -3
  4. package/dist/package.json +21 -15
  5. package/dist/src/ai/batch.d.ts +24 -1
  6. package/dist/src/ai/batch.js +75 -23
  7. package/dist/src/ai/estimate.d.ts +40 -0
  8. package/dist/src/ai/estimate.js +113 -0
  9. package/dist/src/ai/fake.d.ts +1 -1
  10. package/dist/src/ai/fake.js +1 -1
  11. package/dist/src/ai/hetzner.js +1 -1
  12. package/dist/src/ai/openrouter.d.ts +13 -0
  13. package/dist/src/ai/openrouter.js +66 -12
  14. package/dist/src/ai/pricing.d.ts +14 -0
  15. package/dist/src/ai/pricing.js +77 -0
  16. package/dist/src/ai/provider.js +8 -0
  17. package/dist/src/ai/verify.d.ts +25 -0
  18. package/dist/src/ai/verify.js +78 -0
  19. package/dist/src/cli/args.js +25 -29
  20. package/dist/src/cli/commands/config.d.ts +44 -0
  21. package/dist/src/cli/commands/config.js +220 -0
  22. package/dist/src/cli/commands/probe.js +70 -103
  23. package/dist/src/cli/commands/registry.d.ts +57 -0
  24. package/dist/src/cli/commands/registry.js +713 -0
  25. package/dist/src/cli/commands/review.js +66 -43
  26. package/dist/src/cli/commands/serve.d.ts +4 -2
  27. package/dist/src/cli/commands/serve.js +14 -10
  28. package/dist/src/cli/commands/set.d.ts +2 -1
  29. package/dist/src/cli/commands/set.js +63 -17
  30. package/dist/src/cli/commands/view.d.ts +1 -0
  31. package/dist/src/cli/commands/view.js +180 -0
  32. package/dist/src/cli/error.d.ts +39 -0
  33. package/dist/src/cli/error.js +64 -0
  34. package/dist/src/cli/main.d.ts +7 -0
  35. package/dist/src/cli/main.js +82 -1
  36. package/dist/src/cli/prompt.js +21 -7
  37. package/dist/src/cli/review_args.d.ts +4 -0
  38. package/dist/src/cli/review_args.js +22 -9
  39. package/dist/src/cli/review_output.d.ts +2 -2
  40. package/dist/src/cli/review_output.js +7 -6
  41. package/dist/src/cli/review_result.d.ts +59 -8
  42. package/dist/src/cli/review_result.js +145 -103
  43. package/dist/src/config.d.ts +15 -0
  44. package/dist/src/config.js +31 -0
  45. package/dist/src/github/app.d.ts +13 -0
  46. package/dist/src/github/app.js +23 -0
  47. package/dist/src/github/app_manifest.d.ts +50 -0
  48. package/dist/src/github/app_manifest.js +138 -0
  49. package/dist/src/github/client.js +7 -1
  50. package/dist/src/github/collect.js +1 -1
  51. package/dist/src/github/gh.js +75 -18
  52. package/dist/src/knowledge/facts.js +8 -3
  53. package/dist/src/knowledge/guide.d.ts +8 -0
  54. package/dist/src/knowledge/guide.js +32 -8
  55. package/dist/src/knowledge/probe.js +13 -3
  56. package/dist/src/knowledge/sections.d.ts +9 -0
  57. package/dist/src/knowledge/sections.js +16 -0
  58. package/dist/src/knowledge/skill.d.ts +5 -0
  59. package/dist/src/knowledge/skill.js +68 -21
  60. package/dist/src/knowledge/synthesis.d.ts +17 -1
  61. package/dist/src/knowledge/synthesis.js +70 -15
  62. package/dist/src/knowledge/types.d.ts +5 -0
  63. package/dist/src/local/codegraph_prepare.d.ts +3 -2
  64. package/dist/src/local/codegraph_prepare.js +4 -2
  65. package/dist/src/local/git_ops.d.ts +5 -4
  66. package/dist/src/local/git_ops.js +8 -9
  67. package/dist/src/local/review_local.js +36 -14
  68. package/dist/src/pr/checkout.js +14 -3
  69. package/dist/src/pr/codegraph_tools.js +3 -3
  70. package/dist/src/pr/diff_summary.js +3 -3
  71. package/dist/src/pr/findings.js +16 -5
  72. package/dist/src/pr/findings_json.d.ts +26 -0
  73. package/dist/src/pr/findings_json.js +187 -0
  74. package/dist/src/pr/review_copy.d.ts +20 -0
  75. package/dist/src/pr/review_copy.js +40 -0
  76. package/dist/src/pr/reviewer.d.ts +2 -1
  77. package/dist/src/pr/reviewer.js +84 -91
  78. package/dist/src/remote/client.js +42 -42
  79. package/dist/src/remote/http.d.ts +5 -0
  80. package/dist/src/remote/http.js +49 -0
  81. package/dist/src/remote/server/guides.d.ts +22 -0
  82. package/dist/src/remote/server/guides.js +81 -0
  83. package/dist/src/remote/server/routes.js +24 -0
  84. package/dist/src/review/blocking.d.ts +35 -0
  85. package/dist/src/review/blocking.js +45 -0
  86. package/dist/src/review/carry_over.d.ts +12 -1
  87. package/dist/src/review/carry_over.js +35 -9
  88. package/dist/src/review/engine.d.ts +1 -0
  89. package/dist/src/review/guides.js +28 -1
  90. package/dist/src/server/api/installations.js +1 -1
  91. package/dist/src/server/api/repos.js +86 -3
  92. package/dist/src/server/api/settings.js +3 -3
  93. package/dist/src/server/app.d.ts +3 -0
  94. package/dist/src/server/app.js +12 -5
  95. package/dist/src/server/pages/activity.d.ts +1 -0
  96. package/dist/src/server/pages/activity.js +2 -2
  97. package/dist/src/server/pages/add_repo.js +73 -5
  98. package/dist/src/server/pages/client.d.ts +1 -1
  99. package/dist/src/server/pages/client.js +1 -1
  100. package/dist/src/server/pages/home.d.ts +6 -1
  101. package/dist/src/server/pages/home.js +25 -2
  102. package/dist/src/server/pages/knowledge.js +1 -1
  103. package/dist/src/server/pages/layout.js +2 -2
  104. package/dist/src/server/pages/repo.d.ts +1 -1
  105. package/dist/src/server/pages/repo.js +21 -2
  106. package/dist/src/server/pages/repo_prs.d.ts +5 -1
  107. package/dist/src/server/pages/repo_prs.js +36 -1
  108. package/dist/src/server/pages/repo_remote.js +2 -2
  109. package/dist/src/server/pages/repo_settings.js +2 -2
  110. package/dist/src/server/pages/router.d.ts +6 -0
  111. package/dist/src/server/pages/router.js +129 -9
  112. package/dist/src/server/pages/settings.d.ts +1 -1
  113. package/dist/src/server/pages/settings.js +87 -9
  114. package/dist/src/server/pages/styles.d.ts +1 -1
  115. package/dist/src/server/pages/styles.js +1 -1
  116. package/dist/src/services/probe.d.ts +28 -0
  117. package/dist/src/services/probe.js +133 -0
  118. package/dist/src/services/remake_cron.d.ts +1 -1
  119. package/dist/src/services/remake_cron.js +3 -3
  120. package/dist/src/services/remote_review.js +14 -4
  121. package/dist/src/services/review.d.ts +11 -0
  122. package/dist/src/services/review.js +39 -11
  123. package/dist/src/services/setup.js +37 -19
  124. package/dist/src/services/setup_checklist.d.ts +10 -0
  125. package/dist/src/services/setup_checklist.js +87 -0
  126. package/dist/src/store/app_db.js +5 -2
  127. package/dist/src/store/cache_db.d.ts +4 -0
  128. package/dist/src/store/cache_db.js +27 -4
  129. package/dist/src/store/deliveries.d.ts +7 -0
  130. package/dist/src/store/deliveries.js +15 -0
  131. package/dist/src/tools/codegraph.d.ts +30 -0
  132. package/dist/src/tools/codegraph.js +49 -12
  133. package/dist/src/types.d.ts +10 -0
  134. package/dist/src/util/log.d.ts +3 -0
  135. package/dist/src/util/log.js +12 -0
  136. package/dist/src/util/run_summary.d.ts +33 -0
  137. package/dist/src/util/run_summary.js +47 -0
  138. package/dist/src/util/webhook_reachability.d.ts +11 -0
  139. package/dist/src/util/webhook_reachability.js +64 -0
  140. package/package.json +21 -15
@@ -1,5 +1,6 @@
1
1
  import { AiBatch } from "../ai/batch.js";
2
- import { sectionTitles } from "./sections.js";
2
+ import { isPullRequestFact } from "./guide.js";
3
+ import { sectionAllowsPrFacts, sectionTitles } from "./sections.js";
3
4
  const allowedSections = new Set([
4
5
  "identity",
5
6
  "devloop",
@@ -40,7 +41,7 @@ function parseJson(text) {
40
41
  return JSON.parse(`${cleaned.slice(start, lastCompleteObject + 1)}]`);
41
42
  }
42
43
  }
43
- function factsFromResponse(text, evidence, defaultScope) {
44
+ function factsFromResponse(text, evidence, defaultScope, origin) {
44
45
  const parsed = parseJson(text);
45
46
  if (!Array.isArray(parsed)) {
46
47
  throw new Error("AI fact output must be an array");
@@ -73,6 +74,7 @@ function factsFromResponse(text, evidence, defaultScope) {
73
74
  scope,
74
75
  confidence,
75
76
  status: "active",
77
+ origin,
76
78
  },
77
79
  ];
78
80
  });
@@ -124,8 +126,31 @@ function pullRequestUnit(pr) {
124
126
  diff: promptText(pr.diff, 8_000),
125
127
  });
126
128
  }
127
- function queueFor(provider, repo, ai, model, concurrency) {
128
- return new AiBatch(provider, repo, Math.max(1, concurrency), `${ai}:v2:${model}`);
129
+ function queueFor(provider, repo, ai, model, concurrency, validate) {
130
+ return new AiBatch(provider, repo, Math.max(1, concurrency), `${ai}:v2:${model}`, validate);
131
+ }
132
+ /** `factsFromResponse` as a validator: `null` when the text parses into facts,
133
+ * otherwise the parse error's message. Used so an unparseable response is never
134
+ * cached (CORE-30). */
135
+ function validateFacts(response, evidence, defaultScope) {
136
+ try {
137
+ factsFromResponse(response.text, evidence, defaultScope, "repository");
138
+ return null;
139
+ }
140
+ catch (error) {
141
+ const message = error instanceof Error ? error.message : String(error);
142
+ return `${evidence}: ${message}`;
143
+ }
144
+ }
145
+ /** `validSection` as a validator: `null` when the section body is usable,
146
+ * otherwise a short reason. A section that is empty or malformed is retried
147
+ * once and then left uncached, so a later `sync` does not replay it. */
148
+ function validateSection(response, key) {
149
+ const section = cleanSection(response.text, key);
150
+ if (!validSection(section, key)) {
151
+ return "synthesis output is not a valid section";
152
+ }
153
+ return null;
129
154
  }
130
155
  function extractRequest(prompt, maxTokens) {
131
156
  return {
@@ -151,17 +176,20 @@ function validSection(text, key) {
151
176
  }
152
177
  function sectionGoal(key) {
153
178
  const goals = {
154
- layout: "Describe module responsibilities and important dependency or change-impact paths; do not list routine public symbols.",
179
+ layout: "Describe module responsibilities and important dependency or change-impact paths. Do not list routine public symbols.",
155
180
  tests: "State which verification is required after a behavior change and the dependency/setup that makes it meaningful.",
156
181
  devloop: "Give local build, debugging, or change-impact guidance that is not already stated in Shipping.",
157
- ship: "Give one concise release/CI checklist; merge overlapping workflow, artifact, and release-gate facts.",
182
+ ship: "Give one concise release/CI checklist. Merge overlapping workflow, artifact, and release-gate facts.",
158
183
  style: "Keep only repository-specific, evidenced conventions. Return only the heading when none are actionable.",
159
184
  };
160
185
  return goals[key] ?? "Keep only actionable repository-specific guidance.";
161
186
  }
162
187
  function synthesisFacts(facts, key) {
163
- return facts
164
- .filter((item) => item.sectionKey === key)
188
+ return (facts
189
+ // A section that states repository policy never learns from a single pull
190
+ // request's narrative; only `review-bar` may (CORE-32 / F26b).
191
+ .filter((item) => item.sectionKey === key &&
192
+ (sectionAllowsPrFacts(key) || !isPullRequestFact(item)))
165
193
  .sort((a, b) => (b.status === "active" ? 1 : 0) - (a.status === "active" ? 1 : 0) ||
166
194
  ({
167
195
  current: 3,
@@ -176,9 +204,12 @@ function synthesisFacts(facts, key) {
176
204
  b.weight - a.weight ||
177
205
  b.evidence.length - a.evidence.length ||
178
206
  a.claim.localeCompare(b.claim))
179
- .slice(0, 20);
207
+ .slice(0, 20));
180
208
  }
181
209
  export async function extractAiFacts(provider, repo, source, options, usage) {
210
+ return (await extractAiFactsWithReport(provider, repo, source, options, usage)).facts;
211
+ }
212
+ export async function extractAiFactsWithReport(provider, repo, source, options, usage) {
182
213
  const requests = [];
183
214
  const evidence = [];
184
215
  const scopes = [];
@@ -220,7 +251,13 @@ ${files}`, 2_200));
220
251
  scopes.push("current");
221
252
  }
222
253
  }
223
- const queue = queueFor(provider, repo, options.ai, options.lowModel ?? "", options.aiConcurrent);
254
+ const queue = queueFor(provider, repo, options.ai, options.lowModel ?? "", options.aiConcurrent,
255
+ // Each request carries different evidence, so the validator looks the
256
+ // request up by identity to find which evidence it belongs to.
257
+ (request, response) => {
258
+ const at = requests.indexOf(request);
259
+ return validateFacts(response, evidence[at] ?? "repository files", scopes[at] ?? "historical-example");
260
+ });
224
261
  const responses = await queue.run(requests, usage);
225
262
  const facts = [];
226
263
  for (let index = 0; index < responses.length; index++) {
@@ -228,7 +265,9 @@ ${files}`, 2_200));
228
265
  if (!response)
229
266
  continue;
230
267
  try {
231
- facts.push(...factsFromResponse(response.text, evidence[index], scopes[index] ?? "historical-example").filter((item) => !hasUnsupportedIdentifier(item.claim, source) &&
268
+ facts.push(...factsFromResponse(response.text, evidence[index], scopes[index] ?? "historical-example", evidence[index] === "repository files"
269
+ ? "repository"
270
+ : "pull-request").filter((item) => !hasUnsupportedIdentifier(item.claim, source) &&
232
271
  !hasUnsupportedPath(item.claim, source) &&
233
272
  (evidence[index] !== "repository files" ||
234
273
  allowsContributionSections(source) ||
@@ -241,7 +280,13 @@ ${files}`, 2_200));
241
280
  console.log(`[ai] extract units ${index + 1}/${responses.length}`);
242
281
  }
243
282
  }
244
- return facts;
283
+ return { facts, skipped: queue.skippedUnits() };
284
+ }
285
+ /** Same as {@link extractAiFacts}, but also reports what the batch skipped so
286
+ * the caller can put it in the final `[done]` line (CORE-30). */
287
+ export async function enrichFactsWithReport(provider, repo, base, source, options, usage) {
288
+ const { facts, skipped } = await extractAiFactsWithReport(provider, repo, source, options, usage);
289
+ return { facts: mergeFacts(base, facts), skipped };
245
290
  }
246
291
  function mergeFacts(base, extra) {
247
292
  const merged = new Map(base.map((item) => [item.id, { ...item, evidence: [...item.evidence] }]));
@@ -268,6 +313,11 @@ export async function synthesizeSections(provider, repo, facts, _previousMarkdow
268
313
  if (onlySections && !onlySections.has(key))
269
314
  continue;
270
315
  const relevant = synthesisFacts(facts, key);
316
+ // A section whose only facts came from a single pull request has no
317
+ // repository policy to synthesize, so it is left to the deterministic
318
+ // renderer instead of asking the model for an empty answer (CORE-32).
319
+ if (!relevant.length)
320
+ continue;
271
321
  keys.push(key);
272
322
  requests.push({
273
323
  job: "synth_section",
@@ -304,12 +354,17 @@ FACTS:
304
354
  ${JSON.stringify(relevant)}`,
305
355
  });
306
356
  }
307
- const queue = queueFor(provider, repo, ai, model, ai === "hetzner" ? 1 : concurrency);
357
+ const queue = queueFor(provider, repo, ai, model, ai === "hetzner" ? 1 : concurrency, (request, response) => validateSection(response, keys[requests.indexOf(request)] ?? ""));
308
358
  const responses = await queue.run(requests, usage);
359
+ // A section that the batch skipped has no response, and a unit that failed
360
+ // validation never produces one: omit those keys entirely rather than
361
+ // passing an empty string, so `assembleSkill` keeps its own content for them
362
+ // (an empty string is a *present* override and would blank the section).
309
363
  const overrides = {};
310
364
  responses.forEach((response, index) => {
311
365
  const section = response ? cleanSection(response.text, keys[index]) : "";
312
- overrides[keys[index]] = validSection(section, keys[index]) ? section : "";
366
+ if (validSection(section, keys[index]))
367
+ overrides[keys[index]] = section;
313
368
  });
314
- return overrides;
369
+ return { overrides, skipped: queue.skippedUnits() };
315
370
  }
@@ -39,6 +39,11 @@ export type Fact = {
39
39
  scope: "current" | "repeated-history" | "historical-example";
40
40
  confidence: "high" | "medium" | "low";
41
41
  status: "active" | "contradicted" | "stale";
42
+ /** Where the fact was mined from. A fact read out of a single pull request
43
+ * describes that request, not the repository, so it may not become a
44
+ * repository-wide rule (CORE-32 / F26b). Missing means `repository`, which
45
+ * keeps facts written before this field existed. */
46
+ origin?: "repository" | "pull-request";
42
47
  };
43
48
  export type State = {
44
49
  version: 1;
@@ -1,5 +1,5 @@
1
- import type { ToolHandler } from "../ai/mermaid_loop.ts";
2
1
  import { type Presence } from "../tools/codegraph.ts";
2
+ import type { ToolHandler } from "../ai/mermaid_loop.ts";
3
3
  import { type Run } from "../pr/checkout.ts";
4
4
  export type LocalCodegraphState = "used" | "disabled" | "unavailable";
5
5
  export type LocalCodegraphPrepare = {
@@ -11,7 +11,8 @@ export declare function prepareLocalCodegraph(input: {
11
11
  gitRoot: string;
12
12
  enabled: boolean;
13
13
  allowInstall: boolean;
14
- interactive: boolean;
14
+ /** Overrides the TTY/CI detection, for tests. */
15
+ interactive?: boolean;
15
16
  run?: Run;
16
17
  detect?: () => Promise<Presence>;
17
18
  }): Promise<LocalCodegraphPrepare>;
@@ -1,5 +1,5 @@
1
+ import { canPrompt, ensureCodegraphForReview, } from "../tools/codegraph.js";
1
2
  import { codegraphTools, ensureCodegraphIndex } from "../pr/codegraph_tools.js";
2
- import { ensureCodegraphForReview } from "../tools/codegraph.js";
3
3
  import { createCodegraphRunner, LOCAL_CODEGRAPH_DIR, } from "../tools/codegraph_exec.js";
4
4
  import { log, startHeartbeat } from "../util/log.js";
5
5
  import { ensureCodegraphGitExclude } from "./codegraph_exclude.js";
@@ -12,7 +12,9 @@ export async function prepareLocalCodegraph(input) {
12
12
  await ensureCodegraphGitExclude(input.gitRoot, run);
13
13
  const resolved = await ensureCodegraphForReview({
14
14
  allowInstall: input.allowInstall,
15
- interactive: input.interactive,
15
+ // The prompt is safe only when a human can answer it; `canPrompt` checks
16
+ // stdin, stdout and `CI`, so a `</dev/null` or piped run never asks (F01).
17
+ interactive: input.interactive ?? canPrompt(),
16
18
  log: (message) => log("codegraph", message.replace(/^\[codegraph\]\s*/, "")),
17
19
  });
18
20
  if (!("path" in resolved)) {
@@ -1,9 +1,10 @@
1
1
  import { type Run } from "../pr/checkout.ts";
2
+ import { CliError } from "../cli/error.ts";
2
3
  export declare function gitRoot(cwd: string, run?: Run): Promise<string>;
3
- export declare class ReviewCliError extends Error {
4
- code: string;
5
- hint?: string;
6
- exitCode: number;
4
+ /** Backward-compatible name for the shared CLI error. Local and remote review
5
+ * threw this before CORE-10 introduced `CliError`, so it stays as a thin
6
+ * subclass and every existing call site keeps working. */
7
+ export declare class ReviewCliError extends CliError {
7
8
  constructor(code: string, message: string, hint?: string, exitCode?: number);
8
9
  }
9
10
  export declare function assertGitQuiet(cwd: string, run?: Run): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  import { runCommand } from "../pr/checkout.js";
2
2
  import { normalizeGithubRemote } from "./git_parse.js";
3
3
  import { stat } from "../util/runtime.js";
4
+ import { CliError, EXIT_USAGE } from "../cli/error.js";
4
5
  export async function gitRoot(cwd, run = runCommand) {
5
6
  const result = await run("git", ["rev-parse", "--show-toplevel"], cwd);
6
7
  if (result.code !== 0) {
@@ -8,15 +9,13 @@ export async function gitRoot(cwd, run = runCommand) {
8
9
  }
9
10
  return result.stdout.trim();
10
11
  }
11
- export class ReviewCliError extends Error {
12
- code;
13
- hint;
14
- exitCode;
15
- constructor(code, message, hint, exitCode = 2) {
16
- super(message);
17
- this.code = code;
18
- this.hint = hint;
19
- this.exitCode = exitCode;
12
+ /** Backward-compatible name for the shared CLI error. Local and remote review
13
+ * threw this before CORE-10 introduced `CliError`, so it stays as a thin
14
+ * subclass and every existing call site keeps working. */
15
+ export class ReviewCliError extends CliError {
16
+ constructor(code, message, hint, exitCode = EXIT_USAGE) {
17
+ super(code, message, hint, exitCode);
18
+ this.name = "ReviewCliError";
20
19
  }
21
20
  }
22
21
  export async function assertGitQuiet(cwd, run = runCommand) {
@@ -4,16 +4,19 @@ import { buildLocalReviewJson, formatHumanLocalReview, resolvedFromFirstReview,
4
4
  import { emptyAiMetrics, recordAiCost, runInitOrRemake, } from "../services/setup.js";
5
5
  import { aiFor } from "../services/review.js";
6
6
  import { loadGuides } from "../review/guides.js";
7
- import { buildCarryPromptSection, classifyCarryItems, incrementalDiffPaths, parsePreviousVerdicts, resolveCarryOutcomes, } from "../review/carry_over.js";
7
+ import { buildCarryPromptSection, classifyCarryItems, guideRebuiltSince, incrementalDiffPaths, parsePreviousVerdicts, resolveCarryOutcomes, } from "../review/carry_over.js";
8
8
  import { revisionHash } from "../review/revision.js";
9
9
  import { parseFindings } from "../pr/findings.js";
10
10
  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
+ import { printRunSummary, summaryFromMetrics } from "../util/run_summary.js";
13
14
  import { setCliInteractive } from "../cli/args.js";
14
15
  import { prepareLocalCodegraph } from "./codegraph_prepare.js";
16
+ import { canPrompt } from "../tools/codegraph.js";
15
17
  import { acquireLocalReviewLock } from "./review_lock.js";
16
18
  import { assertGitQuiet, currentBranch, detectRemoteRepo, gitRoot, headSha, ReviewCliError, } from "./git_ops.js";
19
+ import { EXIT_RUNTIME, exitWith } from "../cli/error.js";
17
20
  import { buildLocalRevision, mergeBase, resolveBaseRef, } from "./git_revision.js";
18
21
  import { clearLocalCarry, localSubjectId, saveLocalCarry, tryLoadLocalCarry, } from "./carry_over_store.js";
19
22
  function storedFindings(resolved) {
@@ -45,7 +48,9 @@ function fail(error, json) {
45
48
  if (error.hint)
46
49
  console.error(`Hint: ${error.hint}`);
47
50
  }
48
- process.exit(error.exitCode);
51
+ // Set the exit code and return; `process.exit` here would assert in libuv on
52
+ // Windows whenever a fetch pool is open (CORE-11).
53
+ exitWith(error.exitCode);
49
54
  }
50
55
  function installInterruptCleanup(releaseSync, json) {
51
56
  const onSignal = () => {
@@ -58,10 +63,10 @@ function installInterruptCleanup(releaseSync, json) {
58
63
  code: "aborted",
59
64
  message: "Review canceled.",
60
65
  },
61
- exitCode: 3,
66
+ exitCode: EXIT_RUNTIME,
62
67
  }));
63
68
  }
64
- process.exit(3);
69
+ exitWith(EXIT_RUNTIME);
65
70
  };
66
71
  for (const signal of ["SIGINT", "SIGTERM"]) {
67
72
  try {
@@ -106,7 +111,7 @@ export async function runLocalReview(cli) {
106
111
  }
107
112
  if (cli.remakeBeforeReview) {
108
113
  if (options.auth !== "gh") {
109
- throw new ReviewCliError("usage", "Remake before review requires GitHub CLI authentication (`--auth=gh`).");
114
+ throw new ReviewCliError("usage", "Sync before review requires GitHub CLI authentication (`--auth=gh`).");
110
115
  }
111
116
  await runInitOrRemake({ ...options, command: "remake" });
112
117
  }
@@ -139,7 +144,7 @@ export async function runLocalReview(cli) {
139
144
  else {
140
145
  console.log("No changes to review.");
141
146
  }
142
- process.exit(0);
147
+ exitWith(0);
143
148
  }
144
149
  const subjectId = localSubjectId(repo, root, branch);
145
150
  if (cli.fresh)
@@ -148,10 +153,19 @@ export async function runLocalReview(cli) {
148
153
  if (carryLoad.unavailable) {
149
154
  warnings.push({
150
155
  code: "carry_over_unavailable",
151
- message: "Could not read the local carry-over cache; continuing without prior findings.",
156
+ message: "Could not read the local carry-over cache. Continuing without prior findings.",
152
157
  });
153
158
  }
154
- const previous = carryLoad.data;
159
+ let previous = carryLoad.data;
160
+ // A guide rebuilt after the last review judged its findings under rules
161
+ // that no longer exist. Rather than let those stale findings mask new
162
+ // ones, start fresh automatically (CORE-42 / F03) — the same effect as
163
+ // `--fresh`, without making the user remember the flag.
164
+ if (previous &&
165
+ guideRebuiltSince(previous.guideBuiltAt, guides.guideBuiltAt)) {
166
+ await clearLocalCarry(repo, root, branch).catch(() => { });
167
+ previous = null;
168
+ }
155
169
  let carryPrevious = null;
156
170
  let carryItems = [];
157
171
  const extras = {};
@@ -174,7 +188,9 @@ export async function runLocalReview(cli) {
174
188
  gitRoot: root,
175
189
  enabled: options.useCodegraph === true,
176
190
  allowInstall: cli.allowToolInstall,
177
- interactive: !json,
191
+ // `--json` output must stay machine-readable, so never prompt then;
192
+ // `canPrompt` adds the TTY and CI checks (F01).
193
+ interactive: !json && canPrompt(),
178
194
  });
179
195
  extras.prepareCodegraphTools = () => Promise.resolve(codegraphPrep.tools);
180
196
  const stopHeartbeat = startHeartbeat("reviewing local changes");
@@ -194,10 +210,12 @@ export async function runLocalReview(cli) {
194
210
  }, aiFor(options), (message) => log("review", message), extras));
195
211
  }
196
212
  finally {
213
+ // 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).
215
+ stopHeartbeat();
197
216
  await lock?.release();
198
217
  lock = null;
199
218
  }
200
- stopHeartbeat();
201
219
  const codegraphState = codegraphPrep.state;
202
220
  const visiblePaths = new Set(response.visiblePaths);
203
221
  if (carryPrevious) {
@@ -206,7 +224,7 @@ export async function runLocalReview(cli) {
206
224
  const parsed = parseFindings(response.text);
207
225
  const filesByPath = new Map(revision.files.map((f) => [f.path, f]));
208
226
  const allResolved = carryPrevious
209
- ? resolveCarryOutcomes(carryItems, revision, visiblePaths, parsePreviousVerdicts(response.text), parsed, response.guideBuiltAt, carryPrevious)
227
+ ? resolveCarryOutcomes(carryItems, revision, visiblePaths, parsePreviousVerdicts(response.text), parsed)
210
228
  : resolvedFromFirstReview(parsed, filesByPath);
211
229
  const findingsToStore = storedFindings(allResolved);
212
230
  const afterBuilt = await buildLocalRevision(root, baseSha, base.label, runCommand);
@@ -252,18 +270,22 @@ export async function runLocalReview(cli) {
252
270
  warnings,
253
271
  usage,
254
272
  durationMs,
273
+ reviewBlocking: options.reviewBlocking,
255
274
  }));
256
275
  }
257
276
  else {
258
277
  console.log("\n" +
259
- formatHumanLocalReview(header, revision, response.guideBuiltAt, codegraphState, allResolved, warnings) +
278
+ formatHumanLocalReview(header, revision, response.guideBuiltAt, codegraphState, allResolved, warnings, options.reviewBlocking) +
260
279
  "\n");
280
+ printRunSummary(summaryFromMetrics(aiMetrics, performance.now() - started));
261
281
  }
262
- process.exit(reviewExitCodeFromResolved(allResolved));
282
+ exitWith(reviewExitCodeFromResolved(allResolved, options.reviewBlocking));
263
283
  }
264
284
  catch (error) {
265
- if (error instanceof ReviewCliError)
285
+ if (error instanceof ReviewCliError) {
266
286
  fail(error, json);
287
+ return;
288
+ }
267
289
  throw error;
268
290
  }
269
291
  finally {
@@ -32,17 +32,28 @@ export async function pathExists(path) {
32
32
  async function cloneInto(repo, dir, run) {
33
33
  log("checkout", `cloning ${repo} into ${dir}`);
34
34
  const started = performance.now();
35
+ // `-c core.longpaths=true` has to be in effect for the clone itself: a deep
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.
35
38
  const result = await run("git", [
39
+ "-c",
40
+ "core.longpaths=true",
36
41
  "clone",
37
42
  "--filter=blob:none",
38
43
  `https://github.com/${repo}`,
39
44
  dir,
40
45
  ]);
41
46
  if (result.code !== 0) {
42
- throw new Error(`git clone failed: ${result.stderr.trim()}`);
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.
49
+ const firstLine = result.stderr
50
+ .split(/\r?\n/)
51
+ .map((line) => line.trim())
52
+ .find((line) => line.length > 0) ?? "";
53
+ throw new Error(`git clone failed (exit ${result.code}) for ${repo}, so this review has no ` +
54
+ `repository scope and falls back to the diff only` +
55
+ (firstLine ? `: ${firstLine}` : ""));
43
56
  }
44
- // Deep source trees overrun the Windows path limit even under a short root.
45
- await run("git", ["config", "core.longpaths", "true"], dir);
46
57
  log("checkout", `cloned in ${((performance.now() - started) / 1000).toFixed(1)}s`);
47
58
  return dir;
48
59
  }
@@ -143,7 +143,7 @@ export function codegraphTools(binary, worktree, runner = createCodegraphRunner(
143
143
  },
144
144
  file: {
145
145
  type: "string",
146
- description: "A file path — reads the file's symbol map instead of a single symbol.",
146
+ description: "A file path. Reads the file's symbol map instead of a single symbol.",
147
147
  },
148
148
  symbolsOnly: {
149
149
  type: "boolean",
@@ -324,7 +324,7 @@ export function codegraphTools(binary, worktree, runner = createCodegraphRunner(
324
324
  type: "function",
325
325
  function: {
326
326
  name: "codegraph-impact",
327
- description: "Analyze what else in the codebase is affected by changing a specific symbol — its blast radius.",
327
+ description: "Analyze what else in the codebase is affected by changing a specific symbol, meaning its blast radius.",
328
328
  parameters: {
329
329
  type: "object",
330
330
  properties: {
@@ -362,7 +362,7 @@ export function codegraphTools(binary, worktree, runner = createCodegraphRunner(
362
362
  type: "function",
363
363
  function: {
364
364
  name: "codegraph-affected",
365
- description: "Find test files affected by one or more changed source files — the direct way to check whether a change is covered by any test, instead of guessing from the diff.",
365
+ description: "Find test files affected by one or more changed source files. This is the direct way to check whether a change is covered by any test, instead of guessing from the diff.",
366
366
  parameters: {
367
367
  type: "object",
368
368
  properties: {
@@ -10,7 +10,7 @@ export async function summarizeDiff(path, patch, provider, usage) {
10
10
  prompt: `Summarize this diff for \`${path}\` in 3-5 sentences: what changed, the
11
11
  mechanism, and precisely which branches, patterns, or call paths are affected.
12
12
  Do not speculate beyond what the diff shows. Do not suggest fixes or judge
13
- whether the change is correct — only describe it.
13
+ whether the change is correct. Only describe it.
14
14
 
15
15
  ${patch}`,
16
16
  maxTokens: 500,
@@ -23,7 +23,7 @@ export const READ_FULL_DIFF_TOOL = {
23
23
  type: "function",
24
24
  function: {
25
25
  name: "read-full-diff",
26
- description: "Read the complete, untruncated diff for one file in this pull request's own changes. Only files whose diff was summarized (over 500 changed lines) are available this way — everything else is already shown in full.",
26
+ description: "Read the complete, untruncated diff for one file in this pull request's own changes. Only files whose diff was summarized (over 500 changed lines) are available this way. Everything else is already shown in full.",
27
27
  parameters: {
28
28
  type: "object",
29
29
  properties: {
@@ -45,7 +45,7 @@ export function readFullDiff(patchByPath, args) {
45
45
  if (patch)
46
46
  return numberPatch(patch);
47
47
  return patchByPath.size === 0
48
- ? "No large files were summarized in this review; every file's diff is already shown in full above."
48
+ ? "No large files were summarized in this review. Every file's diff is already shown in full above."
49
49
  : `No summarized diff found for \`${path}\`. Summarized files: ${[
50
50
  ...patchByPath.keys(),
51
51
  ].join(", ")}`;
@@ -1,5 +1,12 @@
1
1
  const FILE_LINE = /(?<path>(?:[\w.-]+\/)*[\w.-]+\.[A-Za-z0-9]+):(?<from>\d+)(?:-(?<to>\d+))?/g;
2
- const NEW_HEADING = /^\[(?<severity>P[0-3])\s*·\s*(?<impact>blocking|non-blocking)\]\s+(?<path>.+?)\s+—\s+(?<symbol>.+)$/;
2
+ /** The finding heading: `[P2 · non-blocking] \`path\` — \`symbol\``. The model
3
+ * sometimes writes `:` where the prompt asked for the em dash, and sometimes
4
+ * leaves the symbol off. Both are accepted: an unrecognised heading used to
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
7
+ * against the path (`\`src/a.ts\`: \`helper()\``), which is how the model
8
+ * usually writes the colon form. */
9
+ const NEW_HEADING = /^\[(?<severity>P[0-3])\s*·\s*(?<impact>blocking|non-blocking)\]\s+(?<path>.+?)(?:\s*(?:—|:)\s*(?<symbol>.+))?$/;
3
10
  function uncode(value) {
4
11
  return value.trim().replace(/^`|`$/g, "").trim();
5
12
  }
@@ -27,19 +34,23 @@ function newFinding(header, block) {
27
34
  if (!at)
28
35
  return undefined;
29
36
  const path = uncode(meta.groups.path);
30
- const symbol = uncode(meta.groups.symbol);
37
+ const symbol = uncode(meta.groups.symbol ?? "");
31
38
  const severity = meta.groups.severity;
32
39
  const impact = meta.groups.impact;
33
40
  const excerpt = bodyWithoutLocation(block);
34
41
  const summary = excerpt.split(/\r?\n/).find((line) => line.trim()) ?? "";
35
42
  return {
36
43
  ...at,
37
- path,
38
- heading: `[${severity} · ${impact}] \`${path}\` — \`${symbol}\``,
44
+ // The Location line is authoritative for the path; the heading only names
45
+ // it, and may omit the symbol entirely.
46
+ path: at.path || path,
47
+ heading: symbol
48
+ ? `[${severity} · ${impact}] \`${path}\`: \`${symbol}\``
49
+ : `[${severity} · ${impact}] \`${path}\``,
39
50
  excerpt,
40
51
  severity,
41
52
  blocking: impact === "blocking",
42
- symbol,
53
+ symbol: symbol || undefined,
43
54
  summary,
44
55
  };
45
56
  }
@@ -0,0 +1,26 @@
1
+ /** Structured review output (CORE-40 / F02).
2
+ *
3
+ * F02: findings were truncated mid-sentence (`... while \`u`) and split at the
4
+ * wrong offsets, because the model's free-form Markdown was sliced by the
5
+ * fallback parser. The fix is to have the model return JSON and generate the
6
+ * Markdown ourselves, so a finding's boundaries can no longer be guessed from
7
+ * prose. Markdown stays the internal contract — every downstream consumer
8
+ * (`parseFindings`, carry-over, GitHub posting) keeps working unchanged.
9
+ */
10
+ import type { Json } from "../types.ts";
11
+ import type { ParsedFinding } from "./findings.ts";
12
+ /** OpenRouter `response_format` for a review. Providers that ignore it still
13
+ * get the prompt's instruction to return a fenced JSON block. */
14
+ export declare const FINDINGS_JSON_SCHEMA: Json;
15
+ /** The prompt's contract, kept next to the schema so the two cannot drift. */
16
+ export declare const FINDINGS_JSON_INSTRUCTIONS = "Return a single JSON object, and nothing else, with this shape:\n{\"findings\":[{\"severity\":\"P1\",\"blocking\":true,\"path\":\"src/a.ts\",\"lineFrom\":42,\"lineTo\":42,\"symbol\":\"helper()\",\"title\":\"The helper ignores its argument\",\"body\":\"One sentence on what is wrong and its impact, then a short evidence paragraph.\",\"suggestion\":\"the exact replacement lines, or omit this field\"}],\"previousFindings\":[{\"id\":\"F1\",\"state\":\"open\"}]}\n\nRules for the JSON:\n- \"severity\" is exactly one of P0, P1, P2, P3. \"blocking\" is true or false.\n- \"lineFrom\" and \"lineTo\" are numbers in the new file, copied from the DIFF\n column, never counted from the @@ header. \"lineTo\" may equal \"lineFrom\".\n- \"title\" is one sentence naming the defect, without the severity or the path.\n- \"body\" is the explanation. Do not repeat the title, the path, or the location\n line inside it; our code renders those. Do not add a closing sentence asking\n whether to explain more, and do not use the words Mechanism, Symptom,\n Scenario, Verified, Repro, Options, or Scope as labels.\n- \"suggestion\" holds only the replacement source lines, with their original\n indentation, when the fix replaces the exact lines lineFrom-lineTo in one\n hunk of the same file. Omit it otherwise. Never wrap it in a code fence.\n- When a fix needs removed lines, another file, or more than one hunk, omit\n \"suggestion\".\n- Return every independently actionable finding, including none: use\n {\"findings\":[]} when the change is clean. Never invent a finding to fill the\n array.";
17
+ /** Reads the structured output and renders it as Markdown. `undefined` means
18
+ * the text was not usable JSON, so the caller falls back to the legacy
19
+ * Markdown parser rather than dropping the review. */
20
+ export declare function findingsMarkdownFromJson(text: string): string | undefined;
21
+ /** The findings a JSON review produced, for callers that want the objects
22
+ * rather than the Markdown. */
23
+ export type JsonFindingResult = {
24
+ markdown: string;
25
+ findings: ParsedFinding[];
26
+ };