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,53 +1,33 @@
1
1
  import { VERSION } from "../version.js";
2
- import { printLocalReview } from "../cli/review_output.js";
3
- import { formatHumanJsonFindings, reviewExitCodeFromJsonFindings, } from "../cli/review_result.js";
2
+ import { CliError, EXIT_USAGE, exitWith } from "../cli/error.js";
3
+ import { formatHumanReview, humanFindingsFromJson, reviewExitCodeFromJsonFindings, } from "../cli/review_result.js";
4
+ import { baseUrl, die, readApiError, remoteFetch } from "./http.js";
4
5
  import { readConfig, writeUserConfig } from "../config.js";
5
6
  import { prepareLocalCodegraph } from "../local/codegraph_prepare.js";
7
+ import { canPrompt } from "../tools/codegraph.js";
6
8
  import { runRemoteToolCalls, toolHandlerMap, } from "./client_tools.js";
7
9
  import { REMOTE_KNOWN_TOOL_NAMES } from "./schema.js";
8
10
  import { runCommand } from "../pr/checkout.js";
9
- import { assertGitQuiet, currentBranch, detectRemoteRepo, gitRoot, ReviewCliError, } from "../local/git_ops.js";
11
+ import { assertGitQuiet, currentBranch, detectRemoteRepo, gitRoot, } from "../local/git_ops.js";
10
12
  import { buildLocalRevision, mergeBase, resolveBaseRef, } from "../local/git_revision.js";
11
13
  import { withCliLogsToStderr } from "../util/log.js";
14
+ import { printRunSummary, summaryFromMetrics } from "../util/run_summary.js";
12
15
  import { setCliInteractive } from "../cli/args.js";
13
16
  import { MIN_SERVER_SCHEMA, REMOTE_CLI_UPGRADE_COMMAND, REMOTE_SCHEMA_VERSION, } from "./schema.js";
14
- function die(code, message, hint, exitCode = 2) {
15
- throw new ReviewCliError(code, message, hint, exitCode);
16
- }
17
- function baseUrl(host) {
18
- const trimmed = host.trim().replace(/\/+$/, "");
19
- if (!/^https?:\/\//i.test(trimmed)) {
20
- die("remote_not_configured", "remoteHost must be an absolute http(s) URL.", "co-maintainer set --remote-host=https://your-server");
21
- }
22
- return trimmed;
23
- }
24
- async function remoteFetch(host, token, path, init) {
25
- const url = `${baseUrl(host)}${path}`;
26
- const headers = new Headers(init.headers);
27
- headers.set("authorization", `Bearer ${token}`);
28
- if (init.body && !headers.has("content-type")) {
29
- headers.set("content-type", "application/json");
30
- }
31
- return await fetch(url, { ...init, headers });
32
- }
33
- async function readApiError(response) {
34
- try {
35
- const body = await response.json();
36
- return String(body.error?.message ?? response.statusText);
37
- }
38
- catch {
39
- return response.statusText;
40
- }
41
- }
42
17
  export async function runRemoteReview(cli) {
43
18
  const config = readConfig();
44
- const host = config.remoteHost;
45
- const token = config.remoteToken;
19
+ // Inline flags win over the saved config for this run only (CORE-25), so a
20
+ // 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;
46
23
  if (!host || !token) {
47
- die("remote_not_configured", "Remote review is not configured.", "co-maintainer set --remote-host=... --remote-token=...");
24
+ die("remote_not_configured", "Remote review is not configured.", "co-maintainer config set remote-host https://your-server " +
25
+ "&& co-maintainer config set remote-token <token> " +
26
+ "(or pass --remote-host=... --remote-token=... for one run)");
48
27
  }
49
28
  setCliInteractive(!cli.json);
50
29
  await withCliLogsToStderr(async () => {
30
+ const reviewStarted = performance.now();
51
31
  const root = await gitRoot(process.cwd());
52
32
  await assertGitQuiet(root);
53
33
  const repo = await detectRemoteRepo(root, cli.repoOverride);
@@ -76,7 +56,7 @@ export async function runRemoteReview(cli) {
76
56
  else {
77
57
  console.log("No changes to review.");
78
58
  }
79
- process.exit(0);
59
+ exitWith(0);
80
60
  }
81
61
  const handshake = await remoteFetch(host, token, "/api/remote/handshake", {
82
62
  method: "POST",
@@ -109,7 +89,8 @@ export async function runRemoteReview(cli) {
109
89
  gitRoot: root,
110
90
  enabled: !cli.disableCodegraph,
111
91
  allowInstall: cli.allowToolInstall,
112
- interactive: !cli.json,
92
+ // `--json` must stay machine-readable; `canPrompt` adds TTY and CI (F01).
93
+ interactive: !cli.json && canPrompt(),
113
94
  });
114
95
  const localTools = toolHandlerMap(codegraphPrep.tools);
115
96
  const capabilityTools = [...localTools.keys()]
@@ -181,14 +162,33 @@ export async function runRemoteReview(cli) {
181
162
  else {
182
163
  const findings = (payload.result.findings ??
183
164
  []);
184
- const summary = payload.result.summary;
185
- const summaryLine = summary
186
- ? `${summary.new ?? 0} new · ${summary.open ?? 0} open · ${summary.closed ?? 0} closed · ${summary.blocking ?? 0} blocking`
187
- : "";
188
- printLocalReview(`co-maintainer review · ${repo} · ${branch} (remote)\n${summaryLine}`, formatHumanJsonFindings(findings));
165
+ const resultGuide = payload.result.guide;
166
+ const resultCodegraph = payload.result.codegraph;
167
+ console.log("\n" +
168
+ formatHumanReview({
169
+ title: `co-maintainer review · ${repo} · ${branch} (remote)`,
170
+ guideBuiltAt: resultGuide?.builtAt ?? null,
171
+ codegraphState: resultCodegraph?.state ?? null,
172
+ findings: humanFindingsFromJson(findings),
173
+ }) +
174
+ "\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
+ }
189
185
  }
190
186
  const findings = (payload.result.findings ?? []);
191
- process.exit(reviewExitCodeFromJsonFindings(findings));
187
+ exitWith(reviewExitCodeFromJsonFindings(findings));
188
+ // `done` is terminal. Without this return the loop polls `sync` again
189
+ // and, since the server keeps reporting the same finished job, the
190
+ // client reprints the result and re-requests forever (CORE-25).
191
+ return;
192
192
  }
193
193
  if (payload.status === "failed" || payload.status === "canceled") {
194
194
  die(payload.abort?.reason ?? "remote_failed", payload.abort?.message ?? "Remote review did not complete.");
@@ -0,0 +1,5 @@
1
+ /** A usage error the CLI prints with its hint and exit code (CORE-12). */
2
+ export declare function die(code: string, message: string, hint?: string, exitCode?: number): never;
3
+ export declare function baseUrl(host: string): string;
4
+ export declare function remoteFetch(host: string, token: string, path: string, init: RequestInit): Promise<Response>;
5
+ export declare function readApiError(response: Response): Promise<string>;
@@ -0,0 +1,49 @@
1
+ /** Shared HTTPS plumbing for the remote endpoints (CORE-25, CORE-44).
2
+ *
3
+ * `review --remote` and `view --remote` talk to the same server with the same
4
+ * bearer token, so the base URL, the token header and the explanations for a
5
+ * rejected token or an unreachable host live here rather than in the review
6
+ * client alone. */
7
+ import { CliError, EXIT_USAGE, networkFailure } from "../cli/error.js";
8
+ /** A usage error the CLI prints with its hint and exit code (CORE-12). */
9
+ export function die(code, message, hint, exitCode = EXIT_USAGE) {
10
+ throw new CliError(code, message, hint, exitCode);
11
+ }
12
+ export function baseUrl(host) {
13
+ const trimmed = host.trim().replace(/\/+$/, "");
14
+ if (!/^https?:\/\//i.test(trimmed)) {
15
+ die("remote_not_configured", "remoteHost must be an absolute http(s) URL.", "co-maintainer config set remote-host https://your-server");
16
+ }
17
+ return trimmed;
18
+ }
19
+ export async function remoteFetch(host, token, path, init) {
20
+ const url = `${baseUrl(host)}${path}`;
21
+ const headers = new Headers(init.headers);
22
+ headers.set("authorization", `Bearer ${token}`);
23
+ if (init.body && !headers.has("content-type")) {
24
+ headers.set("content-type", "application/json");
25
+ }
26
+ try {
27
+ const response = await fetch(url, { ...init, headers });
28
+ // The server's 401 text is `invalid or missing token`, which does not say
29
+ // where the token comes from or how to get another one (CORE-12).
30
+ if (response.status === 401 || response.status === 403) {
31
+ die("remote_token_rejected", `The server at ${baseUrl(host)} rejected the remote review token.`, "Create one in its dashboard under Settings, Remote review tokens.", EXIT_USAGE);
32
+ }
33
+ return response;
34
+ }
35
+ catch (error) {
36
+ if (error instanceof CliError)
37
+ throw error;
38
+ throw networkFailure(url, error);
39
+ }
40
+ }
41
+ export async function readApiError(response) {
42
+ try {
43
+ const body = await response.json();
44
+ return String(body.error?.message ?? response.statusText);
45
+ }
46
+ catch {
47
+ return response.statusText;
48
+ }
49
+ }
@@ -0,0 +1,22 @@
1
+ export type RemoteGuide = {
2
+ kind: string;
3
+ file: string;
4
+ size: number;
5
+ builtAt: string;
6
+ content: string;
7
+ };
8
+ /** Canonicalizes `owner/repo` from the query string. A value that escapes is
9
+ * refused before any directory is touched, the same guard the revision payload
10
+ * uses. */
11
+ export declare function parseRepoQuery(value: string | null): {
12
+ repo: string;
13
+ } | {
14
+ error: string;
15
+ };
16
+ /** Every file in `repo`'s guide directory, in `GUIDES` order first so the
17
+ * reading order matches a local `view`, with an unexpected file (an older
18
+ * name, a hand-written note) last. */
19
+ export declare function readRemoteGuides(repo: string): Promise<{
20
+ guides: RemoteGuide[];
21
+ guideBuiltAt: string | null;
22
+ }>;
@@ -0,0 +1,81 @@
1
+ /** `GET /api/remote/guides?repo=owner/repo` — read a repository's guides with a
2
+ * remote token (CORE-44).
3
+ *
4
+ * This is what `view --remote` calls. It is a read-only addition: no existing
5
+ * route changes, and it uses the same bearer model the review endpoints use
6
+ * (valid token, active token, lockout after repeated failures). The response
7
+ * carries every file in the guide directory with its size and build date, so
8
+ * the CLI prints the same headers a local `view` prints without a round trip
9
+ * per file. */
10
+ import { reposDir } from "../../config.js";
11
+ import { loadGuides } from "../../review/guides.js";
12
+ import { readDir, readTextFile, stat } from "../../util/runtime.js";
13
+ import { validateRemotePath } from "../validate.js";
14
+ /** The four generated guides, in the order a reader wants them (CORE-23). */
15
+ const GUIDES = [
16
+ { kind: "skill", file: "SKILL.md" },
17
+ { kind: "codebase", file: "CODEBASE.md" },
18
+ { kind: "review-guide", file: "PR_REVIEW_GUIDE.md" },
19
+ { kind: "detailed-guide", file: "PR_REVIEW_DETAILED_GUIDE.md" },
20
+ ];
21
+ /** Canonicalizes `owner/repo` from the query string. A value that escapes is
22
+ * refused before any directory is touched, the same guard the revision payload
23
+ * uses. */
24
+ export function parseRepoQuery(value) {
25
+ if (!value)
26
+ return { error: "repo: required" };
27
+ if (!/^[^/]+\/[^/]+$/.test(value)) {
28
+ return { error: "repo: must be owner/repo" };
29
+ }
30
+ const pathErr = validateRemotePath("repo", value);
31
+ if (pathErr)
32
+ return { error: pathErr };
33
+ return { repo: value };
34
+ }
35
+ /** Every file in `repo`'s guide directory, in `GUIDES` order first so the
36
+ * reading order matches a local `view`, with an unexpected file (an older
37
+ * name, a hand-written note) last. */
38
+ export async function readRemoteGuides(repo) {
39
+ const loaded = await loadGuides(repo);
40
+ const byFile = new Map([
41
+ ["PR_REVIEW_GUIDE.md", loaded.shortGuide],
42
+ ["PR_REVIEW_DETAILED_GUIDE.md", loaded.detailed],
43
+ ["CODEBASE.md", loaded.codebase],
44
+ ["SKILL.md", loaded.skill],
45
+ ]);
46
+ const dir = `${reposDir()}/${repo}`;
47
+ const names = [];
48
+ try {
49
+ for await (const entry of readDir(dir)) {
50
+ if (entry.isFile)
51
+ names.push(entry.name);
52
+ }
53
+ }
54
+ catch {
55
+ // No directory: `loadGuides` already returned empty strings, so the caller
56
+ // gets an empty list rather than an error.
57
+ return { guides: [], guideBuiltAt: loaded.guideBuiltAt };
58
+ }
59
+ const order = (name) => {
60
+ const index = GUIDES.findIndex((guide) => guide.file === name);
61
+ return index === -1 ? GUIDES.length : index;
62
+ };
63
+ const guides = [];
64
+ for (const file of names.sort((a, b) => order(a) - order(b) || a.localeCompare(b))) {
65
+ try {
66
+ const info = await stat(`${dir}/${file}`);
67
+ guides.push({
68
+ kind: GUIDES.find((guide) => guide.file === file)?.kind ?? "other",
69
+ file,
70
+ size: info.size,
71
+ builtAt: info.mtime.toISOString(),
72
+ content: byFile.get(file) ?? (await readTextFile(`${dir}/${file}`)),
73
+ });
74
+ }
75
+ catch {
76
+ // The file vanished between the listing and the read: skip it rather
77
+ // than fail the whole directory.
78
+ }
79
+ }
80
+ return { guides, guideBuiltAt: loaded.guideBuiltAt };
81
+ }
@@ -11,6 +11,7 @@ import { MIN_CLIENT_SCHEMA, REMOTE_CLI_UPGRADE_COMMAND, REMOTE_MAX_BODY_BYTES, R
11
11
  import { readBoundedJson } from "./body.js";
12
12
  import { submitRemoteReview } from "../../services/remote_review.js";
13
13
  import { handleRemoteCancel, handleRemoteSync } from "./sync.js";
14
+ import { parseRepoQuery, readRemoteGuides } from "./guides.js";
14
15
  import { validateHandshakeRequest, validateSubmitRequest, } from "../validate.js";
15
16
  import { stat } from "../../util/runtime.js";
16
17
  const REPO_UNAVAILABLE = "Sorry, we could not access this repository.";
@@ -128,10 +129,33 @@ async function handleSubmit(request, ip) {
128
129
  reviewId: result.reviewId,
129
130
  }, { status: 202 });
130
131
  }
132
+ async function handleGuides(request, ip, url) {
133
+ const auth = await authenticateRemote(request, ip);
134
+ if (auth instanceof Response)
135
+ return auth;
136
+ const parsed = parseRepoQuery(url.searchParams.get("repo"));
137
+ if ("error" in parsed) {
138
+ return errorResponse(400, "bad_request", parsed.error);
139
+ }
140
+ const repoRow = findRepoByFullName(parsed.repo);
141
+ if (!repoRow || repoRow.active !== 1) {
142
+ return errorResponse(404, "repo_unavailable", REPO_UNAVAILABLE);
143
+ }
144
+ const { guides, guideBuiltAt } = await readRemoteGuides(repoRow.full_name);
145
+ return Response.json({
146
+ schemaVersion: REMOTE_SCHEMA_VERSION,
147
+ repo: { fullName: repoRow.full_name },
148
+ guideBuiltAt,
149
+ guides,
150
+ });
151
+ }
131
152
  export async function handleRemoteRoute(request, url, remoteAddr) {
132
153
  if (url.pathname === "/api/remote/handshake" && request.method === "POST") {
133
154
  return await handleHandshake(request, remoteAddr);
134
155
  }
156
+ if (url.pathname === "/api/remote/guides" && request.method === "GET") {
157
+ return await handleGuides(request, remoteAddr, url);
158
+ }
135
159
  if (url.pathname === "/api/remote/reviews" && request.method === "POST") {
136
160
  return await handleSubmit(request, remoteAddr);
137
161
  }
@@ -0,0 +1,35 @@
1
+ /** How a review decides a blocking finding (CORE-41, plan decision 2 option C).
2
+ *
3
+ * F19: the same pull request was reviewed twice, the same P2 finding came back
4
+ * `non-blocking` the first time and `blocking` the second, and the exit code
5
+ * changed because the model's own label moved. `model` keeps that behavior
6
+ * (the model decides) because it is what 0.4.13 shipped; `severity` ignores
7
+ * the model's label and uses a fixed threshold, so the same finding set always
8
+ * yields the same exit code.
9
+ *
10
+ * This is the one place that answers "does this finding block". Every review
11
+ * path — local, PR, remote and the GitHub App — calls it, so the modes cannot
12
+ * drift apart.
13
+ */
14
+ export type ReviewBlocking = "model" | "severity";
15
+ export declare const DEFAULT_REVIEW_BLOCKING: ReviewBlocking;
16
+ /** Reads a config, flag or env value. Anything but `severity` is the default,
17
+ * so an unset or mistyped value never silently tightens the rule. */
18
+ export declare function reviewBlockingFrom(value: unknown): ReviewBlocking;
19
+ /** One finding, in the shape any of the review paths happens to have. Only
20
+ * `severity` is always available; the other two describe the model's own
21
+ * claim, which `severity` mode ignores. */
22
+ export type BlockingInput = {
23
+ severity?: string;
24
+ /** The model's structured `blocking` field, when the answer was JSON. */
25
+ blocked?: boolean;
26
+ /** The heading or title text, when the answer came from Markdown. */
27
+ text?: string;
28
+ };
29
+ /** Whether one finding blocks under the configured rule.
30
+
31
+ * `model`: the model decides. A P0 always blocks even when the model labeled
32
+ * it otherwise, which is the 0.4.13 rule and is kept so no existing review
33
+ * changes its exit code.
34
+ * `severity`: P0 and P1 block, P2 and P3 do not, whatever the model said. */
35
+ export declare function isBlocking(mode: ReviewBlocking, finding: BlockingInput): boolean;
@@ -0,0 +1,45 @@
1
+ /** How a review decides a blocking finding (CORE-41, plan decision 2 option C).
2
+ *
3
+ * F19: the same pull request was reviewed twice, the same P2 finding came back
4
+ * `non-blocking` the first time and `blocking` the second, and the exit code
5
+ * changed because the model's own label moved. `model` keeps that behavior
6
+ * (the model decides) because it is what 0.4.13 shipped; `severity` ignores
7
+ * the model's label and uses a fixed threshold, so the same finding set always
8
+ * yields the same exit code.
9
+ *
10
+ * This is the one place that answers "does this finding block". Every review
11
+ * path — local, PR, remote and the GitHub App — calls it, so the modes cannot
12
+ * drift apart.
13
+ */
14
+ export const DEFAULT_REVIEW_BLOCKING = "model";
15
+ /** Reads a config, flag or env value. Anything but `severity` is the default,
16
+ * so an unset or mistyped value never silently tightens the rule. */
17
+ export function reviewBlockingFrom(value) {
18
+ return value === "severity" ? "severity" : DEFAULT_REVIEW_BLOCKING;
19
+ }
20
+ /** The model's claim as written into a heading: `[P2 · non-blocking] …`. An
21
+ * unrecognised heading says nothing, rather than guessing "not blocking". */
22
+ function claimFromText(text) {
23
+ if (/non-blocking\]/i.test(text))
24
+ return false;
25
+ if (/·\s*blocking\]/i.test(text))
26
+ return true;
27
+ return undefined;
28
+ }
29
+ /** Whether one finding blocks under the configured rule.
30
+
31
+ * `model`: the model decides. A P0 always blocks even when the model labeled
32
+ * it otherwise, which is the 0.4.13 rule and is kept so no existing review
33
+ * changes its exit code.
34
+ * `severity`: P0 and P1 block, P2 and P3 do not, whatever the model said. */
35
+ export function isBlocking(mode, finding) {
36
+ const severity = finding.severity?.toUpperCase();
37
+ if (mode === "severity") {
38
+ return severity === "P0" || severity === "P1";
39
+ }
40
+ if (severity === "P0")
41
+ return true;
42
+ if (finding.blocked === true)
43
+ return true;
44
+ return finding.text ? claimFromText(finding.text) === true : false;
45
+ }
@@ -45,6 +45,17 @@ export declare function relocate(anchorText: string | null, oldFrom: number, pat
45
45
  from: number;
46
46
  to: number;
47
47
  } | null;
48
+ /** CORE-42 / F03: a guide rebuilt after the previous review invalidates that
49
+ * review's verdicts, its "unchanged" suppression and its open findings — they
50
+ * were all judged against the old rules. Such a run starts fresh instead:
51
+ * every file is scanned again and old findings are no longer carried over.
52
+ *
53
+ * `previousBuiltAt` is the guide's own build time as of that review. A
54
+ * previous review that never recorded one (a snapshot written before CORE-42)
55
+ * counts as rebuilt too: re-scanning once is cheaper than letting a stale
56
+ * verdict mask a new violation. With no current time there is nothing to
57
+ * compare, so nothing is treated as rebuilt. */
58
+ export declare function guideRebuiltSince(previousBuiltAt: string | null, currentBuiltAt: string | null): boolean;
48
59
  export declare function classifyCarryItems(previous: CarryPrevious, current: Revision, visiblePaths: Set<string>, currentGuideBuiltAt: string | null): CarryItem[];
49
60
  export declare function buildCarryPromptSection(items: CarryItem[], current: Revision): string;
50
61
  export type PreviousVerdict = {
@@ -56,7 +67,7 @@ export type PreviousVerdict = {
56
67
  };
57
68
  export declare function parsePreviousVerdicts(markdown: string): PreviousVerdict[];
58
69
  export declare function findingsMarkdownForParse(markdown: string): string;
59
- export declare function resolveCarryOutcomes(items: CarryItem[], current: Revision, visiblePaths: Set<string>, verdicts: PreviousVerdict[], parsedNew: ParsedFinding[], currentGuideBuiltAt: string | null, previous: CarryPrevious): ResolvedFinding[];
70
+ export declare function resolveCarryOutcomes(items: CarryItem[], current: Revision, visiblePaths: Set<string>, verdicts: PreviousVerdict[], parsedNew: ParsedFinding[]): ResolvedFinding[];
60
71
  export declare function incrementalDiffPaths(current: Revision, previousFiles: RevisionFile[] | null): {
61
72
  changed: RevisionFile[];
62
73
  unchanged: string[];
@@ -1,6 +1,6 @@
1
1
  import { spansOverlap } from "../pr/rounds.js";
2
2
  import { rightLines } from "../pr/hunks.js";
3
- import { normalizeAnchor, normalizeBody, normalizePath, revisionFilesEquivalent, resolvePathAfterRename, } from "./revision.js";
3
+ import { normalizeAnchor, normalizePath, revisionFilesEquivalent, resolvePathAfterRename, } from "./revision.js";
4
4
  export function anchorTextFromPatch(patch, from, to) {
5
5
  const lines = rightLines(patch);
6
6
  const low = Math.min(from, to);
@@ -70,6 +70,28 @@ export function relocate(anchorText, oldFrom, patch) {
70
70
  function guideChanged(previous, currentGuideBuiltAt) {
71
71
  return (previous.guideBuiltAt ?? "") !== (currentGuideBuiltAt ?? "");
72
72
  }
73
+ /** CORE-42 / F03: a guide rebuilt after the previous review invalidates that
74
+ * review's verdicts, its "unchanged" suppression and its open findings — they
75
+ * were all judged against the old rules. Such a run starts fresh instead:
76
+ * every file is scanned again and old findings are no longer carried over.
77
+ *
78
+ * `previousBuiltAt` is the guide's own build time as of that review. A
79
+ * previous review that never recorded one (a snapshot written before CORE-42)
80
+ * counts as rebuilt too: re-scanning once is cheaper than letting a stale
81
+ * verdict mask a new violation. With no current time there is nothing to
82
+ * compare, so nothing is treated as rebuilt. */
83
+ export function guideRebuiltSince(previousBuiltAt, currentBuiltAt) {
84
+ if (!currentBuiltAt)
85
+ return false;
86
+ if (!previousBuiltAt)
87
+ return true;
88
+ const before = Date.parse(previousBuiltAt);
89
+ const after = Date.parse(currentBuiltAt);
90
+ if (!Number.isFinite(before) || !Number.isFinite(after)) {
91
+ return previousBuiltAt !== currentBuiltAt;
92
+ }
93
+ return after > before;
94
+ }
73
95
  export function classifyCarryItems(previous, current, visiblePaths, currentGuideBuiltAt) {
74
96
  const currentByPath = new Map(current.files.map((file) => [normalizePath(file.path), file]));
75
97
  const items = [];
@@ -86,7 +108,6 @@ export function classifyCarryItems(previous, current, visiblePaths, currentGuide
86
108
  const prevFile = finding.path
87
109
  ? previous.files.find((f) => normalizePath(f.path) === normalizePath(finding.path))
88
110
  : undefined;
89
- const prevPatch = prevFile?.patch ?? "";
90
111
  const sameBody = Boolean(prevFile && file && revisionFilesEquivalent(prevFile, file));
91
112
  if (!finding.path) {
92
113
  klass = "unverifiable";
@@ -178,12 +199,17 @@ export function buildCarryPromptSection(items, current) {
178
199
  if (verify.length === 0 && unchanged.length === 0)
179
200
  return "";
180
201
  const lines = [
181
- "PREVIOUS FINDINGS:",
182
- "These were raised by an earlier review of this same change. For each one",
183
- "listed under VERIFY, decide whether the code in the DIFF still has the",
184
- 'problem. Report every decision in a final "## Previous findings" section,',
185
- 'one line per id, exactly as "- F1: open path:from-to" or "- F1: closed".',
186
- 'Do not repeat an open previous finding under "## Findings".',
202
+ "PREVIOUS FINDINGS (this run has two separate jobs):",
203
+ "1. Scan the DIFF from scratch for every actionable violation under the",
204
+ " current review guide, exactly as a first review would. Do not let the",
205
+ " list below shorten or replace that scan. A changed file can contain a",
206
+ " new finding even when a previous finding already covers its location.",
207
+ "2. For each entry under VERIFY, decide whether the code in the DIFF still",
208
+ " has that problem. Report every decision in a final section headed",
209
+ ' exactly "## Previous findings", one line per id, exactly as',
210
+ ' "- F1: open path:from-to" or "- F1: closed".',
211
+ 'Do not repeat an open previous finding under "## Findings"; report there',
212
+ "only findings from job 1 that are not already covered below.",
187
213
  "",
188
214
  ];
189
215
  if (verify.length) {
@@ -219,7 +245,7 @@ export function findingsMarkdownForParse(markdown) {
219
245
  const cut = markdown.search(/^## Previous findings\b/im);
220
246
  return cut === -1 ? markdown : markdown.slice(0, cut);
221
247
  }
222
- export function resolveCarryOutcomes(items, current, visiblePaths, verdicts, parsedNew, currentGuideBuiltAt, previous) {
248
+ export function resolveCarryOutcomes(items, current, visiblePaths, verdicts, parsedNew) {
223
249
  const verdictByPromptId = new Map(verdicts.map((v) => [v.id, v]));
224
250
  const promptIds = new Map();
225
251
  let seq = 0;
@@ -9,5 +9,6 @@ type ProgressSink = (message: string) => void;
9
9
  export declare function runReviewEngine(client: GitHubClient, options: Options, usage?: UsageSink, snapshot?: Snapshot, ai?: AiProvider, progress?: ProgressSink, extras?: ReviewExtras): Promise<AiResponse & {
10
10
  visiblePaths: string[];
11
11
  guideBuiltAt: string | null;
12
+ codegraphState: "used" | "disabled" | "unavailable";
12
13
  }>;
13
14
  export {};
@@ -1,7 +1,7 @@
1
1
  import { reposDir } from "../config.js";
2
2
  import { getRepo } from "../store/repos.js";
3
3
  import { withKeyedLock } from "../util/keyed_lock.js";
4
- import { readTextFile } from "../util/runtime.js";
4
+ import { readTextFile, stat } from "../util/runtime.js";
5
5
  export function knowledgeLockKey(repo) {
6
6
  return `knowledge:${repo}`;
7
7
  }
@@ -13,6 +13,29 @@ async function readGuideFile(repo, name) {
13
13
  return "";
14
14
  }
15
15
  }
16
+ /** When the guides were generated, taken from the files themselves when this
17
+ * process has no `repos` row (a plain local `review` never opens app.db).
18
+ * The newest mtime wins, so rewriting any guide file counts as a rebuild. */
19
+ async function guideFilesBuiltAt(repo) {
20
+ const names = [
21
+ "PR_REVIEW_GUIDE.md",
22
+ "PR_REVIEW_DETAILED_GUIDE.md",
23
+ "CODEBASE.md",
24
+ "SKILL.md",
25
+ ];
26
+ const times = await Promise.all(names.map(async (name) => {
27
+ try {
28
+ return (await stat(`${reposDir()}/${repo}/${name}`)).mtime;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }));
34
+ const known = times
35
+ .filter((time) => time !== null)
36
+ .map((time) => time.getTime());
37
+ return known.length ? new Date(Math.max(...known)).toISOString() : null;
38
+ }
16
39
  /** Reads all review guides and `knowledge_built_at` under one lock so every
17
40
  * file belongs to the same generation. */
18
41
  export async function loadGuides(repo) {
@@ -30,6 +53,10 @@ export async function loadGuides(repo) {
30
53
  catch {
31
54
  // Local CLI review without `serve` — guides still load from disk.
32
55
  }
56
+ // With no `repos` row, the files are the only evidence of when the guide
57
+ // was built. Without this a local review always says `guide unknown` and
58
+ // can never notice that a rebuild made its carry-over stale (CORE-42).
59
+ guideBuiltAt ??= await guideFilesBuiltAt(repo);
33
60
  return {
34
61
  shortGuide,
35
62
  detailed,
@@ -6,7 +6,7 @@ export async function handleInstallationsRoute(request, githubApp) {
6
6
  return errorResponse(404, "not_found", "no route for /api/installations");
7
7
  }
8
8
  if (!githubApp) {
9
- return errorResponse(422, "app_not_configured", "GitHub App is not configured; run: co-maintainer set --github-app-id=... --github-app-private-key=...");
9
+ return errorResponse(422, "app_not_configured", "GitHub App is not configured. Run: co-maintainer set --github-app-id=... --github-app-private-key=...");
10
10
  }
11
11
  const installations = await listInstallationsWithRepos(githubApp.appId, githubApp.privateKeyPem);
12
12
  return Response.json({