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
@@ -2,6 +2,7 @@
2
2
  * (pat.ts, and app.ts). `GhClient` shells out to the `gh` binary and does
3
3
  * not use `githubFetch`; both paths share `paginate` and `waitForRateLimit`. */
4
4
  import { log } from "../util/log.js";
5
+ import { networkFailure } from "../cli/error.js";
5
6
  export class GitHubHttpError extends Error {
6
7
  status;
7
8
  constructor(status, body) {
@@ -85,7 +86,12 @@ export async function githubFetch(url, init) {
85
86
  let response;
86
87
  let exhausted;
87
88
  for (let attempt = 1; attempt <= 3; attempt++) {
88
- response = await fetch(url, init);
89
+ try {
90
+ response = await fetch(url, init);
91
+ }
92
+ catch (error) {
93
+ throw networkFailure(url, error);
94
+ }
89
95
  if (response.status !== 403 && response.status !== 429)
90
96
  return response;
91
97
  const retryAfterHeader = response.headers.get("retry-after");
@@ -286,7 +286,7 @@ async function pullRequests(client, options, previous, phase, progress) {
286
286
  detail = await client.request(`repos/${options.repo}/pulls/${number}`);
287
287
  }
288
288
  catch (error) {
289
- log("fetch", `PR #${number} detail unavailable; diff skipped: ${String(error)}`);
289
+ log("fetch", `PR #${number} detail unavailable. Diff skipped: ${String(error)}`);
290
290
  }
291
291
  }
292
292
  const current = {
@@ -1,7 +1,44 @@
1
1
  import { paginate, probeDelay, RATE_LIMIT_PROBE_MS, rateLimitError, waitForRateLimit, } from "./client.js";
2
2
  import { log } from "../util/log.js";
3
- import { commandOutput, commandWithInput, } from "../util/runtime.js";
3
+ import { CliError, EXIT_RUNTIME, EXIT_USAGE } from "../cli/error.js";
4
+ import { commandOutput, commandWithInput, getEnv, } from "../util/runtime.js";
4
5
  const PULSE_MS = 120_000;
6
+ /** Test seam: `CM_GH_BIN` replaces the `gh` command and `CM_GH_SCRIPT` (if
7
+ * set) is prepended as its first argument, so a fake can be run as
8
+ * `<bin> <script> api …` with no shell. Unset means the real `gh` on PATH, so
9
+ * production behaviour is unchanged. Spawning without a shell matters: a shell
10
+ * concatenates arguments unescaped, so an endpoint's `?`, `&` and `=` would be
11
+ * reinterpreted by cmd.exe on Windows. */
12
+ function ghSpawn() {
13
+ const bin = getEnv("CM_GH_BIN");
14
+ if (!bin)
15
+ return { command: "gh", prefix: [] };
16
+ const script = getEnv("CM_GH_SCRIPT");
17
+ return { command: bin, prefix: script ? [script] : [] };
18
+ }
19
+ /** Turns a failed `gh api` call into a message that says what broke, why, and
20
+ * what to do next (CORE-12, F09/F24/F28). The four shapes the DX research hit:
21
+ * `gh` missing from PATH, the repo missing or unreadable, an empty stderr, and
22
+ * everything else. The first line is the only part a human reads, so it always
23
+ * names the situation; the raw gh text moves to the hint so it stays available
24
+ * without drowning the message. */
25
+ function ghFailure(endpoint, stderr) {
26
+ if (/ENOENT|command not found|not recognized/i.test(stderr)) {
27
+ return new CliError("gh_not_installed", "GitHub CLI (gh) was not found.", "Install it from https://cli.github.com or use --auth=pat.", EXIT_USAGE);
28
+ }
29
+ if (/HTTP 404|Not Found/i.test(stderr)) {
30
+ // The endpoint is `repos/owner/repo/...`; the user thinks in `owner/repo`.
31
+ const match = endpoint.match(/^repos\/([^/]+)\/([^/]+)/);
32
+ const subject = match ? `${match[1]}/${match[2]}` : endpoint;
33
+ return new CliError("repo_not_found", `${subject} was not found, or your GitHub account cannot read it.`, "Check the name and run gh auth status.", EXIT_USAGE);
34
+ }
35
+ // An empty stderr used to collapse to just the endpoint, which told the user
36
+ // nothing about what happened.
37
+ if (stderr.length === 0) {
38
+ return new CliError("gh_failed", `gh exited without an error message while reading ${endpoint}.`, "Run gh auth status and try again with --debug.", EXIT_RUNTIME);
39
+ }
40
+ return new CliError("gh_failed", `gh could not read ${endpoint}.`, `gh said: ${stderr}`, EXIT_RUNTIME);
41
+ }
5
42
  let lastCallAt = 0;
6
43
  let pulse;
7
44
  function count(value) {
@@ -23,8 +60,9 @@ export function quotaLine(body) {
23
60
  }
24
61
  async function printQuota() {
25
62
  try {
26
- const result = await commandOutput("gh", {
27
- args: ["api", "rate_limit"],
63
+ const { command, prefix } = ghSpawn();
64
+ const result = await commandOutput(command, {
65
+ args: [...prefix, "api", "rate_limit"],
28
66
  stdout: "piped",
29
67
  stderr: "piped",
30
68
  });
@@ -62,8 +100,9 @@ function noteGhCall(debug) {
62
100
  }
63
101
  async function readLimit(endpoint) {
64
102
  try {
65
- const result = await commandOutput("gh", {
66
- args: ["api", "rate_limit"],
103
+ const { command, prefix } = ghSpawn();
104
+ const result = await commandOutput(command, {
105
+ args: [...prefix, "api", "rate_limit"],
67
106
  stdout: "piped",
68
107
  stderr: "piped",
69
108
  });
@@ -90,11 +129,14 @@ export class GhClient {
90
129
  this.debug = debug;
91
130
  }
92
131
  request(endpoint) {
93
- return this.call(endpoint, () => commandOutput("gh", {
94
- args: ["api", endpoint],
95
- stdout: "piped",
96
- stderr: "piped",
97
- }));
132
+ return this.call(endpoint, () => {
133
+ const { command, prefix } = ghSpawn();
134
+ return commandOutput(command, {
135
+ args: [...prefix, "api", endpoint],
136
+ stdout: "piped",
137
+ stderr: "piped",
138
+ });
139
+ });
98
140
  }
99
141
  write(endpoint, body) {
100
142
  return this.send(endpoint, "POST", body);
@@ -109,19 +151,34 @@ export class GhClient {
109
151
  return paginate((e) => this.request(e), endpoint, limit, progress);
110
152
  }
111
153
  send(endpoint, method, body) {
112
- return this.call(endpoint, () => commandWithInput("gh", {
113
- args: ["api", "-X", method, endpoint, "--input", "-"],
114
- stdin: "piped",
115
- stdout: "piped",
116
- stderr: "piped",
117
- }, JSON.stringify(body)));
154
+ return this.call(endpoint, () => {
155
+ const { command, prefix } = ghSpawn();
156
+ return commandWithInput(command, {
157
+ args: [...prefix, "api", "-X", method, endpoint, "--input", "-"],
158
+ stdin: "piped",
159
+ stdout: "piped",
160
+ stderr: "piped",
161
+ }, JSON.stringify(body));
162
+ });
118
163
  }
119
164
  async call(endpoint, run) {
120
165
  noteGhCall(this.debug);
121
166
  const started = Date.now();
122
167
  let spun = false;
123
168
  for (;;) {
124
- const result = await run();
169
+ // A missing `gh` binary rejects the spawn instead of returning a failed
170
+ // result, so the ENOENT case has to be caught here too (CORE-12).
171
+ let result;
172
+ try {
173
+ result = await run();
174
+ }
175
+ catch (error) {
176
+ const message = error instanceof Error ? error.message : String(error);
177
+ if (/ENOENT/i.test(message)) {
178
+ throw new CliError("gh_not_installed", "GitHub CLI (gh) was not found.", "Install it from https://cli.github.com or use --auth=pat.", EXIT_USAGE);
179
+ }
180
+ throw error;
181
+ }
125
182
  if (result.success) {
126
183
  try {
127
184
  return JSON.parse(new TextDecoder().decode(result.stdout));
@@ -136,7 +193,7 @@ export class GhClient {
136
193
  ? bucket.remaining === 0
137
194
  : /rate limit/i.test(error);
138
195
  if (!limited)
139
- throw new Error(`gh api failed: ${error || endpoint}`);
196
+ throw ghFailure(endpoint, error);
140
197
  const resetAt = bucket?.resetAt ?? new Date(Date.now() + RATE_LIMIT_PROBE_MS);
141
198
  const delay = probeDelay(resetAt, Date.now());
142
199
  if (delay === 0) {
@@ -266,7 +266,7 @@ export function extractFacts(source, options) {
266
266
  }
267
267
  if (source.pullRequests.length) {
268
268
  const merged = source.pullRequests.filter((pr) => pr.merged).length;
269
- add(facts, fact("process", `${merged}/${source.pullRequests.length} selected pull requests are merged; keep work mergeable before requesting review.`, "pull request metadata", 2));
269
+ add(facts, fact("process", `${merged}/${source.pullRequests.length} selected pull requests are merged. Keep work mergeable before requesting review.`, "pull request metadata", 2));
270
270
  const labels = counts(source.pullRequests.flatMap((pr) => pr.labels))
271
271
  .slice(0, 12)
272
272
  .map(([label, count]) => `\`${label}\` (${count})`);
@@ -275,7 +275,7 @@ export function extractFacts(source, options) {
275
275
  }
276
276
  const described = source.pullRequests.filter((pr) => pr.body.trim().length > 80).length;
277
277
  if (described / source.pullRequests.length >= 0.7) {
278
- add(facts, fact("title-body", "Most selected pull requests contain a substantive description; explain purpose, scope, and verification before requesting review.", "pull request descriptions", 3));
278
+ add(facts, fact("title-body", "Most selected pull requests contain a substantive description. Explain purpose, scope, and verification before requesting review.", "pull request descriptions", 3));
279
279
  }
280
280
  const reviewText = source.pullRequests
281
281
  .flatMap((pr) => [...pr.comments, ...pr.reviews])
@@ -292,7 +292,12 @@ export function extractFacts(source, options) {
292
292
  ]) {
293
293
  const count = (reviewText.match(new RegExp(`\\b${term}\\w*\\b`, "g")) ?? []).length;
294
294
  if (count >= 2) {
295
- add(facts, fact("review-bar", instruction, `review discussion (${count} mentions)`, count));
295
+ add(facts, {
296
+ ...fact("review-bar", instruction, `review discussion (${count} mentions)`, count),
297
+ // A rule mined from discussion across several pull requests is
298
+ // repository policy, not one request's narrative (CORE-32).
299
+ origin: "repository",
300
+ });
296
301
  }
297
302
  }
298
303
  if (source.pullRequests.some((pr) => pr.additions + pr.deletions > 1000)) {
@@ -3,4 +3,12 @@ export type ReviewDocuments = {
3
3
  guide: string;
4
4
  detailed?: string;
5
5
  };
6
+ /** A fact read out of a single pull request describes that request, not the
7
+ * repository. `origin` is absent on facts written before the field existed, so
8
+ * those count as repository policy (CORE-32 / F26b). */
9
+ export declare function isPullRequestFact(item: Fact): boolean;
10
+ /** The selected review signals, before the `>2` threshold is applied. Exported
11
+ * so `init` can tell the user how far short of the threshold a repository is
12
+ * (CORE-31), rather than silently skipping the two guide files. */
13
+ export declare function reviewSignalCount(facts: Fact[]): number;
6
14
  export declare function buildReviewDocuments(facts: Fact[]): ReviewDocuments | undefined;
@@ -1,11 +1,12 @@
1
- function reviewFacts(facts) {
1
+ /** A fact read out of a single pull request describes that request, not the
2
+ * repository. `origin` is absent on facts written before the field existed, so
3
+ * those count as repository policy (CORE-32 / F26b). */
4
+ export function isPullRequestFact(item) {
5
+ return item.origin === "pull-request";
6
+ }
7
+ function dedupe(items) {
2
8
  const seen = new Set();
3
- return facts
4
- .filter((item) => item.sectionKey === "review-bar" &&
5
- item.evidence.some((evidence) => evidence.startsWith("PR #") ||
6
- evidence.startsWith("review discussion")))
7
- .sort((a, b) => b.weight - a.weight || a.claim.localeCompare(b.claim))
8
- .filter((item) => {
9
+ return items.filter((item) => {
9
10
  const key = item.claim.toLowerCase().replace(/\W+/g, " ").trim();
10
11
  if (seen.has(key))
11
12
  return false;
@@ -13,6 +14,23 @@ function reviewFacts(facts) {
13
14
  return true;
14
15
  });
15
16
  }
17
+ function reviewBarFacts(facts) {
18
+ return facts
19
+ .filter((item) => item.sectionKey === "review-bar" &&
20
+ item.evidence.some((evidence) => evidence.startsWith("PR #") ||
21
+ evidence.startsWith("review discussion")))
22
+ .sort((a, b) => b.weight - a.weight || a.claim.localeCompare(b.claim));
23
+ }
24
+ /** The short guide's checklist: every recurring review signal, including the
25
+ * ones mined from pull requests (CORE-32 keeps this file's PR source). */
26
+ function reviewFacts(facts) {
27
+ return dedupe(reviewBarFacts(facts));
28
+ }
29
+ /** The detailed guide's checklist: only expectations backed by repository-wide
30
+ * evidence, never a single pull request's narrative (CORE-32). */
31
+ function detailedFacts(facts) {
32
+ return dedupe(reviewBarFacts(facts).filter((item) => !isPullRequestFact(item)));
33
+ }
16
34
  function detailedDocument(facts) {
17
35
  const bullets = facts.flatMap((fact) => {
18
36
  const claim = fact.claim.replace(/\bPR\s*#?\s*\d+\b/gi, "the change");
@@ -46,11 +64,17 @@ It describes failure patterns to look for, not historical pull requests.
46
64
  ${bullets.join("\n")}
47
65
  `;
48
66
  }
67
+ /** The selected review signals, before the `>2` threshold is applied. Exported
68
+ * so `init` can tell the user how far short of the threshold a repository is
69
+ * (CORE-31), rather than silently skipping the two guide files. */
70
+ export function reviewSignalCount(facts) {
71
+ return reviewFacts(facts).length;
72
+ }
49
73
  export function buildReviewDocuments(facts) {
50
74
  const selected = reviewFacts(facts);
51
75
  if (selected.length <= 2)
52
76
  return undefined;
53
- const detailed = detailedDocument(selected.slice(0, 120));
77
+ const detailed = detailedDocument(detailedFacts(facts).slice(0, 120));
54
78
  const detailedLines = detailed.trimEnd().split("\n").length;
55
79
  if (detailedLines <= 100) {
56
80
  return {
@@ -103,7 +103,7 @@ export function analyzeProbe(meta, pulls, detailSamples, commits) {
103
103
  : Infinity;
104
104
  reasons.push(releaseAgeMonths <= 18
105
105
  ? `A release from ${latestReleaseAt.slice(0, 10)} is recent; use the last ${maxPrMonths} months to prioritize the current contribution and release process.`
106
- : `The last ${maxPrMonths} months cover at least 85% of pull requests; older history is a minority.`);
106
+ : `The last ${maxPrMonths} months cover at least 85% of pull requests. Older history is a minority.`);
107
107
  }
108
108
  else if (pulls.length) {
109
109
  reasons.push("Pull request volume or history span is small enough to keep all PR months.");
@@ -112,10 +112,20 @@ export function analyzeProbe(meta, pulls, detailSamples, commits) {
112
112
  reasons.push(`${usefulCommits} non-merge commits contain enough signal to analyze commit conventions.`);
113
113
  }
114
114
  else if (commits.length) {
115
- reasons.push("Commit history is mostly merge/noise commits, so it is not recommended as a source.");
115
+ // Only claim the history is noise when the useful-commit ratio actually
116
+ // supports it. With `commits: 8 · useful: 5` the ratio is above a half,
117
+ // and the old unconditional sentence contradicted the numbers beside it
118
+ // (CORE-24).
119
+ const usefulRatio = usefulCommits / commits.length;
120
+ if (usefulRatio < 0.5) {
121
+ reasons.push("Commit history is mostly merge/noise commits, so it is not recommended as a source.");
122
+ }
123
+ else {
124
+ reasons.push(`Commit history is short (${commits.length} ${commits.length === 1 ? "commit" : "commits"}), so it is not recommended as a source yet.`);
125
+ }
116
126
  }
117
127
  if (maxChangeLines) {
118
- reasons.push(`The sampled diff p90 is ${p90Diff} lines; the cap keeps the largest outliers out while retaining about 90% of sampled diffs.`);
128
+ reasons.push(`The sampled diff p90 is ${p90Diff} lines. The cap keeps the largest outliers out while retaining about 90% of sampled diffs.`);
119
129
  }
120
130
  return {
121
131
  includePullRequests: pulls.length > 0,
@@ -1,2 +1,11 @@
1
1
  export declare const sectionTitles: Record<string, string>;
2
2
  export declare const sectionKeys: string[];
3
+ /** Sections whose content also lives in `CODEBASE.md`. The skill links to that
4
+ * file instead of keeping a second copy (CORE-32 / F26c). */
5
+ export declare const codebaseSectionKeys: Set<string>;
6
+ /** A fact read out of a single pull request describes that request, not the
7
+ * repository, so it may only feed the `review-bar` checklist. Every other
8
+ * section states repository policy and must not learn from one PR's narrative
9
+ * (CORE-32 / F26b). Keep this in sync with `guide.ts`, which applies the same
10
+ * rule to the review guides. */
11
+ export declare function sectionAllowsPrFacts(key: string): boolean;
@@ -11,3 +11,19 @@ export const sectionTitles = {
11
11
  process: "Pull request process",
12
12
  };
13
13
  export const sectionKeys = Object.keys(sectionTitles);
14
+ /** Sections whose content also lives in `CODEBASE.md`. The skill links to that
15
+ * file instead of keeping a second copy (CORE-32 / F26c). */
16
+ export const codebaseSectionKeys = new Set([
17
+ "layout",
18
+ "style",
19
+ "tests",
20
+ "devloop",
21
+ ]);
22
+ /** A fact read out of a single pull request describes that request, not the
23
+ * repository, so it may only feed the `review-bar` checklist. Every other
24
+ * section states repository policy and must not learn from one PR's narrative
25
+ * (CORE-32 / F26b). Keep this in sync with `guide.ts`, which applies the same
26
+ * rule to the review guides. */
27
+ export function sectionAllowsPrFacts(key) {
28
+ return key === "review-bar";
29
+ }
@@ -1,4 +1,9 @@
1
1
  import type { Fact } from "./types.ts";
2
+ /** The full text of the sections that live in `CODEBASE.md`. The skill itself
3
+ * only links to that file, so this is the single place the codebase guidance is
4
+ * written out: the synthesized override when there is one, otherwise the
5
+ * rendered facts (CORE-32 / F26c). */
6
+ export declare function codebaseBody(facts: Fact[], overrides?: Record<string, string>): string;
2
7
  /** Slices an assembled skill's `## Heading` sections back out by section key,
3
8
  * from `<!-- section:key -->` markers or (fallback) canonical heading text. */
4
9
  export declare function extractSections(markdown: string): Record<string, string>;
@@ -1,15 +1,54 @@
1
- import { sectionKeys, sectionTitles } from "./sections.js";
1
+ import { isPullRequestFact } from "./guide.js";
2
+ import { codebaseSectionKeys, sectionAllowsPrFacts, sectionKeys, sectionTitles, } from "./sections.js";
2
3
  function sectionFacts(facts, key) {
3
4
  return facts
4
5
  .filter((item) => item.sectionKey === key)
5
6
  .sort((a, b) => b.weight - a.weight);
6
7
  }
8
+ /** Facts a section may state. A fact read out of a single pull request is that
9
+ * request's narrative, not a rule, so only `review-bar` may use it (CORE-32 /
10
+ * F26b). Facts written before `origin` existed count as repository policy. */
11
+ function usableFacts(facts, key) {
12
+ const items = sectionFacts(facts, key);
13
+ return sectionAllowsPrFacts(key)
14
+ ? items
15
+ : items.filter((item) => !isPullRequestFact(item));
16
+ }
17
+ /** The link that stands in for a codebase section's text. */
18
+ function isCodebaseLink(section) {
19
+ return section.includes("[CODEBASE.md](CODEBASE.md)");
20
+ }
7
21
  function render(key, facts) {
8
- const items = sectionFacts(facts, key).slice(0, 6);
22
+ if (codebaseSectionKeys.has(key)) {
23
+ return `## ${sectionTitles[key] ?? key}\n\nSee [CODEBASE.md](CODEBASE.md).`;
24
+ }
25
+ const items = usableFacts(facts, key).slice(0, 6);
9
26
  if (!items.length)
10
27
  return "";
11
28
  return `## ${sectionTitles[key] ?? key}\n\n${items.map((item) => `- ${item.claim}`).join("\n")}`;
12
29
  }
30
+ function codebaseSectionText(key, facts, overrides) {
31
+ const override = overrides[key]?.trim();
32
+ if (override && hasBullets(override) && !isCodebaseLink(override)) {
33
+ return cleanSection(override);
34
+ }
35
+ const items = usableFacts(facts, key).slice(0, 6);
36
+ if (!items.length)
37
+ return "";
38
+ return `## ${sectionTitles[key] ?? key}\n\n${items
39
+ .map((item) => `- ${item.claim}`)
40
+ .join("\n")}`;
41
+ }
42
+ /** The full text of the sections that live in `CODEBASE.md`. The skill itself
43
+ * only links to that file, so this is the single place the codebase guidance is
44
+ * written out: the synthesized override when there is one, otherwise the
45
+ * rendered facts (CORE-32 / F26c). */
46
+ export function codebaseBody(facts, overrides = {}) {
47
+ return [...codebaseSectionKeys]
48
+ .map((key) => codebaseSectionText(key, facts, overrides))
49
+ .filter(Boolean)
50
+ .join("\n\n");
51
+ }
13
52
  function cleanSection(value) {
14
53
  return value.replace(/^\s*```[a-zA-Z0-9_-]*\s*$/gm, "").trim();
15
54
  }
@@ -42,18 +81,25 @@ async function hash(value) {
42
81
  .map((byte) => byte.toString(16).padStart(2, "0"))
43
82
  .join("");
44
83
  }
84
+ /** The fields that decide a section's text. `origin` is included so a fact
85
+ * moving from PR narrative to repository policy (or the reverse) counts as a
86
+ * change rather than reusing a section built under the old rule (CORE-32). */
87
+ function hashInput(item) {
88
+ return [
89
+ item.id,
90
+ item.claim,
91
+ item.weight,
92
+ item.scope,
93
+ item.confidence,
94
+ item.status,
95
+ item.origin ?? "repository",
96
+ ];
97
+ }
45
98
  export async function factSectionHashes(facts) {
46
99
  const hashes = {};
47
100
  for (const key of new Set(facts.map((item) => item.sectionKey))) {
48
101
  const relevant = sectionFacts(facts, key);
49
- hashes[key] = await hash(JSON.stringify(relevant.map((item) => [
50
- item.id,
51
- item.claim,
52
- item.weight,
53
- item.scope,
54
- item.confidence,
55
- item.status,
56
- ])));
102
+ hashes[key] = await hash(JSON.stringify(relevant.map(hashInput)));
57
103
  }
58
104
  return hashes;
59
105
  }
@@ -68,26 +114,27 @@ export async function assembleSkill(repo, facts, previousMarkdown, previousHashe
68
114
  if (!content)
69
115
  continue;
70
116
  const relevant = sectionFacts(facts, key);
71
- const sectionHash = await hash(JSON.stringify(relevant.map((item) => [
72
- item.id,
73
- item.claim,
74
- item.weight,
75
- item.scope,
76
- item.confidence,
77
- item.status,
78
- ])));
117
+ const sectionHash = await hash(JSON.stringify(relevant.map(hashInput)));
79
118
  hashes[key] = sectionHash;
80
119
  const canonicalHeading = `## ${sectionTitles[key] ?? key}`;
81
- const hasOverride = Object.hasOwn(overrides, key);
120
+ // The codebase sections hold a link, not text, so the skill never takes a
121
+ // synthesized override for them; the override goes to CODEBASE.md instead.
122
+ const hasOverride = Object.hasOwn(overrides, key) && !codebaseSectionKeys.has(key);
82
123
  if (!hasOverride &&
83
124
  previousMarkdown &&
84
125
  previousHashes[key] === sectionHash &&
85
- previous[key]?.startsWith(canonicalHeading)) {
126
+ previous[key]?.startsWith(canonicalHeading) &&
127
+ // A section whose shape changed (the codebase sections now carry a link
128
+ // instead of the text) is rewritten rather than kept because its facts
129
+ // happen to be unchanged.
130
+ isCodebaseLink(content) === isCodebaseLink(previous[key])) {
86
131
  sections[key] = cleanSection(previous[key]);
87
132
  continue;
88
133
  }
89
134
  const section = hasOverride ? overrides[key] : content;
90
- if (!hasBullets(section))
135
+ // A codebase section is a link, not a bullet list, so it is kept even
136
+ // though it has no bullets; a text section without bullets is dropped.
137
+ if (!isCodebaseLink(section) && !hasBullets(section))
91
138
  continue;
92
139
  sections[key] = section;
93
140
  changed.push(key);
@@ -1,7 +1,23 @@
1
+ import type { SkippedUnit } from "../ai/batch.ts";
1
2
  import type { AiProvider, AiResponse, Options } from "../types.ts";
2
3
  import type { Fact, Source } from "./types.ts";
3
4
  type UsageSink = (job: string, response: AiResponse) => Promise<void>;
5
+ export type SkippedUnits = SkippedUnit[];
6
+ export type SynthesisResult = Record<string, string>;
4
7
  export declare function extractAiFacts(provider: AiProvider, repo: string, source: Source, options: Options, usage?: UsageSink): Promise<Fact[]>;
8
+ export declare function extractAiFactsWithReport(provider: AiProvider, repo: string, source: Source, options: Options, usage?: UsageSink): Promise<{
9
+ facts: Fact[];
10
+ skipped: SkippedUnit[];
11
+ }>;
12
+ /** Same as {@link extractAiFacts}, but also reports what the batch skipped so
13
+ * the caller can put it in the final `[done]` line (CORE-30). */
14
+ export declare function enrichFactsWithReport(provider: AiProvider, repo: string, base: Fact[], source: Source, options: Options, usage?: UsageSink): Promise<{
15
+ facts: Fact[];
16
+ skipped: SkippedUnit[];
17
+ }>;
5
18
  export declare function enrichFacts(provider: AiProvider, repo: string, base: Fact[], source: Source, options: Options, usage?: UsageSink): Promise<Fact[]>;
6
- export declare function synthesizeSections(provider: AiProvider, repo: string, facts: Fact[], _previousMarkdown: string | undefined, ai: Options["ai"], model: string, concurrency: number, usage?: UsageSink, onlySections?: Set<string>): Promise<Record<string, string>>;
19
+ export declare function synthesizeSections(provider: AiProvider, repo: string, facts: Fact[], _previousMarkdown: string | undefined, ai: Options["ai"], model: string, concurrency: number, usage?: UsageSink, onlySections?: Set<string>): Promise<{
20
+ overrides: Record<string, string>;
21
+ skipped: SkippedUnit[];
22
+ }>;
7
23
  export {};