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,153 @@
1
+ /**
2
+ * The entries of a decision record, a plan, a task index or an execution arrangement, as the program counts them (CJ;
3
+ * CKC-23 AC-21: every heading of a decision record becomes a `Decision`, named as the document names it). The ledger's
4
+ * numbering (numbering.ts) already knows every number a document defines at an entry position — a heading, a bold entry
5
+ * line (`**D1 · …**`), a table's first column; this module says which of those are the document's own entries and what
6
+ * each entry is called there.
7
+ *
8
+ * - A heading that defines a number is an entry.
9
+ * - A bold line or a table row that defines a number is an entry, except when it stands in the section of a numbered
10
+ * heading of another family: `**P1 · …**` under `### E73`, or the `| S3 |` rows under `### P1 · …`, are points of that
11
+ * entry, not entries of the document. (A bold line of the same family is the same numbering: `**E50 补充 · …**` under
12
+ * `### E50` is E50's supplement.)
13
+ * - A number defined more than once in the document is one entry: the first definition names it, and the others —
14
+ * `**D100 补(2026-09-30)**`, `**D100 补二**` — are folded into it (its supplements), carried by its item.
15
+ * - An entry's name is its number and its title as written: the heading's words, the bold words (and, when the bold holds
16
+ * only the number, the words right after it), a table row's first cell with words.
17
+ */
18
+ import { definitionsInText, familyOf, type Definition } from '../../ledger/numbering.ts';
19
+
20
+ export interface EntryDef {
21
+ readonly num: string;
22
+ readonly position: Definition['position'];
23
+ readonly line: number;
24
+ }
25
+
26
+ /** A Markdown heading of a text, outside code fences, with its 1-based line. */
27
+ export interface HeadingLine { readonly level: number; readonly line: number }
28
+
29
+ export function headingLines(lines: readonly string[]): HeadingLine[] {
30
+ const out: HeadingLine[] = [];
31
+ let fence = false;
32
+ lines.forEach((l, i) => {
33
+ if (/^\s*(```|~~~)/.test(l)) { fence = !fence; return; }
34
+ if (fence) return;
35
+ const m = /^\s{0,3}(#{1,6})\s+/.exec(l);
36
+ if (m) out.push({ level: m[1]!.length, line: i + 1 });
37
+ });
38
+ return out;
39
+ }
40
+
41
+ const familyName = (num: string): string => familyOf(num)?.family ?? num;
42
+
43
+ /**
44
+ * The definitions that are the document's own entries (see the module comment): headings always; a bold line or table row
45
+ * unless the nearest heading above it defines a number of another family. `lines` is the document as it stands; without
46
+ * it (the file is not on disk), every definition counts.
47
+ */
48
+ export function entryDefinitions<T extends EntryDef>(defs: readonly T[], lines: readonly string[] | null): T[] {
49
+ if (!lines) return [...defs];
50
+ const headings = headingLines(lines);
51
+ const numberedHeading = new Map<number, string>();
52
+ for (const d of defs) if (d.position === 'heading') numberedHeading.set(d.line, familyName(d.num));
53
+ return defs.filter((d) => {
54
+ if (d.position === 'heading') return true;
55
+ if (d.position !== 'bold entry' && d.position !== 'table first column') return true;
56
+ let above: HeadingLine | null = null;
57
+ for (const h of headings) { if (h.line >= d.line) break; above = h; }
58
+ if (!above) return true;
59
+ const family = numberedHeading.get(above.line);
60
+ return family === undefined || family === familyName(d.num);
61
+ });
62
+ }
63
+
64
+ /** One entry of a document: its number, the line that defines it, its name as written, and the lines folded into it. */
65
+ export interface Entry {
66
+ readonly num: string;
67
+ readonly position: Definition['position'];
68
+ readonly line: number;
69
+ /** The number and its title as written (`D1 · 做成通用产品…`), or the number alone when the line has no words. */
70
+ readonly name: string;
71
+ /** Whether the defining line gives a title besides the number. */
72
+ readonly titled: boolean;
73
+ /** Later definitions of the same number in the document (its supplements), by line. */
74
+ readonly folded: readonly number[];
75
+ /** Each supplement's lines: from its definition to where its text ends. */
76
+ readonly foldedRanges: readonly { readonly line: number; readonly endLine: number }[];
77
+ /** The last line of its text: up to the next entry of another number, or a heading at or above its own level. */
78
+ readonly endLine: number;
79
+ }
80
+
81
+ const plain = (s: string): string => s
82
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
83
+ .replace(/<br\s*\/?>/gi, ' ')
84
+ .replace(/\*\*|__|`/g, '')
85
+ .replace(/\\\|/g, '|')
86
+ .replace(/\s+/g, ' ')
87
+ .trim();
88
+ /** Words in a string (letters or digits of any script), after the number and the punctuation are taken away. */
89
+ const hasWords = (s: string): boolean => /[\p{L}]/u.test(s);
90
+ const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
91
+ const DATE_CELL = /^\s*\d{4}-\d{2}-\d{2}(?:[ T][\d:]+)?\s*$/;
92
+
93
+ /** What the defining line calls the entry: its number and its title as written; the title is '' when the line has none. */
94
+ export function entryTitle(lineText: string, num: string, position: Definition['position']): { name: string; title: string } {
95
+ const numRe = new RegExp(`^[\\s\\[((]*${escapeRe(num)}(?![A-Za-z0-9_])[\\]))]*\\s*[·::.、—–-]*\\s*`);
96
+ const clean = (s: string) => plain(s).replace(numRe, '').replace(/^[\s·::、—–-]+/, '').replace(/[\s::]+$/, '').trim();
97
+ let title = '';
98
+ if (position === 'heading') {
99
+ title = clean(lineText.replace(/^\s{0,3}#{1,6}\s+/, '').replace(/\s+#+\s*$/, '').replace(/^(?:\d+(?:\.\d+)*[.、]?\s+|第[^\s]{1,4}[轮节章部分]\s*[·::]?\s*)/, ''));
100
+ } else if (position === 'bold entry') {
101
+ const body = lineText.replace(/^\s{0,3}(?:[-*+]\s+|\d{1,3}[.)]\s+)?/, '');
102
+ const m = /^\*\*(.+?)\*\*(.*)$/.exec(body);
103
+ const bold = m ? clean(m[1]!) : clean(body);
104
+ // A bold that holds the number only (`**P5**(…)`): its title is the words right after it, to the end of the clause.
105
+ title = bold || (m ? clean(m[2]!).split(/[。;;]/)[0]!.trim() : '');
106
+ } else if (position === 'table first column') {
107
+ const cells = lineText.trim().replace(/^\|/, '').replace(/\|\s*$/, '').split(/(?<!\\)\|/).map((c) => plain(c));
108
+ const rest = cells.slice(1).find((c) => hasWords(c) && !DATE_CELL.test(c)) ?? '';
109
+ title = clean(cells[0] ?? '') || rest;
110
+ } else {
111
+ title = clean(lineText);
112
+ }
113
+ title = title.replace(/\s+/g, ' ').trim();
114
+ if (!hasWords(title)) return { name: num, title: '' };
115
+ return { name: `${num} · ${title}`, title };
116
+ }
117
+
118
+ /**
119
+ * A document's entries (see the module comment), read from its text by the ledger's own rules (`definitionsInText`):
120
+ * one per number, in order. `families`, when given, keeps the entries of those families only (`D<n>`, `two letters`).
121
+ */
122
+ export function entriesOf(text: string, opts: { readonly positions?: readonly Definition['position'][]; readonly families?: readonly string[] } = {}): Entry[] {
123
+ const lines = text.split(/\r?\n/);
124
+ const all = definitionsInText(text).filter((d) => d.position === 'heading' || d.position === 'bold entry' || d.position === 'table first column');
125
+ const own = entryDefinitions(all.map((d) => ({ ...d, line: d.line ?? 0 })), lines)
126
+ .filter((d) => !opts.positions || opts.positions.includes(d.position))
127
+ .filter((d) => !opts.families || opts.families.includes(d.family) || opts.families.includes(d.num.replace(/\d+$/, '')));
128
+ const headings = headingLines(lines);
129
+ const levelAt = new Map(headings.map((h) => [h.line, h.level]));
130
+ const byNum = new Map<string, (typeof own)[number][]>();
131
+ for (const d of own) byNum.set(d.num, [...(byNum.get(d.num) ?? []), d]);
132
+ const sorted = [...own].sort((a, b) => a.line - b.line);
133
+ /** Where the text that starts at a definition ends: before the next definition of another number, or a heading at or above its own level. */
134
+ const endOf = (d: (typeof own)[number]): number => {
135
+ const nextEntry = sorted.find((x) => x.line > d.line && x.num !== d.num);
136
+ const ownLevel = levelAt.get(d.line) ?? 7;
137
+ const nextHeading = headings.find((h) => h.line > d.line && h.level <= ownLevel && !byNum.get(d.num)!.some((x) => x.line === h.line));
138
+ return Math.max(d.line, Math.min(nextEntry ? nextEntry.line - 1 : lines.length, nextHeading ? nextHeading.line - 1 : lines.length));
139
+ };
140
+ const firsts = [...byNum.values()].map((ds) => ds[0]!).sort((a, b) => a.line - b.line);
141
+ return firsts.map((d) => {
142
+ const { name, title } = entryTitle(lines[d.line - 1] ?? '', d.num, d.position);
143
+ // A later definition of the same number, wherever it stands, is this entry's supplement.
144
+ const folded = byNum.get(d.num)!.slice(1).map((x) => ({ line: x.line, endLine: endOf(x) }));
145
+ return { num: d.num, position: d.position, line: d.line, name, titled: title !== '', folded: folded.map((f) => f.line), foldedRanges: folded, endLine: endOf(d) };
146
+ });
147
+ }
148
+
149
+ /** The name is the number alone (`D1`, `[E50]`, `AP:`), with no title. */
150
+ export function isNumberOnly(name: string): boolean {
151
+ const n = plain(name).replace(/^[\s[((]+|[\s\])).::·、—–-]+$/g, '');
152
+ return n !== '' && familyOf(n.toUpperCase()) !== null && n === n.toUpperCase() && !/\s/.test(n);
153
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * The entry completeness gate (CJ; E150 「保证重要事项全量通过」; CKC-23 AC-21, AC-23). What the program can count does not
3
+ * rest on the model's promise: every number a current decision record, plan, task index or execution arrangement defines
4
+ * at an entry position (entries.ts) is carried by an item of the workbench — a reference item or a work item that has the
5
+ * number in its ids, or whose name starts with it — or is accounted for by its number, with why (`pk_account_entries`).
6
+ * An account in bulk ("process records, read in part") is not one: every number is named.
7
+ *
8
+ * Which files: the layer map orientation wrote (`pk_write_layers`), the entries marked current as `Decision record`,
9
+ * `Plan`, `Task index` or `Execution arrangement`. Which numbers: the ledger's current definitions in each (§1.16 row 4),
10
+ * less the points of another numbered entry (entries.ts `entryDefinitions`); a number defined twice in one file is one
11
+ * entry, its supplements (`D100 补`) folded into it and carried by its item.
12
+ *
13
+ * CZ: besides those four layers, a current document that alone defines a number family is counted — whatever layer it is
14
+ * mapped to, or unmapped: two or more numbers of the family at entry positions (headings, a table's first column), and no
15
+ * other current document that defines the family so. The readings a role keeps in a README table, a side decision log the
16
+ * layer map never named: on the flash run neither was counted by any gate, and neither was recorded. Only the numbers of
17
+ * that family count there. A document set aside — under an archive, or mapped not current — is not a current document.
18
+ * A document named by a number (a contract, a prompt) is that item's own document: its rows are the item's points, so it
19
+ * is never counted this way — though it is a definer, and what it defines no other document defines alone. The folder
20
+ * the Keeper itself maintains in the project (project-folder.ts) is its own writing, not the project's.
21
+ *
22
+ * `pk_stage` refuses to leave `reconcile` in a First usable round, and to enter `cross-check` in a Deepen, while a number
23
+ * is neither carried nor accounted for; `pk_round_state.open.entries` lists them by file. Without a ledger (it is built by
24
+ * the round's first step) or a layer map there is nothing to count, and nothing is refused.
25
+ */
26
+ import { readFileSync } from 'node:fs';
27
+ import { join, sep } from 'node:path';
28
+ import { Type } from 'typebox';
29
+ import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
30
+ import type { ClerkRound, EntryAccount, LayerKind } from '../../model/k-types.ts';
31
+ import type { ProjectStore } from '../../store/project-store.ts';
32
+ import { Ledger } from '../../ledger/index.ts';
33
+ import { definitionsInPath } from '../../ledger/numbering.ts';
34
+ import { STAGE_WRITERS } from '../clerk-steps.ts';
35
+ import { pathKey } from '../../util/paths.ts';
36
+ import type { ToolContext } from '../tools.ts';
37
+ import { primaryIdentifier } from '../tools.ts';
38
+ import { entryDefinitions } from './entries.ts';
39
+ import { archiveRootOf } from './generation-check.ts';
40
+
41
+ /** The layers whose entries must all be on the workbench. */
42
+ export const ENTRY_LAYERS: readonly LayerKind[] = ['Decision record', 'Plan', 'Task index', 'Execution arrangement'];
43
+
44
+ export interface MissingEntry { readonly num: string; readonly line: number; readonly context: string }
45
+
46
+ /** How many numbers of a family a document defines at entry positions before it is a definer of that family (CZ). */
47
+ export const SOLE_DEFINER_MIN = 2;
48
+
49
+ export interface EntryFile {
50
+ readonly path: string;
51
+ readonly layer: LayerKind;
52
+ /** CZ: the number families this document alone defines, when that is why it is counted (only their numbers count). */
53
+ readonly families?: readonly string[];
54
+ /** How many numbers it defines as entries. */
55
+ readonly defined: number;
56
+ readonly carried: number;
57
+ readonly accounted: number;
58
+ /** Neither carried by an item nor accounted for, in line order. */
59
+ readonly missing: readonly MissingEntry[];
60
+ }
61
+
62
+ export interface EntryGaps {
63
+ /** How many numbers are missing, over every file. */
64
+ readonly count: number;
65
+ readonly files: readonly EntryFile[];
66
+ }
67
+
68
+ const clip = (s: string, n = 140): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
69
+ const readLines = (file: string): string[] | null => { try { return readFileSync(file, 'utf8').split(/\r?\n/); } catch { return null; } };
70
+
71
+ const LEADING_NUMBER = /^[\s[((*_`]*([A-Z]{1,6}-[A-Z]?\d{1,4}|[A-Z]\d{1,4})(?![A-Za-z0-9_]|\.\d)/;
72
+
73
+ /** Every number an item of the workbench carries: in its ids, or at the start of its name (reference items and work items). */
74
+ export function carriedNumbers(store: ProjectStore): Set<string> {
75
+ const out = new Set<string>();
76
+ const add = (ids: readonly string[], name: string) => {
77
+ for (const i of ids) if (i.trim()) out.add(i.trim().toUpperCase());
78
+ const p = primaryIdentifier(ids, name);
79
+ if (p) out.add(p);
80
+ // CZ: the number a name opens with, as the document writes it — also one of a family that counts only in its own
81
+ // document (a side decision log's `V3 · …`), which is never an item's primary identifier.
82
+ const lead = LEADING_NUMBER.exec(name);
83
+ if (lead) out.add(lead[1]!.toUpperCase());
84
+ };
85
+ for (const r of store.reference.all()) add(r.ids, r.name);
86
+ for (const t of store.threads.all()) add(t.ids, t.title);
87
+ return out;
88
+ }
89
+
90
+ /** The accounts the main agent gave by number, in every round, by file (a number accounted once stays accounted). */
91
+ export function accountedEntries(store: ProjectStore): Map<string, Map<string, EntryAccount>> {
92
+ const out = new Map<string, Map<string, EntryAccount>>();
93
+ for (const round of store.clerkRounds.all()) {
94
+ for (const a of round.entryAccounts ?? []) {
95
+ const m = out.get(a.path) ?? new Map<string, EntryAccount>();
96
+ for (const n of a.numbers) m.set(n, a);
97
+ out.set(a.path, m);
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+
103
+ export interface RequiredFile { readonly path: string; readonly repo: string; readonly layer: LayerKind; readonly entries: MissingEntry[]; readonly families?: readonly string[] }
104
+
105
+ const slash = (p: string): string => p.replace(/\\/g, '/').replace(/\/+$/, '');
106
+
107
+ /** The layer entry that covers a path: the file's own, else the longest directory above it. */
108
+ function layerOver(store: ProjectStore, repo: string, path: string): { readonly layer: LayerKind; readonly current: boolean } | null {
109
+ const hits = store.layers.filter((l) => (!l.repo || pathKey(l.repo) === pathKey(repo)) && (slash(l.path) === path || path.startsWith(`${slash(l.path)}/`)));
110
+ return hits.sort((x, y) => slash(y.path).length - slash(x.path).length)[0] ?? null;
111
+ }
112
+
113
+ /**
114
+ * CZ: the current documents that alone define a number family (see the module comment), each with the families it alone
115
+ * defines. One query over the ledger's current definitions; a document set aside is neither counted nor a definer.
116
+ */
117
+ export function soleDefiners(store: ProjectStore, ledger: Ledger): { repo: string; path: string; families: string[] }[] {
118
+ let rows: { repo: string | null; path: string | null; rule: string; num: string }[] = [];
119
+ try {
120
+ rows = ledger.db.prepare("SELECT DISTINCT repo, path, rule, num FROM nums WHERE kind = 'doc' AND place = 'definition' AND current = 1 AND position IN ('heading', 'table first column')").all() as typeof rows;
121
+ } catch { return []; }
122
+ const repos = new Map(ledger.repos().map((r) => [r.id, r.path]));
123
+ const aside = new Map<string, boolean>();
124
+ // The Keeper's own folder in the project, whether or not its authorization still stands.
125
+ const own = store.authorizations.all().flatMap((a) => (a.projectFolder ? [pathKey(a.projectFolder.path)] : []));
126
+ const keepers = (repo: string, path: string): boolean => { const abs = pathKey(join(repos.get(repo) ?? repo, ...path.split('/'))); return own.some((f) => abs === f || abs.startsWith(`${f}${sep}`)); };
127
+ const setAside = (repo: string, path: string): boolean => {
128
+ const k = `${repo}\u0000${path}`;
129
+ if (!aside.has(k)) aside.set(k, archiveRootOf(path) !== null || layerOver(store, repos.get(repo) ?? repo, path)?.current === false || keepers(repo, path));
130
+ return aside.get(k)!;
131
+ };
132
+ // family → document → the numbers of it the document defines at entry positions
133
+ const byFamily = new Map<string, Map<string, Set<string>>>();
134
+ for (const r of rows) {
135
+ if (!r.repo || !r.path || setAside(r.repo, r.path)) continue;
136
+ const docs = byFamily.get(r.rule) ?? new Map<string, Set<string>>();
137
+ const k = `${r.repo}\u0000${r.path}`;
138
+ docs.set(k, (docs.get(k) ?? new Set<string>()).add(r.num));
139
+ byFamily.set(r.rule, docs);
140
+ }
141
+ let named = new Set<string>();
142
+ try { named = new Set((ledger.db.prepare("SELECT DISTINCT repo, path FROM nums WHERE kind = 'file-name' AND place = 'definition' AND current = 1").all() as { repo: string | null; path: string | null }[]).map((r) => `${r.repo}\u0000${r.path}`)); } catch { named = new Set(); }
143
+ /** A document named by a number: that item's own document. */
144
+ const numberNamed = (k: string, path: string): boolean => named.has(k) && definitionsInPath(path.split('/').pop() ?? path).length > 0;
145
+ const out = new Map<string, { repo: string; path: string; families: string[] }>();
146
+ for (const [family, docs] of byFamily) {
147
+ const definers = [...docs].filter(([, nums]) => nums.size >= SOLE_DEFINER_MIN);
148
+ if (definers.length !== 1) continue;
149
+ const [k] = definers[0]!;
150
+ const [repo, path] = k.split('\u0000') as [string, string];
151
+ if (numberNamed(k, path)) continue;
152
+ const d = out.get(k) ?? { repo, path, families: [] };
153
+ d.families.push(family);
154
+ out.set(k, d);
155
+ }
156
+ return [...out.values()].sort((x, y) => x.path.localeCompare(y.path));
157
+ }
158
+
159
+ /** The entries each gated file defines now: `{ num, line, context }` per number, first definition only. */
160
+ export function requiredEntries(store: ProjectStore, ledger: Ledger): RequiredFile[] {
161
+ const out: RequiredFile[] = [];
162
+ const POSITIONS = new Set(['heading', 'bold entry', 'table first column']);
163
+ type Def = { readonly num: string; readonly rule: string; readonly position: string; readonly line: number; readonly context: string };
164
+ /** A file's own entries, read from the text the ledger's definitions were read from (the file on disk otherwise). */
165
+ const ownOf = (repoPath: string, path: string, fileDefs: readonly Def[]): Def[] => {
166
+ const abs = join(repoPath, ...path.split('/'));
167
+ const taken = ledger.currentText(abs);
168
+ return entryDefinitions(fileDefs.map((d) => ({ ...d, position: d.position as 'heading' })), taken !== null ? taken.split(/\r?\n/) : readLines(abs));
169
+ };
170
+ const firsts = (own: readonly Def[]): MissingEntry[] => {
171
+ const seen = new Map<string, MissingEntry>();
172
+ for (const d of own) if (!seen.has(d.num)) seen.set(d.num, { num: d.num, line: d.line, context: clip(d.context) });
173
+ return [...seen.values()].sort((x, y) => x.line - y.line);
174
+ };
175
+ const counted = new Set<string>();
176
+ const layers = store.layers.filter((l) => l.current && ENTRY_LAYERS.includes(l.layer));
177
+ for (const l of layers) {
178
+ if (!l.repo) continue;
179
+ const defs = ledger.definitionsIn(l.repo, l.path).filter((d) => POSITIONS.has(d.position));
180
+ const byFile = new Map<string, typeof defs>();
181
+ for (const d of defs) byFile.set(d.path, [...(byFile.get(d.path) ?? []), d]);
182
+ for (const [path, fileDefs] of byFile) {
183
+ counted.add(`${pathKey(l.repo)}\u0000${path}`);
184
+ const entries = firsts(ownOf(l.repo, path, fileDefs));
185
+ if (entries.length) out.push({ path, repo: l.repo, layer: l.layer, entries });
186
+ }
187
+ }
188
+ // CZ: a current document that alone defines a number family, whatever its layer: the numbers of that family.
189
+ const repos = new Map(ledger.repos().map((r) => [r.id, r.path]));
190
+ for (const d of soleDefiners(store, ledger)) {
191
+ const repoPath = repos.get(d.repo);
192
+ if (!repoPath || counted.has(`${pathKey(repoPath)}\u0000${d.path}`)) continue;
193
+ const own = ownOf(repoPath, d.path, ledger.definitionsIn(d.repo, d.path).filter((x) => x.path === d.path && POSITIONS.has(x.position)));
194
+ // What stands under a numbered heading of another family is a point of that entry, so a family may fall short here.
195
+ const families = d.families.filter((f) => new Set(own.filter((x) => x.rule === f && x.position !== 'bold entry').map((x) => x.num)).size >= SOLE_DEFINER_MIN);
196
+ if (!families.length) continue;
197
+ out.push({ path: d.path, repo: repoPath, layer: layerOver(store, repoPath, d.path)?.layer ?? 'Other', entries: firsts(own.filter((x) => families.includes(x.rule))), families });
198
+ }
199
+ return out;
200
+ }
201
+
202
+ /** What the gate holds against now (see the module comment). */
203
+ export function entryGaps(store: ProjectStore, ledger: Ledger | null): EntryGaps {
204
+ if (!ledger) return { count: 0, files: [] };
205
+ const carried = carriedNumbers(store);
206
+ const accounts = accountedEntries(store);
207
+ const files: EntryFile[] = requiredEntries(store, ledger).map((f) => {
208
+ const acc = accounts.get(f.path);
209
+ const isCarried = (n: string) => carried.has(n.toUpperCase());
210
+ const missing = f.entries.filter((e) => !isCarried(e.num) && !acc?.has(e.num));
211
+ return {
212
+ path: f.path, layer: f.layer, ...(f.families ? { families: f.families } : {}), defined: f.entries.length,
213
+ carried: f.entries.filter((e) => isCarried(e.num)).length,
214
+ accounted: f.entries.filter((e) => !isCarried(e.num) && acc?.has(e.num)).length,
215
+ missing,
216
+ };
217
+ });
218
+ return { count: files.reduce((n, f) => n + f.missing.length, 0), files };
219
+ }
220
+
221
+ /** `entryGaps` with the project's ledger opened and closed here. */
222
+ export function entryGapsNow(store: ProjectStore): EntryGaps {
223
+ const ledger = Ledger.openDir(store.dir);
224
+ try { return entryGaps(store, ledger); } finally { ledger?.close(); }
225
+ }
226
+
227
+ /** The refusal `pk_stage` gives while entries are missing, or null. */
228
+ export function entryGateRefusal(gaps: EntryGaps, leaving: string): string | null {
229
+ if (gaps.count === 0) return null;
230
+ const shown = gaps.files.filter((f) => f.missing.length).map((f) => `${f.path} (${f.families ? `the only current document defining ${f.families.join(', ')}` : f.layer}): ${f.missing.length} of ${f.defined} — ${f.missing.slice(0, 12).map((m) => m.num).join(', ')}${f.missing.length > 12 ? `, and ${f.missing.length - 12} more` : ''}`);
231
+ return `Every number a current decision record, plan, task index or execution arrangement defines at an entry position — and every number of a family that one current document alone defines — is on the workbench before you ${leaving}: carried by an item — in its ids, or at the start of its name, as the document names it — or accounted for by its number, with why (pk_account_entries). ${gaps.count} number${gaps.count === 1 ? ' is' : 's are'} neither:\n${shown.join('\n')}\npk_round_state lists them all (open.entries). Fill each log whole (pk_fill_from_headings, pk_fill_from_bold, pk_fill_from_table), give a lane with its slot the rest, or account for a number that is not an item, by number. Nothing changed.`;
232
+ }
233
+
234
+ // ───────────────────────── the account, by number ─────────────────────────
235
+
236
+ const text = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
237
+ const ok = (value: unknown) => ({ content: [{ type: 'text' as const, text: JSON.stringify(value, null, 1) }], details: {} });
238
+ const fail = (message: string) => ({ content: [{ type: 'text' as const, text: `ERROR: ${message}` }], details: {}, isError: true });
239
+ const stagesOf = (tool: string): string[] => Object.entries(STAGE_WRITERS).filter(([, tools]) => tools.includes(tool)).map(([s]) => s);
240
+
241
+ export function entryGateTools(ctx: ToolContext): ToolDefinition[] {
242
+ const { store } = ctx;
243
+ return [defineTool({
244
+ name: 'pk_account_entries', label: 'Account for entries by number',
245
+ description: 'Account for numbers a current decision record, plan, task index or execution arrangement defines — or a current document that alone defines their family — that are not items of the workbench and should not be: each by its number, with why — a point of a table that is not an item of its own, a heading that only groups others. The numbers a document defines at an entry position must each be carried by an item (its ids, or the start of its name) or accounted for here before the round leaves reconcile (First usable) or enters the cross-check (Deepen); pk_round_state lists the ones still open (open.entries). An account in bulk is not one: numbers names every number, as open.entries lists it, and a range or a family is refused. What should be an item, fill instead.',
246
+ parameters: Type.Object({
247
+ path: Type.String({ description: 'the document, as open.entries names it (repository-relative)' }),
248
+ numbers: Type.Array(Type.String(), { description: 'every number accounted for, one by one, as the document writes it' }),
249
+ why: Type.String({ description: 'why these are not items of the workbench' }),
250
+ }),
251
+ execute: async (_id, raw) => {
252
+ const p = raw as Record<string, unknown>;
253
+ const step = ctx.step;
254
+ if (!step || step.kind !== 'main') return fail('pk_account_entries is the main agent’s account of its round’s entries; this job is not a round’s main agent, so nothing was recorded.');
255
+ const round = store.clerkRounds.get(step.roundId);
256
+ if (!round) return fail(`The round ${step.roundId} is not in the assets.`);
257
+ const allowed = stagesOf('pk_account_entries');
258
+ if (round.stage && !allowed.includes(round.stage)) return fail(`pk_account_entries is written in the ${allowed.join(', ')} stage; this round is in ${round.stage}, so nothing was recorded.`);
259
+ const path = text(p.path).replace(/\\/g, '/');
260
+ const why = text(p.why);
261
+ if (!why) return fail('why is empty: say why these numbers are not items of the workbench.');
262
+ const numbers = [...new Set((Array.isArray(p.numbers) ? p.numbers : []).map(text).filter(Boolean))];
263
+ if (!numbers.length) return fail('numbers is empty: name every number you account for, one by one.');
264
+ const bulk = numbers.filter((n) => /[~~–—]|\.\.|…|\*|<n>|\ball\b|全部|其余/i.test(n) || /\s/.test(n));
265
+ if (bulk.length) return fail(`An account names each number (an account in bulk is not one); ${bulk.map((b) => `“${b}”`).join(', ')} ${bulk.length === 1 ? 'is' : 'are'} not one number. Write them out one by one. Nothing was recorded.`);
266
+ const ledger = Ledger.openDir(store.dir);
267
+ try {
268
+ if (!ledger) return fail('This project has no ledger yet, so there are no counted entries to account for.');
269
+ const gaps = entryGaps(store, ledger);
270
+ const file = gaps.files.find((f) => f.path === path) ?? gaps.files.find((f) => f.path.toLowerCase() === path.toLowerCase());
271
+ if (!file) return fail(`${path || '(empty)'} is not a file whose entries are counted: ${gaps.files.map((f) => f.path).join(', ') || 'none — the layer map marks no current decision record, plan, task index or execution arrangement, and no current document alone defines a number family'}.`);
272
+ const missing = new Set(file.missing.map((m) => m.num));
273
+ const defined = new Set(requiredEntries(store, ledger).find((f) => f.path === file.path)?.entries.map((e) => e.num) ?? []);
274
+ const chosen = numbers.filter((n) => missing.has(n));
275
+ const already = numbers.filter((n) => !missing.has(n) && defined.has(n));
276
+ const unknown = numbers.filter((n) => !defined.has(n));
277
+ if (!chosen.length) return fail(`None of these is an open entry of ${file.path}${unknown.length ? `; not defined there as an entry: ${unknown.join(', ')}` : ''}${already.length ? `; already carried or accounted for: ${already.join(', ')}` : ''}. Nothing was recorded.`);
278
+ const account: EntryAccount = { path: file.path, numbers: chosen, why, at: new Date().toISOString() };
279
+ const current = store.clerkRounds.get(round.id) ?? round;
280
+ const next: ClerkRound = { ...current, entryAccounts: [...(current.entryAccounts ?? []), account], updatedAt: account.at };
281
+ store.clerkRounds.put(next, { jobId: ctx.jobId, basisSourceIds: [], summary: `Entries accounted for by number in ${file.path}: ${chosen.join(', ')} — ${why}` });
282
+ const left = entryGaps(store, ledger);
283
+ return ok({ accounted: chosen.length, path: file.path, left: left.count, ...(unknown.length ? { notEntries: unknown } : {}), ...(already.length ? { alreadySettled: already } : {}) });
284
+ } finally {
285
+ ledger?.close();
286
+ }
287
+ },
288
+ })];
289
+ }