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,64 @@
1
+ /**
2
+ * `pk_owner_utterances`: the owner's own words out of the project's sessions, for the framing round's "原话整理"
3
+ * (Spec §3.3, §1.3, §3.7; CKC-05 AC-13). The rule of what counts is in sources/sessions/utterances.ts; this module pages
4
+ * it for the model and reports how much there is (D37: how long this step takes grows with it).
5
+ *
6
+ * Interface (agreed with the framing round's prompts):
7
+ * pk_owner_utterances({ cursor?, limit?, since?, sessionSourceId? })
8
+ * → { utterances: [{ sourceId, host, at, text, chars, pasted?, headless?, truncated? }], next, total, totalChars, notYetRead? }
9
+ */
10
+ import { Type } from 'typebox';
11
+ import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
12
+ import { ownerUtterances } from '../sources/sessions/utterances.ts';
13
+ import type { ToolContext } from './tools.ts';
14
+
15
+ /** One answer stays well inside what a tool result carries (120,000 characters); the cursor fetches the rest. */
16
+ const PAGE_CHARS = 80_000;
17
+
18
+ function ok(value: unknown) {
19
+ return { content: [{ type: 'text' as const, text: JSON.stringify(value, null, 1) }], details: {} };
20
+ }
21
+ function fail(message: string) {
22
+ return { content: [{ type: 'text' as const, text: `ERROR: ${message}` }], details: {}, isError: true };
23
+ }
24
+
25
+ export function ownerUtteranceTools(ctx: ToolContext): ToolDefinition[] {
26
+ return [defineTool({
27
+ name: 'pk_owner_utterances', label: 'The owner’s own words',
28
+ description: 'What the owner typed in this project’s sessions (Claude Code, Codex) and said in the Keeper conversation, oldest first — the input for the Owner’s words layer. Only the owner’s own messages: never tool results, blocks the host or system injected, command wrappers, compaction summaries, subagent branches, or the instruction files and environment Codex injects. Each message comes whole, as typed: sourceId (the session source to cite, whose excerpt holds these words), host, at, text, chars. pasted marks text inside the message that is recognisably pasted from elsewhere (a report, a log, code): character ranges of text with what makes them recognisable — the message is not cut; judge for yourself what the owner meant. headless: true marks a non-interactive run (codex exec, an SDK run), whose prompt is usually a program’s or an agent’s, not the owner’s. total and totalChars count everything that matches, not just this page; next is the cursor for the following page (null at the end). since: an ISO time or a date; sessionSourceId: any source of a session (or its session id) for that whole session.',
29
+ parameters: Type.Object({
30
+ cursor: Type.Optional(Type.String({ description: 'the next value of the previous answer' })),
31
+ limit: Type.Optional(Type.Number({ description: 'most utterances per page (default 50, at most 500); a page also stops before it gets too long to answer' })),
32
+ since: Type.Optional(Type.String({ description: 'only what was said at or after this ISO time or date' })),
33
+ sessionSourceId: Type.Optional(Type.String({ description: 'one session: any of its source ids, or its session id' })),
34
+ }),
35
+ execute: async (_id, p) => {
36
+ const found = ownerUtterances(ctx.store, { since: typeof p.since === 'string' ? p.since : null, sessionSourceId: typeof p.sessionSourceId === 'string' ? p.sessionSourceId : null });
37
+ if (typeof found === 'string') return fail(found);
38
+ const all = found.utterances;
39
+ const cursor = typeof p.cursor === 'string' && p.cursor.trim() ? Number(p.cursor) : 0;
40
+ if (!Number.isInteger(cursor) || cursor < 0 || cursor > all.length) return fail(`cursor “${p.cursor}” is not one this tool gave; start without a cursor.`);
41
+ const limit = Math.max(1, Math.min(500, Math.floor(Number(p.limit) || 50)));
42
+ const page: Record<string, unknown>[] = [];
43
+ let room = PAGE_CHARS;
44
+ let i = cursor;
45
+ for (; i < all.length && page.length < limit; i++) {
46
+ const u = all[i]!;
47
+ const size = u.text.length + 300;
48
+ if (page.length > 0 && size > room) break;
49
+ // A single message longer than a page is given as far as a page goes; its source holds all of it.
50
+ const truncated = u.text.length > PAGE_CHARS;
51
+ page.push({
52
+ sourceId: u.sourceId, host: u.host, at: u.at, text: truncated ? u.text.slice(0, PAGE_CHARS) : u.text, chars: u.chars,
53
+ ...(u.pasted.length ? { pasted: u.pasted } : {}), ...(u.headless ? { headless: true } : {}), ...(truncated ? { truncated: true } : {}),
54
+ });
55
+ room -= size;
56
+ }
57
+ const totalChars = all.reduce((n, u) => n + u.chars, 0);
58
+ return ok({
59
+ utterances: page, next: i < all.length ? String(i) : null, total: all.length, totalChars,
60
+ ...(found.notYetRead ? { notYetRead: found.notYetRead, note: `${found.notYetRead} owner message(s) in the session logs are in a part not read into the assets yet; they can be cited once it is (the next intake reads them).` } : {}),
61
+ });
62
+ },
63
+ })];
64
+ }
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Words recorded as someone's own are the words of the sources they cite (Spec §1.3, §1.15; E80, E86): an owner
3
+ * quote on any product-reference item, an owner's confirmation of a rule, and the project's own wording (`excerpt`)
4
+ * of an Explicit rule. Each item has to open to what was actually said or written, and an agent that searches for
5
+ * those words has to find them.
6
+ *
7
+ * How a quote is checked:
8
+ * - It is cut into parts at every ellipsis (…, ……, ...): what the quote leaves out is marked there. Every part has to
9
+ * stand in the text of the sources cited — several sources cited, any of them — in the same order as the quote.
10
+ * Sections of one file that follow each other are one text, so a part may run across a place where ProjectKeeper cut
11
+ * a long section.
12
+ * - Both sides are read the same way first. Case, spacing and line breaks, punctuation and quotation marks (Chinese or
13
+ * English), and the marks of Markdown — emphasis, code, headings, quotes, table bars, list bullets and numbers, link
14
+ * targets, escapes — make no difference; a doubled backslash counts as one (a JSON escape that leaked into the text).
15
+ * The slash, the backslash and %, & and @ stay: in paths, values and names they are part of the words.
16
+ * - A word added, changed, or left out without an ellipsis makes a part that is not there.
17
+ *
18
+ * A part that is not there comes back with what helps to put it right: the section of the same document that holds
19
+ * it, or where it parts from the source. That search looks only in the other sections of the files and sessions cited,
20
+ * and only when a part is missing; a session of 200 segments (2.4 million characters) takes about 0.2 s, a part that is
21
+ * there about a millisecond.
22
+ *
23
+ * The owner's messages are whole in their session sources (read.ts shows the owner's words in full and never cuts a
24
+ * message across segments; the Keeper conversation keeps each message whole), so the sources are what the owner's words
25
+ * are checked against — the same text pk_owner_utterances gives.
26
+ */
27
+ import type { Source } from '../model/types.ts';
28
+ import type { ProjectStore } from '../store/project-store.ts';
29
+ import { anchorLabel } from '../sources/anchor.ts';
30
+ import { pathKey } from '../util/paths.ts';
31
+
32
+ /** Where a quote leaves words out. */
33
+ const ELLIPSIS = /(?:…|⋯|\.{3,})+/u;
34
+
35
+ /** What makes no difference between a quote and its source (see above), less what is part of the words. */
36
+ const NO_DIFFERENCE = /[\s\p{Cc}\p{Cf}\p{P}`~|>+⋯]/u;
37
+ const PART_OF_WORDS: ReadonlySet<string> = new Set(['/', '\\', '%', '&', '@']);
38
+ const noDifference = (c: string): boolean => NO_DIFFERENCE.test(c) && !PART_OF_WORDS.has(c);
39
+
40
+ /**
41
+ * Every text is read two ways, and a quote matches when it matches either: as written, and as it reads once rendered.
42
+ * As it reads leaves out what opens a list item at the start of a line — a number (1. 1) 1、 (1) (一) 一、) or a task
43
+ * box, with any quote mark or bullet before it — and where a link points, so a quote that joins list items, or gives a
44
+ * link as the words it shows, matches the text as it reads; one that keeps the numbers or the link matches it as
45
+ * written. (Bullets and quote marks make no difference either way.)
46
+ */
47
+ const LIST_MARKER = /^[ \t]*(?:>[ \t]*)*(?:[-*+•·][ \t]+)?(?:\d{1,3}(?:[.)][ \t]+|、[ \t]*)|[((][\d一二三四五六七八九十]{1,3}[))][ \t]*|[一二三四五六七八九十]{1,3}、[ \t]*)?(?:\[[ xX]\][ \t]+)?/gmu;
48
+ const LINK_TARGET = /\]\([^)\n]*\)/gu;
49
+
50
+ interface Folded {
51
+ /** What is compared: the words, without what makes no difference, in lower case. */
52
+ readonly text: string;
53
+ /** Where each character of `text` stands in `original`. */
54
+ readonly at: readonly number[];
55
+ /** The text as read (in composed form), which `at` points into. */
56
+ readonly original: string;
57
+ }
58
+
59
+ /** Full-width forms, ligatures and the like as their plain letters, in lower case. */
60
+ function plainForm(cp: string): string {
61
+ const code = cp.codePointAt(0)!;
62
+ if (code < 0x80) return code >= 0x41 && code <= 0x5a ? String.fromCharCode(code + 0x20) : cp;
63
+ if (code >= 0x4e00 && code <= 0x9fff) return cp;
64
+ return cp.normalize('NFKC').toLowerCase();
65
+ }
66
+
67
+ function fold(raw: string, asRead: boolean): Folded {
68
+ const original = raw.normalize('NFC');
69
+ // What reading the text as it reads leaves out, as [start, end) spans in order.
70
+ const skipped: [number, number][] = [];
71
+ if (asRead) {
72
+ for (const re of [LIST_MARKER, LINK_TARGET]) for (const m of original.matchAll(re)) if (m[0].length > 0) skipped.push([m.index!, m.index! + m[0].length]);
73
+ skipped.sort((a, b) => a[0] - b[0]);
74
+ }
75
+ const chars: string[] = [];
76
+ const from: number[] = [];
77
+ let next = 0;
78
+ let i = 0;
79
+ for (const cp of original) {
80
+ while (next < skipped.length && skipped[next]![1] <= i) next++;
81
+ if (!(next < skipped.length && skipped[next]![0] <= i)) for (const c of plainForm(cp)) { chars.push(c); from.push(i); }
82
+ i += cp.length;
83
+ }
84
+ const text: string[] = [];
85
+ const at: number[] = [];
86
+ for (let k = 0; k < chars.length; k++) {
87
+ const c = chars[k]!;
88
+ if (c === '\\') {
89
+ // A run of backslashes counts as one (a doubled one is a JSON escape). Before a mark that makes no difference
90
+ // (\* \_ \| \.), a space, a line break or the end, it is a Markdown escape or line break and is left out too.
91
+ const first = k;
92
+ while (chars[k + 1] === '\\') k++;
93
+ const after = chars[k + 1];
94
+ if (after === undefined || noDifference(after)) continue;
95
+ text.push('\\');
96
+ at.push(from[first]!);
97
+ continue;
98
+ }
99
+ if (noDifference(c)) continue;
100
+ text.push(c);
101
+ at.push(from[k]!);
102
+ }
103
+ return { text: text.join(''), at, original };
104
+ }
105
+
106
+ /** Whether two quotes are the same words, read the way the check reads them. */
107
+ export function sameWords(a: string | null | undefined, b: string | null | undefined): boolean {
108
+ return fold(a ?? '', false).text === fold(b ?? '', false).text;
109
+ }
110
+
111
+ /** The parts of a quote: what stands between its ellipses. A part that is only punctuation or spacing is no part. */
112
+ export function quoteParts(quote: string): string[] {
113
+ return quote.split(ELLIPSIS).map((p) => p.trim()).filter((p) => fold(p, false).text.length > 0);
114
+ }
115
+
116
+ /** A text to look in: one cited source, or cited sections of one file that follow each other. */
117
+ interface Text {
118
+ readonly ids: readonly string[];
119
+ readonly label: string;
120
+ readonly raw: string;
121
+ asWritten?: Folded;
122
+ asRead?: Folded;
123
+ }
124
+ const read = (t: Text, asRead: boolean): Folded =>
125
+ asRead ? (t.asRead ??= fold(t.raw, true)) : (t.asWritten ??= fold(t.raw, false));
126
+
127
+ interface Hit { readonly text: Text; readonly asRead: boolean; readonly index: number; readonly length: number }
128
+ interface Occurrence { readonly textIndex: number; readonly originalStart: number; readonly originalEnd: number }
129
+
130
+ /** Where these words stand in the texts, as written or as they read, or null. */
131
+ function locate(words: string, texts: readonly Text[]): Hit | null {
132
+ for (const asRead of [false, true]) {
133
+ const needle = fold(words, asRead).text;
134
+ if (!needle) continue;
135
+ for (const t of texts) {
136
+ const index = read(t, asRead).text.indexOf(needle);
137
+ if (index >= 0) return { text: t, asRead, index, length: needle.length };
138
+ }
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /**
144
+ * Every place these words occur, in original-text offsets. A source-id list is a set of citations, not an ordering of
145
+ * different documents, so ordering is enforced within each text. Sections of one file that follow each other have
146
+ * already been joined into one text by `textsOf`.
147
+ */
148
+ function occurrences(words: string, texts: readonly Text[]): Occurrence[] {
149
+ const found = new Map<string, Occurrence>();
150
+ for (const asRead of [false, true]) {
151
+ const needle = fold(words, asRead).text;
152
+ if (!needle) continue;
153
+ for (let textIndex = 0; textIndex < texts.length; textIndex++) {
154
+ const t = texts[textIndex]!;
155
+ const haystack = read(t, asRead);
156
+ for (let from = 0;;) {
157
+ const index = haystack.text.indexOf(needle, from);
158
+ if (index < 0) break;
159
+ const originalStart = haystack.at[index]!;
160
+ const originalEnd = haystack.at[index + needle.length - 1]! + 1;
161
+ found.set(`${textIndex}:${originalStart}:${originalEnd}`, { textIndex, originalStart, originalEnd });
162
+ from = index + 1;
163
+ }
164
+ }
165
+ }
166
+ return [...found.values()].sort((a, b) => a.textIndex - b.textIndex || a.originalStart - b.originalStart || a.originalEnd - b.originalEnd);
167
+ }
168
+
169
+ /**
170
+ * Advance every possible in-order placement of the quote so far. Only the earliest usable occurrence in one text is
171
+ * needed for a state: a later one leaves less room. Dominated states are removed for the same reason, keeping repeated
172
+ * phrases in long messages from multiplying the search.
173
+ */
174
+ function advanceOrder(words: string, texts: readonly Text[], states: readonly (readonly number[])[]): number[][] {
175
+ const hits = occurrences(words, texts);
176
+ const next = new Map<string, number[]>();
177
+ for (const state of states) {
178
+ for (let textIndex = 0; textIndex < texts.length; textIndex++) {
179
+ const hit = hits.find((h) => h.textIndex === textIndex && h.originalStart >= (state[textIndex] ?? 0));
180
+ if (!hit) continue;
181
+ const advanced = [...state];
182
+ advanced[textIndex] = hit.originalEnd;
183
+ next.set(advanced.join(','), advanced);
184
+ }
185
+ }
186
+ const values = [...next.values()];
187
+ return values.filter((candidate, i) => !values.some((other, j) => j !== i
188
+ && other.every((cursor, k) => cursor <= candidate[k]!)
189
+ && other.some((cursor, k) => cursor < candidate[k]!)));
190
+ }
191
+
192
+ const lineRange = (s: Source): [number, number] => (s.anchor.kind === 'file' ? [s.anchor.lineStart, s.anchor.lineEnd] : [0, 0]);
193
+
194
+ /** The cited sources as texts: sections of one file that follow each other are joined into one. */
195
+ function textsOf(sources: readonly Source[]): Text[] {
196
+ const out: Text[] = [];
197
+ const files = new Map<string, Source[]>();
198
+ for (const s of sources) {
199
+ if (s.anchor.kind !== 'file') { out.push({ ids: [s.id], label: anchorLabel(s.anchor), raw: s.excerpt }); continue; }
200
+ const key = pathKey(s.anchor.path);
201
+ files.set(key, [...(files.get(key) ?? []), s]);
202
+ }
203
+ for (const sections of files.values()) {
204
+ let run: Source[] = [];
205
+ const flush = () => {
206
+ if (run.length) out.push({ ids: run.map((s) => s.id), label: run.map((s) => anchorLabel(s.anchor)).join(' + '), raw: run.map((s) => s.excerpt).join('\n') });
207
+ run = [];
208
+ };
209
+ for (const s of [...sections].sort((a, b) => lineRange(a)[0] - lineRange(b)[0])) {
210
+ const last = run[run.length - 1];
211
+ if (last && lineRange(s)[0] > lineRange(last)[1] + 1) flush();
212
+ run.push(s);
213
+ }
214
+ flush();
215
+ }
216
+ return out;
217
+ }
218
+
219
+ /** The document a source is part of — a file, a session — for finding the section a quote was really taken from. */
220
+ function documentOf(s: Source): string | null {
221
+ if (s.anchor.kind === 'file') return `file:${pathKey(s.anchor.path)}`;
222
+ if (s.anchor.kind === 'session') return `session:${s.anchor.host}:${s.anchor.sessionId}`;
223
+ return null;
224
+ }
225
+
226
+ /** The other sources of the documents cited: another section of the same file, another segment of the same session. */
227
+ function otherSectionsOf(store: ProjectStore, cited: readonly Source[]): Text[] {
228
+ const documents = new Set(cited.map(documentOf).filter((d): d is string => d !== null));
229
+ const ids = new Set(cited.map((s) => s.id));
230
+ if (documents.size === 0) return [];
231
+ return store.sources.filter((s) => !ids.has(s.id) && documents.has(documentOf(s) ?? ''))
232
+ .map((s) => ({ ids: [s.id], label: anchorLabel(s.anchor), raw: s.excerpt }));
233
+ }
234
+
235
+ /** Text shown in a refusal: on one line, and not too long. */
236
+ function snippet(s: string, max: number, fromEnd = false): string {
237
+ const cps = [...s.replace(/\s+/g, ' ').trim()];
238
+ if (cps.length <= max) return cps.join('');
239
+ return fromEnd ? `…${cps.slice(cps.length - max).join('')}` : `${cps.slice(0, max).join('')}…`;
240
+ }
241
+
242
+ /**
243
+ * Where a part that is not in the texts parts from them: how far its start matches and what the source has there, or —
244
+ * when only its end matches — what the source has before that end.
245
+ */
246
+ function whereItParts(part: string, texts: readonly Text[]): string {
247
+ const cps = [...part];
248
+ const size = fold(part, false).text.length;
249
+ // A match is only shown when it is a good share of the part: a few common letters say nothing about where it was taken from.
250
+ const enough = (hit: Hit | null): hit is Hit => hit !== null && hit.length >= Math.max(4, Math.ceil(size * 0.4));
251
+ // The longest start of the part that is in the texts.
252
+ let lo = 0;
253
+ let hi = cps.length;
254
+ while (lo < hi) {
255
+ const mid = Math.ceil((lo + hi) / 2);
256
+ if (locate(cps.slice(0, mid).join(''), texts)) lo = mid; else hi = mid - 1;
257
+ }
258
+ const head = lo > 0 ? locate(cps.slice(0, lo).join(''), texts) : null;
259
+ if (enough(head)) {
260
+ const f = read(head.text, head.asRead);
261
+ const end = head.index + head.length;
262
+ const sourceGoesOn = end < f.at.length ? snippet(f.original.slice(f.at[end]!), 40) : '';
263
+ const quoteGoesOn = snippet(cps.slice(lo).join(''), 40);
264
+ return sourceGoesOn
265
+ ? `it follows ${head.text.ids.join(' + ')} as far as «${snippet(cps.slice(0, lo).join(''), 30, true)}»; there the source goes on «${sourceGoesOn}», the quote «${quoteGoesOn}»`
266
+ : `it follows ${head.text.ids.join(' + ')} as far as «${snippet(cps.slice(0, lo).join(''), 30, true)}», where the source ends; the quote goes on «${quoteGoesOn}»`;
267
+ }
268
+ // The longest end of the part that is in the texts.
269
+ lo = 0;
270
+ hi = cps.length;
271
+ while (lo < hi) {
272
+ const mid = Math.floor((lo + hi) / 2);
273
+ if (locate(cps.slice(mid).join(''), texts)) hi = mid; else lo = mid + 1;
274
+ }
275
+ const tail = lo < cps.length ? locate(cps.slice(lo).join(''), texts) : null;
276
+ if (enough(tail)) {
277
+ const f = read(tail.text, tail.asRead);
278
+ const start = f.at[tail.index]!;
279
+ const sourceBefore = snippet(f.original.slice(0, start), 40, true);
280
+ return `only its end «${snippet(cps.slice(lo).join(''), 30)}» follows ${tail.text.ids.join(' + ')}; before it the source has «${sourceBefore}», the quote «${snippet(cps.slice(0, lo).join(''), 40, true)}»`;
281
+ }
282
+ return 'these words are not in the sources cited';
283
+ }
284
+
285
+ /**
286
+ * Where these words stand in one text, read the way a quote is checked (case, spacing, punctuation, quotation marks and
287
+ * Markdown marks make no difference), or null. It gives the words as the text itself has them — so what is stored as a
288
+ * cited line is the source's, never the caller's rendering — and where they start in the text as read (`raw` in NFC).
289
+ * One stretch of words: an ellipsis is not read as leaving words out here.
290
+ */
291
+ export function findWords(words: string, raw: string): { readonly text: string; readonly start: number } | null {
292
+ const hit = locate(words, [{ ids: [], label: '', raw }]);
293
+ if (!hit) return null;
294
+ const f = read(hit.text, hit.asRead);
295
+ const start = f.at[hit.index]!;
296
+ // `at` holds where each compared character's code point starts: the words end after the whole last code point.
297
+ const last = f.at[hit.index + hit.length - 1]!;
298
+ const end = last + String.fromCodePoint(f.original.codePointAt(last)!).length;
299
+ return { text: f.original.slice(start, end), start };
300
+ }
301
+
302
+ export interface MissingPart {
303
+ /** The part as the quote has it: absent from the cited sources, or present only before an earlier quote part. */
304
+ readonly part: string;
305
+ /** What helps to put it right: the section that holds it, or where it parts from the source. */
306
+ readonly hint: string;
307
+ }
308
+ export interface VerbatimCheck {
309
+ readonly parts: number;
310
+ readonly missing: readonly MissingPart[];
311
+ }
312
+
313
+ /** Which parts of a quote are absent from the sources cited or cannot follow the preceding parts there. */
314
+ export function checkVerbatim(store: ProjectStore, quote: string, sourceIds: readonly string[]): VerbatimCheck {
315
+ const cited = [...new Set(sourceIds)].map((id) => store.sources.get(id)).filter((s): s is Source => s !== undefined);
316
+ const parts = quoteParts(quote);
317
+ if (parts.length === 0) return { parts: 0, missing: [{ part: quote.trim(), hint: 'it holds no words' }] };
318
+ const texts = textsOf(cited);
319
+ const missing: MissingPart[] = [];
320
+ let others: Text[] | null = null;
321
+ let states: number[][] = [Array.from({ length: texts.length }, () => 0)];
322
+ for (const part of parts) {
323
+ const ordered = advanceOrder(part, texts, states);
324
+ if (ordered.length) { states = ordered; continue; }
325
+ const outOfOrder = locate(part, texts);
326
+ if (outOfOrder) {
327
+ missing.push({ part, hint: 'these words are in the sources cited, but only before the previous quoted part; keep the ellipsis-separated parts in the order the sources say them' });
328
+ continue;
329
+ }
330
+ others ??= otherSectionsOf(store, cited);
331
+ const elsewhere = locate(part, others);
332
+ missing.push({
333
+ part,
334
+ hint: elsewhere ? `these words are in ${elsewhere.text.ids[0]} (${elsewhere.text.label}), which is not cited: cite that one` : whereItParts(part, texts),
335
+ });
336
+ }
337
+ return { parts: parts.length, missing };
338
+ }
339
+
340
+ // ───────────────────────── what the tools say ─────────────────────────
341
+
342
+ const HOW_CHECKED = 'The quote is read in parts — an ellipsis (…… or ...) separates them — and every part must stand in the sources cited in the same order; case, spacing, punctuation, quotation marks and Markdown marks make no difference.';
343
+
344
+ const notThere = (check: VerbatimCheck): string =>
345
+ (check.parts <= 1 ? 'It is not there:' : `${check.missing.length} of its ${check.parts} parts ${check.missing.length === 1 ? 'does' : 'do'} not stand there in that order:`);
346
+
347
+ /** A part as a refusal names it: whole unless it is very long. */
348
+ function named(part: string): string {
349
+ const cps = [...part.replace(/\s+/g, ' ')];
350
+ return cps.length <= 160 ? cps.join('') : `${cps.slice(0, 100).join('')} … ${cps.slice(-40).join('')}`;
351
+ }
352
+ const listed = (check: VerbatimCheck): string => check.missing.map((m) => `- “${named(m.part)}” — ${m.hint}`).join('\n');
353
+
354
+ /** Why an Owner's words item is refused, and how to put it right (Spec §1.3). */
355
+ export function ownerWordsRefusal(check: VerbatimCheck): string {
356
+ return [
357
+ `The quote is not the owner’s words as the sources cited hold them, so nothing was written (an Owner's words item opens to what the owner actually said). ${HOW_CHECKED} ${notThere(check)}`,
358
+ listed(check),
359
+ `Copy the owner’s words as they are, adding and changing nothing — pk_owner_utterances gives each message whole — mark each place you leave words out with ……, and cite the message or the decision record each part was said in. What the owner did not say in these words is not an Owner's words item: record your reading as the product description it is, refining the owner’s words it restates.`,
360
+ ].join('\n');
361
+ }
362
+
363
+ /** Why an owner quote carried on another product-reference category is refused (Spec §1.3). */
364
+ export function referenceQuoteRefusal(check: VerbatimCheck): string {
365
+ return [
366
+ `The quote is not the owner’s words as the sources cited hold them, so the reference item was not written. ${HOW_CHECKED} ${notThere(check)}`,
367
+ listed(check),
368
+ 'Copy the owner’s words as they are, adding and changing nothing, mark each place you leave words out with ……, and cite the message or decision record each part was said in. Put an agent’s reading in the item text, not in quote.',
369
+ ].join('\n');
370
+ }
371
+
372
+ /** Why a rule's owner confirmation is refused (Spec §1.15, §3.9). */
373
+ export function ownerConfirmationRefusal(check: VerbatimCheck): string {
374
+ return [
375
+ `The confirmation quote is not the owner’s words as the current conversation message holds them, so the rule was not confirmed. ${HOW_CHECKED} ${notThere(check)}`,
376
+ listed(check),
377
+ 'Copy the confirming words from the current owner message as they are, adding and changing nothing, with …… at each omission.',
378
+ ].join('\n');
379
+ }
380
+
381
+ /** Why an Explicit rule is refused, and how to put it right (Spec §1.15, §3.9). */
382
+ export function ruleExcerptRefusal(check: VerbatimCheck): string {
383
+ return [
384
+ `The excerpt is not the project’s own words as the sources cited hold them, so the rule was not written (an Explicit rule gives the project’s own text). ${HOW_CHECKED.replace('The quote', 'The excerpt')} ${notThere(check)}`,
385
+ listed(check),
386
+ `Copy the project’s words as they are, adding and changing nothing, mark each place you leave words out with ……, and cite the section each part is written in. If the project never wrote this rule in its own words, the rule is basis Inferred: write it without excerpt, with your reading in summary and the records you drew it from as its sources — the rules you infer are put to the owner, in one note per round.`,
387
+ ].join('\n');
388
+ }
389
+
390
+ /**
391
+ * What a rule not resting on the project's own wording — inferred from practice, or confirmed by the owner — is told
392
+ * when its excerpt is not the project's words: it is written, as before.
393
+ */
394
+ export function inferredExcerptWarning(check: VerbatimCheck): string {
395
+ const parts = check.missing.map((m) => `“${named(m.part)}” (${m.hint})`).join('; ');
396
+ return `The excerpt is shown as the project’s own words, but ${check.parts <= 1 ? 'it is' : `${check.missing.length} of its ${check.parts} parts ${check.missing.length === 1 ? 'is' : 'are'}`} not in the sources cited: ${parts}. Copy the project’s words as they are, with …… where you leave words out, or leave excerpt out: a rule inferred from practice or confirmed by the owner needs none.`;
397
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The ledger as the rest of the app uses it — the four interfaces the clerk method and the workbench were built against:
3
+ *
4
+ * - `LedgerHook` (keeper/evidence.ts): an entry id read back as a label, when it happened (the ledger's, never the
5
+ * model's) and its text, so evidence `{ kind: 'ledger', id, line? }` can be checked; plus, for session drafts, a
6
+ * session's messages and the agent message an owner line answers (`LedgerHookPlus`).
7
+ * - `LedgerRunner` (keeper/organize/clerk.ts): step 0 of a round, an incremental rebuild on a worker thread.
8
+ * - `KEngines.ledger` (server/k-views.ts): coverage, versions, `How it got here`, the current version of a document,
9
+ * `Code` and a territory's files.
10
+ * - the `pk_ledger_*` tools (keeper/ledger-tools.ts), which open the ledger from the store's directory themselves.
11
+ *
12
+ * One `LedgerService` per ProjectKeeper home holds them. Reads go through read-only connections, kept for a short while
13
+ * per project and dropped when a rebuild of that project ends; rebuilds never block them (WAL).
14
+ */
15
+ import type { Project } from '../model/types.ts';
16
+ import type { LedgerEntry, LedgerHook } from '../keeper/evidence.ts';
17
+ import type { LedgerRunner } from '../keeper/organize/clerk.ts';
18
+ import type { KEngines } from '../server/k-views.ts';
19
+ import { projectKeeperHome } from '../store/paths.ts';
20
+ import { Ledger } from './index.ts';
21
+ import { ledgerPath, rebuildLedger, rebuildLedgerInPlace, sessionInputOf, type RebuildOptions, type RebuildStats } from './rebuild.ts';
22
+ import { codeView, coverageView, docCurrentView, lineageView, territoryView, versionsView } from './views.ts';
23
+ import { materialTime } from './time.ts';
24
+
25
+ /** A session as the ledger recorded it: every message with its speaker and time; the text of the owner's messages and of the agent messages they answer. */
26
+ export interface LedgerSessionView {
27
+ readonly id: string;
28
+ readonly host: string;
29
+ readonly sessionId: string;
30
+ readonly file: string;
31
+ readonly startedAt: string | null;
32
+ readonly endedAt: string | null;
33
+ /** The log is gone since it was read: the ledger is the only copy left (D71). */
34
+ readonly missing: boolean;
35
+ readonly messages: readonly {
36
+ /** The ledger's message id (`msg:…`): what a session draft's line refers to. */
37
+ readonly id: string;
38
+ /** Position in the session, as the transcripts number it (`[n]`). */
39
+ readonly index: number;
40
+ readonly speaker: 'owner' | 'agent' | 'subagent' | 'host';
41
+ readonly at: string | null;
42
+ readonly text: string | null;
43
+ /** An owner message: the position of the agent message it answers. */
44
+ readonly answers: number | null;
45
+ }[];
46
+ }
47
+
48
+ /** The agent message an owner line answers, verbatim. */
49
+ export interface AnsweredMessage { readonly id: string; readonly index: number; readonly at: string | null; readonly text: string | null }
50
+
51
+ /** `LedgerHook` and what the session drafts need besides. */
52
+ export interface LedgerHookPlus extends LedgerHook {
53
+ resolve(id: string): (LedgerEntry & { readonly id: string; readonly kind: string }) | null;
54
+ /** A session by host and native id (or a prefix of it, or its ledger id); null when the ledger has not read it. */
55
+ session(host: string, sessionId: string): LedgerSessionView | null;
56
+ /** The agent message right before an owner line (by the owner message's ledger id), or null when there is none. */
57
+ agentBefore(ownerMessageId: string): AnsweredMessage | null;
58
+ }
59
+
60
+ const KEEP_MS = 60_000;
61
+
62
+ export class LedgerService {
63
+ readonly home: string;
64
+ private readonly open = new Map<string, { ledger: Ledger; at: number }>();
65
+ private readonly running = new Map<string, Promise<RebuildStats>>();
66
+
67
+ constructor(opts: { home?: string } = {}) {
68
+ this.home = opts.home ?? projectKeeperHome();
69
+ }
70
+
71
+ /** Where a project's ledger file is. */
72
+ file(projectId: string): string {
73
+ return ledgerPath(projectId, this.home);
74
+ }
75
+
76
+ /** A read-only view of a project's ledger (kept a short while); null when it has never been built. */
77
+ ledger(projectId: string): Ledger | null {
78
+ const hit = this.open.get(projectId);
79
+ if (hit && Date.now() - hit.at < KEEP_MS) return hit.ledger;
80
+ hit?.ledger.close();
81
+ this.open.delete(projectId);
82
+ const l = Ledger.openPath(this.file(projectId));
83
+ if (l) this.open.set(projectId, { ledger: l, at: Date.now() });
84
+ return l;
85
+ }
86
+
87
+ /** Resolves once no rebuild of the project's ledger is under way (a project being cleared waits for it). */
88
+ async idle(projectId: string): Promise<void> {
89
+ await this.running.get(projectId)?.catch(() => undefined);
90
+ }
91
+
92
+ /** Drop the kept connection of a project (after a rebuild, or when the project goes away). */
93
+ release(projectId?: string): void {
94
+ for (const [id, e] of this.open) if (!projectId || id === projectId) { e.ledger.close(); this.open.delete(id); }
95
+ }
96
+
97
+ /**
98
+ * Bring a project's ledger up to date on a worker thread; a rebuild already under way for the project is joined, not
99
+ * started twice. Sessions are those of the project's scope (their own native homes).
100
+ */
101
+ rebuild(project: Project, opts: RebuildOptions = {}): Promise<RebuildStats> {
102
+ const current = this.running.get(project.id);
103
+ if (current) return current;
104
+ const run = rebuildLedger(this.file(project.id), project, { sessions: sessionInputOf(project), ...opts })
105
+ .finally(() => { this.running.delete(project.id); this.release(project.id); });
106
+ this.running.set(project.id, run);
107
+ return run;
108
+ }
109
+
110
+ /** The same on the calling thread (a script, a test). */
111
+ rebuildNow(project: Project, opts: RebuildOptions = {}): RebuildStats {
112
+ try {
113
+ return rebuildLedgerInPlace(this.file(project.id), project, { sessions: sessionInputOf(project), ...opts });
114
+ } finally {
115
+ this.release(project.id);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Step 0 of a round (clerk.ts `LedgerRunner`): how many commits it added, and in words what else happened. The Keeper's
121
+ * numbers the round hands over are recorded with the rest (CKC-22 AC-5).
122
+ */
123
+ readonly runner: LedgerRunner = {
124
+ run: async (project: Project, extra) => {
125
+ const stats = await this.rebuild(project, extra?.keeperNumbers ? { keeperNumbers: extra.keeperNumbers.map((k) => ({ number: k.number, objectId: k.objectId, objectKind: k.objectKind, projectNumber: k.projectNumber, at: k.at })) } : {});
126
+ return { commitsAdded: stats.commitsAdded, note: runNote(stats) };
127
+ },
128
+ };
129
+
130
+ /** The hook the evidence resolver and the clerk tools read the ledger through, for one project. */
131
+ hook(projectId: string): LedgerHookPlus {
132
+ return {
133
+ resolve: (id: string) => this.ledger(projectId)?.resolve(id) ?? null,
134
+ session: (host: string, sessionId: string) => {
135
+ const l = this.ledger(projectId);
136
+ if (!l) return null;
137
+ const s = l.findSession(sessionId, host || undefined);
138
+ if (typeof s === 'string') return null;
139
+ const rows = l.db.prepare('SELECT * FROM session_messages WHERE session = ? ORDER BY idx').all(String(s.key)) as { key: string; idx: number; speaker: string; at: string | null; text: string | null; answers: number | null }[];
140
+ return {
141
+ id: String(s.key), host: String(s.host), sessionId: String(s.session_id), file: String(s.file),
142
+ startedAt: (s.started_at as string | null) ?? null, endedAt: (s.ended_at as string | null) ?? null, missing: s.missing === 1,
143
+ messages: rows.map((m) => ({ id: m.key, index: m.idx, speaker: m.speaker as 'owner', at: m.at ? materialTime(m.at) ?? m.at : null, text: m.text, answers: m.answers })),
144
+ };
145
+ },
146
+ agentBefore: (ownerMessageId: string) => {
147
+ const m = this.ledger(projectId)?.message(ownerMessageId);
148
+ if (!m || m.speaker !== 'owner' || !m.answers) return null;
149
+ return { id: m.answers.id, index: m.answers.index, at: m.answers.occurred.undated ? null : m.answers.occurred.at, text: m.answers.text };
150
+ },
151
+ };
152
+ }
153
+
154
+ /** `KEngines.ledger` for the workbench's increment K views. */
155
+ engines(): NonNullable<KEngines['ledger']> {
156
+ const withLedger = <T>(project: Project, fn: (l: Ledger) => T | null): T | null => {
157
+ const l = this.ledger(project.id);
158
+ if (!l) return null;
159
+ try { return fn(l); } catch { return null; }
160
+ };
161
+ return {
162
+ coverage: (project) => withLedger(project, (l) => coverageView(l)),
163
+ versions: (store, project, objectId) => withLedger(project, (l) => versionsView(l, store, objectId)),
164
+ lineage: (store, project, objectId) => withLedger(project, (l) => lineageView(l, store, objectId)),
165
+ docCurrent: (store, project, objectId) => withLedger(project, (l) => docCurrentView(l, store, objectId)),
166
+ code: (store, project) => withLedger(project, (l) => codeView(l, store, project)),
167
+ territory: (store, project, territoryId) => withLedger(project, (l) => territoryView(l, store, territoryId)),
168
+ };
169
+ }
170
+ }
171
+
172
+ /** What a rebuild did, in one line for the round's program step. */
173
+ export function runNote(s: RebuildStats): string {
174
+ const commits = s.repos.reduce((n, r) => n + r.commits, 0);
175
+ const docs = s.repos.reduce((n, r) => n + r.docs.versions, 0);
176
+ const docsAdded = s.repos.reduce((n, r) => n + r.docs.versionsAdded, 0);
177
+ const sessions = s.sessions === 'not scanned' ? 'sessions not read' : `${s.sessions.sessions} sessions (${s.sessions.read} read now${s.sessions.unreadable ? `, ${s.sessions.unreadable} unreadable` : ''}${s.sessions.missing ? `, ${s.sessions.missing} logs gone` : ''})`;
178
+ const parts = [
179
+ `Ledger ${s.kind === 'full' ? 'built' : 'brought up to date'} in ${(s.ms / 1000).toFixed(1)} s`,
180
+ `${commits} commits in ${s.repos.length} ${s.repos.length === 1 ? 'repository' : 'repositories'} (${s.commitsAdded} new)`,
181
+ `${docs} document versions (${docsAdded} new)`,
182
+ sessions,
183
+ `${s.numRules} numbering rules`,
184
+ ];
185
+ return `${parts.join(' · ')}${s.notes.length ? `. Not read: ${s.notes.join(' ')}` : ''}`;
186
+ }