projectkeeper 0.0.0-stage → 1.0.0

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 (276) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/LICENSE +21 -0
  3. package/README.md +283 -2
  4. package/README.zh-CN.md +285 -0
  5. package/THIRD_PARTY_NOTICES.md +53 -0
  6. package/bin/pk.mjs +6 -0
  7. package/bin/postinstall.mjs +5 -0
  8. package/bin/strip-types.mjs +28 -0
  9. package/package.json +68 -4
  10. package/patches/README.md +111 -0
  11. package/patches/pi-ai-0.85.0.ts +122 -0
  12. package/patches/pi-ai-0.87.1.ts +122 -0
  13. package/scripts/apply-pi-streaming-patch.ts +86 -0
  14. package/skills/clerk/coverage/SKILL.md +35 -0
  15. package/skills/clerk/cross-check/SKILL.md +118 -0
  16. package/skills/clerk/deepen-dispatch/SKILL.md +69 -0
  17. package/skills/clerk/lane-follow-up/SKILL.md +94 -0
  18. package/skills/clerk/lane-plan/SKILL.md +107 -0
  19. package/skills/clerk/lane-slot/SKILL.md +113 -0
  20. package/skills/clerk/lane-topic/SKILL.md +116 -0
  21. package/skills/clerk/orientation/SKILL.md +160 -0
  22. package/skills/clerk/reconcile/SKILL.md +77 -0
  23. package/skills/clerk/skeleton-dispatch/SKILL.md +43 -0
  24. package/skills/clerk/spot-check/SKILL.md +52 -0
  25. package/skills/clerk/synthesis/SKILL.md +65 -0
  26. package/skills/table-follow-up/SKILL.md +42 -0
  27. package/src/cli.ts +319 -0
  28. package/src/codemap/README.md +13 -0
  29. package/src/codemap/engines.ts +102 -0
  30. package/src/codemap/facts.ts +504 -0
  31. package/src/context/agent-entry.ts +232 -0
  32. package/src/context/ask.ts +97 -0
  33. package/src/context/assemble.ts +1058 -0
  34. package/src/context/code-source.ts +103 -0
  35. package/src/context/k-briefs.ts +114 -0
  36. package/src/context/lineage.ts +100 -0
  37. package/src/context/object-brief.ts +300 -0
  38. package/src/context/owner-words.ts +191 -0
  39. package/src/context/pack-fixture.ts +342 -0
  40. package/src/context/project-rules.ts +160 -0
  41. package/src/intake/intake.ts +392 -0
  42. package/src/intake/material-rules.ts +166 -0
  43. package/src/intake/removal.ts +316 -0
  44. package/src/intake/skipped.ts +72 -0
  45. package/src/keeper/adjustment.ts +565 -0
  46. package/src/keeper/bounds/boundary.ts +509 -0
  47. package/src/keeper/bounds/command.ts +1665 -0
  48. package/src/keeper/bounds/paths.ts +164 -0
  49. package/src/keeper/bounds/reads.ts +718 -0
  50. package/src/keeper/bounds/shell-write-guard.ts +521 -0
  51. package/src/keeper/capabilities.ts +136 -0
  52. package/src/keeper/clerk-steps.ts +151 -0
  53. package/src/keeper/clerk-tools.ts +1424 -0
  54. package/src/keeper/conversation.ts +253 -0
  55. package/src/keeper/evidence.ts +662 -0
  56. package/src/keeper/fake-provider.ts +132 -0
  57. package/src/keeper/held.ts +15 -0
  58. package/src/keeper/history-tools.ts +163 -0
  59. package/src/keeper/jobs/answering.ts +53 -0
  60. package/src/keeper/key-check.ts +105 -0
  61. package/src/keeper/ledger-tools.ts +295 -0
  62. package/src/keeper/link-check.ts +185 -0
  63. package/src/keeper/merge.ts +260 -0
  64. package/src/keeper/note-origin.ts +56 -0
  65. package/src/keeper/numbers-check.ts +102 -0
  66. package/src/keeper/open-in-pi.ts +57 -0
  67. package/src/keeper/organize/absence.ts +266 -0
  68. package/src/keeper/organize/carried-on.ts +214 -0
  69. package/src/keeper/organize/clerk-coverage.ts +293 -0
  70. package/src/keeper/organize/clerk-prompts.ts +132 -0
  71. package/src/keeper/organize/clerk.ts +1395 -0
  72. package/src/keeper/organize/coverage-gate.ts +19 -0
  73. package/src/keeper/organize/coverage-tools.ts +765 -0
  74. package/src/keeper/organize/entries.ts +153 -0
  75. package/src/keeper/organize/entry-gate.ts +289 -0
  76. package/src/keeper/organize/fill-tools.ts +718 -0
  77. package/src/keeper/organize/follow-up.ts +280 -0
  78. package/src/keeper/organize/generation-check.ts +504 -0
  79. package/src/keeper/organize/graph.ts +230 -0
  80. package/src/keeper/organize/lane-tools.ts +289 -0
  81. package/src/keeper/organize/looked-tools.ts +54 -0
  82. package/src/keeper/organize/materials.ts +243 -0
  83. package/src/keeper/organize/owner-lines.ts +493 -0
  84. package/src/keeper/organize/place-tools.ts +216 -0
  85. package/src/keeper/organize/placement-inference.ts +1045 -0
  86. package/src/keeper/organize/placement-time.ts +320 -0
  87. package/src/keeper/organize/placing.ts +821 -0
  88. package/src/keeper/organize/positioning.ts +33 -0
  89. package/src/keeper/organize/readiness.ts +47 -0
  90. package/src/keeper/organize/reading.ts +401 -0
  91. package/src/keeper/organize/round-blocks.ts +65 -0
  92. package/src/keeper/organize/round-news.ts +164 -0
  93. package/src/keeper/organize/round-open.ts +586 -0
  94. package/src/keeper/organize/schedule-state.ts +39 -0
  95. package/src/keeper/organize/schedule.ts +137 -0
  96. package/src/keeper/organize/service.ts +409 -0
  97. package/src/keeper/organize/skills.ts +116 -0
  98. package/src/keeper/organize/slots.ts +85 -0
  99. package/src/keeper/organize/stage-gate.ts +175 -0
  100. package/src/keeper/organize/stage-tools.ts +471 -0
  101. package/src/keeper/organize/standing-notes.ts +141 -0
  102. package/src/keeper/organize/table-follow-up.ts +330 -0
  103. package/src/keeper/organize/takeover-clerk.ts +832 -0
  104. package/src/keeper/organize/takeover-state.ts +103 -0
  105. package/src/keeper/organize/takeover.ts +216 -0
  106. package/src/keeper/organize/update-pending.ts +535 -0
  107. package/src/keeper/organize/workbench-placement.ts +92 -0
  108. package/src/keeper/own-keys.ts +114 -0
  109. package/src/keeper/owner-text.ts +165 -0
  110. package/src/keeper/project-folder.ts +334 -0
  111. package/src/keeper/prompts.ts +101 -0
  112. package/src/keeper/references.ts +177 -0
  113. package/src/keeper/relook/compose.ts +120 -0
  114. package/src/keeper/relook/prompts.ts +36 -0
  115. package/src/keeper/roles.ts +259 -0
  116. package/src/keeper/round-tools.ts +227 -0
  117. package/src/keeper/route.ts +148 -0
  118. package/src/keeper/rules.ts +97 -0
  119. package/src/keeper/runtime.ts +1858 -0
  120. package/src/keeper/scope-tools.ts +194 -0
  121. package/src/keeper/sendback-text.ts +83 -0
  122. package/src/keeper/step-timing.ts +207 -0
  123. package/src/keeper/testing/clerk-script.ts +212 -0
  124. package/src/keeper/testing/streaming-arguments.ts +57 -0
  125. package/src/keeper/tools.ts +2340 -0
  126. package/src/keeper/utterance-tools.ts +64 -0
  127. package/src/keeper/verbatim.ts +397 -0
  128. package/src/ledger/adapters.ts +186 -0
  129. package/src/ledger/arrangements.ts +257 -0
  130. package/src/ledger/blame.ts +58 -0
  131. package/src/ledger/code-engine-child.ts +69 -0
  132. package/src/ledger/code-engine.ts +548 -0
  133. package/src/ledger/code.ts +607 -0
  134. package/src/ledger/docs.ts +294 -0
  135. package/src/ledger/git-read.ts +321 -0
  136. package/src/ledger/index.ts +1850 -0
  137. package/src/ledger/item-range.ts +16 -0
  138. package/src/ledger/lines.ts +328 -0
  139. package/src/ledger/numbering.ts +269 -0
  140. package/src/ledger/ranges.ts +74 -0
  141. package/src/ledger/rebuild-worker.ts +15 -0
  142. package/src/ledger/rebuild.ts +387 -0
  143. package/src/ledger/repo-scan.ts +181 -0
  144. package/src/ledger/schema.ts +450 -0
  145. package/src/ledger/sessions.ts +134 -0
  146. package/src/ledger/text-scan.ts +466 -0
  147. package/src/ledger/time.ts +185 -0
  148. package/src/ledger/ts-symbols.ts +180 -0
  149. package/src/ledger/views.ts +377 -0
  150. package/src/mcp/server.ts +127 -0
  151. package/src/mcp/tools.ts +187 -0
  152. package/src/model/ids.ts +23 -0
  153. package/src/model/k-types.ts +912 -0
  154. package/src/model/time.ts +85 -0
  155. package/src/model/types.ts +1382 -0
  156. package/src/model/views-k.ts +707 -0
  157. package/src/model/vocab.ts +269 -0
  158. package/src/process/analysis.ts +79 -0
  159. package/src/process/breakpoint-candidates.ts +383 -0
  160. package/src/process/breakpoints.ts +746 -0
  161. package/src/process/claim-check.ts +295 -0
  162. package/src/process/delivery-links.ts +52 -0
  163. package/src/process/facts.ts +487 -0
  164. package/src/process/generations.ts +138 -0
  165. package/src/process/index.ts +243 -0
  166. package/src/process/integration.ts +128 -0
  167. package/src/process/placement.ts +107 -0
  168. package/src/process/plan.ts +276 -0
  169. package/src/process/sendbacks.ts +244 -0
  170. package/src/process/text.ts +185 -0
  171. package/src/process/units.ts +543 -0
  172. package/src/process/unnumbered.ts +431 -0
  173. package/src/process/work.ts +884 -0
  174. package/src/scope/candidates.ts +86 -0
  175. package/src/scope/decisions.ts +229 -0
  176. package/src/scope/discover.ts +741 -0
  177. package/src/scope/ignore.ts +126 -0
  178. package/src/scope/ignored-place.ts +93 -0
  179. package/src/scope/skip.ts +161 -0
  180. package/src/scope/toolchain.ts +180 -0
  181. package/src/scope/worktree.ts +187 -0
  182. package/src/server/api.ts +549 -0
  183. package/src/server/app.ts +595 -0
  184. package/src/server/drafts-api.ts +170 -0
  185. package/src/server/folders-api.ts +243 -0
  186. package/src/server/graph-view.ts +639 -0
  187. package/src/server/http.ts +224 -0
  188. package/src/server/k-agent-api.ts +56 -0
  189. package/src/server/k-rounds-d99.ts +91 -0
  190. package/src/server/k-views.ts +405 -0
  191. package/src/server/keeper-page.ts +446 -0
  192. package/src/server/keys-api.ts +239 -0
  193. package/src/server/project-folder-api.ts +104 -0
  194. package/src/server/vendor.ts +14 -0
  195. package/src/server/workbench-content.ts +561 -0
  196. package/src/sources/anchor.ts +129 -0
  197. package/src/sources/files.ts +267 -0
  198. package/src/sources/gitobs.ts +41 -0
  199. package/src/sources/history.ts +231 -0
  200. package/src/sources/scan-worker.ts +10 -0
  201. package/src/sources/sessions/locate.ts +292 -0
  202. package/src/sources/sessions/read.ts +346 -0
  203. package/src/sources/sessions/scope.ts +42 -0
  204. package/src/sources/sessions/utterances.ts +223 -0
  205. package/src/sources/watch.ts +215 -0
  206. package/src/store/json-file.ts +50 -0
  207. package/src/store/paths.ts +39 -0
  208. package/src/store/project-store.ts +295 -0
  209. package/src/store/versions.ts +172 -0
  210. package/src/store/workspace.ts +216 -0
  211. package/src/table/check.ts +333 -0
  212. package/src/table/follow-up-tools.ts +30 -0
  213. package/src/table/follow-up.ts +344 -0
  214. package/src/table/import.ts +610 -0
  215. package/src/table/project-home.ts +61 -0
  216. package/src/table/sessions.ts +168 -0
  217. package/src/table/takeover.ts +483 -0
  218. package/src/util/git-check.ts +29 -0
  219. package/src/util/git.ts +411 -0
  220. package/src/util/own-ts.ts +15 -0
  221. package/src/util/paths.ts +225 -0
  222. package/template/FILLING-IN.md +148 -0
  223. package/template/examples/papertrail.json +747 -0
  224. package/template/projectkeeper.schema.json +1001 -0
  225. package/ui/app.js +1135 -0
  226. package/ui/failures.js +82 -0
  227. package/ui/flyout.js +120 -0
  228. package/ui/folder-chooser.js +264 -0
  229. package/ui/glossary.js +156 -0
  230. package/ui/graph-fit.js +157 -0
  231. package/ui/graph-material.js +61 -0
  232. package/ui/graph-palette.js +109 -0
  233. package/ui/graph-tools.js +62 -0
  234. package/ui/graph.js +1712 -0
  235. package/ui/index.html +31 -0
  236. package/ui/k/code-view.js +230 -0
  237. package/ui/k/draft-view.js +93 -0
  238. package/ui/k/k-views.css +224 -0
  239. package/ui/k/round-tree.js +450 -0
  240. package/ui/k/scope-k.js +159 -0
  241. package/ui/k-data.js +118 -0
  242. package/ui/k-fold.js +106 -0
  243. package/ui/k-process.css +302 -0
  244. package/ui/k-process.js +934 -0
  245. package/ui/keeper-activity.js +175 -0
  246. package/ui/keeper-view.js +801 -0
  247. package/ui/keeper.css +101 -0
  248. package/ui/key-order.js +62 -0
  249. package/ui/list-fold.js +67 -0
  250. package/ui/markdown.d.ts +21 -0
  251. package/ui/markdown.js +472 -0
  252. package/ui/placement.d.ts +92 -0
  253. package/ui/placement.js +445 -0
  254. package/ui/popover-place.d.ts +16 -0
  255. package/ui/popover-place.js +89 -0
  256. package/ui/popover.js +198 -0
  257. package/ui/project-folder.js +37 -0
  258. package/ui/shell-state.js +40 -0
  259. package/ui/strip-rows.js +171 -0
  260. package/ui/styles.css +492 -0
  261. package/ui/theme-pick.js +36 -0
  262. package/ui/theme.js +74 -0
  263. package/ui/themes/LICENSE.md +13 -0
  264. package/ui/themes/README.md +44 -0
  265. package/ui/themes/TOKENS.md +291 -0
  266. package/ui/themes/a1/board.css +1557 -0
  267. package/ui/themes/a1/pk.css +392 -0
  268. package/ui/themes/b1/board.css +1163 -0
  269. package/ui/themes/b1/pk.css +218 -0
  270. package/ui/themes/d2/board.css +518 -0
  271. package/ui/themes/d2/pk.css +243 -0
  272. package/ui/themes/guard.css +73 -0
  273. package/ui/themes/i1/board.css +776 -0
  274. package/ui/themes/i1/pk.css +223 -0
  275. package/ui/themes/manifest.json +13 -0
  276. package/ui/views.js +2796 -0
@@ -0,0 +1,16 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+
3
+ export interface LineRange { readonly from: number; readonly to: number }
4
+
5
+ /** A source may point at a whole chapter or table. An object's own definition gives its actual row or entry span. */
6
+ export function itemRanges(db: DatabaseSync, repo: string | null, path: string, ids: readonly string[], source: LineRange): LineRange[] {
7
+ if (ids.length === 0) return [source];
8
+ const defs = db.prepare(`SELECT num, line, position FROM nums WHERE repo IS ? AND path = ? AND kind IN ('doc','loose')
9
+ AND place = 'definition' AND current = 1 AND line BETWEEN ? AND ? ORDER BY line`).all(repo, path, source.from, source.to) as { num: string; line: number; position: string | null }[];
10
+ const own = defs.filter((d) => ids.includes(d.num));
11
+ if (own.length === 0) return [source];
12
+ return own.map((d) => {
13
+ const next = defs.find((x) => x.line > d.line);
14
+ return { from: d.line, to: d.position?.includes('table') ? d.line : Math.min(source.to, (next?.line ?? source.to + 1) - 1) };
15
+ });
16
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * What the program reads off single lines of the material (Spec §1.16 rows 3 and 5; CKC-22 AC-4, AC-6). The ledger
3
+ * records the two sides a line explicitly names, never a semantic judgement that the change really holds; "this report's
4
+ * line says fail", with the line. Pure functions over a text; the scan in text-scan.ts follows each line through the
5
+ * versions of its document.
6
+ */
7
+ import { splitMarkdown } from '../sources/files.ts';
8
+
9
+ const clip = (s: string, n = 300): string => (s.length > n ? `${s.slice(0, n)}…` : s);
10
+ /** A named target without the closing brackets and marks of the sentence around it: `(superseded by D83)` → `D83`. */
11
+ export function balanced(s: string): string {
12
+ let t = s.replace(/[\s*_`]+$/, '');
13
+ const count = (c: string) => [...t].filter((x) => x === c).length;
14
+ while (t.endsWith(')') && count(')') > count('(')) t = t.slice(0, -1).replace(/[\s*_`]+$/, '');
15
+ while (t.endsWith(')') && count(')') > count('(')) t = t.slice(0, -1).replace(/[\s*_`]+$/, '');
16
+ return t.replace(/[.。]$/, '');
17
+ }
18
+
19
+ // ───────────────────────── 明写的取代 ─────────────────────────
20
+
21
+ interface Phrase { readonly re: RegExp; readonly label: string; readonly target?: number }
22
+
23
+ /**
24
+ * A section is the project's own obsolete list when its own heading says so (`5. 已纠正的旧规则`, `Deprecated APIs`,
25
+ * `Fix round 2 (superseded)`), or its opening lines do (`以下规则……已作废`). A heading that opens with a finding's number
26
+ * (`T9_R4-CX-01 — P2 — … 已作废的后台任务 …`, `F-1 …`) describes a finding about something obsolete, not a list of them.
27
+ */
28
+ const OBSOLETE_HEADING = /作废|废弃|已纠正|纠正的旧|旧规则|不再使用|已取消|取消的|已撤回|退役|superseded|deprecated|obsolete|retired|withdrawn|no longer/i;
29
+ const FINDING_HEADING = /^\s*(?:[A-Z0-9]+[_-])*[A-Z]{1,6}-?\d{1,4}\b\s*(?:[—–::|·-]|\s)/;
30
+ const OBSOLETE_LEAD = /(以下|下列|这些|below|following)[^。.\n]{0,40}(已作废|作废|废弃|不再使用|不再适用|已取消|已撤回|无效|superseded|deprecated|obsolete|no longer)/i;
31
+
32
+ export interface SupersessionLine {
33
+ readonly line: number;
34
+ readonly text: string;
35
+ readonly pattern: string;
36
+ readonly target: string | null;
37
+ /** The old and new sides read from this line; neither is guessed from other numbers on the line. */
38
+ readonly replaced: string | null;
39
+ readonly replacement: string | null;
40
+ readonly syntax: string;
41
+ /** A row of the project's own obsolete list (a table row or a list item of such a section; never its header). */
42
+ readonly obsoleteList: boolean;
43
+ readonly listHeading: string | null;
44
+ }
45
+
46
+ const TABLE_ROW = /^\s*\|.*\|\s*$/;
47
+ const TABLE_SEP = /^\s*\|[\s:|-]+\|\s*$/;
48
+
49
+ const GENERIC_SIDE = /^(?:什么|哪些|谁|某(?:项|条|些|个|种)?|任何|以下|下列|这些|其(?:他|余)|这(?:些|个|一条)|那(?:些|个)|后来的决定|取代|内容|规则|做法|的|了|先|something|anything|someone|somebody|which|what|who|whatever|the following|the above)(?:\b|[的里中项条个种:: ]|$)/i;
50
+ const GENERIC_CHINESE = /^(?:什么|哪些|谁|某|任何|以下|下列|这些|后来的决定|取代|内容|规则|做法|的|了|先)/;
51
+ const ENTRY_NUMBER = /(?:^|\*\*\s*|#{1,6}\s+)([A-Z]{1,6}-?[A-Z]?\d{1,4})\s*(?:[·::|—-]|\*\*)/;
52
+ const NUMBER_START = /^([A-Z]{1,6}-?[A-Z]?\d{1,4})(?=$|[、,,]|\s*(?:[·::—-]|里|中的|和|与|及|&))/;
53
+
54
+ function side(raw: string): string | null {
55
+ const s = balanced(raw.trim().replace(/^\*+|\*+$/g, '').replace(/^~~|~~$/g, '').replace(/^[「“'"`]+|[」”'"`]+$/g, '').trim())
56
+ .replace(/\s*(?:\(.*?\))?\s*(?:;|;|。|\.)$/, '').trim();
57
+ return s && s.length <= 160 && !GENERIC_SIDE.test(s) && !GENERIC_CHINESE.test(s) && !/[→⟶➜➔]/.test(s) ? s : null;
58
+ }
59
+
60
+ /** Only numbers at the start of the old side, including an explicit enumeration, denote superseded entries. */
61
+ export function replacedNumbers(old: string | null): string[] {
62
+ if (!old) return [];
63
+ const out: string[] = [];
64
+ let rest = old.replace(/^[\s*_`~「“"'((\[]+/, '');
65
+ for (;;) {
66
+ const m = NUMBER_START.exec(rest);
67
+ if (!m) break;
68
+ out.push(m[1]!);
69
+ rest = rest.slice(m[0].length);
70
+ const sep = /^\s*[、,,和与及&]\s*/.exec(rest);
71
+ if (!sep) break;
72
+ rest = rest.slice(sep[0].length);
73
+ }
74
+ return out;
75
+ }
76
+
77
+ interface Reading { pattern: string; replaced: string | null; replacement: string | null; syntax: string }
78
+ const sentence = (s: string): string => s.split(/[。.!?!?;;]/).at(-1)!.trim();
79
+ const clause = (s: string): string => s.split(/[,,。!?;;::|]/).at(-1)!.trim();
80
+ const NAMED_SIDE = /^(?:[A-Z]{1,6}-?[A-Z]?\d{1,4}|[vV]\d+(?:\.\d+)+|本条|本项|此条|此项|新[^,。;;|]{1,45}|新版[^,。;;|]{1,45}|现行[^,。;;|]{1,45}|the new [^,;|]{1,45})$/i;
81
+
82
+ function readRelation(t: string, entry: string | null, firstLine: boolean): Reading | null {
83
+ const plain = t.replace(/^\s*(?:#{1,6}\s+|[-*+]\s+|>\s*)/, '').replace(/^\*\*/, '').replace(/\*\*$/,'').trim();
84
+ let m: RegExpExecArray | null;
85
+ // A struck old side, a version transition, or a comparison row is an explicit arrow.
86
+ if ((m = /~~([^~]{1,120})~~\s*(?:→|->|=>|⟶|➜|➔)\s*(.{1,120})/.exec(plain))) {
87
+ const replaced = side(m[1]!); const replacement = side(m[2]!);
88
+ return replaced && replacement ? { pattern: 'struck replacement', replaced, replacement, syntax: 'struck-arrow' } : null;
89
+ }
90
+ if ((m = /\b[vV](\d+(?:\.\d+)*)\s*(?:→|->|=>|⟶|➜|➔|升级为|升级到|升到|改为|换成)\s*[vV]?(\d+(?:\.\d+)*)\b/.exec(plain))) {
91
+ return { pattern: 'version replacement', replaced: `v${m[1]}`, replacement: `v${m[2]}`, syntax: 'version-arrow' };
92
+ }
93
+ // English passive and active forms state both sides. The subject is the current sentence, not the whole line.
94
+ if ((m = /\b(?:is|are|was|were|has been|have been)?\s*(superseded|replaced|obsoleted)\s+by\s+(.+)$/i.exec(plain))) {
95
+ const prefix = sentence(plain.slice(0, m.index)).replace(/\b(?:is|are|was|were|has been|have been)\s*$/i, '').trim();
96
+ const replaced = firstLine && entry ? entry : side(prefix);
97
+ const replacement = side(m[2]!);
98
+ return replaced && replacement ? { pattern: `${m[1]!.toLowerCase()} by`, replaced, replacement, syntax: 'passive' } : null;
99
+ }
100
+ if ((m = /\b(supersedes|replaces|obsoletes)\s+(.+)$/i.exec(plain))) {
101
+ const replacement = side(sentence(plain.slice(0, m.index))) ?? (firstLine ? entry : null);
102
+ const replaced = side(m[2]!);
103
+ return replaced && replacement ? { pattern: m[1]!.toLowerCase(), replaced, replacement, syntax: 'active' } : null;
104
+ }
105
+ if ((m = /被\s*([^,。;;,|]{1,50}?)\s*(?:所)?取代/.exec(plain))) {
106
+ const prefix = clause(plain.slice(0, m.index)).replace(/\s*已$/, '').trim();
107
+ const afterEntry = firstLine && entry ? prefix.replace(new RegExp(`^${entry}\\s*[·::—-]\\s*`), '') : prefix;
108
+ const version = /(?:^|、)\s*([vV]\d+(?:\.\d+)+)$/.exec(afterEntry)?.[1];
109
+ const replaced = firstLine && entry && afterEntry !== prefix && (!afterEntry || afterEntry === '已' || !/^[A-Z]{1,6}-?[A-Z]?\d/.test(afterEntry))
110
+ ? entry : side(version ?? afterEntry);
111
+ const replacement = side(m[1]!);
112
+ if (/例如|比如|e\.g\./i.test(prefix) || /取代|哪些|什么|是否|有没有|哪次|写明/.test(m[1]!)) return null;
113
+ return replaced && replacement ? { pattern: '被…取代', replaced, replacement, syntax: 'passive' } : null;
114
+ }
115
+ if (!/^\|/.test(t) && (m = /取代\s*(?:了|掉)?\s*([^,。;;,|]{1,100})/.exec(plain))) {
116
+ const before = plain.slice(0, m.index);
117
+ if (/被[^,。;;,|]{0,50}$/.test(before)) return null;
118
+ const prefix = clause(before).replace(/^.*?[·—]\s*(?=本条|本项|此条|此项)/, '').trim();
119
+ const replacement = /^(?:本条|本项|此条|此项)$/.test(prefix) ? entry : NAMED_SIDE.test(prefix) ? side(prefix) : null;
120
+ const replaced = side(m[1]!);
121
+ return replaced && replacement ? { pattern: '取代', replaced, replacement, syntax: 'active' } : null;
122
+ }
123
+ // A status belongs to the named entry only when the entry itself says so, not when its body describes a rule.
124
+ if ((m = /\b(?:deprecated|obsolete|retired|withdrawn|no longer (?:used|in use|applies|valid|in force))\b|(?:已)?(?:作废|废弃|撤回|退役|不再使用|不再适用|不再有效)/i.exec(plain))) {
125
+ if (/撤回/.test(m[0]) && /^\s*\S/.test(plain.slice(m.index + m[0].length))) return null;
126
+ const prefix = sentence(plain.slice(0, m.index)).replace(/\s*(?:is|are|was|were|has been|have been|已)\s*$/i, '').trim();
127
+ const selfStatus = firstLine && !!entry && new RegExp(`^${entry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*[·::—-]\\s*$`).test(prefix);
128
+ const replaced = selfStatus ? entry : side(prefix);
129
+ if (replaced && (selfStatus || /^[A-Z]{1,6}-?[A-Z]?\d{1,4}\s/.test(replaced) || replacedNumbers(replaced).length > 0 || /^(?:旧|原|此前|the |an? )/i.test(replaced)))
130
+ return { pattern: m[0]!, replaced, replacement: null, syntax: selfStatus ? 'entry-status' : 'status' };
131
+ }
132
+ return null;
133
+ }
134
+
135
+ /** For each 1-based line, the obsolete list it lies in (its section's own heading or opening lines say so, or an enclosing section's do). */
136
+ function obsoleteLists(text: string, lineCount: number): (string | null)[] {
137
+ const byLine: (string | null)[] = new Array(lineCount + 2).fill(null);
138
+ const listOfPath = new Map<string, string>();
139
+ for (const s of splitMarkdown(text)) {
140
+ const key = s.headingPath.join(' › ');
141
+ const own = s.headingPath[s.headingPath.length - 1] ?? '';
142
+ const lead = s.text.split(/\r?\n/).slice(s.headingPath.length ? 1 : 0).filter((l) => l.trim() && !TABLE_ROW.test(l)).slice(0, 3).join(' ');
143
+ let list: string | null = (own && OBSOLETE_HEADING.test(own) && !FINDING_HEADING.test(own)) || OBSOLETE_LEAD.test(lead) ? key || '(before the first heading)' : null;
144
+ // A subsection of an obsolete section is part of it (a section ends at the next heading of any level).
145
+ for (let k = s.headingPath.length - 1; list === null && k >= 1; k--) list = listOfPath.get(s.headingPath.slice(0, k).join(' › ')) ?? null;
146
+ if (list === null) continue;
147
+ listOfPath.set(key, list);
148
+ for (let ln = s.lineStart; ln <= s.lineEnd && ln <= lineCount; ln++) byLine[ln] = list;
149
+ }
150
+ return byLine;
151
+ }
152
+
153
+ export function supersessionLines(text: string): SupersessionLine[] {
154
+ const lines = text.split(/\r?\n/);
155
+ const lists = obsoleteLists(text, lines.length);
156
+ const listOf = (ln: number) => lists[ln] ?? null;
157
+ const out: SupersessionLine[] = [];
158
+ let inFence = false;
159
+ let entry: string | null = null;
160
+ let tableHeads: string[] = [];
161
+ for (let i = 0; i < lines.length; i++) {
162
+ const raw = lines[i]!;
163
+ if (/^\s*(```|~~~)/.test(raw)) { inFence = !inFence; continue; }
164
+ const t = raw.trim();
165
+ if (!t || t.length > 1500) continue;
166
+ const ln = i + 1;
167
+ const isRow = TABLE_ROW.test(raw) && !TABLE_SEP.test(raw);
168
+ const isHeaderRow = isRow && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1]!);
169
+ if (isHeaderRow) { tableHeads = raw.split('|').slice(1, -1).map((c) => c.trim()); continue; }
170
+ const ownEntry = ENTRY_NUMBER.exec(raw)?.[1] ?? null;
171
+ if (ownEntry) entry = ownEntry;
172
+ else if (/^\s*#{1,3}\s+/.test(raw)) entry = null;
173
+ const isItem = /^\s{0,1}(?:[-*+]|\d{1,3}[.)])\s+\S/.test(raw);
174
+ const list = !inFence && (isRow || isItem) && !isHeaderRow ? listOf(ln) : null;
175
+ if (list) {
176
+ const cells = isRow ? raw.split('|').slice(1, -1).map((c) => c.trim()) : [];
177
+ const replaced = side(isRow ? cells[0] ?? '' : t.replace(/^\s*(?:[-*+]|\d{1,3}[.)])\s+/, ''));
178
+ if (!replaced) continue;
179
+ const newColumn = tableHeads.findIndex((h) => /现在|现行|改为|新(?:说法|读法|规则)|读作|current|new|now/i.test(h));
180
+ const replacement = newColumn > 0 ? side(cells[newColumn] ?? '') : null;
181
+ out.push({ line: ln, text: clip(t), pattern: 'obsolete-list', target: isRow ? replaced : null, replaced, replacement, syntax: 'obsolete-list-row', obsoleteList: true, listHeading: list });
182
+ continue;
183
+ }
184
+ // A code block is an example, a command or a quoted configuration: a phrase inside it is not the document saying
185
+ // something is replaced (QC AY, CKC-22 AC-4). The verdicts skip fences the same way.
186
+ if (inFence) continue;
187
+ if (isRow && tableHeads.length) {
188
+ const oldColumn = tableHeads.findIndex((h) => /旧(?:说法|读法|规则|版本|方案|内容)|原(?:说法|规则|方案)|old|before/i.test(h));
189
+ const newColumn = tableHeads.findIndex((h) => /读作|现在|现行|新(?:说法|读法|规则|版本|方案|内容)|current|new|after/i.test(h));
190
+ if (oldColumn >= 0 && newColumn >= 0 && oldColumn !== newColumn) {
191
+ const cells = raw.split('|').slice(1, -1).map((c) => c.trim());
192
+ const replaced = side(cells[oldColumn] ?? '');
193
+ const replacement = side(cells[newColumn] ?? '');
194
+ if (replaced && replacement) {
195
+ out.push({ line: ln, text: clip(t), pattern: 'comparison row', target: replacement, replaced, replacement, syntax: 'comparison-row', obsoleteList: false, listHeading: null });
196
+ continue;
197
+ }
198
+ }
199
+ }
200
+ const r = readRelation(t, entry, ownEntry !== null);
201
+ if (r) out.push({ line: ln, text: clip(t), pattern: r.pattern, target: r.syntax === 'version-arrow' ? `${r.replaced} → ${r.replacement}` : r.replacement, replaced: r.replaced, replacement: r.replacement, syntax: r.syntax, obsoleteList: false, listHeading: null });
202
+ }
203
+ return out;
204
+ }
205
+
206
+ /** Supersession words in a file or directory name. */
207
+ /** Supersession words in a file or directory name — only words that say it; `archive` or `old` say where, not that. */
208
+ export const NAME_PHRASES: readonly Phrase[] = [
209
+ { re: /superseded[-_ ]by[-_ ]([A-Za-z0-9._-]+)/i, label: 'superseded-by name', target: 1 },
210
+ { re: /(?:^|[-_. ])(deprecated|obsolete|superseded|retired|withdrawn)(?:[-_. ]|$)/i, label: 'obsolete name' },
211
+ { re: /作废|废弃|退役|已取代/, label: '作废 name' },
212
+ ];
213
+
214
+ /** The first segment of a path whose name says superseded / obsolete, with the path up to it (a directory covers what it holds). */
215
+ export function supersessionInPath(path: string): { at: string; pattern: string; target: string | null } | null {
216
+ const segs = path.split('/');
217
+ for (let i = 0; i < segs.length; i++) {
218
+ for (const p of NAME_PHRASES) {
219
+ const m = p.re.exec(segs[i]!);
220
+ if (!m) continue;
221
+ return { at: segs.slice(0, i + 1).join('/') + (i < segs.length - 1 ? '/' : ''), pattern: p.label, target: p.target !== undefined ? m[p.target] ?? null : null };
222
+ }
223
+ }
224
+ return null;
225
+ }
226
+
227
+ // ───────────────────────── 判定 ─────────────────────────
228
+
229
+ /** A report, review, QC, walkthrough or receipt: by where it is, by its name, or by its title. */
230
+ export function isReportLike(path: string, text: string): boolean {
231
+ const p = path.toLowerCase();
232
+ if (/(^|\/)(reports?|qc|reviews?|walkthroughs?|receipts?|audits?|回执|报告|走查|审阅|验收|复核)(\/)/.test(p)) return true;
233
+ const base = p.split('/').pop() ?? '';
234
+ if (/(report|review|walkthrough|receipt|audit|qc-result|回执|报告|走查|审阅|验收|复核)/.test(base)) return true;
235
+ const title = /^\s*#\s+(.+)$/m.exec(text.slice(0, 3000))?.[1] ?? '';
236
+ return /\b(QC|review|report|walkthrough|audit|verdict)\b|审阅|走查|验收报告|复核报告|回执|检查报告|测试报告|QC 报告/i.test(title);
237
+ }
238
+
239
+ const VERDICT_WORDS: readonly (readonly [RegExp, string])[] = [
240
+ [/\bneeds?[\s-]+(?:repair|fix(?:es|ing)?|rework)\b|需修复|待修复|需要修复/i, 'needs-repair'],
241
+ [/\bconditional(?:ly)?\s+pass(?:ed)?\b|有条件通过/i, 'conditional-pass'],
242
+ [/\bincomplete\b|未完成|不完整/i, 'incomplete'],
243
+ [/\bpartial(?:ly)?(?:\s+(?:done|pass(?:ed)?))?\b|部分做到|部分通过|部分成立/i, 'partial'],
244
+ [/\b(?:fail(?:ed|s|ure)?|rejected|not\s+accepted)\b|不通过|未通过|没通过|不成立|未做到|没做到|不合格|打回/i, 'fail'],
245
+ [/\b(?:pass(?:ed|es)?|approved|accepted|lgtm)\b|(?<![不未没非])(?:通过|成立|做到|合格)/i, 'pass'],
246
+ [/\bblocked\b|阻塞/i, 'blocked'],
247
+ ];
248
+ /** A verdict field: the verdict must follow it at once (markup and a mark such as ✅ aside), `结论:**✅ 通过**(…)`. */
249
+ const VERDICT_FIELD = /(?:^|[\s*>|((【#])(?:结论|总体结论|总结论|最终结论|判定|裁决|评定|QC\s*结论|里程碑结论|verdict|Verdict|VERDICT|overall|Overall|result|Result|结果)\s*\**\s*[::]\s*[*_`「“"'【\[((]*\s*(?:[✅❌⚠️✔✖✗✘]\s*)?/u;
250
+ const VERDICT_CONTEXT = /判定|结论|裁决|验收|QC|审阅|走查|复核|评审|结果|verdict|result|review|status|状态|scenario|场景|AC-\d/i;
251
+
252
+ /** The verdict a stretch of text states, normalised; null when it names none. */
253
+ export function verdictWord(text: string): string | null {
254
+ for (const [re, v] of VERDICT_WORDS) if (re.test(text)) return v;
255
+ return null;
256
+ }
257
+
258
+ /** A line that lists the verdicts to choose from (`pass / fail / incomplete`) is a template, not a verdict. */
259
+ function isTemplate(t: string): boolean {
260
+ const found = new Set<string>();
261
+ for (const [re, v] of VERDICT_WORDS) if (re.test(t)) found.add(v);
262
+ // The choices are separated by a slash, 、 or "or"; a table's pipes separate cells, not choices.
263
+ return found.size >= 2 && /[/、]|\bor\b|或/.test(t.replace(/^\s*\||\|\s*$/g, ''));
264
+ }
265
+
266
+ export interface VerdictLine {
267
+ readonly line: number;
268
+ readonly kind: 'verdict' | 'count' | 'finding';
269
+ readonly verdict: string | null;
270
+ readonly confidence: 'stated' | 'candidate';
271
+ readonly text: string;
272
+ }
273
+
274
+ const COUNT_PAIR = /(?<![\d.])(\d{1,6})\s*\/\s*(\d{1,6})(?![\d.])/;
275
+ const COUNT_CONTEXT = /\b(?:pass(?:ed|ing)?|fail(?:ed|ing|ures?)?|tests?|specs?|cases?|suites?|assertions?)\b|通过|失败|测试|用例/i;
276
+ const COUNT_WORDS = /(?<![A-Za-z0-9_.])(\d{1,6})\s+(?:tests?\s+)?(passed|passing|pass|failed|failing|fail|failures|skipped|todo)\b|(?:ℹ|#)\s*(tests|pass|fail|skipped|todo)\s+(\d{1,6})\b|(?<![A-Za-z0-9_.])(\d{1,6})\s*(?:个|条|项)\s*(?:测试|用例)?\s*(通过|失败)|(?<![A-Za-z0-9_.])(\d{1,6})\s*(?:个)?\s*(?:测试|用例)\s*(?:全部)?(通过|失败)/i;
277
+ /** A finding introduced by its number at the start of a heading, a bold line or a list item: `### F-1【严重】…`, `- **D-3** …`. */
278
+ const FINDING = /^\s{0,3}(?:#{1,6}\s+|[-*+]\s+|\d{1,3}[.)]\s+)?\**\s*\[?([A-Z]{1,3}-\d{1,3})\]?\**\s*(?:[【((\[::·—-]|\s)/;
279
+
280
+ /** Verdicts, test counts and numbered findings stated in a report (only report-like documents are read for these). */
281
+ export function verdictLines(text: string, frontMatter = true): VerdictLine[] {
282
+ const lines = text.split(/\r?\n/);
283
+ const out: VerdictLine[] = [];
284
+ let inFence = false;
285
+ const front = frontMatter && lines[0] !== undefined && /^---\s*$/.test(lines[0]);
286
+ // A section headed as the verdict (`## 结论`, `## Verdict`): its first line opening with a verdict states it.
287
+ let verdictSection = false;
288
+ for (let i = 0; i < lines.length; i++) {
289
+ const raw = lines[i]!;
290
+ if (/^\s*(```|~~~)/.test(raw)) { inFence = !inFence; continue; }
291
+ const t = raw.trim();
292
+ if (!t || t.length > 1200) continue;
293
+ const ln = i + 1;
294
+ if (!inFence && /^\s{0,3}#{1,6}\s+(?:\d+(?:\.\d+)*[.、]?\s*)?(?:总体结论|最终结论|结论|判定|裁决|QC\s*结论|verdict|result|conclusion|结果)\s*[::]?\s*$/i.test(raw)) { verdictSection = true; continue; }
295
+ if (verdictSection && !/^\s{0,3}#/.test(raw)) {
296
+ verdictSection = false;
297
+ const opening = verdictWord(t.replace(/^[\s*_`>「“"'【\[((✅❌⚠️✔✖✗✘-]+/u, '').slice(0, 14));
298
+ if (opening && !isTemplate(t)) { out.push({ line: ln, kind: 'verdict', verdict: opening, confidence: 'stated', text: clip(t) }); continue; }
299
+ }
300
+ if (front && i > 0 && i < 40) {
301
+ const f = /^\s*(?:verdict|result|qc_result|status)\s*:\s*["']?([^"'#]+?)["']?\s*$/i.exec(raw);
302
+ if (f) { const v = verdictWord(f[1]!); if (v) { out.push({ line: ln, kind: 'verdict', verdict: v, confidence: 'stated', text: clip(t) }); continue; } }
303
+ }
304
+ const finding = inFence ? null : FINDING.exec(raw);
305
+ if (finding && !/^\s*\|/.test(raw)) out.push({ line: ln, kind: 'finding', verdict: finding[1]!, confidence: /^\s{0,3}(#|\*\*|[-*+]\s+\*\*)/.test(raw) ? 'stated' : 'candidate', text: clip(t) });
306
+ const pair = COUNT_PAIR.exec(t);
307
+ const words = COUNT_WORDS.exec(t);
308
+ if ((pair && COUNT_CONTEXT.test(t)) || words) {
309
+ const value = pair && COUNT_CONTEXT.test(t) ? `${pair[1]}/${pair[2]}` : words ? (words[1] !== undefined ? `${words[1]} ${words[2]!.toLowerCase()}` : words[3] !== undefined ? `${words[4]} ${words[3]!.toLowerCase()}` : words[5] !== undefined ? `${words[5]} ${words[6]}` : `${words[7]} ${words[8]}`) : null;
310
+ out.push({ line: ln, kind: 'count', verdict: value, confidence: 'stated', text: clip(t) });
311
+ }
312
+ if (inFence) continue;
313
+ const v = verdictWord(t);
314
+ if (!v || isTemplate(t)) continue;
315
+ const field = VERDICT_FIELD.exec(t);
316
+ if (field) {
317
+ // The verdict the field names: the word right after it; a verdict word further on (「待复审通过后」) is only a candidate.
318
+ const rest = t.slice(field.index + field[0].length);
319
+ const atOnce = verdictWord(rest.slice(0, 14));
320
+ out.push({ line: ln, kind: 'verdict', verdict: atOnce ?? verdictWord(rest) ?? v, confidence: atOnce ? 'stated' : 'candidate', text: clip(t) });
321
+ continue;
322
+ }
323
+ const heading = /^\s{0,3}#{1,6}\s/.test(raw);
324
+ const row = TABLE_ROW.test(raw) && !TABLE_SEP.test(raw);
325
+ if (heading || row || VERDICT_CONTEXT.test(t)) out.push({ line: ln, kind: 'verdict', verdict: v, confidence: 'candidate', text: clip(t) });
326
+ }
327
+ return out;
328
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * 编号 (Spec §1.16 row 4; CKC-22 AC-5): the project's numbering rules, recognised from the places a project defines its
3
+ * numbers, and every place each number shows up.
4
+ *
5
+ * How a rule is recognised — the basis is written into `num_rules.basis` for each rule:
6
+ * - A number is *defined* where a project introduces it: at the start of a Markdown heading (`### F-1【严重】`), at the
7
+ * start of a line in bold (`**D83 · …**`, `- **R-52**`), in the first column of a table (`| AC-1 |`, `| AA | kimi |`), as
8
+ * a prompt's front-matter `id`, as the prefix of a file or directory name (`CKC-22-ledger-and-time.md`, `AP-kimi-…md`,
9
+ * `runs/AA-1/`, `2026-07-22_T5-…md`), as the prefix of a branch-name segment (`wip/AB-byok`), or at the start of a
10
+ * commit subject (`AB: rewire …`, `B0 foundation: …`).
11
+ * - A family is a rule when at least `MIN_DEFINED` different numbers of it are defined. Families: one capital letter and
12
+ * digits (`D83`, `E102`, `T5`), capitals, a hyphen and digits (`CKC-22`, `R-52`, `PA-16`, `AC-25`), and two capital
13
+ * letters (`AA`…`AZ`, `BA`…; task or prompt numbers).
14
+ * - Two capital letters are only ever read in the defining positions above (never in prose), and only in capitals, so an
15
+ * ordinary word never becomes a number; once a two-letter number is defined, its mentions are found in prose too, and
16
+ * those of numbers that are also ordinary words (`AI`, `AM`, `AS` …) are marked `candidate`.
17
+ * - A few families are not project numbers whatever their count: version labels (`V2`; version replacement is read as
18
+ * a supersession), HTML heading levels, and standard names (`SHA-256`, `UTF-8`, `ISO-8601` …).
19
+ * - CZ: one of those families is a numbering all the same in a document that defines a series of it at heading positions
20
+ * — `SERIES_MIN` or more different numbers, consecutive or nearly (`### V1 · …`, `### V2 · …`, `### V4 · …`: a side
21
+ * decision log numbered V). The headings of that document are definitions; everywhere else — another document, prose,
22
+ * a file or branch name, a commit subject — the family stays what it was (a version label), and its mentions are not
23
+ * looked for.
24
+ */
25
+
26
+ export const MIN_DEFINED = 3;
27
+
28
+ /**
29
+ * The version of how definitions are read from a document's lines. A ledger built under another version reads the lines of
30
+ * its documents again at its next rebuild (text-scan.ts), so a rule added here reaches documents already recorded.
31
+ * 2 (CZ): a series of a not-a-number family at one document's headings.
32
+ */
33
+ export const NUMBERING_VERSION = '2';
34
+
35
+ export type Position = 'heading' | 'bold entry' | 'table first column' | 'front matter' | 'file name' | 'branch name' | 'commit subject';
36
+
37
+ export interface NumToken { readonly num: string; readonly family: string; readonly shape: 'letter-digits' | 'prefix-hyphen' | 'two-letters' }
38
+
39
+ /** Families that are never a project's numbering (see the module comment). */
40
+ const NOT_NUMBERS = new Set(['V<n>', 'H<n>', 'SHA-<n>', 'UTF-<n>', 'ISO-<n>', 'RFC-<n>', 'CVE-<n>', 'TLS-<n>', 'HTTP-<n>', 'X-<n>', 'U-<n>', 'GPT-<n>', 'COVID-<n>', 'ES-<n>']);
41
+
42
+ /** Two capital letters that are also ordinary words or abbreviations: their prose mentions are only candidates. */
43
+ const COMMON_PAIRS = new Set(['AI', 'AM', 'AN', 'AS', 'AT', 'BE', 'BY', 'DO', 'GO', 'HE', 'IF', 'IN', 'IS', 'IT', 'ME', 'MY', 'NO', 'OF', 'OH', 'OK', 'ON', 'OR', 'SO', 'TO', 'UP', 'US', 'WE', 'UI', 'UX', 'QC', 'PR', 'ID', 'OS', 'CI', 'CD', 'DB', 'JS', 'TS', 'VS', 'VM', 'IO', 'PM', 'TV', 'OP', 'IP', 'EN', 'ZH', 'CN', 'PS', 'CC', 'RE', 'HR', 'QA', 'PC', 'KB', 'MB', 'GB', 'TB', 'MS', 'NS', 'OR', 'EO', 'PA']);
44
+ export const isCommonPair = (num: string): boolean => COMMON_PAIRS.has(num);
45
+
46
+ /** A number's family: its letters and hyphen with `<n>` for the digits (`D<n>`, `CKC-<n>`, `CK-M<n>`), or two letters. */
47
+ export function familyOf(num: string): NumToken | null {
48
+ let m = /^([A-Z])(\d{1,4})$/.exec(num);
49
+ if (m) return { num, family: `${m[1]}<n>`, shape: 'letter-digits' };
50
+ m = /^([A-Z]{1,6}-[A-Z]?)(\d{1,4})$/.exec(num);
51
+ if (m) return { num, family: `${m[1]}<n>`, shape: 'prefix-hyphen' };
52
+ if (/^[A-Z]{2}$/.test(num)) return { num, family: 'two letters', shape: 'two-letters' };
53
+ return null;
54
+ }
55
+
56
+ const notNumber = (t: NumToken | null): boolean => t === null || NOT_NUMBERS.has(t.family);
57
+
58
+ /** How many different numbers of a not-a-number family one document defines at heading positions before they count there. */
59
+ export const SERIES_MIN = 3;
60
+
61
+ /** A series: `SERIES_MIN` or more different numbers, consecutive or nearly — they span at most twice their count. */
62
+ export function isSeries(nums: readonly string[]): boolean {
63
+ const values = [...new Set(nums.map((n) => Number(/(\d+)$/.exec(n)?.[1] ?? NaN)).filter((v) => Number.isFinite(v)))];
64
+ if (values.length < SERIES_MIN) return false;
65
+ return Math.max(...values) - Math.min(...values) + 1 <= values.length * 2;
66
+ }
67
+
68
+ /** A family that is a numbering only inside the one document that defines a series of it (see the module comment). */
69
+ export const isSeriesOnlyFamily = (family: string): boolean => NOT_NUMBERS.has(family);
70
+
71
+ /** One id at the start of a defining position: letter+digits, prefix-hyphen-digits, or (where allowed) two letters. */
72
+ const ID_AT_START = String.raw`(?:[A-Z]{1,6}-[A-Z]?\d{1,4}|[A-Z]\d{1,4}|[A-Z]{2})(?![A-Za-z0-9_]|\.\d)`;
73
+ export const HEADING_DEF = new RegExp(String.raw`^\s{0,3}#{1,6}\s+(?:\d+(?:\.\d+)*[.、]?\s+|第[^\s]{1,4}[轮节章部分]\s*[·::]?\s*)?[*_\x60\[]*(` + ID_AT_START + ')');
74
+ export const BOLD_DEF = new RegExp(String.raw`^\s{0,3}(?:[-*+]\s+|\d{1,3}[.)]\s+)?\*\*\s*\[?(` + ID_AT_START + ')');
75
+ export const TABLE_DEF = new RegExp(String.raw`^\s*\|\s*[*_\x60\[]*(` + ID_AT_START + String.raw`)[*_\x60\]]*(?:\([^)|]*\))?[*_\x60]*\s*\|`);
76
+ const ID = String.raw`[A-Z]{1,6}-[A-Z]?\d{1,4}|[A-Z]\d{1,4}|[A-Z]{2}`;
77
+ const FRONT_ID = new RegExp(String.raw`^\s*id\s*:\s*["']?(` + ID + String.raw`)["']?\s*$`);
78
+ const NAME_DEF = new RegExp(String.raw`^(?:\d{4}-\d{2}-\d{2}[_-])?(` + ID + String.raw`)(?=[-_. ]|$)`);
79
+ const SUBJECT_DEF = new RegExp(String.raw`^\s*\[?(` + ID + String.raw`)\]?(?:\s*[+&,]\s*(?:` + ID + String.raw`))*\s*(?:[::·—-]\s|\s)`);
80
+
81
+ export interface Definition { readonly num: string; readonly family: string; readonly position: Position; readonly line: number | null; readonly context: string }
82
+
83
+ const clip = (s: string, n = 200): string => (s.length > n ? `${s.slice(0, n)}…` : s);
84
+
85
+ /**
86
+ * Two letters defined in names or a table count only where at least `MIN_DEFINED` different ones stand together — one
87
+ * table's first column, one directory's entries, one repository's branches or subjects — which is what an index or a
88
+ * numbered folder looks like; a lone `| UI | Compose |` row or a `QC-notes.md` is not a numbering.
89
+ */
90
+ export function keepTwoLetterGroups<T extends { readonly num: string }>(defs: readonly T[], groupOf: (d: T) => string): T[] {
91
+ const groups = new Map<string, Set<string>>();
92
+ for (const d of defs) {
93
+ if (familyOf(d.num)?.shape !== 'two-letters') continue;
94
+ const g = groupOf(d);
95
+ const s = groups.get(g) ?? new Set<string>();
96
+ s.add(d.num);
97
+ groups.set(g, s);
98
+ }
99
+ return defs.filter((d) => familyOf(d.num)?.shape !== 'two-letters' || (groups.get(groupOf(d))?.size ?? 0) >= MIN_DEFINED);
100
+ }
101
+
102
+ /** Numbers a document defines (headings, bold entry lines, first table columns, the front-matter id). */
103
+ export function definitionsInText(text: string): Definition[] {
104
+ const found: (Definition & { table: number })[] = [];
105
+ /** Headings that open with a number of a not-a-number family: definitions only when the document holds a series of it. */
106
+ const held: Definition[] = [];
107
+ const lines = text.split(/\r?\n/);
108
+ let inFence = false;
109
+ let table = -1;
110
+ const front = lines[0] !== undefined && /^---\s*$/.test(lines[0]);
111
+ const out = { push: (d: Definition) => found.push({ ...d, table: d.position === 'table first column' ? table : -1 }) };
112
+ for (let i = 0; i < lines.length; i++) {
113
+ const l = lines[i]!;
114
+ if (/^\s*(```|~~~)/.test(l)) { inFence = !inFence; continue; }
115
+ if (inFence) continue;
116
+ // A table: consecutive `|` lines; a header row (the next line is a |---| separator) starts a new one.
117
+ if (/^\s*\|/.test(l)) { if (table < 0 || !/^\s*\|/.test(lines[i - 1] ?? '') || /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1] ?? '')) table = i; } else table = -1;
118
+ if (front && i > 0 && i < 40) {
119
+ const f = FRONT_ID.exec(l);
120
+ if (f) { const t = familyOf(f[1]!); if (!notNumber(t)) out.push({ num: f[1]!, family: t!.family, position: 'front matter', line: i + 1, context: clip(l.trim()) }); continue; }
121
+ }
122
+ const tries: [RegExp, Position][] = [[HEADING_DEF, 'heading'], [BOLD_DEF, 'bold entry'], [TABLE_DEF, 'table first column']];
123
+ for (const [re, position] of tries) {
124
+ const m = re.exec(l);
125
+ if (!m) continue;
126
+ const t = familyOf(m[1]!);
127
+ if (t !== null && NOT_NUMBERS.has(t.family) && position === 'heading') held.push({ num: m[1]!, family: t.family, position, line: i + 1, context: clip(l.trim()) });
128
+ // Two letters count in a table's first column (an index) only; a heading or bold line starting with two capitals is prose.
129
+ if (notNumber(t) || (t!.shape === 'two-letters' && position !== 'table first column')) break;
130
+ if (position === 'table first column' && isSeparatorOrHeaderContext(lines, i)) break;
131
+ out.push({ num: m[1]!, family: t!.family, position, line: i + 1, context: clip(l.trim()) });
132
+ break;
133
+ }
134
+ }
135
+ // The front matter's id names the document itself; two letters in a table count where the table is an index of them.
136
+ const tables = keepTwoLetterGroups(found.filter((d) => d.position === 'table first column'), (d) => `table:${d.table}`);
137
+ const kept: Definition[] = found.filter((d) => d.position !== 'table first column' || tables.includes(d)).map(({ table: _t, ...d }) => d);
138
+ // CZ: a series of a not-a-number family at this document's headings is this document's numbering.
139
+ const families = new Map<string, Definition[]>();
140
+ for (const d of held) families.set(d.family, [...(families.get(d.family) ?? []), d]);
141
+ const series = [...families.values()].filter((ds) => isSeries(ds.map((d) => d.num))).flat();
142
+ return series.length ? [...kept, ...series].sort((a, b) => (a.line ?? 0) - (b.line ?? 0)) : kept;
143
+ }
144
+
145
+ /** A table's header row is not an entry (the row followed by the |---| separator). */
146
+ function isSeparatorOrHeaderContext(lines: readonly string[], i: number): boolean {
147
+ const next = lines[i + 1];
148
+ return next !== undefined && /^\s*\|[\s:|-]+\|\s*$/.test(next);
149
+ }
150
+
151
+ /**
152
+ * Numbers a path defines: the prefix of any segment (file or directory), two letters in capitals only. A segment like
153
+ * `AA-1` (a run directory) reads both ways — the number `AA-1` and the two letters `AA` — and the rules decide which
154
+ * family is the project's (a run number rarely repeats three times; a prompt number does).
155
+ */
156
+ export function definitionsInPath(path: string): Definition[] {
157
+ const out: Definition[] = [];
158
+ for (const seg of path.split('/')) {
159
+ const m = NAME_DEF.exec(seg);
160
+ if (!m) continue;
161
+ const t = familyOf(m[1]!);
162
+ if (!notNumber(t)) out.push({ num: m[1]!, family: t!.family, position: 'file name', line: null, context: path });
163
+ const pair = /^([A-Z]{2})-\d{1,4}$/.exec(m[1]!);
164
+ if (pair) out.push({ num: pair[1]!, family: 'two letters', position: 'file name', line: null, context: path });
165
+ }
166
+ return out;
167
+ }
168
+
169
+ /** Numbers a branch name defines: a segment's prefix, in capitals (`wip/AB-byok`); letters+digits in any case (`t5-…`). */
170
+ export function definitionsInBranch(name: string): Definition[] {
171
+ const out: Definition[] = [];
172
+ for (const seg of name.split('/')) {
173
+ const m = new RegExp(`^(${ID})(?=[-_.]|$)`).exec(seg);
174
+ if (!m) continue;
175
+ const t = familyOf(m[1]!);
176
+ if (notNumber(t)) continue;
177
+ out.push({ num: m[1]!, family: t!.family, position: 'branch name', line: null, context: name });
178
+ }
179
+ return out;
180
+ }
181
+
182
+ /** Numbers a commit subject defines at its start (`AB: …`, `B1+B2: …`, `[AK] …`). */
183
+ export function definitionsInSubject(subject: string): Definition[] {
184
+ const m = SUBJECT_DEF.exec(subject);
185
+ if (!m) return [];
186
+ const head = m[0];
187
+ const out: Definition[] = [];
188
+ for (const id of head.matchAll(new RegExp(ID, 'g'))) {
189
+ const t = familyOf(id[0]);
190
+ if (notNumber(t)) continue;
191
+ out.push({ num: id[0], family: t!.family, position: 'commit subject', line: 1, context: clip(subject) });
192
+ }
193
+ return out;
194
+ }
195
+
196
+ // ───────────────────────── recognised rules, and the mentions they give ─────────────────────────
197
+
198
+ export interface Rule {
199
+ readonly family: string;
200
+ readonly shape: NumToken['shape'];
201
+ /** Two letters: the numbers defined (only these are looked for in prose). */
202
+ readonly defined: ReadonlySet<string>;
203
+ }
204
+
205
+ export interface Mention { readonly num: string; readonly family: string; readonly line: number; readonly context: string; readonly confidence: 'stated' | 'candidate' }
206
+
207
+ const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
208
+
209
+ /** Build the matcher for the recognised rules; null when there are none. */
210
+ export function mentionMatcher(all: readonly Rule[]): ((text: string) => Mention[]) | null {
211
+ // CZ: a family that counts only in the document holding its series is not looked for anywhere else.
212
+ const rules = all.filter((r) => !isSeriesOnlyFamily(r.family));
213
+ const letters = rules.filter((r) => r.shape === 'letter-digits').map((r) => r.family.slice(0, 1));
214
+ const prefixes = rules.filter((r) => r.shape === 'prefix-hyphen').map((r) => r.family.replace(/<n>$/, ''));
215
+ const pairs = rules.filter((r) => r.shape === 'two-letters').flatMap((r) => [...r.defined]);
216
+ const alts: string[] = [];
217
+ if (prefixes.length) alts.push(`(?:${prefixes.map(escapeRe).sort((a, b) => b.length - a.length).join('|')})\\d{1,4}`);
218
+ if (letters.length) alts.push(`[${[...new Set(letters)].join('')}]\\d{1,4}`);
219
+ if (pairs.length) alts.push(`(?:${[...new Set(pairs)].sort().join('|')})`);
220
+ if (alts.length === 0) return null;
221
+ const re = new RegExp(`(?<![A-Za-z0-9_\\-./])(${alts.join('|')})(?![A-Za-z0-9_]|\\.\\d)`, 'g');
222
+ return (text: string) => {
223
+ const out: Mention[] = [];
224
+ const lines = text.split(/\r?\n/);
225
+ for (let i = 0; i < lines.length; i++) {
226
+ const l = lines[i]!;
227
+ if (l.length > 4000) continue;
228
+ const seen = new Set<string>();
229
+ for (const m of l.matchAll(re)) {
230
+ const num = m[1]!;
231
+ if (seen.has(num)) continue;
232
+ seen.add(num);
233
+ const t = familyOf(num);
234
+ if (!t) continue;
235
+ const confidence = t.shape === 'two-letters' && isCommonPair(num) ? 'candidate' : 'stated';
236
+ out.push({ num, family: t.family, line: i + 1, context: clip(l.trim()), confidence });
237
+ }
238
+ }
239
+ return out;
240
+ };
241
+ }
242
+
243
+ /** Mentions in names (a path, a branch, a worktree) of the recognised numbers; build once, use for every name. */
244
+ export function nameMatcher(rules: readonly Rule[]): (name: string) => string[] {
245
+ const match = mentionMatcher(rules);
246
+ return (name: string) => mentionsInNameWith(name, rules, match);
247
+ }
248
+
249
+ /** Mentions in a name (a path, a branch, a worktree): segment prefixes in any case for recognised numbers. */
250
+ export function mentionsInName(name: string, rules: readonly Rule[]): string[] {
251
+ return mentionsInNameWith(name, rules, mentionMatcher(rules));
252
+ }
253
+
254
+ function mentionsInNameWith(name: string, rules: readonly Rule[], match: ((text: string) => Mention[]) | null): string[] {
255
+ const out = new Set<string>();
256
+ if (match) for (const m of match(name.replace(/\//g, ' / '))) if (m.confidence === 'stated') out.add(m.num);
257
+ // `am-ui`, `t18-t23-backend`: lowercase segment prefixes of recognised numbers.
258
+ for (const seg of name.split(/[\\/]/)) {
259
+ for (const part of seg.split(/[-_.]/)) {
260
+ const up = part.toUpperCase();
261
+ const t = familyOf(up);
262
+ if (!t) continue;
263
+ if (isSeriesOnlyFamily(t.family) || !rules.some((r) => r.family === t.family && (t.shape !== 'two-letters' || r.defined.has(up)))) continue;
264
+ if (t.shape === 'two-letters' && part !== seg.split(/[-_.]/)[0]) continue; // two letters: only a segment's prefix
265
+ out.add(up);
266
+ }
267
+ }
268
+ return [...out];
269
+ }