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
@@ -0,0 +1,133 @@
1
+ /** The reading half of `co-maintainer probe`, shared by the CLI and the
2
+ * dashboard's Add repository preview (CORE-73).
3
+ *
4
+ * It only reads: no guide is written, nothing is cached here. The CLI adds
5
+ * the estimate, the printing and `--run`; `/api/repos/preview` adds the
6
+ * access check and returns the plan for confirmation. Keeping one
7
+ * implementation means the number an operator confirms is the number `init`
8
+ * would be given. */
9
+ import { analyzeProbe } from "../knowledge/probe.js";
10
+ import { timed } from "../util/log.js";
11
+ /** Samples this many pull requests for diff sizes, on top of a few per year. */
12
+ const SAMPLE_HEAD = 30;
13
+ const SAMPLE_PER_YEAR = 3;
14
+ export async function probePlan(client, repo, opts) {
15
+ const say = opts.log ?? (() => { });
16
+ const timedRun = (label, fn) => timed(label, opts.logTime ?? false, fn);
17
+ say(`reading ${repo} metadata`);
18
+ const meta = await timedRun("probe repository metadata", () => client.request(`repos/${repo}`));
19
+ let latestReleaseAt = "";
20
+ try {
21
+ const releases = await timedRun("probe release metadata", () => client.pages(`repos/${repo}/releases?per_page=1`, 1));
22
+ latestReleaseAt = String(releases[0]?.published_at ?? releases[0]?.created_at ?? "");
23
+ }
24
+ catch {
25
+ // Release metadata is an optional probe signal.
26
+ }
27
+ say("reading pull request list");
28
+ const pulls = await timedRun("probe pull request listing", () => client.pages(`repos/${repo}/pulls?state=all&sort=updated&direction=desc`, undefined, (page, fetched) => say(`pull request listing · page ${page} · fetched ${fetched}`)));
29
+ const yearBuckets = new Map();
30
+ for (const pull of pulls) {
31
+ const pullYear = new Date(String(pull.updated_at)).getFullYear();
32
+ yearBuckets.set(pullYear, [...(yearBuckets.get(pullYear) ?? []), pull]);
33
+ }
34
+ const sampleTargets = new Map();
35
+ for (const pull of pulls.slice(0, SAMPLE_HEAD)) {
36
+ sampleTargets.set(Number(pull.number), pull);
37
+ }
38
+ for (const yearPulls of yearBuckets.values()) {
39
+ for (const pull of yearPulls.slice(0, SAMPLE_PER_YEAR)) {
40
+ sampleTargets.set(Number(pull.number), pull);
41
+ }
42
+ }
43
+ const samplePulls = [...sampleTargets.values()];
44
+ const detailSamples = [];
45
+ say(`sampling ${samplePulls.length} pull request details · concurrency=${opts.ghConcurrent}`);
46
+ await timedRun("probe PR detail sampling", async () => {
47
+ const details = new Array(samplePulls.length);
48
+ let cursor = 0;
49
+ let completed = 0;
50
+ const worker = async () => {
51
+ while (cursor < samplePulls.length) {
52
+ const index = cursor++;
53
+ const pull = samplePulls[index];
54
+ try {
55
+ details[index] = await client.request(`repos/${repo}/pulls/${Number(pull.number)}`);
56
+ }
57
+ catch {
58
+ // A missing detail should not invalidate the rest of the probe.
59
+ }
60
+ finally {
61
+ completed++;
62
+ say(`PR detail sampling · ${completed}/${samplePulls.length}`);
63
+ }
64
+ }
65
+ };
66
+ await Promise.all(Array.from({ length: Math.min(opts.ghConcurrent, samplePulls.length) }, worker));
67
+ detailSamples.push(...details.filter((detail) => !!detail));
68
+ });
69
+ const branch = String(meta.default_branch ?? "main");
70
+ say(`sampled ${detailSamples.length} PR details · reading ${branch} commits`);
71
+ const commits = await timedRun("probe commit history", () => client.pages(`repos/${repo}/commits?sha=${encodeURIComponent(branch)}`, undefined, (page, fetched) => say(`commit history · page ${page} · fetched ${fetched}`)));
72
+ const analysis = await timedRun("probe analysis", async () => analyzeProbe({ ...meta, latest_release_at: latestReleaseAt }, pulls, detailSamples, commits));
73
+ const recommendation = ["--include-codebase"];
74
+ if (analysis.includePullRequests)
75
+ recommendation.push("--include-pull-requests");
76
+ if (analysis.includePullRequestChanges) {
77
+ recommendation.push("--include-pull-request-changes");
78
+ }
79
+ if (analysis.includeCommitHistory) {
80
+ recommendation.push("--include-commit-history");
81
+ }
82
+ if (pulls.length || Boolean(meta.has_issues)) {
83
+ recommendation.push("--include-how-repo-works");
84
+ }
85
+ if (analysis.maxPullRequestChangeLines) {
86
+ recommendation.push(`--max-pull-request-change-lines=${analysis.maxPullRequestChangeLines}`);
87
+ }
88
+ if (analysis.maxPrMonths) {
89
+ recommendation.push(`--max-pr-months=${analysis.maxPrMonths}`);
90
+ }
91
+ if (analysis.maxCommits) {
92
+ recommendation.push(`--max-commits=${analysis.maxCommits}`);
93
+ }
94
+ const command = ["co-maintainer", "init", repo, ...recommendation].join(" ");
95
+ const report = {
96
+ repo,
97
+ ...analysis.report,
98
+ recommendations: {
99
+ includePullRequests: analysis.includePullRequests,
100
+ includePullRequestChanges: analysis.includePullRequestChanges,
101
+ includeCommitHistory: analysis.includeCommitHistory,
102
+ maxPrMonths: analysis.maxPrMonths ?? "all",
103
+ maxCommits: analysis.maxCommits ?? "all",
104
+ maxPullRequestChangeLines: analysis.maxPullRequestChangeLines ?? "all",
105
+ },
106
+ reasons: analysis.reasons,
107
+ recommendedCommand: command,
108
+ createdAt: new Date().toISOString(),
109
+ };
110
+ return { analysis, recommendation, command, report };
111
+ }
112
+ /** The `RepoConfig` patch that matches a recommendation, so a confirmed
113
+ * preview stores the same limits the printed command carries. */
114
+ export function recommendationPatch(recommendation) {
115
+ const has = (flag) => recommendation.includes(flag);
116
+ const value = (name) => {
117
+ const match = recommendation.find((flag) => flag.startsWith(`${name}=`));
118
+ if (!match)
119
+ return undefined;
120
+ const number = Number(match.slice(name.length + 1));
121
+ return Number.isFinite(number) ? number : undefined;
122
+ };
123
+ return {
124
+ includeCodebase: has("--include-codebase"),
125
+ includePullRequests: has("--include-pull-requests"),
126
+ includePullRequestChanges: has("--include-pull-request-changes"),
127
+ includeCommitHistory: has("--include-commit-history"),
128
+ includeHowRepoWorks: has("--include-how-repo-works"),
129
+ maxPrMonths: value("--max-pr-months"),
130
+ maxCommits: value("--max-commits"),
131
+ maxPullRequestChangeLines: value("--max-pull-request-change-lines"),
132
+ };
133
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Cron } from "../util/cron.ts";
2
- /** A remake costs model tokens, so a schedule may fire at most once an hour. */
2
+ /** A sync costs model tokens, so a schedule may fire at most once an hour. */
3
3
  export declare function parseRemakeCron(expression: string): Cron;
4
4
  /** Each minute is handled once however often the ticker is called. A minute
5
5
  * the process slept through is not made up. */
@@ -3,11 +3,11 @@ import { getQueuedJobByKey, getRunningJobByKey } from "../store/jobs.js";
3
3
  import { listActiveRepos } from "../store/repos.js";
4
4
  import { cronMatches, parseCron } from "../util/cron.js";
5
5
  import { enqueueSetup } from "./setup.js";
6
- /** A remake costs model tokens, so a schedule may fire at most once an hour. */
6
+ /** A sync costs model tokens, so a schedule may fire at most once an hour. */
7
7
  export function parseRemakeCron(expression) {
8
8
  const cron = parseCron(expression);
9
9
  if (cron.minutes.size !== 1) {
10
- throw new Error("the minute field must be a single value, so a remake runs at most once an hour");
10
+ throw new Error("the minute field must be a single value, so a sync runs at most once an hour");
11
11
  }
12
12
  return cron;
13
13
  }
@@ -51,7 +51,7 @@ export function startRemakeScheduler(intervalMs = 15_000) {
51
51
  const ticker = createRemakeCronTicker();
52
52
  timer = setInterval(() => {
53
53
  for (const repo of ticker(new Date())) {
54
- console.log(`[cron] queued a scheduled remake for ${repo}`);
54
+ console.log(`[cron] queued a scheduled sync for ${repo}`);
55
55
  }
56
56
  }, intervalMs);
57
57
  }
@@ -3,11 +3,13 @@ import { resolvedFromFirstReview, revisionStats, sortResolvedFindings, summaryCo
3
3
  import { parseFindings } from "../pr/findings.js";
4
4
  import { codegraphToolHandlersForBridge } from "../pr/codegraph_tools.js";
5
5
  import { reviewWorkspaceRevision } from "../pr/reviewer.js";
6
- import { buildCarryPromptSection, classifyCarryItems, incrementalDiffPaths, } from "../review/carry_over.js";
6
+ import { buildCarryPromptSection, classifyCarryItems, guideRebuiltSince, incrementalDiffPaths, } from "../review/carry_over.js";
7
7
  import { remoteBridgeToolHandlers } from "../remote/tool_bridge.js";
8
8
  import { revisionFromSubmitJson } from "../remote/revision_from_submit.js";
9
9
  import { closeRemoteSession, openRemoteSession, setRemoteSyncResult, } from "../remote/server/sessions.js";
10
10
  import { readConfig } from "../config.js";
11
+ import { reviewBlockingFrom } from "../review/blocking.js";
12
+ import { loadGuides } from "../review/guides.js";
11
13
  import { withLogSink } from "../util/log.js";
12
14
  import { cancel, registerHandler } from "./jobs.js";
13
15
  import { recordAiCost } from "./setup.js";
@@ -45,6 +47,7 @@ function reviewOptionsForRepo(repo, useCodegraph) {
45
47
  includeCommitHistory: true,
46
48
  includeHowRepoWorks: true,
47
49
  onlyRequestChangedPr: false,
50
+ reviewBlocking: reviewBlockingFrom(config.reviewBlocking),
48
51
  };
49
52
  }
50
53
  function supersedeActiveRemoteJobs(queueKey, supersededBy) {
@@ -158,7 +161,12 @@ export function registerRemoteReviewHandler() {
158
161
  const extras = {};
159
162
  if (subjectId) {
160
163
  const subjectRevision = getSubjectRevision(subjectId);
161
- if (subjectRevision) {
164
+ // A guide rebuilt after the previous review invalidates its verdicts
165
+ // and its "unchanged" suppression (CORE-42 / F03): start fresh so a
166
+ // stale finding cannot mask a new one.
167
+ const guideRebuilt = subjectRevision !== undefined &&
168
+ guideRebuiltSince(subjectRevision.guide_built_at, (await loadGuides(job.repo)).guideBuiltAt);
169
+ if (subjectRevision && !guideRebuilt) {
162
170
  const prevFiles = parseRevisionFiles(subjectRevision);
163
171
  const { unchanged } = incrementalDiffPaths(revision, prevFiles);
164
172
  extras.unchangedPaths = unchanged;
@@ -207,6 +215,7 @@ export function registerRemoteReviewHandler() {
207
215
  const filesByPath = new Map(revision.files.map((file) => [file.path, { patch: file.patch }]));
208
216
  const findings = sortResolvedFindings(resolvedFromFirstReview(parsed, filesByPath));
209
217
  const durationMs = Math.round(performance.now() - started);
218
+ const blockingMode = reviewBlockingFrom(options.reviewBlocking);
210
219
  setRemoteSyncResult(job.id, {
211
220
  subject: {
212
221
  repo: job.repo,
@@ -215,8 +224,9 @@ export function registerRemoteReviewHandler() {
215
224
  },
216
225
  revision: revisionStats(revision),
217
226
  guide: { builtAt: result.guideBuiltAt },
218
- summary: summaryCounts(findings),
219
- findings: findings.map(toJsonFinding),
227
+ codegraph: { state: result.codegraphState },
228
+ summary: summaryCounts(findings, blockingMode),
229
+ findings: findings.map((row) => toJsonFinding(row, blockingMode)),
220
230
  usage: {
221
231
  tokensIn,
222
232
  tokensOut,
@@ -1,5 +1,6 @@
1
1
  import { type ParsedFinding } from "../pr/findings.ts";
2
2
  import { type Anchor } from "../pr/hunks.ts";
3
+ import { type ReviewBlocking } from "../review/blocking.ts";
3
4
  import { type LogFn } from "./jobs.ts";
4
5
  import type { AiProvider, GitHubClient, Json, Options } from "../types.ts";
5
6
  import type { JobRow } from "../store/rows.ts";
@@ -13,6 +14,16 @@ export declare function splitInline(findings: ParsedFinding[], files: Json[]): {
13
14
  leftover: ParsedFinding[];
14
15
  };
15
16
  export declare function reviewEvent(findingsCount: number): "REQUEST_CHANGES" | "COMMENT";
17
+ /** The GitHub review event for a set of stored findings (CORE-41).
18
+ *
19
+ * `model` keeps the 0.4.13 behavior: any finding requests changes, because the
20
+ * model's label already decided. `severity` ignores that label and requests
21
+ * changes only for a P0 or P1, so the same finding set always yields the same
22
+ * event. */
23
+ export declare function appReviewEvent(findings: {
24
+ severity: string;
25
+ title: string;
26
+ }[], mode?: ReviewBlocking): "REQUEST_CHANGES" | "COMMENT";
16
27
  export interface ReviewMetadata {
17
28
  jobId: string;
18
29
  model: string;
@@ -8,11 +8,12 @@ import { parseFindings } from "../pr/findings.js";
8
8
  import { anchorFor } from "../pr/hunks.js";
9
9
  import { stripSuggestion, suggestionAnchor, withoutSuggestionLine, } from "../pr/suggestion.js";
10
10
  import { githubPullRequestToRevision } from "../pr/github_revision.js";
11
- import { anchorTextFromPatch, buildCarryPromptSection, classifyCarryItems, incrementalDiffPaths, parsePreviousVerdicts, resolveCarryOutcomes, } from "../review/carry_over.js";
11
+ import { anchorTextFromPatch, buildCarryPromptSection, classifyCarryItems, guideRebuiltSince, incrementalDiffPaths, parsePreviousVerdicts, resolveCarryOutcomes, } from "../review/carry_over.js";
12
12
  import { runReviewEngine } from "../review/engine.js";
13
13
  import { loadGuides } from "../review/guides.js";
14
+ import { reviewBlockingFrom, isBlocking, DEFAULT_REVIEW_BLOCKING, } from "../review/blocking.js";
14
15
  import { matchRepeat } from "../pr/rounds.js";
15
- import { readConfig } from "../config.js";
16
+ import { readConfig, resolveAppPrivateKey } from "../config.js";
16
17
  import { outsideCode, redact } from "../util/redact.js";
17
18
  import { getRepo, setInstallationId } from "../store/repos.js";
18
19
  import { getReviewByJobId, insertReview, latestPostedReview, setReviewScope, setReviewStatus, } from "../store/reviews.js";
@@ -22,6 +23,10 @@ import { enqueue, registerHandler } from "./jobs.js";
22
23
  import { getEnv } from "../util/runtime.js";
23
24
  const ACCESS_DENIED_BODY = "The App does not have access to review this pull request.";
24
25
  export function humanCopy(text) {
26
+ // The semicolon is prose and always goes. The em dash is left alone here:
27
+ // this function also sanitizes a finding title, and a title posted before
28
+ // 0.5.0 uses ` — ` as its path/symbol separator, which the legacy heading
29
+ // reader still needs (CORE-83 keeps that one allowlisted exception).
25
30
  return outsideCode(redact(text), (prose) => prose.replaceAll(";", "."));
26
31
  }
27
32
  function hasForbiddenCopy(text) {
@@ -61,6 +66,20 @@ export function splitInline(findings, files) {
61
66
  export function reviewEvent(findingsCount) {
62
67
  return findingsCount > 0 ? "REQUEST_CHANGES" : "COMMENT";
63
68
  }
69
+ /** The GitHub review event for a set of stored findings (CORE-41).
70
+ *
71
+ * `model` keeps the 0.4.13 behavior: any finding requests changes, because the
72
+ * model's label already decided. `severity` ignores that label and requests
73
+ * changes only for a P0 or P1, so the same finding set always yields the same
74
+ * event. */
75
+ export function appReviewEvent(findings, mode = DEFAULT_REVIEW_BLOCKING) {
76
+ if (findings.length === 0)
77
+ return "COMMENT";
78
+ if (mode === "model")
79
+ return "REQUEST_CHANGES";
80
+ const blocking = findings.some((row) => isBlocking(mode, { severity: row.severity, text: row.title }));
81
+ return reviewEvent(blocking ? 1 : 0);
82
+ }
64
83
  export function formatDuration(ms) {
65
84
  const totalSeconds = Math.max(0, Math.round(ms / 1000));
66
85
  const hours = Math.floor(totalSeconds / 3600);
@@ -132,6 +151,7 @@ export function reviewOptions(repo, prNumber) {
132
151
  includeHowRepoWorks: true,
133
152
  onlyRequestChangedPr: false,
134
153
  useCodegraph: getRepo(repo)?.use_codegraph === 1,
154
+ reviewBlocking: reviewBlockingFrom(config.reviewBlocking),
135
155
  };
136
156
  }
137
157
  export function aiFor(options) {
@@ -145,19 +165,21 @@ export function aiFor(options) {
145
165
  }
146
166
  export function clientFor(installationId) {
147
167
  const config = readConfig();
148
- if (!config.githubAppId || !config.githubAppPrivateKey) {
168
+ const privateKeyPem = resolveAppPrivateKey(config);
169
+ if (!config.githubAppId || !privateKeyPem) {
149
170
  throw new Error("review jobs need the GitHub App configured");
150
171
  }
151
- return new AppClient(config.githubAppId, config.githubAppPrivateKey, installationId);
172
+ return new AppClient(config.githubAppId, privateKeyPem, installationId);
152
173
  }
153
174
  export async function resolveInstallationId(fullName) {
154
175
  const current = getRepo(fullName)?.installation_id;
155
176
  if (current)
156
177
  return current;
157
178
  const config = readConfig();
158
- if (!config.githubAppId || !config.githubAppPrivateKey)
179
+ const privateKeyPem = resolveAppPrivateKey(config);
180
+ if (!config.githubAppId || !privateKeyPem)
159
181
  return undefined;
160
- const installationId = await findInstallationForRepo(config.githubAppId, config.githubAppPrivateKey, fullName);
182
+ const installationId = await findInstallationForRepo(config.githubAppId, privateKeyPem, fullName);
161
183
  if (installationId !== undefined)
162
184
  setInstallationId(fullName, installationId);
163
185
  return installationId;
@@ -244,7 +266,7 @@ async function finishCheck(client, job, checkRunId, conclusion, title, findings,
244
266
  },
245
267
  }, log);
246
268
  }
247
- async function publish(client, job, reviewId, headSha, files, log, metadata) {
269
+ async function publish(client, job, reviewId, headSha, files, log, metadata, mode = DEFAULT_REVIEW_BLOCKING) {
248
270
  const stored = listFindingsForReview(reviewId);
249
271
  const replies = stored.filter((row) => row.thread_comment_id);
250
272
  const freshRows = stored.filter((row) => row.state === "new" && !row.thread_comment_id);
@@ -314,7 +336,7 @@ async function publish(client, job, reviewId, headSha, files, log, metadata) {
314
336
  const payload = {
315
337
  commit_id: headSha,
316
338
  body,
317
- event: reviewEvent(stored.length),
339
+ event: appReviewEvent(stored, mode),
318
340
  comments,
319
341
  };
320
342
  setReviewStatus(reviewId, "posting");
@@ -523,7 +545,13 @@ async function runReviewJobCore(job, log, client, ai) {
523
545
  let carryItems = [];
524
546
  let carryPrevious = null;
525
547
  const extras = {};
526
- if (subjectRevision) {
548
+ // A guide rebuilt after the previous review invalidates that review's
549
+ // verdicts and its "unchanged" suppression (CORE-42 / F03). Treat the run as
550
+ // if it had started fresh: carry-over off, every changed file back in scope,
551
+ // so a stale finding can never mask a new one.
552
+ const guideRebuilt = subjectRevision !== undefined &&
553
+ guideRebuiltSince(subjectRevision.guide_built_at, guidesBefore.guideBuiltAt);
554
+ if (subjectRevision && !guideRebuilt) {
527
555
  const prevFiles = parseRevisionFiles(subjectRevision);
528
556
  const { unchanged } = incrementalDiffPaths(revision, prevFiles);
529
557
  extras.unchangedPaths = scope === "incremental" ? unchanged : undefined;
@@ -564,7 +592,7 @@ async function runReviewJobCore(job, log, client, ai) {
564
592
  row.posted_comment_id,
565
593
  ]));
566
594
  const verdicts = parsePreviousVerdicts(response.text);
567
- const resolved = resolveCarryOutcomes(carryItems, revision, visiblePaths, verdicts, parsed, guideBuiltAt, carryPrevious);
595
+ const resolved = resolveCarryOutcomes(carryItems, revision, visiblePaths, verdicts, parsed);
568
596
  totalFindings = resolved.length;
569
597
  for (const row of resolved) {
570
598
  if (row.state === "open")
@@ -636,7 +664,7 @@ async function runReviewJobCore(job, log, client, ai) {
636
664
  jobId: job.id,
637
665
  model: options.highModel ?? "unknown",
638
666
  durationMs: Date.now() - startedAt,
639
- });
667
+ }, options.reviewBlocking);
640
668
  await finishCheck(github, job, checkRunId, totalFindings === 0 ? "success" : "neutral", totalFindings === 0 ? "Review completed" : "Review findings", listFindingsForReview(reviewId), log);
641
669
  }
642
670
  export function registerReviewJobHandler() {
@@ -2,18 +2,19 @@
2
2
  * directly; it used to re-spawn the CLI as a subprocess, a second
3
3
  * execution path with its own failure modes. */
4
4
  import { createAiProvider } from "../ai/provider.js";
5
- import { enrichFacts, synthesizeSections } from "../knowledge/synthesis.js";
5
+ import { enrichFactsWithReport, synthesizeSections, } from "../knowledge/synthesis.js";
6
6
  import { collectSource } from "../github/collect.js";
7
7
  import { GhClient } from "../github/gh.js";
8
8
  import { PatClient } from "../github/pat.js";
9
9
  import { extractFacts } from "../knowledge/facts.js";
10
- import { buildReviewDocuments } from "../knowledge/guide.js";
11
- import { assembleSkill, extractSections, factSectionHashes, } from "../knowledge/skill.js";
10
+ import { buildReviewDocuments, reviewSignalCount } from "../knowledge/guide.js";
11
+ import { assembleSkill, codebaseBody, factSectionHashes, } from "../knowledge/skill.js";
12
12
  import { validateSkill } from "../knowledge/validate.js";
13
13
  import { readConfig, reposDir, writeRepoConfig } from "../config.js";
14
14
  import { readState, writeState } from "../store/skill_state.js";
15
15
  import { cacheSet } from "../store/cache_db.js";
16
16
  import { log, timed, withLogSink } from "../util/log.js";
17
+ import { printRunSummary, summaryFromMetrics } from "../util/run_summary.js";
17
18
  import { enqueue, registerHandler } from "./jobs.js";
18
19
  import { closeAppDb, isAppDbOpen, openAppDb } from "../store/app_db.js";
19
20
  import { getRepo, markKnowledgeBuilt } from "../store/repos.js";
@@ -72,9 +73,12 @@ async function skillPath(repo) {
72
73
  await mkdir(`${reposDir()}/${repo}`, { recursive: true });
73
74
  return path;
74
75
  }
75
- async function writeReviewDocuments(repo, documents) {
76
+ async function writeReviewDocuments(repo, documents, signalCount) {
76
77
  const directory = `${reposDir()}/${repo}`;
77
78
  if (!documents) {
79
+ // F05: this used to delete the files without a word. The plan's rule is
80
+ // "says why the third file is missing": keep it to one line.
81
+ log("write", `PR_REVIEW_GUIDE.md skipped: found ${signalCount} review signals, needs at least 3`);
78
82
  await Promise.all([
79
83
  remove(`${directory}/PR_REVIEW_GUIDE.md`).catch(() => { }),
80
84
  remove(`${directory}/PR_REVIEW_DETAILED_GUIDE.md`).catch(() => { }),
@@ -93,14 +97,12 @@ async function writeReviewDocuments(repo, documents) {
93
97
  /** Splits the codebase-description sections (layout/style/tests/devloop) out
94
98
  * of the assembled skill into their own file, so `review` can check a pull
95
99
  * request against how this repository's code actually looks, not just the
96
- * review-bar checklist mined from past PR comments. */
97
- async function writeCodebaseDocument(repo, skillMarkdown) {
100
+ * review-bar checklist mined from past PR comments. The skill links to this
101
+ * file rather than repeating it (CORE-32 / F26c), so its body is built from
102
+ * the facts instead of being scraped back out of the skill. */
103
+ async function writeCodebaseDocument(repo, facts, overrides = {}) {
98
104
  const path = `${reposDir()}/${repo}/CODEBASE.md`;
99
- const sections = extractSections(skillMarkdown);
100
- const body = ["layout", "style", "tests", "devloop"]
101
- .map((key) => sections[key])
102
- .filter(Boolean)
103
- .join("\n\n");
105
+ const body = codebaseBody(facts, overrides);
104
106
  if (!body) {
105
107
  await remove(path).catch(() => { });
106
108
  return;
@@ -133,7 +135,7 @@ async function initOrRemake(options) {
133
135
  const aiMetrics = emptyAiMetrics();
134
136
  const previous = await readState(options.repo);
135
137
  if (options.command === "remake" && !previous) {
136
- throw new Error("remake requires a previous init or remake for this repository");
138
+ throw new Error("sync requires a previous init or sync for this repository");
137
139
  }
138
140
  if (options.command === "remake" && previous) {
139
141
  options.maxCommits ??= previous.options.maxCommits;
@@ -182,6 +184,7 @@ async function initOrRemake(options) {
182
184
  log("time", `extract deterministic facts · ${((performance.now() - factsStarted) / 1000).toFixed(2)}s`);
183
185
  }
184
186
  let overrides = {};
187
+ const skipped = [];
185
188
  if (lowAi) {
186
189
  log("ai", "starting extract_unit jobs");
187
190
  const usage = async (job, response) => {
@@ -196,7 +199,9 @@ async function initOrRemake(options) {
196
199
  aiMetrics.cost += response.cost;
197
200
  await recordAiCost(options.repo, job, response);
198
201
  };
199
- facts = await timed("extract_unit AI", options.logTime, () => enrichFacts(lowAi, options.repo, facts, source, options, usage));
202
+ const enriched = await timed("extract_unit AI", options.logTime, () => enrichFactsWithReport(lowAi, options.repo, facts, source, options, usage));
203
+ facts = enriched.facts;
204
+ skipped.push(...enriched.skipped);
200
205
  log("ai", `extract_unit complete · ${facts.length} facts`);
201
206
  const hashes = await factSectionHashes(facts);
202
207
  const synthesisChanged = previous &&
@@ -211,7 +216,9 @@ async function initOrRemake(options) {
211
216
  : undefined;
212
217
  log("ai", `starting synth_section jobs${dirtySections ? ` · ${dirtySections.size} dirty sections` : ""}`);
213
218
  if (highAi) {
214
- overrides = await timed("synth_section AI", options.logTime, () => synthesizeSections(highAi, options.repo, facts, previousMarkdown, options.ai, options.highModel ?? "", options.aiConcurrent, usage, dirtySections));
219
+ const synth = await timed("synth_section AI", options.logTime, () => synthesizeSections(highAi, options.repo, facts, previousMarkdown, options.ai, options.highModel ?? "", options.aiConcurrent, usage, dirtySections));
220
+ overrides = synth.overrides;
221
+ skipped.push(...synth.skipped);
215
222
  }
216
223
  log("ai", `synth_section complete · ${Object.keys(overrides).length} sections`);
217
224
  }
@@ -237,8 +244,8 @@ async function initOrRemake(options) {
237
244
  const { writeTextFileAtomic } = await import("../util/atomic.js");
238
245
  await withKnowledgeLock(options.repo, async () => {
239
246
  await writeTextFileAtomic(path, result.markdown);
240
- await writeCodebaseDocument(options.repo, result.markdown);
241
- await writeReviewDocuments(options.repo, reviewDocuments);
247
+ await writeCodebaseDocument(options.repo, facts, overrides);
248
+ await writeReviewDocuments(options.repo, reviewDocuments, reviewSignalCount(facts));
242
249
  const head = source.commits[0];
243
250
  const baseSha = head?.sha ? String(head.sha) : new Date().toISOString();
244
251
  markKnowledgeBuilt(options.repo, baseSha);
@@ -257,7 +264,7 @@ async function initOrRemake(options) {
257
264
  },
258
265
  updatedAt: new Date().toISOString(),
259
266
  });
260
- // Remember everything but the token, so `remake owner/repo` alone
267
+ // Remember everything but the token, so `sync owner/repo` alone
261
268
  // (no flags, no prompts) reuses what this run resolved.
262
269
  await writeRepoConfig(options.repo, {
263
270
  auth: options.auth,
@@ -281,9 +288,20 @@ async function initOrRemake(options) {
281
288
  ? `updated ${result.changed.join(", ")}`
282
289
  : "already current"}`);
283
290
  log("done", `${facts.length} facts · ${source.pullRequests.length} pull requests · ${source.commits.length} commits`);
291
+ if (skipped.length) {
292
+ // F04: a skipped unit used to vanish into a log line and recur as a "cache
293
+ // hit" forever. Say it in the final line instead, in the plan's shape:
294
+ // `1 unit skipped (PR #3: output was not JSON)`.
295
+ const parts = skipped.map((unit) => unit.reason);
296
+ const line = skipped.length === 1
297
+ ? `1 unit skipped (${parts[0]})`
298
+ : `${skipped.length} units skipped (${parts.join("; ")})`;
299
+ log("done", line);
300
+ }
301
+ printRunSummary(summaryFromMetrics(aiMetrics, performance.now() - operationStarted));
284
302
  if (options.logTime) {
285
303
  log("time", `AI total · calls=${aiMetrics.calls} · input=${aiMetrics.tokensIn} tokens · output=${aiMetrics.tokensOut} tokens · cost=${aiMetrics.costKnown ? aiMetrics.cost.toFixed(4) : "unknown"}`);
286
- log("time", `total init/remake · ${((performance.now() - operationStarted) / 1000).toFixed(2)}s`);
304
+ log("time", `total init/sync · ${((performance.now() - operationStarted) / 1000).toFixed(2)}s`);
287
305
  }
288
306
  }
289
307
  /** Builds `Options` for a server-triggered `init`/`remake` from config
@@ -299,7 +317,7 @@ export function optionsFromConfig(repo, command) {
299
317
  const lowModel = repoConfig.lowModel ?? config.lowModel;
300
318
  const highModel = repoConfig.highModel ?? config.highModel;
301
319
  if (ai !== "none" && (!config.token || !lowModel || !highModel)) {
302
- throw new Error("AI is enabled but token/low-model/high-model are not fully configured; run: " +
320
+ throw new Error("AI is enabled but token/low-model/high-model are not fully configured. Run: " +
303
321
  "co-maintainer set --token=... --low-model=... --high-model=...");
304
322
  }
305
323
  return {
@@ -0,0 +1,10 @@
1
+ export type SetupItem = {
2
+ id: string;
3
+ title: string;
4
+ detail: string;
5
+ done: boolean;
6
+ /** Where the fix lives. In-page anchors on /settings where one exists. */
7
+ href: string;
8
+ linkLabel: string;
9
+ };
10
+ export declare function setupChecklist(webhookUrl: string): SetupItem[];
@@ -0,0 +1,87 @@
1
+ /** The "Finish setup" checklist shown on the home page (CORE-75, C01/F26).
2
+ *
3
+ * Each item is derived from state that already exists, so the card reflects
4
+ * what serve can actually do rather than a parallel bookkeeping of it. The
5
+ * webhook item reuses CORE-71's reachability check. */
6
+ import { readConfig, resolveAppPrivateKey } from "../config.js";
7
+ import { countReviews } from "../store/reviews.js";
8
+ import { listRemoteTokens } from "../store/remote_tokens.js";
9
+ import { listActiveRepos } from "../store/repos.js";
10
+ import { lastDelivery } from "../store/deliveries.js";
11
+ import { webhookReachabilityProblem } from "../util/webhook_reachability.js";
12
+ export function setupChecklist(webhookUrl) {
13
+ const config = readConfig();
14
+ const aiDone = Boolean(config.ai && config.ai !== "none" && config.token);
15
+ const appDone = Boolean(config.githubAppId && resolveAppPrivateKey(config));
16
+ const repoCount = listActiveRepos().length;
17
+ const reviewCount = countReviews();
18
+ const tokenCount = listRemoteTokens().length;
19
+ const reach = webhookReachabilityProblem(webhookUrl);
20
+ const reached = reach === undefined && lastDelivery() !== undefined;
21
+ return [
22
+ {
23
+ id: "ai",
24
+ title: "Models and API key",
25
+ detail: aiDone
26
+ ? "An AI provider and key are saved."
27
+ : "Add a provider and API key so reviews and guides can be written.",
28
+ done: aiDone,
29
+ href: "/settings#ai",
30
+ linkLabel: "Open models",
31
+ },
32
+ {
33
+ id: "app",
34
+ title: "GitHub App",
35
+ detail: appDone
36
+ ? "The App is configured."
37
+ : "Create and install the App so reviews can be posted to pull requests.",
38
+ done: appDone,
39
+ href: "/settings#github",
40
+ linkLabel: "Open GitHub",
41
+ },
42
+ {
43
+ id: "webhook",
44
+ title: "Webhook reachable",
45
+ detail: !appDone
46
+ ? "Set up the GitHub App first, then point its webhook at this server."
47
+ : reached
48
+ ? "GitHub has delivered a webhook to this server."
49
+ : reach
50
+ ? `GitHub cannot reach ${webhookUrl}. ${reach}`
51
+ : "Waiting for the first delivery. Open a pull request to confirm.",
52
+ done: reached,
53
+ href: "/settings#github",
54
+ linkLabel: "Open webhook",
55
+ },
56
+ {
57
+ id: "repo",
58
+ title: "First repository",
59
+ detail: repoCount === 0
60
+ ? "Add a repository to generate its guides."
61
+ : `${repoCount} ${repoCount === 1 ? "repository" : "repositories"} added.`,
62
+ done: repoCount > 0,
63
+ href: "/repos/new",
64
+ linkLabel: "Add repository",
65
+ },
66
+ {
67
+ id: "review",
68
+ title: "First review",
69
+ detail: reviewCount === 0
70
+ ? "A review appears after the first pull request, or run one by number."
71
+ : `${reviewCount} ${reviewCount === 1 ? "review" : "reviews"} so far.`,
72
+ done: reviewCount > 0,
73
+ href: "/activity",
74
+ linkLabel: "Open activity",
75
+ },
76
+ {
77
+ id: "cli",
78
+ title: "CLI connected",
79
+ detail: tokenCount === 0
80
+ ? "Create a remote token to run co-maintainer review --remote."
81
+ : `${tokenCount} remote ${tokenCount === 1 ? "token" : "tokens"} created.`,
82
+ done: tokenCount > 0,
83
+ href: "/settings#remote",
84
+ linkLabel: "Open tokens",
85
+ },
86
+ ];
87
+ }
@@ -1,5 +1,6 @@
1
1
  import { Database } from "./sqlite.js";
2
2
  import { getCacheDir } from "../config.js";
3
+ import { CliError, EXIT_USAGE } from "../cli/error.js";
3
4
  import { migrations } from "./migrations.js";
4
5
  import { getEnv, isNotFound, isProcessAlive, mkdir, readTextFile, remove, writeTextFile, } from "../util/runtime.js";
5
6
  export function appDbDir() {
@@ -27,7 +28,9 @@ async function acquireLock() {
27
28
  if (existingPid !== undefined &&
28
29
  Number.isInteger(existingPid) &&
29
30
  isProcessAlive(existingPid)) {
30
- throw new Error(`co-maintainer serve is already running (pid ${existingPid}) against this app.db. Only one serve process may write to it at a time.`);
31
+ // Says what is holding the database, what to do about it, and offers the
32
+ // path that does not need another process. CORE-12.
33
+ throw new CliError("serve_already_running", `A co-maintainer serve process (pid ${existingPid}) is using this data directory.`, "Stop it, or run sync from its dashboard.", EXIT_USAGE);
31
34
  }
32
35
  await writeTextFile(path, String(process.pid));
33
36
  }
@@ -79,7 +82,7 @@ export async function openAppDb() {
79
82
  }
80
83
  export function getAppDb() {
81
84
  if (!db)
82
- throw new Error("app.db is not open; call openAppDb() first");
85
+ throw new Error("app.db is not open. Call openAppDb() first");
83
86
  return db;
84
87
  }
85
88
  export async function closeAppDb() {