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,1850 @@
1
+ /**
2
+ * The ledger's queries (Spec §1.16; CKC-22 AC-1…AC-13; CKC-03 AC-26, AC-27): exact answers over what the program
3
+ * recorded — the history map, commits, document versions and their section diffs, explicit supersessions, numbering,
4
+ * verdicts, execution arrangements, code structure, sessions and the owner's words — plus the provenance (`How it got
5
+ * here`) and the coverage. Every row carries its entry id (cite it; `resolve` reads it back) and when it happened with the
6
+ * basis the program read it from. Lists are paged. The ledger states facts, never judgements: "this line reads superseded
7
+ * by X", "no file references this file".
8
+ *
9
+ * Reading goes through a read-only connection; a rebuild on its worker thread never blocks it (WAL).
10
+ */
11
+ import type { DatabaseSync } from 'node:sqlite';
12
+ import { join, sep } from 'node:path';
13
+ import { nameForm, normalizePath, pathKey, relativeDisplay } from '../util/paths.ts';
14
+ import { openLedgerReadOnly, blobTextKey } from './schema.ts';
15
+ import { catBlobs, isText, ledgerGit } from './git-read.ts';
16
+ import { boundMs, dayOf, materialTime, msOf, occurred, undated, type Occurred } from './time.ts';
17
+ import { symbolQuery, type SymbolQueryKind, type SymbolResult } from './ts-symbols.ts';
18
+ import { CLEANUP_RULE, IMPORT_RULE, MAX_DOC_BYTES, datesOf, diffSections, occurredOfLine, versionTime } from './docs.ts';
19
+ import { NOT_CODE, compilerReads, depsOf, fileNameOf, langOf, namingOf, namingThatCounts, reachOf, type CodeReach, type LanguageReach, type Naming } from './code.ts';
20
+ import { COUNTED_RULE, ENGINE_LABEL, engineDirOf, engineImportsOf, engineLanguage, engineSymbol, specifierNames, type EngineSymbolResult } from './code-engine.ts';
21
+ import { MIN_DEFINED } from './numbering.ts';
22
+ import { isReportLike } from './lines.ts';
23
+ import { MAX_LOOSE_BYTES, engineRootOf } from './rebuild.ts';
24
+
25
+ export interface Page<T> {
26
+ readonly total: number;
27
+ readonly offset: number;
28
+ readonly rows: readonly T[];
29
+ /** Where the next page starts; null on the last page. */
30
+ readonly next: number | null;
31
+ }
32
+ export interface Paging { readonly limit?: number; readonly offset?: number }
33
+
34
+ const LIMIT = 50;
35
+ const lim = (q: Paging, max = 500): number => Math.max(1, Math.min(max, Math.floor(Number(q.limit) || LIMIT)));
36
+ const off = (q: Paging): number => Math.max(0, Math.floor(Number(q.offset) || 0));
37
+ const pageOf = <T>(total: number, rows: T[], offset: number): Page<T> => ({ total, offset, rows, next: offset + rows.length < total ? offset + rows.length : null });
38
+ const likeEscape = (s: string): string => s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
39
+ const plural = (n: number, one: string, many = `${one}s`): string => `${n} ${n === 1 ? one : many}`;
40
+
41
+ /**
42
+ * Why a file other files name is not shown to be unreferenced (code.ts `NamingKind`), in words: a build or manifest file
43
+ * naming it makes it a named entry point; code naming it, a reference no reader resolved.
44
+ */
45
+ function namedByNote(naming: readonly Naming[]): string {
46
+ const entry = [...new Set(naming.filter((n) => n.kind === 'entry').map((n) => fileNameOf(n.path)))];
47
+ return [
48
+ 'No counted reference reaches this file, yet other files name it.',
49
+ entry.length ? `It is a named entry point: named in ${entry.join(', ')} — a build or manifest file, which compiles, launches, loads or runs it.` : null,
50
+ naming.some((n) => n.kind === 'reference') ? 'Code names it: a reference its reader did not resolve (an include directive it does not follow, a file started by its path).' : null,
51
+ 'That it is unreferenced is no computed fact here; read them.',
52
+ ].filter(Boolean).join(' ');
53
+ }
54
+ /** The words for the files under some paths that other files name: the named entry points, and the references no reader resolved. */
55
+ function namedGaps(named: readonly { readonly path: string; readonly by: Naming }[], where: string): string[] {
56
+ const entry = named.filter((x) => x.by.kind === 'entry');
57
+ const code = named.filter((x) => x.by.kind !== 'entry');
58
+ const list = (xs: typeof named) => xs.slice(0, 3).map((x) => `${x.path} (named in ${x.by.path})`).join('; ');
59
+ return [
60
+ ...(entry.length ? [`${plural(entry.length, 'file')}${where} no counted reference reaches ${entry.length === 1 ? 'is' : 'are'} named by a build or manifest file, so ${entry.length === 1 ? 'it is a named entry point' : 'they are named entry points'}: ${list(entry)}`] : []),
61
+ ...(code.length ? [`${plural(code.length, 'file')}${where} no counted reference reaches ${code.length === 1 ? 'is' : 'are'} named by other files, so a reference went unresolved: ${list(code)}`] : []),
62
+ ];
63
+ }
64
+ const clip = (s: string, n = 200): string => (s.length > n ? `${s.slice(0, n)}…` : s);
65
+ const short = (h: string | null | undefined): string | null => (h ? h.slice(0, 10) : null);
66
+ const json = <T>(s: string | null | undefined, fallback: T): T => { try { return s ? (JSON.parse(s) as T) : fallback; } catch { return fallback; } };
67
+
68
+ /** One entry of the ledger as a citation needs it: its id, a label a person reads, when it happened, and its text. */
69
+ export interface LedgerEntry { readonly id: string; readonly kind: string; readonly label: string; readonly occurred: Occurred; readonly text?: string }
70
+
71
+ // ───────────────────────── row shapes ─────────────────────────
72
+
73
+ export interface CommitRow {
74
+ readonly id: string; readonly repo: string; readonly hash: string; readonly subject: string; readonly author: string;
75
+ /** When it happened (UTC instant), and the author's and committer's times as git wrote them (their own offset: the local day). */
76
+ readonly occurred: Occurred; readonly authoredAt: string; readonly committedAt: string | null;
77
+ readonly parents: number; readonly merge: boolean; readonly onTrunk: boolean; readonly reach: string; readonly files: number;
78
+ readonly importRoot?: boolean;
79
+ }
80
+ export interface DocVersionRow {
81
+ readonly id: string; readonly repo: string; readonly path: string; readonly commit: string; readonly change: string; readonly from: string | null;
82
+ readonly occurred: Occurred; readonly sections: number; readonly lines: number;
83
+ readonly diff: { readonly added: readonly string[]; readonly removed: readonly string[]; readonly changed: readonly string[] } | null;
84
+ readonly current: boolean; readonly onTrunk: boolean; readonly importRoot: boolean;
85
+ }
86
+ export interface LineRow {
87
+ readonly id: string; readonly repo: string | null; readonly path: string | null; readonly line: number | null;
88
+ readonly text: string; readonly occurred: Occurred;
89
+ readonly firstIn: string | null; readonly lastIn: string | null; readonly current: boolean;
90
+ }
91
+ export interface SupersessionRow extends LineRow { readonly source: string; readonly pattern: string; readonly target: string | null; readonly replaced: string | null; readonly replacement: string | null; readonly syntax: string | null; readonly obsoleteList: boolean; readonly list: string | null }
92
+ export interface NumRow extends LineRow { readonly num: string; readonly rule: string; readonly kind: string; readonly place: string; readonly confidence: string }
93
+ export interface VerdictRow extends LineRow { readonly kind: string; readonly verdict: string | null; readonly confidence: string }
94
+ export interface ArrangementRow {
95
+ readonly id: string; readonly repo: string | null; readonly path: string; readonly commit: string | null; readonly kind: string; readonly ident: string | null;
96
+ readonly parsed: boolean; readonly current: boolean; readonly occurred: Occurred; readonly data: unknown;
97
+ }
98
+ export interface SessionRow {
99
+ readonly id: string; readonly host: string; readonly sessionId: string; readonly file: string; readonly cwd: string | null;
100
+ readonly startedAt: string | null; readonly endedAt: string | null; readonly messages: number; readonly ownerMessages: number;
101
+ readonly headless: boolean; readonly subagent: boolean; readonly missing: boolean; readonly unreadable: string | null;
102
+ }
103
+ export interface MessageRow {
104
+ readonly id: string; readonly session: string; readonly index: number; readonly speaker: string; readonly occurred: Occurred;
105
+ readonly text: string | null; readonly chars: number; readonly tools?: readonly string[];
106
+ /** The agent message this owner message answers (verbatim), when the session has one before it. */
107
+ readonly answers?: { readonly id: string; readonly index: number; readonly occurred: Occurred; readonly text: string | null } | null;
108
+ }
109
+ export interface WordHit {
110
+ readonly id: string; readonly kind: string; readonly label: string; readonly occurred: Occurred; readonly snippet: string; readonly current?: boolean;
111
+ /** A document: the last version that has the word. */
112
+ readonly last?: { readonly id: string; readonly at: string } | null;
113
+ }
114
+
115
+ export interface ProvenanceInput {
116
+ readonly nums?: readonly string[];
117
+ readonly paths?: readonly string[];
118
+ readonly commits?: readonly string[];
119
+ readonly words?: readonly string[];
120
+ readonly entries?: readonly string[];
121
+ readonly repo?: string;
122
+ }
123
+ export type StepKind =
124
+ | 'first appeared' | 'changed' | 'deleted' | 'says superseded' | 'obsolete-list row' | 'named in plan' | 'arrangement'
125
+ | 'commit' | 'merged' | 'verdict' | 'mentioned' | 'owner said' | 'now';
126
+ export interface ProvenanceStep {
127
+ readonly kind: StepKind;
128
+ readonly occurred: Occurred;
129
+ readonly title: string;
130
+ /** The ledger entry the step is (cite it). */
131
+ readonly entry: string;
132
+ /** The step lies only in history: an old version, a deleted document, a side branch never merged (D82). */
133
+ readonly history: boolean;
134
+ /** What replaced it, when the ledger knows: the next version, the deleting commit. */
135
+ readonly replacedBy?: string | null;
136
+ }
137
+
138
+ // ───────────────────────── the ledger ─────────────────────────
139
+
140
+ export class Ledger {
141
+ readonly db: DatabaseSync;
142
+ readonly path: string;
143
+
144
+ private constructor(db: DatabaseSync, path: string) {
145
+ this.db = db;
146
+ this.path = path;
147
+ }
148
+
149
+ /** The ledger of a project directory (`<ProjectKeeper home>/projects/<id>`); null when it has never been built. */
150
+ static openDir(projectDirPath: string): Ledger | null {
151
+ return Ledger.openPath(join(projectDirPath, 'ledger.sqlite'));
152
+ }
153
+ static openPath(path: string): Ledger | null {
154
+ const db = openLedgerReadOnly(path);
155
+ return db ? new Ledger(db, path) : null;
156
+ }
157
+ close(): void { try { this.db.close(); } catch { /* already closed */ } }
158
+
159
+ private all<T>(sql: string, ...params: (string | number | null)[]): T[] { return this.db.prepare(sql).all(...params) as T[]; }
160
+ private one<T>(sql: string, ...params: (string | number | null)[]): T | undefined { return this.db.prepare(sql).get(...params) as T | undefined; }
161
+
162
+ private readerColumns: boolean | null = null;
163
+ /** Whether the ledger already records who reads each file's references (a ledger not yet rebuilt since D98 does not). */
164
+ private hasReader(): boolean {
165
+ this.readerColumns ??= (this.db.prepare('PRAGMA table_info(code_files)').all() as { name: string }[]).some((c) => c.name === 'reader');
166
+ return this.readerColumns;
167
+ }
168
+ /** A code file's reader: recorded, or on a ledger not yet rebuilt since D98, the TypeScript compiler's by extension. */
169
+ private readerOf(f: { path: string; reader?: string | null }): 'compiler' | 'engine' | null {
170
+ if (this.hasReader()) return f.reader === 'compiler' || f.reader === 'engine' ? f.reader : null;
171
+ return compilerReads(f.path) ? 'compiler' : null;
172
+ }
173
+ /** The folder of a repository's (or directory's) code snapshot and index, next to this ledger. */
174
+ private engineDir(repo: string): string { return engineDirOf(engineRootOf(this.path), repo); }
175
+
176
+ // ───────────────────────── repositories ─────────────────────────
177
+
178
+ repos(): { id: string; path: string; trunk: string | null; trunkTip: string | null; head: string | null; headBranch: string | null; versionFrom: string | null }[] {
179
+ return this.all<{ id: string; path: string; trunk: string | null; trunk_tip: string | null; head: string | null; head_branch: string | null; version_from: string | null }>('SELECT * FROM repos ORDER BY rowid')
180
+ .map((r) => ({ id: r.id, path: r.path, trunk: r.trunk, trunkTip: r.trunk_tip, head: r.head, headBranch: r.head_branch, versionFrom: r.version_from }));
181
+ }
182
+
183
+ /** A repository by scope item id or path (any case, any slash); the first one when none is named. Null when unknown. */
184
+ repoId(input?: string | null): string | null {
185
+ const all = this.repos();
186
+ if (!input) return all[0]?.id ?? null;
187
+ const norm = (p: string) => nameForm(p).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
188
+ return all.find((r) => r.id === input)?.id ?? all.find((r) => norm(r.path) === norm(input))?.id ?? all.find((r) => norm(r.path).endsWith(`/${norm(input)}`))?.id ?? null;
189
+ }
190
+ private repoPath(id: string): string | null { return this.repos().find((r) => r.id === id)?.path ?? null; }
191
+ private repoFilter(input: string | undefined, column = 'repo'): { sql: string; params: string[] } | string {
192
+ if (!input) return { sql: '', params: [] };
193
+ const id = this.repoId(input);
194
+ if (!id) return `${input} is not a repository of this ledger. Repositories: ${this.repos().map((r) => `${r.id} (${r.path})`).join(', ')}.`;
195
+ return { sql: ` AND ${column} = ?`, params: [id] };
196
+ }
197
+
198
+ /** A since / until range on a milliseconds column (a date: the whole day, UTC). */
199
+ private timeFilter(where: string[], params: (string | number)[], column: string, q: { since?: string; until?: string }): void {
200
+ const since = boundMs(q.since, false);
201
+ const until = boundMs(q.until, true);
202
+ if (since !== null) { where.push(`${column} >= ?`); params.push(since); }
203
+ if (until !== null) { where.push(`${column} <= ?`); params.push(until); }
204
+ }
205
+
206
+ // ───────────────────────── occurred helpers ─────────────────────────
207
+
208
+ private commitOccurred(hash: string, at: string, committedAt?: string | null): Occurred {
209
+ const a = materialTime(at) ?? at;
210
+ const c = committedAt ? materialTime(committedAt) ?? committedAt : null;
211
+ return { at: a, basis: 'Commit', anchor: `commit:${hash.slice(0, 12)}`, ...(c && c !== a ? { other: { at: c, basis: 'Commit', anchor: 'committer time' } } : {}) };
212
+ }
213
+ private rowOccurred(r: { occurred_at: string; occurred_basis: string; occurred_anchor: string | null; other_at?: string | null; other_basis?: string | null; undated?: number }): Occurred {
214
+ return {
215
+ at: r.occurred_at, basis: r.occurred_basis as Occurred['basis'], anchor: r.occurred_anchor,
216
+ ...(r.other_at ? { other: { at: r.other_at, basis: (r.other_basis ?? 'Commit') as Occurred['basis'], anchor: null } } : {}),
217
+ ...(r.undated ? { undated: true } : {}),
218
+ };
219
+ }
220
+ /**
221
+ * When a document version happened: its commit, with the date the document states for itself (docs.ts, rule 4) kept as
222
+ * the other time (§2.11: "正文写明日期的,另记"; QC AY — a version row used to drop it); for the content of an
223
+ * import-style root commit (AC-15), the date the document states, else the file's time in the working tree, else
224
+ * undated — with the import commit kept as the other time.
225
+ */
226
+ private docOccurred(d: { path: string; commit_hash: string; at: string; import_root: number; blob: string; repo: string }): Occurred {
227
+ const anchor = `doc:${d.path}@${d.commit_hash.slice(0, 10)}`;
228
+ const text = this.blobText(d.repo, d.blob);
229
+ if (!d.import_root) {
230
+ const written = text === null ? null : datesOf(`${d.repo}:${d.blob}`, text).document;
231
+ return occurred(d.at, 'Commit', anchor, written ? { at: written.at, basis: 'Written in text', anchor: `${d.path}:${written.line}` } : null);
232
+ }
233
+ const root = this.repoPath(d.repo);
234
+ return occurredOfLine({
235
+ commit: d.commit_hash, commitAt: d.at, importRoot: true, path: d.path, firstSeen: d.at,
236
+ fileTime: root ? versionTime(root, d.path, d.commit_hash, d.at, true, d.at).fileTime : null,
237
+ }, datesOf(`${d.repo}:${d.blob}`, text ?? ''), null);
238
+ }
239
+ /**
240
+ * When a document outside version control says something (§2.11, the table's last row; CKC-22 AC-10, AC-15): the date
241
+ * its text writes for that line — the line's own, else its entry's, else the one the document states for itself — with
242
+ * the file's own time kept as the other time; else the file's own time (`File time`), which the rebuild recorded with
243
+ * the file when it last read it (`loose:<path>`: size and time); else `Undated · first seen` when the rebuild first read
244
+ * it. `line` null asks for the document as a whole. Never a made-up time: this used to be the epoch dressed as a `File
245
+ * time` in a word hit, and the moment of the query as "first seen" in `resolve` (QC AY).
246
+ */
247
+ private looseOccurred(path: string, content: string, line: number | null): Occurred {
248
+ const state = (key: string) => this.one<{ value: string }>('SELECT value FROM state WHERE key = ?', key)?.value ?? null;
249
+ const stamp = state(`loose:${path}`);
250
+ const ms = stamp ? Number(stamp.slice(stamp.indexOf(':') + 1)) : Number.NaN;
251
+ const fileTime = Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : null;
252
+ const dates = datesOf(`loose:${path}:${fileTime ?? ''}`, content);
253
+ const written = line === null ? dates.document : dates.at(line);
254
+ const file = fileTime ? { at: fileTime, basis: 'File time' as const, anchor: path } : null;
255
+ if (written) return { at: written.at, basis: 'Written in text', anchor: `${path}:${written.line}`, ...(file ? { other: file } : {}) };
256
+ if (file) return file;
257
+ // The rebuild records the file's time with its text, so this is for a ledger that lost the record: the rebuild that
258
+ // first read the file, else the last one (which had read it), both said as "first seen", undated.
259
+ return undated(state(`loosefirst:${path}`) ?? this.lastRead(), `loose:${path}`);
260
+ }
261
+
262
+ /** When the ledger last read the project: its last rebuild, which had read whatever it holds. */
263
+ private lastRead(): string {
264
+ return this.one<{ value: string }>("SELECT value FROM state WHERE key = 'lastRebuildAt'")?.value ?? this.one<{ ended_at: string }>('SELECT ended_at FROM rebuilds ORDER BY id DESC LIMIT 1')?.ended_at ?? '';
265
+ }
266
+
267
+ /**
268
+ * When a file of the current version happened (§2.11; CKC-22 AC-10): its last commit; outside version control, its own
269
+ * time as the rebuild read it (`File time`: copying and moving change it, so a weak basis); else `Undated · first seen`
270
+ * at the rebuild that first read it — never the moment of the query. A ledger kept before the file time and the first
271
+ * reading were recorded (schema.ts `migrate`) falls back to its last rebuild, which had read the file.
272
+ */
273
+ private codeFileOccurred(f: Record<string, unknown>, anchor: string): Occurred {
274
+ if (f.last_at) return { at: materialTime(String(f.last_at)) ?? String(f.last_at), basis: 'Commit', anchor: f.last_commit ? `commit:${String(f.last_commit).slice(0, 12)}` : null };
275
+ if (typeof f.file_at === 'string' && f.file_at) return { at: f.file_at, basis: 'File time', anchor };
276
+ return undated(typeof f.first_seen === 'string' && f.first_seen ? f.first_seen : this.lastRead(), anchor);
277
+ }
278
+
279
+ private blobText(repo: string, blob: string | null): string | null {
280
+ if (!blob) return null;
281
+ return this.one<{ content: string }>('SELECT content FROM texts WHERE key = ?', blobTextKey(repo, blob))?.content ?? null;
282
+ }
283
+
284
+ // ───────────────────────── the history map (AC-1) ─────────────────────────
285
+
286
+ /** The orientation map of the whole history, per repository, computed in one call. */
287
+ overview(opts: { repo?: string } = {}) {
288
+ const ids = opts.repo ? [this.repoId(opts.repo)].filter((x): x is string => Boolean(x)) : this.repos().map((r) => r.id);
289
+ return ids.map((id) => {
290
+ const r = this.repos().find((x) => x.id === id)!;
291
+ const c = this.one<{ c: number; t: number; m: number; tm: number; fp: number; a: string | null; b: string | null }>(
292
+ 'SELECT count(*) c, ifnull(sum(on_trunk), 0) t, ifnull(sum(merge), 0) m, ifnull(sum(merge * on_trunk), 0) tm, ifnull(sum(first_parent_trunk), 0) fp, min(author_ms) a, max(author_ms) b FROM commits WHERE repo = ?', id)!;
293
+ const first = this.one<{ author_at: string }>('SELECT author_at FROM commits WHERE repo = ? ORDER BY author_ms LIMIT 1', id);
294
+ const last = this.one<{ author_at: string }>('SELECT author_at FROM commits WHERE repo = ? ORDER BY author_ms DESC LIMIT 1', id);
295
+ const perDay = new Map<string, number>();
296
+ for (const row of this.all<{ author_at: string }>('SELECT author_at FROM commits WHERE repo = ?', id)) {
297
+ const d = dayOf(row.author_at) ?? '?';
298
+ perDay.set(d, (perDay.get(d) ?? 0) + 1);
299
+ }
300
+ const refs: Record<string, number> = {};
301
+ for (const x of this.all<{ namespace: string; c: number }>('SELECT namespace, count(*) c FROM refs WHERE repo = ? AND present = 1 GROUP BY namespace', id)) refs[x.namespace] = x.c;
302
+ const otherRefs = this.all<{ refname: string; type: string; tip: string }>("SELECT refname, type, tip FROM refs WHERE repo = ? AND present = 1 AND (namespace = 'other' OR type <> 'commit') ORDER BY refname LIMIT 50", id)
303
+ .map((x) => ({ ref: x.refname, names: x.type, object: short(x.tip) }));
304
+ const branches = this.all<{ name: string; namespace: string; tip: string; tip_at: string | null; merged: number | null; ahead: number | null }>('SELECT * FROM branches WHERE repo = ? ORDER BY namespace, name', id);
305
+ const worktrees = this.all<{ path: string; branch: string | null; head: string | null; merged: number | null; detached: number; uncommitted: number }>('SELECT * FROM worktrees WHERE repo = ? ORDER BY path', id);
306
+ const cleanups = this.all<{ key: string; commit_hash: string; at: string; deleted_docs: number; deleted_total: number; catalog_before: string; catalog_after: string }>('SELECT * FROM cleanups WHERE repo = ? ORDER BY at_ms', id);
307
+ const deleted = this.one<{ c: number; p: number }>(`SELECT count(*) c, count(DISTINCT path) p FROM deleted_docs d WHERE repo = ?
308
+ AND NOT EXISTS (SELECT 1 FROM code_files f WHERE f.repo = d.repo AND f.path = d.path)`, id)!;
309
+ const deletedByDir = this.all<{ dir: string; n: number }>(`SELECT CASE WHEN instr(path, '/') > 0 THEN rtrim(path, replace(path, '/', '')) ELSE './' END dir, count(DISTINCT path) n
310
+ FROM deleted_docs d WHERE repo = ? AND NOT EXISTS (SELECT 1 FROM code_files f WHERE f.repo = d.repo AND f.path = d.path) GROUP BY dir ORDER BY n DESC LIMIT 12`, id);
311
+ const imports = this.all<{ hash: string; author_at: string }>('SELECT hash, author_at FROM commits WHERE repo = ? AND import_root = 1', id);
312
+ const docs = this.one<{ v: number; p: number }>('SELECT count(*) v, count(DISTINCT path) p FROM docs WHERE repo = ?', id)!;
313
+ return {
314
+ repo: id, path: r.path, trunk: r.trunk, trunkTip: short(r.trunkTip), head: short(r.head), headBranch: r.headBranch,
315
+ commits: { all: c.c, trunk: c.t, trunkFirstParent: c.fp, merges: c.m, mergesIntoTrunkHistory: c.tm, sideOnly: c.c - c.t },
316
+ span: { from: first?.author_at ?? null, to: last?.author_at ?? null, days: perDay.size },
317
+ refs, otherRefs, commitsPerDay: Object.fromEntries([...perDay.entries()].sort()),
318
+ branches: {
319
+ total: branches.length, merged: branches.filter((b) => b.merged === 1).length,
320
+ notMerged: branches.filter((b) => b.merged === 0).map((b) => ({ name: b.name, namespace: b.namespace, tip: short(b.tip), tipAt: b.tip_at, aheadOfTrunk: b.ahead })),
321
+ merged_names: branches.filter((b) => b.merged === 1).map((b) => b.name),
322
+ },
323
+ worktrees: worktrees.map((w) => ({ path: w.path, branch: w.branch, head: short(w.head), mergedIntoTrunk: w.merged === null ? null : w.merged === 1, detached: w.detached === 1, uncommitted: w.uncommitted })),
324
+ documents: { versions: docs.v, paths: docs.p, deleted: deleted.p, deletedByDirectory: deletedByDir.map((d) => ({ dir: d.dir, documents: d.n })) },
325
+ cleanups: cleanups.map((x) => ({ id: x.key, commit: short(x.commit_hash), at: x.at, deletedDocuments: x.deleted_docs, deletedFiles: x.deleted_total, documentsBefore: json<string[]>(x.catalog_before, []).length, documentsAfter: json<string[]>(x.catalog_after, []).length })),
326
+ importRoots: imports.map((i) => ({ id: `commit:${i.hash.slice(0, 12)}`, at: i.author_at })),
327
+ versionHistoryFrom: imports.length ? `${dayOf(first?.author_at ?? null)} (the import commit: content before it is dated by what the text says, else file time)` : dayOf(first?.author_at ?? null),
328
+ };
329
+ });
330
+ }
331
+
332
+ // ───────────────────────── commits ─────────────────────────
333
+
334
+ private commitRow(r: Record<string, unknown>): CommitRow {
335
+ const hash = String(r.hash);
336
+ return {
337
+ id: `commit:${hash.slice(0, 12)}`, repo: String(r.repo), hash, subject: String(r.subject), author: String(r.author),
338
+ occurred: this.commitOccurred(hash, String(r.author_at), String(r.committer_at)), authoredAt: String(r.author_at), committedAt: String(r.committer_at),
339
+ parents: String(r.parents).split(' ').filter(Boolean).length, merge: r.merge === 1, onTrunk: r.on_trunk === 1, reach: String(r.reach ?? ''),
340
+ files: Number(r.fc ?? 0), ...(r.import_root === 1 ? { importRoot: true } : {}),
341
+ };
342
+ }
343
+
344
+ /** Commits of the whole history, newest first (or oldest first), filtered and paged. */
345
+ commits(q: Paging & { repo?: string; path?: string; keyword?: string; num?: string; author?: string; since?: string; until?: string; merges?: boolean; trunk?: boolean; sideOnly?: boolean; importRoots?: boolean; oldestFirst?: boolean }): Page<CommitRow> | string {
346
+ const rf = this.repoFilter(q.repo, 'c.repo');
347
+ if (typeof rf === 'string') return rf;
348
+ const where: string[] = ['1 = 1'];
349
+ const params: (string | number)[] = [...rf.params];
350
+ if (q.path) {
351
+ const p = nameForm(q.path).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
352
+ where.push("EXISTS (SELECT 1 FROM commit_files cf WHERE cf.repo = c.repo AND cf.hash = c.hash AND (cf.path = ? OR cf.path LIKE ? ESCAPE '\\' OR cf.old_path = ?))");
353
+ params.push(p, `${likeEscape(p)}/%`, p);
354
+ }
355
+ if (q.keyword) { where.push("(c.subject LIKE ? ESCAPE '\\' OR c.body LIKE ? ESCAPE '\\')"); const k = `%${likeEscape(q.keyword)}%`; params.push(k, k); }
356
+ if (q.num) { where.push("c.hash IN (SELECT n.commit_hash FROM nums n WHERE n.num = ? AND n.kind = 'commit')"); params.push(q.num.trim()); }
357
+ if (q.author) { where.push("c.author LIKE ? ESCAPE '\\'"); params.push(`%${likeEscape(q.author)}%`); }
358
+ const since = boundMs(q.since, false);
359
+ const until = boundMs(q.until, true);
360
+ if (since !== null) { where.push('c.author_ms >= ?'); params.push(since); }
361
+ if (until !== null) { where.push('c.author_ms <= ?'); params.push(until); }
362
+ if (q.merges) where.push('c.merge = 1');
363
+ if (q.trunk) where.push('c.on_trunk = 1');
364
+ if (q.sideOnly) where.push('c.on_trunk = 0');
365
+ if (q.importRoots) where.push('c.import_root = 1');
366
+ const w = `WHERE ${where.join(' AND ')}${rf.sql}`;
367
+ const total = this.one<{ c: number }>(`SELECT count(*) c FROM commits c ${w}`, ...params)!.c;
368
+ const rows = this.all<Record<string, unknown>>(`SELECT c.*, (SELECT count(*) FROM commit_files cf WHERE cf.repo = c.repo AND cf.hash = c.hash) fc FROM commits c ${w}
369
+ ORDER BY c.author_ms ${q.oldestFirst ? 'ASC' : 'DESC'}, c.hash LIMIT ? OFFSET ?`, ...params, lim(q), off(q));
370
+ return pageOf(total, rows.map((r) => this.commitRow(r)), off(q));
371
+ }
372
+
373
+ /** The commit a (short) hash names; ambiguity and absence are said, not guessed. */
374
+ private findCommit(hashInput: string, repo?: string): Record<string, unknown> | string {
375
+ const h = hashInput.trim().toLowerCase().replace(/^commit:/, '');
376
+ if (!/^[0-9a-f]{4,40}$/.test(h)) return `“${hashInput}” is not a commit hash.`;
377
+ const rf = this.repoFilter(repo);
378
+ if (typeof rf === 'string') return rf;
379
+ const hits = this.all<Record<string, unknown>>(`SELECT * FROM commits WHERE hash LIKE ?${rf.sql} LIMIT 3`, `${h}%`, ...rf.params);
380
+ if (hits.length === 0) return `No commit ${hashInput} in the ledger${repo ? ` of ${repo}` : ''}.`;
381
+ const distinct = new Set(hits.map((x) => x.hash));
382
+ if (distinct.size > 1) return `${hashInput} is short for more than one commit (${[...distinct].map((x) => String(x).slice(0, 12)).join(', ')}): give more of the hash.`;
383
+ return hits[0]!;
384
+ }
385
+
386
+ /** One commit: its message, both times, every file it changed, the branches that contain it and the merge that brought it into the trunk. */
387
+ commit(hashInput: string, opts: Paging & { repo?: string } = {}) {
388
+ const r = this.findCommit(hashInput, opts.repo);
389
+ if (typeof r === 'string') return r;
390
+ const hash = String(r.hash);
391
+ const repo = String(r.repo);
392
+ const files = this.all<{ status: string; path: string; old_path: string | null; added: number | null; deleted: number | null }>(
393
+ 'SELECT status, path, old_path, added, deleted FROM commit_files WHERE repo = ? AND hash = ? ORDER BY path', repo, hash);
394
+ const root = this.repoPath(repo);
395
+ let branches: string[] = [];
396
+ let mergedInto: { id: string; subject: string; at: string } | null = null;
397
+ if (root) {
398
+ const b = ledgerGit(root, ['branch', '-a', '--contains', hash, '--format=%(refname:short)'], { timeoutMs: 30_000 });
399
+ if (b.ok) branches = b.out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
400
+ const trunk = this.repos().find((x) => x.id === repo)?.trunkTip;
401
+ if (trunk && r.on_trunk === 1 && r.first_parent_trunk !== 1) {
402
+ // The first commit of the trunk's first-parent line that has it: the merge that brought it in.
403
+ const m = ledgerGit(root, ['rev-list', '--ancestry-path', '--first-parent', '--reverse', `${hash}..${trunk}`], { timeoutMs: 30_000 });
404
+ const first = m.ok ? m.out.split(/\s+/).find((x) => /^[0-9a-f]{40}$/.test(x)) : undefined;
405
+ const mr = first ? this.one<{ hash: string; subject: string; author_at: string }>('SELECT hash, subject, author_at FROM commits WHERE repo = ? AND hash = ?', repo, first) : undefined;
406
+ if (mr) mergedInto = { id: `commit:${mr.hash.slice(0, 12)}`, subject: mr.subject, at: mr.author_at };
407
+ }
408
+ }
409
+ const nums = this.all<{ num: string }>("SELECT DISTINCT num FROM nums WHERE repo = ? AND kind = 'commit' AND commit_hash = ?", repo, hash).map((x) => x.num);
410
+ const o = off(opts);
411
+ const l = lim(opts, 1000);
412
+ return {
413
+ ...this.commitRow({ ...r, fc: files.length }), body: String(r.body), parentHashes: String(r.parents).split(' ').filter(Boolean).map((p) => p.slice(0, 12)),
414
+ numbers: nums, branches, mergedIntoTrunkBy: mergedInto,
415
+ files: pageOf(files.length, files.slice(o, o + l).map((f) => ({ status: f.status, path: f.path, ...(f.old_path ? { from: f.old_path } : {}), added: f.added, deleted: f.deleted })), o),
416
+ };
417
+ }
418
+
419
+ // ───────────────────────── documents (AC-3) ─────────────────────────
420
+
421
+ private docRow(d: Record<string, unknown>): DocVersionRow {
422
+ const repo = String(d.repo);
423
+ const path = String(d.path);
424
+ const commit = String(d.commit_hash);
425
+ return {
426
+ id: String(d.key), repo, path, commit: commit.slice(0, 12), change: String(d.change), from: d.prev_path && d.prev_path !== path ? String(d.prev_path) : null,
427
+ occurred: this.docOccurred({ repo, path, commit_hash: commit, at: String(d.at), import_root: Number(d.import_root), blob: String(d.blob) }),
428
+ sections: Number(d.sections), lines: Number(d.lines),
429
+ diff: d.added === null ? null : { added: json<string[]>(String(d.added), []), removed: json<string[]>(String(d.removed), []), changed: json<string[]>(String(d.changed), []) },
430
+ current: Number(d.cur) > 0, onTrunk: Number(d.on_trunk) === 1, importRoot: Number(d.import_root) === 1,
431
+ };
432
+ }
433
+
434
+ /** Every version of a document (following it across moves), its deletions, and which version is current. */
435
+ docVersions(pathInput: string, opts: { repo?: string } = {}): ReturnType<Ledger['docVersionsRead']> {
436
+ // The ledger is read only between rebuilds, and a view asks for the same document once per item that cites it (246
437
+ // decisions in one ADR on the resident D99 run): each answer is kept until the next rebuild.
438
+ const stamp = this.lastRead();
439
+ if (!this.docVersionsCache || this.docVersionsCache.stamp !== stamp || this.docVersionsCache.map.size > 2000) this.docVersionsCache = { stamp, map: new Map() };
440
+ const key = `${opts.repo ?? ''}\u0000${pathInput}`;
441
+ const cached = this.docVersionsCache.map.get(key);
442
+ if (cached !== undefined) return cached as ReturnType<Ledger['docVersionsRead']>;
443
+ const answer = this.docVersionsRead(pathInput, opts);
444
+ this.docVersionsCache.map.set(key, answer);
445
+ return answer;
446
+ }
447
+ private docVersionsCache: { stamp: string; map: Map<string, unknown> } | null = null;
448
+
449
+ private docVersionsRead(pathInput: string, opts: { repo?: string } = {}) {
450
+ const rf = this.repoFilter(opts.repo, 'd.repo');
451
+ if (typeof rf === 'string') return rf;
452
+ const path = nameForm(pathInput).replace(/\\/g, '/').replace(/^\.\//, '');
453
+ // The document's names over time: this path and every path it was moved from.
454
+ const names = new Set([path]);
455
+ for (let i = 0; i < 20; i++) {
456
+ const before = names.size;
457
+ for (const p of [...names]) for (const x of this.all<{ prev_path: string }>(`SELECT DISTINCT prev_path FROM docs d WHERE path = ? AND change IN ('Renamed', 'Copied') AND prev_path IS NOT NULL${rf.sql}`, p, ...rf.params)) names.add(x.prev_path);
458
+ if (names.size === before) break;
459
+ }
460
+ const list = [...names];
461
+ const rows = this.all<Record<string, unknown>>(`SELECT d.*, c.on_trunk, (SELECT count(*) FROM code_files f WHERE f.repo = d.repo AND f.path = d.path AND f.blob = d.blob) cur
462
+ FROM docs d JOIN commits c ON c.repo = d.repo AND c.hash = d.commit_hash WHERE d.path IN (${list.map(() => '?').join(',')})${rf.sql} ORDER BY d.at_ms, d.commit_hash`, ...list, ...rf.params);
463
+ if (rows.length === 0) {
464
+ const like = this.all<{ path: string }>(`SELECT DISTINCT path FROM docs WHERE path LIKE ? ESCAPE '\\' LIMIT 8`, `%${likeEscape(path.split('/').pop() ?? path)}%`);
465
+ return `No version of ${path} in the ledger's document history.${like.length ? ` Documents with a similar name: ${like.map((x) => x.path).join(', ')}.` : ''}`;
466
+ }
467
+ const deletions = this.all<{ key: string; path: string; deleted_commit: string; deleted_at: string; readable_at: string | null; cleanup: number }>(
468
+ `SELECT * FROM deleted_docs d WHERE path IN (${list.map(() => '?').join(',')})${rf.sql} ORDER BY deleted_ms`, ...list, ...rf.params);
469
+ return {
470
+ path, names: list,
471
+ versions: rows.map((r) => this.docRow(r)),
472
+ deletions: deletions.map((d) => ({ id: d.key, path: d.path, commit: short(d.deleted_commit), occurred: this.commitOccurred(d.deleted_commit, d.deleted_at), readableAt: short(d.readable_at), cleanup: d.cleanup === 1 })),
473
+ inCurrentVersion: this.one<{ c: number }>('SELECT count(*) c FROM code_files WHERE path = ?', path)!.c > 0,
474
+ };
475
+ }
476
+
477
+ /** Which sections differ between any two versions of a document (by version id, or path and commit). */
478
+ docCompare(from: { id?: string; path?: string; commit?: string; repo?: string }, to: { id?: string; path?: string; commit?: string; repo?: string }) {
479
+ const a = this.docText(from, { from: 1, to: Number.MAX_SAFE_INTEGER });
480
+ const b = this.docText(to, { from: 1, to: Number.MAX_SAFE_INTEGER });
481
+ if (typeof a === 'string') return a;
482
+ if (typeof b === 'string') return b;
483
+ const whole = (x: { id: string; repo: string; path: string }) => {
484
+ const row = this.one<{ blob: string }>('SELECT blob FROM docs WHERE key = ? LIMIT 1', x.id);
485
+ return (row ? this.blobText(x.repo, row.blob) : null) ?? '';
486
+ };
487
+ return { from: { id: a.id, occurred: a.occurred }, to: { id: b.id, occurred: b.occurred }, sections: diffSections(whole(a), whole(b)) };
488
+ }
489
+
490
+ /** Document versions made in a period or under a directory, oldest first (what the documents did then). */
491
+ docChanges(q: Paging & { repo?: string; dir?: string; since?: string; until?: string; change?: string } = {}) {
492
+ const rf = this.repoFilter(q.repo, 'd.repo');
493
+ if (typeof rf === 'string') return rf;
494
+ const where = ['1 = 1'];
495
+ const params: (string | number)[] = [];
496
+ if (q.dir) { where.push("d.path LIKE ? ESCAPE '\\'"); params.push(`${likeEscape(nameForm(q.dir).replace(/\\/g, '/').replace(/\/+$/, ''))}/%`); }
497
+ const since = boundMs(q.since, false);
498
+ const until = boundMs(q.until, true);
499
+ if (since !== null) { where.push('d.at_ms >= ?'); params.push(since); }
500
+ if (until !== null) { where.push('d.at_ms <= ?'); params.push(until); }
501
+ if (q.change) { where.push('d.change = ?'); params.push(q.change); }
502
+ const w = `WHERE ${where.join(' AND ')}${rf.sql}`;
503
+ const total = this.one<{ c: number }>(`SELECT count(*) c FROM docs d ${w}`, ...params, ...rf.params)!.c;
504
+ const rows = this.all<Record<string, unknown>>(`SELECT d.*, c.on_trunk, (SELECT count(*) FROM code_files f WHERE f.repo = d.repo AND f.path = d.path AND f.blob = d.blob) cur
505
+ FROM docs d JOIN commits c ON c.repo = d.repo AND c.hash = d.commit_hash ${w} ORDER BY d.at_ms, d.path LIMIT ? OFFSET ?`, ...params, ...rf.params, lim(q), off(q));
506
+ return pageOf(total, rows.map((r) => this.docRow(r)), off(q));
507
+ }
508
+
509
+ /** A document version by its entry id, or by path and commit; its text from the ledger's copy (paged by lines). */
510
+ docText(input: { id?: string; path?: string; commit?: string; repo?: string }, lines: { from?: number; to?: number } = {}) {
511
+ let row: Record<string, unknown> | undefined;
512
+ if (input.id) row = this.one('SELECT * FROM docs WHERE key = ? LIMIT 1', input.id.trim());
513
+ else if (input.path && input.commit) {
514
+ const c = this.findCommit(input.commit, input.repo);
515
+ if (typeof c === 'string') return c;
516
+ const path = nameForm(input.path).replace(/\\/g, '/');
517
+ row = this.one('SELECT * FROM docs WHERE repo = ? AND path = ? AND commit_hash = ?', String(c.repo), path, String(c.hash));
518
+ if (!row) {
519
+ // Not a version the commit made: the version its tree has is the latest version up to it on its history.
520
+ const root = this.repoPath(String(c.repo));
521
+ const blob = root ? ledgerGit(root, ['rev-parse', '--verify', '--quiet', `${String(c.hash)}:${path}`]).out.trim() : '';
522
+ if (/^[0-9a-f]{40}$/.test(blob)) row = this.one('SELECT * FROM docs WHERE repo = ? AND path = ? AND blob = ? ORDER BY at_ms LIMIT 1', String(c.repo), path, blob) ?? { repo: c.repo, path, commit_hash: c.hash, blob, at: c.author_at, import_root: c.import_root, key: `doc:${path}@${String(c.hash).slice(0, 10)}` };
523
+ }
524
+ } else return 'Give the version: its id (doc:<path>@<commit>), or path and commit.';
525
+ if (!row) return `No such version in the ledger: ${input.id ?? `${input.path} at ${input.commit}`}. pk_ledger_doc_versions lists the versions of a document.`;
526
+ const repo = String(row.repo);
527
+ let text = this.blobText(repo, String(row.blob));
528
+ if (text === null) {
529
+ const root = this.repoPath(repo);
530
+ const buf = root ? catBlobs(root, [String(row.blob)]).get(String(row.blob)) : null;
531
+ if (!buf || !isText(buf)) return `${String(row.path)} at ${String(row.commit_hash).slice(0, 10)} is not text the ledger can show.`;
532
+ text = buf.toString('utf8');
533
+ }
534
+ const all = text.split(/\r?\n/);
535
+ const from = Math.min(Math.max(1, Math.floor(lines.from ?? 1)), Math.max(1, all.length));
536
+ const cap = 60_000;
537
+ const out: string[] = [];
538
+ let size = 0;
539
+ let end = from - 1;
540
+ const last = Math.min(all.length, lines.to && lines.to >= from ? Math.floor(lines.to) : all.length);
541
+ for (let i = from - 1; i < last; i++) {
542
+ if (out.length && size + all[i]!.length + 1 > cap) break;
543
+ out.push(all[i]!);
544
+ size += all[i]!.length + 1;
545
+ end = i + 1;
546
+ }
547
+ const current = this.one<{ c: number }>('SELECT count(*) c FROM code_files WHERE repo = ? AND path = ? AND blob = ?', repo, String(row.path), String(row.blob))!.c > 0;
548
+ return {
549
+ id: String(row.key), repo, path: String(row.path), commit: String(row.commit_hash).slice(0, 12), current,
550
+ occurred: this.docOccurred({ repo, path: String(row.path), commit_hash: String(row.commit_hash), at: String(row.at), import_root: Number(row.import_root ?? 0), blob: String(row.blob) }),
551
+ lines: all.length, fromLine: from, toLine: end, ...(end < last ? { nextFromLine: end + 1 } : {}), text: out.join('\n'),
552
+ };
553
+ }
554
+
555
+ /**
556
+ * The materials a sweep's brief names, as the ledger has them (Spec §3.7: the depth question counts each path's
557
+ * materials by the question list): every version of each document at or under a named path, the current source files
558
+ * there (not documents), and the named commits it knows (a prefix that names one commit). Keys, so a caller can take
559
+ * the union over several briefs.
560
+ */
561
+ named(input: { readonly paths: readonly string[]; readonly commits: readonly string[] }): { versions: { key: string; path: string }[]; codeFiles: { key: string; lines: number }[]; commits: string[] } {
562
+ const versions = new Map<string, string>();
563
+ const code = new Map<string, number>();
564
+ const commits = new Set<string>();
565
+ for (const raw of input.paths) {
566
+ const p = nameForm(raw).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
567
+ if (!p) continue;
568
+ for (const d of this.all<{ key: string; path: string }>(`SELECT key, path FROM docs WHERE path = ? OR path LIKE ? ESCAPE '\\'`, p, `${likeEscape(p)}/%`)) versions.set(d.key, d.path);
569
+ for (const f of this.all<{ repo: string; path: string; lines: number }>(`SELECT repo, path, lines FROM code_files WHERE generated = 0 AND classification IS NULL AND lines IS NOT NULL
570
+ AND (lang IS NULL OR lang NOT IN ('markdown', 'text')) AND (path = ? OR path LIKE ? ESCAPE '\\')`, p, `${likeEscape(p)}/%`)) code.set(`${f.repo}:${f.path}`, f.lines);
571
+ }
572
+ for (const c of input.commits) {
573
+ const hits = this.all<{ hash: string }>('SELECT DISTINCT hash FROM commits WHERE hash LIKE ? LIMIT 2', `${c.toLowerCase()}%`);
574
+ if (hits.length === 1) commits.add(hits[0]!.hash);
575
+ }
576
+ return { versions: [...versions].map(([key, path]) => ({ key, path })), codeFiles: [...code].map(([key, lines]) => ({ key, lines })), commits: [...commits] };
577
+ }
578
+
579
+ // ───────────────────────── what a deepening reads (Spec §3.7: its reading assignments) ─────────────────────────
580
+
581
+ /**
582
+ * Document versions by id, with what reading them needs: the repository and path, the commit and when it was made, the
583
+ * size, and whether the version is the one the current version of its repository has. Ids the ledger lacks are left out.
584
+ */
585
+ versionsByKey(keys: readonly string[]): { key: string; repo: string; path: string; commit: string; blob: string; bytes: number; lines: number; atMs: number; current: boolean }[] {
586
+ const stmt = this.db.prepare('SELECT d.key, d.repo, d.path, d.commit_hash, d.blob, d.bytes, d.lines, d.at_ms, (SELECT count(*) FROM code_files f WHERE f.repo = d.repo AND f.path = d.path AND f.blob = d.blob) cur FROM docs d WHERE d.key = ? LIMIT 1');
587
+ const out: { key: string; repo: string; path: string; commit: string; blob: string; bytes: number; lines: number; atMs: number; current: boolean }[] = [];
588
+ for (const key of new Set(keys)) {
589
+ const r = stmt.get(key) as { key: string; repo: string; path: string; commit_hash: string; blob: string; bytes: number | null; lines: number | null; at_ms: number; cur: number } | undefined;
590
+ if (r) out.push({ key: r.key, repo: r.repo, path: r.path, commit: r.commit_hash, blob: r.blob, bytes: Number(r.bytes ?? 0), lines: Number(r.lines ?? 0), atMs: Number(r.at_ms), current: Number(r.cur) > 0 });
591
+ }
592
+ return out;
593
+ }
594
+
595
+ /** A document version's whole text — the ledger's copy, else git's — by its id; null when the ledger lacks it or it is not text. */
596
+ versionText(key: string): string | null {
597
+ const row = this.one<{ repo: string; blob: string }>('SELECT repo, blob FROM docs WHERE key = ? LIMIT 1', key);
598
+ if (!row) return null;
599
+ const kept = this.blobText(row.repo, row.blob);
600
+ if (kept !== null) return kept;
601
+ const root = this.repoPath(row.repo);
602
+ const buf = root ? catBlobs(root, [row.blob]).get(row.blob) : null;
603
+ return buf && isText(buf) ? buf.toString('utf8') : null;
604
+ }
605
+
606
+ /** Every document version (id and path): what a kind of question counted by the ledger's totals plans whole. */
607
+ allVersions(): { key: string; path: string }[] {
608
+ return this.all<{ key: string; path: string }>('SELECT key, path FROM docs ORDER BY path, at_ms');
609
+ }
610
+
611
+ /** Every code file of the current version — not generated, not third-party, not a document — by `<repo>:<path>`, with its lines. */
612
+ allCodeFiles(): { key: string; lines: number }[] {
613
+ return this.all<{ repo: string; path: string; lines: number | null }>(`SELECT repo, path, lines FROM code_files WHERE generated = 0 AND classification IS NULL AND lines IS NOT NULL
614
+ AND (lang IS NULL OR lang NOT IN ('markdown', 'text')) ORDER BY repo, path`).map((f) => ({ key: `${f.repo}:${f.path}`, lines: Number(f.lines ?? 0) }));
615
+ }
616
+
617
+ /** Code files by `<repo>:<path>`: their size in bytes and lines. */
618
+ codeFilesByKey(keys: readonly string[]): { key: string; repo: string; path: string; bytes: number; lines: number }[] {
619
+ // The repositories, and the scope items outside version control whose files the ledger keeps by their id.
620
+ const repos = this.all<{ repo: string }>('SELECT DISTINCT repo FROM code_files').map((r) => r.repo).sort((a, b) => b.length - a.length);
621
+ const stmt = this.db.prepare('SELECT bytes, lines FROM code_files WHERE repo = ? AND path = ? LIMIT 1');
622
+ const out: { key: string; repo: string; path: string; bytes: number; lines: number }[] = [];
623
+ for (const key of new Set(keys)) {
624
+ const repo = repos.find((r) => key.startsWith(`${r}:`));
625
+ if (!repo) continue;
626
+ const path = key.slice(repo.length + 1);
627
+ const r = stmt.get(repo, path) as { bytes: number | null; lines: number | null } | undefined;
628
+ if (r) out.push({ key, repo, path, bytes: Number(r.bytes ?? 0), lines: Number(r.lines ?? 0) });
629
+ }
630
+ return out;
631
+ }
632
+
633
+ /** Every commit on every ref, by full hash. */
634
+ allCommits(): string[] {
635
+ return this.all<{ hash: string }>('SELECT hash FROM commits ORDER BY author_ms, hash').map((r) => r.hash);
636
+ }
637
+
638
+ /**
639
+ * Commits by full hash, each with what reading it takes (its message, how many files it changed) and what it belongs with:
640
+ * the merge that brought it into the trunk, the branch it is on when it never reached the trunk, else the trunk itself —
641
+ * found from the parents the ledger recorded, oldest merge first.
642
+ */
643
+ commitsByHash(hashes: readonly string[]): { hash: string; repo: string; subject: string; bytes: number; files: number; atMs: number; group: string; groupLabel: string }[] {
644
+ const wanted = new Set(hashes.map((h) => h.toLowerCase()));
645
+ if (wanted.size === 0) return [];
646
+ type Row = { repo: string; hash: string; parents: string; merge: number; on_trunk: number; first_parent_trunk: number; author_ms: number; subject: string; body: string | null };
647
+ const rows = this.all<Row>('SELECT repo, hash, parents, merge, on_trunk, first_parent_trunk, author_ms, subject, body FROM commits ORDER BY author_ms, hash');
648
+ const byHash = new Map(rows.map((r) => [r.hash, r]));
649
+ const groupOf = new Map<string, { group: string; label: string }>();
650
+ // A merge on the trunk's first-parent line takes what it brought in: what its later parents reach that the trunk had not.
651
+ for (const m of rows.filter((r) => r.merge === 1 && r.first_parent_trunk === 1)) {
652
+ const label = `merge ${m.hash.slice(0, 7)}: ${m.subject.slice(0, 80)}`;
653
+ const stack = String(m.parents).split(' ').filter(Boolean).slice(1);
654
+ while (stack.length) {
655
+ const h = stack.pop()!;
656
+ const c = byHash.get(h);
657
+ if (!c || c.first_parent_trunk === 1 || groupOf.has(h)) continue;
658
+ groupOf.set(h, { group: `merge:${m.hash}`, label });
659
+ stack.push(...String(c.parents).split(' ').filter(Boolean));
660
+ }
661
+ groupOf.set(m.hash, { group: `merge:${m.hash}`, label });
662
+ }
663
+ // What never reached the trunk goes with the branch it is on.
664
+ for (const b of this.all<{ repo: string; name: string; tip: string }>('SELECT repo, name, tip FROM branches ORDER BY name')) {
665
+ const stack = [b.tip];
666
+ while (stack.length) {
667
+ const h = stack.pop()!;
668
+ const c = byHash.get(h);
669
+ if (!c || c.on_trunk === 1 || groupOf.has(h)) continue;
670
+ groupOf.set(h, { group: `branch:${b.repo}:${b.name}`, label: `branch ${b.name}` });
671
+ stack.push(...String(c.parents).split(' ').filter(Boolean));
672
+ }
673
+ }
674
+ const files = this.db.prepare('SELECT count(*) c FROM commit_files WHERE repo = ? AND hash = ?');
675
+ return rows.filter((r) => wanted.has(r.hash)).map((r) => {
676
+ const g = groupOf.get(r.hash) ?? { group: `trunk:${r.repo}`, label: 'the trunk' };
677
+ const n = Number((files.get(r.repo, r.hash) as { c: number }).c);
678
+ return { hash: r.hash, repo: r.repo, subject: r.subject, bytes: Buffer.byteLength(`${r.subject}\n${r.body ?? ''}`) + n * 80 + 400, files: n, atMs: Number(r.author_ms), group: g.group, groupLabel: g.label };
679
+ });
680
+ }
681
+
682
+ /** Every session the ledger read, by its id. */
683
+ allSessions(): string[] {
684
+ return this.all<{ key: string }>('SELECT key FROM sessions ORDER BY started_at IS NULL, started_at, key').map((r) => r.key);
685
+ }
686
+
687
+ /** Every session's log, by the session's id. */
688
+ sessionLogs(): { key: string; file: string }[] {
689
+ return this.all<{ key: string; file: string }>('SELECT key, file FROM sessions');
690
+ }
691
+
692
+ /**
693
+ * Sessions by ledger id: host, native id, log, start, how many messages, the index range of its messages and the bytes of
694
+ * text the ledger keeps of them (the owner's messages and the agent messages they answer; the rest by speaker and size).
695
+ */
696
+ sessionsByKey(keys: readonly string[]): { key: string; host: string; sessionId: string; file: string; startedAt: string | null; messages: number; firstIndex: number; lastIndex: number; textBytes: number }[] {
697
+ const s = this.db.prepare('SELECT key, host, session_id, file, started_at, messages FROM sessions WHERE key = ?');
698
+ const m = this.db.prepare('SELECT min(idx) a, max(idx) b, count(*) c, ifnull(sum(length(CAST(text AS BLOB))), 0) t FROM session_messages WHERE session = ?');
699
+ const out: { key: string; host: string; sessionId: string; file: string; startedAt: string | null; messages: number; firstIndex: number; lastIndex: number; textBytes: number }[] = [];
700
+ for (const key of new Set(keys)) {
701
+ const r = s.get(key) as { key: string; host: string; session_id: string; file: string; started_at: string | null; messages: number } | undefined;
702
+ if (!r) continue;
703
+ const x = m.get(key) as { a: number | null; b: number | null; c: number; t: number };
704
+ out.push({ key: r.key, host: r.host, sessionId: r.session_id, file: r.file, startedAt: r.started_at, messages: Number(x.c), firstIndex: Number(x.a ?? 0), lastIndex: Number(x.b ?? -1), textBytes: Number(x.t) });
705
+ }
706
+ return out;
707
+ }
708
+
709
+ /**
710
+ * The sessions a brief names: those whose log is at or under one of these paths (absolute, any case, any slash), and those
711
+ * whose native id begins with one of these ids (eight characters or more, so a short hash names no session by chance).
712
+ */
713
+ namedSessions(paths: readonly string[], ids: readonly string[] = []): string[] {
714
+ const rows = this.all<{ key: string; file: string; session_id: string }>('SELECT key, file, session_id FROM sessions');
715
+ const norm = (p: string) => nameForm(p).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
716
+ const roots = paths.map(norm).filter((p) => /^([a-z]:\/|\/)/.test(p));
717
+ const prefixes = ids.map((i) => i.toLowerCase()).filter((i) => i.length >= 8);
718
+ return rows.filter((r) => {
719
+ const f = norm(r.file);
720
+ return roots.some((root) => f === root || f.startsWith(`${root}/`)) || prefixes.some((p) => r.session_id.toLowerCase().startsWith(p));
721
+ }).map((r) => r.key);
722
+ }
723
+
724
+ /** Documents deleted from the history (not back in the current version), with the commit whose tree still has each in full. */
725
+ deletedDocs(q: Paging & { repo?: string; dir?: string; cleanupsOnly?: boolean } = {}) {
726
+ const rf = this.repoFilter(q.repo, 'd.repo');
727
+ if (typeof rf === 'string') return rf;
728
+ const where = ['NOT EXISTS (SELECT 1 FROM code_files f WHERE f.repo = d.repo AND f.path = d.path)'];
729
+ const params: (string | number)[] = [];
730
+ if (q.dir) { where.push("d.path LIKE ? ESCAPE '\\'"); params.push(`${likeEscape(nameForm(q.dir).replace(/\\/g, '/').replace(/\/+$/, ''))}/%`); }
731
+ if (q.cleanupsOnly) where.push('d.cleanup = 1');
732
+ const w = `WHERE ${where.join(' AND ')}${rf.sql}`;
733
+ const total = this.one<{ c: number }>(`SELECT count(*) c FROM deleted_docs d ${w}`, ...params, ...rf.params)!.c;
734
+ const rows = this.all<{ key: string; repo: string; path: string; deleted_commit: string; deleted_at: string; readable_at: string | null; cleanup: number }>(
735
+ `SELECT d.* FROM deleted_docs d ${w} ORDER BY d.deleted_ms DESC, d.path LIMIT ? OFFSET ?`, ...params, ...rf.params, lim(q), off(q));
736
+ return pageOf(total, rows.map((d) => ({
737
+ id: d.key, repo: d.repo, path: d.path, deletedIn: `commit:${d.deleted_commit.slice(0, 12)}`, occurred: this.commitOccurred(d.deleted_commit, d.deleted_at),
738
+ readableAt: d.readable_at ? `commit:${d.readable_at.slice(0, 12)}` : null, cleanup: d.cleanup === 1,
739
+ })), off(q));
740
+ }
741
+
742
+ /** The cleanup commits (CLEANUP_RULE), each with the document catalogue before and after. */
743
+ cleanups(opts: { repo?: string; id?: string } = {}) {
744
+ const rf = this.repoFilter(opts.repo);
745
+ if (typeof rf === 'string') return rf;
746
+ const rows = this.all<{ key: string; repo: string; commit_hash: string; at: string; deleted_docs: number; deleted_total: number; catalog_before: string; catalog_after: string }>(
747
+ `SELECT * FROM cleanups WHERE 1 = 1${opts.id ? ' AND (key = ? OR commit_hash LIKE ?)' : ''}${rf.sql} ORDER BY at_ms`, ...(opts.id ? [opts.id, `${opts.id.replace(/^cleanup:/, '')}%`] : []), ...rf.params);
748
+ return {
749
+ rule: CLEANUP_RULE,
750
+ cleanups: rows.map((c) => {
751
+ const before = json<string[]>(c.catalog_before, []);
752
+ const after = new Set(json<string[]>(c.catalog_after, []));
753
+ const subject = this.one<{ subject: string }>('SELECT subject FROM commits WHERE repo = ? AND hash = ?', c.repo, c.commit_hash)?.subject ?? '';
754
+ return {
755
+ id: c.key, commit: `commit:${c.commit_hash.slice(0, 12)}`, subject, occurred: this.commitOccurred(c.commit_hash, c.at), deletedDocuments: c.deleted_docs, deletedFiles: c.deleted_total,
756
+ before: before.length, after: after.size, removed: before.filter((p) => !after.has(p)), added: [...after].filter((p) => !before.includes(p)),
757
+ ...(opts.id ? { catalogBefore: before, catalogAfter: [...after] } : {}),
758
+ };
759
+ }),
760
+ };
761
+ }
762
+
763
+ // ───────────────────────── line entries: supersessions, numbers, verdicts ─────────────────────────
764
+
765
+ private lineBase(r: Record<string, unknown>): LineRow {
766
+ const first = (r.first_commit ?? r.commit_hash ?? null) as string | null;
767
+ return {
768
+ id: String(r.key), repo: (r.repo as string | null) ?? null, path: (r.path as string | null) ?? null,
769
+ line: (r.current_line ?? r.first_line ?? r.line ?? null) as number | null, text: String(r.text ?? r.context ?? ''),
770
+ occurred: this.rowOccurred(r as never), firstIn: first ? `commit:${first.slice(0, 12)}` : null,
771
+ lastIn: r.last_commit ? `commit:${String(r.last_commit).slice(0, 12)}` : null, current: r.current === 1,
772
+ };
773
+ }
774
+
775
+ /** Explicit old/new readings and rows of the project's own obsolete lists (AC-4). */
776
+ supersessions(q: Paging & { repo?: string; path?: string; pattern?: string; obsoleteOnly?: boolean; currentOnly?: boolean; source?: string; keyword?: string; since?: string; until?: string } = {}): Page<SupersessionRow> | string {
777
+ const rf = this.repoFilter(q.repo);
778
+ if (typeof rf === 'string') return rf;
779
+ const where = ['1 = 1'];
780
+ const params: (string | number)[] = [];
781
+ if (q.path) { const p = nameForm(q.path).replace(/\\/g, '/'); where.push("(path = ? OR path LIKE ? ESCAPE '\\')"); params.push(p, `${likeEscape(p.replace(/\/+$/, ''))}/%`); }
782
+ if (q.pattern) { where.push('pattern = ?'); params.push(q.pattern); }
783
+ if (q.obsoleteOnly) where.push('obsolete_list = 1');
784
+ if (q.currentOnly) where.push('current = 1');
785
+ if (q.source) { where.push('source = ?'); params.push(q.source); }
786
+ if (q.keyword) { where.push("(text LIKE ? ESCAPE '\\' OR target LIKE ? ESCAPE '\\')"); const k = `%${likeEscape(q.keyword)}%`; params.push(k, k); }
787
+ this.timeFilter(where, params, 'first_ms', q);
788
+ const w = `WHERE ${where.join(' AND ')}${rf.sql}`;
789
+ const total = this.one<{ c: number }>(`SELECT count(*) c FROM supersedes ${w}`, ...params, ...rf.params)!.c;
790
+ const rows = this.all<Record<string, unknown>>(`SELECT * FROM supersedes ${w} ORDER BY first_ms IS NULL, first_ms, path, first_line LIMIT ? OFFSET ?`, ...params, ...rf.params, lim(q), off(q));
791
+ return pageOf(total, rows.map((r) => ({
792
+ ...this.lineBase(r), source: String(r.source), pattern: String(r.pattern), target: (r.target as string | null) ?? null,
793
+ replaced: (r.replaced as string | null) ?? null, replacement: (r.replacement as string | null) ?? null, syntax: (r.syntax as string | null) ?? null,
794
+ obsoleteList: r.obsolete_list === 1, list: (r.list_heading as string | null) ?? null,
795
+ ...(r.source === 'commit' ? { firstIn: `commit:${String(r.first_commit).slice(0, 12)}`, path: null } : {}),
796
+ })), off(q));
797
+ }
798
+
799
+ /**
800
+ * The numbers a document defines as it stands now (CJ; CKC-23 AC-21): every current definition of a recognised number
801
+ * in the file — a heading, a bold entry line, a table's first column, the front-matter id — in line order. `repo` is a
802
+ * repository's scope item id or path; `path` is repository-relative (a directory gives the files under it).
803
+ */
804
+ definitionsIn(repo: string, path: string): { readonly num: string; readonly rule: string; readonly position: string; readonly line: number; readonly context: string; readonly path: string }[] {
805
+ const id = this.repoId(repo);
806
+ if (!id) return [];
807
+ const p = nameForm(path).replace(/\\/g, '/').replace(/\/+$/, '');
808
+ return this.currentLines(this.all<{ key: string; repo: string | null; num: string; rule: string; position: string | null; line: number | null; context: string; path: string }>(
809
+ `SELECT key, repo, num, rule, position, line, context, path FROM nums WHERE repo = ? AND kind IN ('doc', 'loose') AND place = 'definition' AND current = 1
810
+ AND (path = ? OR path LIKE ? ESCAPE '\\')`, id, p, `${likeEscape(p)}/%`,
811
+ )).map((x) => ({ num: x.num, rule: x.rule, position: x.position ?? '', line: x.line ?? 0, context: x.context, path: x.path }))
812
+ .sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line);
813
+ }
814
+
815
+ /**
816
+ * A number's row gives the line and the words of the version it first appeared in; a document read now has it where the
817
+ * current version has it. Each row, moved to its line in the checkout's version (`doc_items` for that blob) and given
818
+ * that line's words; a row the current version does not place keeps what it had.
819
+ */
820
+ private currentLines<T extends { key: string; repo: string | null; path: string | null; line: number | null; context: string }>(rows: readonly T[]): T[] {
821
+ const blobs = new Map<string, { blob: string | null; lines: string[] | null }>();
822
+ const blobOf = (repo: string, path: string) => {
823
+ const k = `${repo}\u0000${path}`;
824
+ if (!blobs.has(k)) {
825
+ const f = this.one<{ blob: string | null }>('SELECT blob FROM code_files WHERE repo = ? AND path = ?', repo, path);
826
+ const text = f?.blob ? this.blobText(repo, f.blob) : null;
827
+ blobs.set(k, { blob: f?.blob ?? null, lines: text === null ? null : text.split(/\r?\n/) });
828
+ }
829
+ return blobs.get(k)!;
830
+ };
831
+ return rows.map((r) => {
832
+ if (!r.repo || !r.path) return r;
833
+ const b = blobOf(r.repo, r.path);
834
+ if (!b.blob) return r;
835
+ const at = this.one<{ line: number }>("SELECT line FROM doc_items WHERE repo = ? AND path = ? AND blob = ? AND tbl = 'nums' AND key = ?", r.repo, r.path, b.blob, r.key);
836
+ if (!at) return r;
837
+ const words = b.lines?.[at.line - 1];
838
+ return { ...r, line: at.line, context: words !== undefined ? words.trim() : r.context };
839
+ });
840
+ }
841
+
842
+ /** Which of these numbers the project defines anywhere the ledger reads (documents, file and branch names, commit subjects). */
843
+ definedNumbers(nums: readonly string[]): Set<string> {
844
+ const out = new Set<string>();
845
+ const wanted = [...new Set(nums.map((n) => n.trim()).filter(Boolean))];
846
+ for (let i = 0; i < wanted.length; i += 400) {
847
+ const chunk = wanted.slice(i, i + 400);
848
+ for (const r of this.all<{ num: string }>(`SELECT DISTINCT num FROM nums WHERE place = 'definition' AND num IN (${chunk.map(() => '?').join(',')})`, ...chunk)) out.add(r.num);
849
+ }
850
+ return out;
851
+ }
852
+
853
+ /** The current documents where a number is defined, with the line that defines it (a number the project defines in several places has several). */
854
+ definitionsOf(num: string): { readonly repo: string | null; readonly path: string; readonly position: string; readonly line: number; readonly context: string }[] {
855
+ return this.currentLines(this.all<{ key: string; repo: string | null; path: string | null; position: string | null; line: number | null; context: string }>(
856
+ "SELECT key, repo, path, position, line, context FROM nums WHERE num = ? AND place = 'definition' AND kind IN ('doc', 'loose') AND current = 1", num.trim(),
857
+ )).filter((x) => x.path).map((x) => ({ repo: x.repo, path: x.path!, position: x.position ?? '', line: x.line ?? 0, context: x.context }))
858
+ .sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line);
859
+ }
860
+
861
+ /** The current documents that define a number, without their lines (cheap: no document text is read). */
862
+ definitionPlaces(num: string): { readonly repo: string | null; readonly path: string }[] {
863
+ return this.all<{ repo: string | null; path: string | null }>(
864
+ "SELECT DISTINCT repo, path FROM nums WHERE num = ? AND place = 'definition' AND kind IN ('doc', 'loose') AND current = 1", num.trim(),
865
+ ).filter((x) => x.path).map((x) => ({ repo: x.repo, path: x.path! }));
866
+ }
867
+
868
+ /** The repository-relative path a ledger entry is about (a document version, a line of one, an arrangement), or null. */
869
+ pathOfEntry(id: string): { readonly repo: string | null; readonly path: string } | null {
870
+ for (const table of ['docs', 'deleted_docs', 'verdicts', 'nums', 'supersedes', 'plans']) {
871
+ const r = this.one<{ repo: string | null; path: string | null }>(`SELECT repo, path FROM ${table} WHERE key = ? LIMIT 1`, id);
872
+ if (r?.path) return { repo: r.repo, path: r.path };
873
+ }
874
+ return null;
875
+ }
876
+
877
+ /** What the arrangement files at a path are, in any version: `prompt`, `receipt` … with the work id each is about. */
878
+ arrangementKinds(path: string): { readonly kind: string; readonly ident: string | null }[] {
879
+ return this.all<{ kind: string; ident: string | null }>('SELECT DISTINCT kind, ident FROM plans WHERE path = ?', nameForm(path).replace(/\\/g, '/'));
880
+ }
881
+
882
+ /** The numbering rules the project uses, each with the basis the program recognised it by (AC-5). */
883
+ numRules() {
884
+ // One grouped count, not a count per rule: coverage() runs this, and a view that ran coverage() per item made the
885
+ // process view take minutes on a project with a few hundred reference items (resident D99 run, 2026-09-30).
886
+ const counts = new Map(this.all<{ rule: string; c: number }>('SELECT rule, count(*) c FROM nums GROUP BY rule').map((x) => [x.rule, x.c]));
887
+ return {
888
+ rule: `A family counts when at least ${MIN_DEFINED} different numbers of it are defined (heading or bold line start, a table's first column, a front-matter id, a file or branch name prefix, a commit subject's start); two capital letters only in an index table's first column, a front-matter id, or names where ${MIN_DEFINED}+ stand together.`,
889
+ rules: this.all<{ rule: string; shape: string; basis: string; examples: string; defined: number }>('SELECT * FROM num_rules ORDER BY defined DESC')
890
+ .map((r) => ({ id: `rule:${r.rule}`, rule: r.rule, shape: r.shape, defined: r.defined, examples: r.examples, basis: r.basis,
891
+ occurrences: counts.get(r.rule) ?? 0 })),
892
+ };
893
+ }
894
+
895
+ /** Every place a number shows up: documents (with first and last version), commit messages, branch, worktree and file names. */
896
+ nums(q: Paging & { num?: string; rule?: string; kind?: string; place?: string; path?: string; repo?: string; currentOnly?: boolean; since?: string; until?: string } = {}): Page<NumRow> | string {
897
+ const rf = this.repoFilter(q.repo);
898
+ if (typeof rf === 'string') return rf;
899
+ const where = ['1 = 1'];
900
+ const params: (string | number)[] = [];
901
+ if (q.num) { where.push('num = ?'); params.push(q.num.trim()); }
902
+ if (q.rule) { where.push('rule = ?'); params.push(q.rule); }
903
+ if (q.kind) { where.push('kind = ?'); params.push(q.kind); }
904
+ if (q.place) { where.push('place = ?'); params.push(q.place); }
905
+ if (q.path) { const p = nameForm(q.path).replace(/\\/g, '/'); where.push("(path = ? OR path LIKE ? ESCAPE '\\')"); params.push(p, `${likeEscape(p.replace(/\/+$/, ''))}/%`); }
906
+ if (q.currentOnly) where.push('current = 1');
907
+ const since = boundMs(q.since, false);
908
+ const until = boundMs(q.until, true);
909
+ if (since !== null) { where.push('first_ms >= ?'); params.push(since); }
910
+ if (until !== null) { where.push('first_ms <= ?'); params.push(until); }
911
+ const w = `WHERE ${where.join(' AND ')}${rf.sql}`;
912
+ const total = this.one<{ c: number }>(`SELECT count(*) c FROM nums ${w}`, ...params, ...rf.params)!.c;
913
+ const rows = this.all<Record<string, unknown>>(`SELECT * FROM nums ${w} ORDER BY first_ms IS NULL, first_ms, kind, path, line LIMIT ? OFFSET ?`, ...params, ...rf.params, lim(q), off(q));
914
+ return pageOf(total, rows.map((r) => ({
915
+ ...this.lineBase(r), num: String(r.num), rule: String(r.rule), kind: String(r.kind), place: String(r.place), confidence: String(r.confidence),
916
+ ...(r.kind === 'branch' || r.kind === 'worktree' ? { path: String(r.context) } : {}),
917
+ })), off(q));
918
+ }
919
+
920
+ /** Verdicts, test counts and numbered findings stated in reports, each with its line (AC-6). */
921
+ verdicts(q: Paging & { repo?: string; path?: string; verdict?: string; kind?: string; confidence?: string; keyword?: string; currentOnly?: boolean; since?: string; until?: string } = {}): Page<VerdictRow> | string {
922
+ const rf = this.repoFilter(q.repo);
923
+ if (typeof rf === 'string') return rf;
924
+ const where = ['1 = 1'];
925
+ const params: (string | number)[] = [];
926
+ if (q.path) { const p = nameForm(q.path).replace(/\\/g, '/'); where.push("(path = ? OR path LIKE ? ESCAPE '\\')"); params.push(p, `%${likeEscape(p)}%`); }
927
+ if (q.verdict) { where.push('verdict = ?'); params.push(q.verdict); }
928
+ if (q.kind) { where.push('kind = ?'); params.push(q.kind); }
929
+ if (q.confidence) { where.push('confidence = ?'); params.push(q.confidence); }
930
+ if (q.keyword) { where.push("(text LIKE ? ESCAPE '\\' OR path LIKE ? ESCAPE '\\')"); const k = `%${likeEscape(q.keyword)}%`; params.push(k, k); }
931
+ if (q.currentOnly) where.push('current = 1');
932
+ this.timeFilter(where, params, 'first_ms', q);
933
+ const w = `WHERE ${where.join(' AND ')}${rf.sql}`;
934
+ const total = this.one<{ c: number }>(`SELECT count(*) c FROM verdicts ${w}`, ...params, ...rf.params)!.c;
935
+ const rows = this.all<Record<string, unknown>>(`SELECT * FROM verdicts ${w} ORDER BY first_ms IS NULL, first_ms, path, first_line LIMIT ? OFFSET ?`, ...params, ...rf.params, lim(q), off(q));
936
+ return pageOf(total, rows.map((r) => ({ ...this.lineBase(r), kind: String(r.kind), verdict: (r.verdict as string | null) ?? null, confidence: String(r.confidence) })), off(q));
937
+ }
938
+
939
+ // ───────────────────────── execution arrangements (AC-7) ─────────────────────────
940
+
941
+ arrangements(q: Paging & { repo?: string; kind?: string; ident?: string; path?: string; currentOnly?: boolean; unparsedOnly?: boolean; status?: string; since?: string; until?: string } = {}): Page<ArrangementRow> | string {
942
+ const rf = this.repoFilter(q.repo);
943
+ if (typeof rf === 'string') return rf;
944
+ const where = ['1 = 1'];
945
+ const params: (string | number)[] = [];
946
+ if (q.kind) { where.push('kind = ?'); params.push(q.kind); }
947
+ if (q.ident) { where.push('ident = ?'); params.push(q.ident); }
948
+ if (q.path) { const p = nameForm(q.path).replace(/\\/g, '/'); where.push("(path = ? OR path LIKE ? ESCAPE '\\')"); params.push(p, `%${likeEscape(p)}%`); }
949
+ if (q.currentOnly) where.push('current = 1');
950
+ if (q.unparsedOnly) where.push('parsed = 0');
951
+ if (q.status) { where.push("json_extract(data, '$.status') LIKE ? ESCAPE '\\'"); params.push(`%${likeEscape(q.status)}%`); }
952
+ this.timeFilter(where, params, 'occurred_ms', q);
953
+ const w = `WHERE ${where.join(' AND ')}${rf.sql}`;
954
+ const total = this.one<{ c: number }>(`SELECT count(*) c FROM plans ${w}`, ...params, ...rf.params)!.c;
955
+ const rows = this.all<Record<string, unknown>>(`SELECT * FROM plans ${w} ORDER BY occurred_ms IS NULL, occurred_ms DESC, path LIMIT ? OFFSET ?`, ...params, ...rf.params, lim(q), off(q));
956
+ return pageOf(total, rows.map((r) => ({
957
+ id: String(r.key), repo: (r.repo as string | null) ?? null, path: String(r.path), commit: r.commit_hash ? String(r.commit_hash).slice(0, 12) : null,
958
+ kind: String(r.kind), ident: (r.ident as string | null) ?? null, parsed: r.parsed === 1, current: r.current === 1,
959
+ occurred: this.rowOccurred(r as never), data: json<unknown>(String(r.data), {}),
960
+ })), off(q));
961
+ }
962
+
963
+ /** How the arrangement fields of one work id changed over the versions (status, agent, worktree, baseline). */
964
+ arrangementHistory(ident: string, repo?: string) {
965
+ const rf = this.repoFilter(repo);
966
+ if (typeof rf === 'string') return rf;
967
+ const rows = this.all<Record<string, unknown>>(`SELECT * FROM plans WHERE ident = ?${rf.sql} ORDER BY occurred_ms, path`, ident, ...rf.params);
968
+ const indexRows = this.all<Record<string, unknown>>(`SELECT * FROM plans WHERE kind IN ('index', 'plan')${rf.sql} ORDER BY occurred_ms`, ...rf.params)
969
+ .flatMap((p) => {
970
+ const data = json<{ rows?: { id: string; cells: Record<string, string>; line: number }[] }>(String(p.data), {});
971
+ return (data.rows ?? []).filter((x) => x.id === ident).map((x) => ({ id: String(p.key), path: String(p.path), occurred: this.rowOccurred(p as never), current: p.current === 1, line: x.line, row: x.cells }));
972
+ });
973
+ // One row per change of the fields, per file; a later version with the same fields (a merge carrying it) only makes
974
+ // the row current.
975
+ const lastOf = new Map<string, { sig: string; row: { id: string; path: string; kind: string; occurred: Occurred; current: boolean; status: string | null; agent: string | null; worktree: string | null; baseline: string | null; accepted: string | null; running: boolean } }>();
976
+ const versions: { id: string; path: string; kind: string; occurred: Occurred; current: boolean; status: string | null; agent: string | null; worktree: string | null; baseline: string | null; accepted: string | null; running: boolean }[] = [];
977
+ for (const r of rows) {
978
+ const data = json<{ status?: string | null; agent?: string | null; worktree?: string | null; baseline?: string | null; accepted?: string | null; running?: boolean }>(String(r.data), {});
979
+ const sig = JSON.stringify([data.status, data.agent, data.worktree, data.baseline, data.accepted]);
980
+ const last = lastOf.get(String(r.path));
981
+ if (last && last.sig === sig) { if (r.current === 1) last.row.current = true; continue; }
982
+ const row = { id: String(r.key), path: String(r.path), kind: String(r.kind), occurred: this.rowOccurred(r as never), current: r.current === 1, status: data.status ?? null, agent: data.agent ?? null, worktree: data.worktree ?? null, baseline: data.baseline ?? null, accepted: data.accepted ?? null, running: data.running === true };
983
+ versions.push(row);
984
+ lastOf.set(String(r.path), { sig, row });
985
+ }
986
+ return { ident, versions, indexRows };
987
+ }
988
+
989
+ // ───────────────────────── words across all history (AC-13) ─────────────────────────
990
+
991
+ private textMatch(word: string): { sql: string; param: string } {
992
+ const w = word.trim();
993
+ return [...w].length >= 3
994
+ ? { sql: 't.id IN (SELECT rowid FROM texts_fts WHERE texts_fts MATCH ?)', param: `"${w.replace(/"/g, '""')}"` }
995
+ : { sql: "t.content LIKE ? ESCAPE '\\'", param: `%${likeEscape(w)}%` };
996
+ }
997
+
998
+ /** Whether any text the ledger read (a document version, a commit message, a session) has this token, as written (CJ). */
999
+ hasToken(token: string): boolean {
1000
+ const w = token.trim();
1001
+ if (!w) return false;
1002
+ const m = this.textMatch(w);
1003
+ const re = new RegExp(`(?<![A-Za-z0-9_-])${w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![A-Za-z0-9_])`);
1004
+ return this.all<{ content: string }>(`SELECT t.content FROM texts t WHERE ${m.sql} LIMIT 200`, m.param).some((t) => re.test(t.content));
1005
+ }
1006
+
1007
+ /**
1008
+ * Where a word appears across everything the ledger read: each document it is in (the first and last version that has it,
1009
+ * and whether the current version does), each commit message, the owner's words and the agent messages they answered.
1010
+ * Case follows the trigram index: case-insensitive for three characters or more; shorter words match exactly.
1011
+ */
1012
+ word(word: string, q: Paging & { kinds?: readonly string[]; repo?: string; since?: string; until?: string; oldestFirst?: boolean } = {}): Page<WordHit> | string {
1013
+ const w = word.trim();
1014
+ if (!w) return 'Give a word to look for.';
1015
+ const m = this.textMatch(w);
1016
+ const kinds = new Set(q.kinds && q.kinds.length ? q.kinds : ['doc', 'commit', 'owner', 'agent', 'loose']);
1017
+ const hits: WordHit[] = [];
1018
+ const texts = this.all<{ id: number; key: string; kind: string; repo: string | null; content: string }>(`SELECT t.id, t.key, t.kind, t.repo, t.content FROM texts t WHERE ${m.sql}`, m.param)
1019
+ .filter((t) => kinds.has(t.kind) && (!q.repo || t.repo === this.repoId(q.repo)) && (m.sql.includes('MATCH') ? t.content.toLowerCase().includes(w.toLowerCase()) : true));
1020
+ const blobs = texts.filter((t) => t.kind === 'doc');
1021
+ // Documents: per path, the first and last version whose content has the word.
1022
+ const byPath = new Map<string, { repo: string; path: string; first: Record<string, unknown>; last: Record<string, unknown>; snippet: string; current: boolean }>();
1023
+ for (const t of blobs) {
1024
+ const blob = t.key.slice(t.key.lastIndexOf(':') + 1);
1025
+ for (const d of this.all<Record<string, unknown>>(`SELECT d.*, (SELECT count(*) FROM code_files f WHERE f.repo = d.repo AND f.path = d.path AND f.blob = d.blob) cur FROM docs d WHERE d.repo = ? AND d.blob = ?`, t.repo!, blob)) {
1026
+ const k = `${String(d.repo)}\x1f${String(d.path)}`;
1027
+ const e = byPath.get(k);
1028
+ const snippet = snippetOf(t.content, w);
1029
+ if (!e) byPath.set(k, { repo: String(d.repo), path: String(d.path), first: d, last: d, snippet, current: Number(d.cur) > 0 });
1030
+ else {
1031
+ if (Number(d.at_ms) < Number(e.first.at_ms)) { e.first = d; e.snippet = snippet; }
1032
+ if (Number(d.at_ms) > Number(e.last.at_ms)) e.last = d;
1033
+ if (Number(d.cur) > 0) e.current = true;
1034
+ }
1035
+ }
1036
+ }
1037
+ for (const e of byPath.values()) {
1038
+ const occ = this.docOccurred({ repo: e.repo, path: e.path, commit_hash: String(e.first.commit_hash), at: String(e.first.at), import_root: Number(e.first.import_root), blob: String(e.first.blob) });
1039
+ hits.push({
1040
+ id: String(e.first.key), kind: 'doc', label: `${e.path} — first in ${String(e.first.commit_hash).slice(0, 10)}, last in ${String(e.last.commit_hash).slice(0, 10)}${e.current ? ', in the current version' : ', not in the current version'}`,
1041
+ occurred: occ, snippet: e.snippet, current: e.current, last: { id: String(e.last.key), at: String(e.last.at) },
1042
+ });
1043
+ }
1044
+ for (const t of texts) {
1045
+ if (t.kind === 'commit') {
1046
+ const hash = t.key.split(':').pop()!;
1047
+ const c = this.one<{ author_at: string; committer_at: string; subject: string }>('SELECT author_at, committer_at, subject FROM commits WHERE repo = ? AND hash = ?', t.repo!, hash);
1048
+ if (c) hits.push({ id: `commit:${hash.slice(0, 12)}`, kind: 'commit', label: `${hash.slice(0, 10)} ${clip(c.subject, 100)}`, occurred: this.commitOccurred(hash, c.author_at), snippet: snippetOf(t.content, w) });
1049
+ } else if (t.kind === 'owner' || t.kind === 'agent') {
1050
+ const msg = this.one<{ key: string; idx: number; at: string | null; session: string }>('SELECT key, idx, at, session FROM session_messages WHERE key = ?', t.key);
1051
+ const s = msg ? this.one<{ host: string; session_id: string; first_seen: string }>('SELECT host, session_id, first_seen FROM sessions WHERE key = ?', msg.session) : undefined;
1052
+ if (msg && s) hits.push({ id: msg.key, kind: t.kind, label: `${t.kind === 'owner' ? 'the owner' : 'agent message the owner answered'}, ${s.host} session ${s.session_id.slice(0, 8)} [${msg.idx}]`, occurred: msg.at ? { at: materialTime(msg.at) ?? msg.at, basis: 'Session', anchor: msg.key } : { at: s.first_seen, basis: 'First observed', anchor: msg.key, undated: true }, snippet: snippetOf(t.content, w) });
1053
+ } else if (t.kind === 'loose') {
1054
+ // Dated like every line of a document outside version control: the date its text writes for the line the word is
1055
+ // on, else the file's own time — not some other line's date, and never the epoch (QC AY, CKC-22 AC-10).
1056
+ const path = t.key.slice('loose:'.length);
1057
+ hits.push({ id: t.key, kind: 'loose', label: `${path} (outside version control)`, occurred: this.looseOccurred(path, t.content, lineOf(t.content, w)), snippet: snippetOf(t.content, w), current: true });
1058
+ }
1059
+ }
1060
+ const since = boundMs(q.since, false);
1061
+ const until = boundMs(q.until, true);
1062
+ const kept = hits.filter((h) => { const ms = msOf(h.occurred.at); return (since === null || (ms !== null && ms >= since)) && (until === null || (ms !== null && ms <= until)); })
1063
+ .sort((a, b) => ((msOf(a.occurred.at) ?? 0) - (msOf(b.occurred.at) ?? 0)) * (q.oldestFirst === false ? -1 : 1));
1064
+ const o = off(q);
1065
+ return pageOf(kept.length, kept.slice(o, o + lim(q)), o);
1066
+ }
1067
+
1068
+ /** The first and the last appearance of a word across all history (documents by version, commit messages, sessions). */
1069
+ wordSpan(word: string, opts: { repo?: string } = {}) {
1070
+ const all = this.word(word, { limit: 100_000, repo: opts.repo });
1071
+ if (typeof all === 'string') return all;
1072
+ const rows = [...all.rows];
1073
+ if (rows.length === 0) return { word, total: 0, first: null, last: null };
1074
+ // The last appearance of a document is its last version having the word, not the first.
1075
+ const lastOf = (h: WordHit): { at: string; id: string } => (h.last ? { at: h.last.at, id: h.last.id } : { at: h.occurred.at, id: h.id });
1076
+ const last = rows.map((h) => ({ h, l: lastOf(h) })).sort((a, b) => (msOf(b.l.at) ?? 0) - (msOf(a.l.at) ?? 0))[0]!;
1077
+ return { word, total: rows.length, byKind: countBy(rows.map((r) => r.kind)), first: rows[0], last: { ...last.h, id: last.l.id, lastAt: last.l.at } };
1078
+ }
1079
+
1080
+ // ───────────────────────── code (AC-8, CKC-03 AC-27) ─────────────────────────
1081
+
1082
+ /** How far a file's references are read, in words: by whom, and to which level. */
1083
+ private levelOf(f: { path: string; reader?: string | null }): string {
1084
+ const reader = this.readerOf(f);
1085
+ return reader === 'compiler' ? 'symbol level (the TypeScript compiler): its imports and references, symbols with pk_ledger_symbol'
1086
+ : reader === 'engine' ? `symbol level (${ENGINE_LABEL}): the references it resolved (${COUNTED_RULE}), symbols with pk_ledger_symbol`
1087
+ : 'file tree only: no reader of its references';
1088
+ }
1089
+
1090
+ /** Who references a file, and what it references (file level; symbols with pk_ledger_symbol). */
1091
+ fileRefs(pathInput: string, opts: { repo?: string } = {}) {
1092
+ const rf = this.repoFilter(opts.repo);
1093
+ if (typeof rf === 'string') return rf;
1094
+ const path = nameForm(pathInput).replace(/\\/g, '/').replace(/^\.\//, '');
1095
+ const f = this.one<{ repo: string; path: string; lines: number | null; lang: string | null; generated: number; classification: string | null; last_commit: string | null; last_at: string | null; reader?: string | null; named_by?: string | null }>(`SELECT * FROM code_files WHERE path = ?${rf.sql}`, path, ...rf.params);
1096
+ if (!f) return `${path} is not in the current version${opts.repo ? ` of ${opts.repo}` : ''}. (File-level references cover the checkout's current version; pk_ledger_ref_history follows a reference back through history.)`;
1097
+ const by = this.all<{ src: string; kind: string }>('SELECT src, kind FROM code_deps WHERE repo = ? AND dst = ? AND external = 0 ORDER BY src', f.repo, path);
1098
+ const to = this.all<{ dst: string; kind: string; external: number }>('SELECT dst, kind, external FROM code_deps WHERE repo = ? AND src = ? ORDER BY external, dst', f.repo, path);
1099
+ const naming = namingOf(f.named_by);
1100
+ const counts = namingThatCounts(naming);
1101
+ const mentionedBy = naming.filter((n) => n.kind === 'mention').map((n) => n.path);
1102
+ return {
1103
+ id: `file:${path}`, repo: f.repo, path, lang: f.lang, lines: f.lines, generated: f.generated === 1, classification: f.classification,
1104
+ level: this.levelOf(f),
1105
+ lastChange: f.last_commit ? { id: `commit:${f.last_commit.slice(0, 12)}`, at: f.last_at } : null,
1106
+ referencedBy: by.map((x) => ({ path: x.src, kind: x.kind })),
1107
+ references: to.filter((x) => x.external === 0).map((x) => ({ path: x.dst, kind: x.kind })),
1108
+ external: to.filter((x) => x.external === 1).map((x) => x.dst),
1109
+ ...(counts.length ? { namedBy: counts.map((n) => n.path), namedByNote: namedByNote(counts) } : {}),
1110
+ ...(mentionedBy.length ? { mentionedBy, mentionedByNote: 'Documents, data or other text that name this file: said there, not referenced.' } : {}),
1111
+ };
1112
+ }
1113
+
1114
+ /**
1115
+ * Files no other file references (a fact; whether one is residue is a judgement): code files whose references a reader
1116
+ * reads. `namedBy`: files that name it though no counted reference reaches it — a build or manifest file (a named entry
1117
+ * point, `entryPoint` says where) or code (a reference no reader resolved): there the fact is uncertain. `mentionedBy`:
1118
+ * other text that names it, which references nothing.
1119
+ */
1120
+ unreferenced(q: Paging & { repo?: string; dir?: string; includeTests?: boolean } = {}) {
1121
+ const rf = this.repoFilter(q.repo, 'f.repo');
1122
+ if (typeof rf === 'string') return rf;
1123
+ const read = this.hasReader() ? 'f.reader IS NOT NULL' : "f.lang IN ('typescript', 'javascript')";
1124
+ const where = [`f.generated = 0 AND f.classification IS NULL AND ${read}`,
1125
+ 'NOT EXISTS (SELECT 1 FROM code_deps d WHERE d.repo = f.repo AND d.dst = f.path AND d.external = 0)'];
1126
+ const params: (string | number)[] = [];
1127
+ if (!q.includeTests) where.push('f.test = 0');
1128
+ if (q.dir) { where.push("f.path LIKE ? ESCAPE '\\'"); params.push(`${likeEscape(nameForm(q.dir).replace(/\\/g, '/').replace(/\/+$/, ''))}/%`); }
1129
+ const w = `WHERE ${where.join(' AND ')}${rf.sql}`;
1130
+ const total = this.one<{ c: number }>(`SELECT count(*) c FROM code_files f ${w}`, ...params, ...rf.params)!.c;
1131
+ const rows = this.all<{ repo: string; path: string; lang: string; ref_lang?: string | null; lines: number | null; last_commit: string | null; last_at: string | null; named_by?: string | null }>(`SELECT f.* FROM code_files f ${w} ORDER BY f.path LIMIT ? OFFSET ?`, ...params, ...rf.params, lim(q, 1000), off(q));
1132
+ return {
1133
+ note: 'An entry point (main, a route file, a test, a script run by hand) is referenced by nothing and is not residue; the judgement is yours. A row with namedBy is named by other files though no counted reference reaches it — a build or manifest file, which makes it a named entry point (entryPoint says where), or code whose reader missed a reference there: read those files before calling it unreferenced. mentionedBy: documents, data or other text that name it, which reference nothing.',
1134
+ ...pageOf(total, rows.map((r) => {
1135
+ const naming = namingOf(r.named_by);
1136
+ const counts = namingThatCounts(naming);
1137
+ const entry = [...new Set(counts.filter((n) => n.kind === 'entry').map((n) => fileNameOf(n.path)))];
1138
+ const mentionedBy = naming.filter((n) => n.kind === 'mention').map((n) => n.path);
1139
+ return {
1140
+ id: `file:${r.path}`, repo: r.repo, path: r.path, lang: r.ref_lang ?? r.lang, lines: r.lines, lastChange: r.last_commit ? `commit:${r.last_commit.slice(0, 12)}` : null,
1141
+ ...(counts.length ? { namedBy: counts.map((n) => n.path) } : {}), ...(entry.length ? { entryPoint: `named in ${entry.join(', ')}` } : {}), ...(mentionedBy.length ? { mentionedBy } : {}),
1142
+ };
1143
+ }), off(q)),
1144
+ };
1145
+ }
1146
+
1147
+ /**
1148
+ * Since when a file references another (file level, from history): each version of `src` on the given history, read the
1149
+ * way its language is read now — TypeScript and JavaScript by the compiler's pre-parser, every other language by the code
1150
+ * engine's extraction of that version's imports — and the commits where the reference appears and disappears. The target
1151
+ * is matched by its file name (the engine's import specifiers by the file's stem), so a moved target is still found.
1152
+ */
1153
+ refHistory(srcInput: string, dstInput: string, opts: { repo?: string } = {}) {
1154
+ const rf = this.repoFilter(opts.repo);
1155
+ if (typeof rf === 'string') return rf;
1156
+ const src = nameForm(srcInput).replace(/\\/g, '/');
1157
+ const dstName = nameForm(dstInput).replace(/\\/g, '/').split('/').pop() ?? dstInput;
1158
+ const repo = rf.params[0] ?? this.repoId(null);
1159
+ if (!repo) return 'No repository in this ledger.';
1160
+ const root = this.repoPath(repo)!;
1161
+ const versions = this.all<{ hash: string; new_blob: string | null; status: string; author_at: string; author_ms: number; on_trunk: number; subject: string }>(
1162
+ `SELECT cf.hash, cf.new_blob, cf.status, c.author_at, c.author_ms, c.on_trunk, c.subject FROM commit_files cf JOIN commits c ON c.repo = cf.repo AND c.hash = cf.hash
1163
+ WHERE cf.repo = ? AND cf.path = ? ORDER BY c.author_ms`, repo, src);
1164
+ if (versions.length === 0) return `No commit in the ledger touched ${src}.`;
1165
+ const byCompiler = compilerReads(src);
1166
+ if (!byCompiler && engineLanguage(src) === null) return `${src} is in a language whose references the ledger does not read (neither the TypeScript compiler nor ${ENGINE_LABEL}).`;
1167
+ const contents = catBlobs(root, versions.map((v) => v.new_blob).filter((b): b is string => Boolean(b)));
1168
+ const texts = versions.map((v) => { const buf = v.new_blob ? contents.get(v.new_blob) : null; return buf ? buf.toString('utf8') : null; });
1169
+ // What each version references: the compiler's resolved paths, or the import specifiers the engine extracts.
1170
+ let targets: (readonly string[])[];
1171
+ if (byCompiler) {
1172
+ const tracked = new Set(this.all<{ path: string }>('SELECT DISTINCT path FROM commit_files WHERE repo = ?', repo).map((r) => r.path));
1173
+ targets = texts.map((t) => (t === null ? [] : depsOf(langOf(src), src, t, tracked).map((d) => d.dst)));
1174
+ } else {
1175
+ const specs = engineImportsOf(src, texts, this.engineDir(repo));
1176
+ if (typeof specs === 'string') return specs;
1177
+ targets = specs.map((s) => s ?? []);
1178
+ }
1179
+ const matches = (t: string): boolean => (byCompiler ? t.split('/').pop() === dstName || t.endsWith(`/${dstName}`) : specifierNames(t, dstName));
1180
+ let had: boolean | null = null;
1181
+ const events: { id: string; subject: string; occurred: Occurred; onTrunk: boolean; references: boolean; target: string | null }[] = [];
1182
+ versions.forEach((v, i) => {
1183
+ const hit = (targets[i] ?? []).find(matches);
1184
+ const has = Boolean(hit) && v.status !== 'D';
1185
+ if (had === null || has !== had) events.push({ id: `commit:${v.hash.slice(0, 12)}`, subject: v.subject, occurred: this.commitOccurred(v.hash, v.author_at), onTrunk: v.on_trunk === 1, references: has, target: hit ?? null });
1186
+ had = has;
1187
+ });
1188
+ const firstOnTrunk = versions.find((v) => v.on_trunk === 1 && events.some((e) => e.id === `commit:${v.hash.slice(0, 12)}` && e.references));
1189
+ return {
1190
+ src, target: dstName, readBy: byCompiler ? 'the TypeScript compiler' : ENGINE_LABEL, versionsRead: versions.length, changes: events,
1191
+ firstOnTrunk: firstOnTrunk ? `commit:${firstOnTrunk.hash.slice(0, 12)}` : null,
1192
+ referencesNow: this.one<{ c: number }>("SELECT count(*) c FROM code_deps WHERE repo = ? AND src = ? AND external = 0 AND (dst = ? OR dst LIKE ?)", repo, src, dstInput, `%/${dstName}`)!.c > 0,
1193
+ };
1194
+ }
1195
+
1196
+ /** Directory sizes (product / generated / other), tests and the last change, for the current version. */
1197
+ dirs(q: Paging & { repo?: string; under?: string; depth?: number } = {}) {
1198
+ const rf = this.repoFilter(q.repo);
1199
+ if (typeof rf === 'string') return rf;
1200
+ const where = ['1 = 1'];
1201
+ const params: (string | number)[] = [];
1202
+ if (q.under) { where.push("dir LIKE ? ESCAPE '\\'"); params.push(`${likeEscape(nameForm(q.under).replace(/\\/g, '/').replace(/\/+$/, ''))}/%`); }
1203
+ if (q.depth) { where.push("(length(dir) - length(replace(dir, '/', ''))) < ?"); params.push(Math.max(1, Math.floor(q.depth))); }
1204
+ const w = `WHERE ${where.join(' AND ')}${rf.sql}`;
1205
+ const total = this.one<{ c: number }>(`SELECT count(*) c FROM dir_stats ${w}`, ...params, ...rf.params)!.c;
1206
+ const rows = this.all<Record<string, number | string | null>>(`SELECT * FROM dir_stats ${w} ORDER BY dir LIMIT ? OFFSET ?`, ...params, ...rf.params, lim(q, 2000), off(q));
1207
+ return pageOf(total, rows.map((r) => ({
1208
+ dir: String(r.dir), files: r.files, lines: r.lines, product: { files: r.product_files, lines: r.product_lines }, generated: { files: r.generated_files, lines: r.generated_lines },
1209
+ other: { files: r.other_files, lines: r.other_lines }, testFiles: r.tests, testCases: r.test_cases, lastChange: r.last_commit ? { id: `commit:${String(r.last_commit).slice(0, 12)}`, at: r.last_at } : null,
1210
+ })), off(q));
1211
+ }
1212
+
1213
+ /** Which directories each merge changed (against its first parent), newest first. */
1214
+ merges(q: Paging & { repo?: string; dir?: string; trunkOnly?: boolean } = {}) {
1215
+ const rf = this.repoFilter(q.repo, 'm.repo');
1216
+ if (typeof rf === 'string') return rf;
1217
+ const where = ['1 = 1'];
1218
+ const params: (string | number)[] = [];
1219
+ if (q.trunkOnly) where.push('m.on_trunk = 1');
1220
+ if (q.dir) { where.push("m.dirs LIKE ? ESCAPE '\\'"); params.push(`%"${likeEscape(nameForm(q.dir).replace(/\\/g, '/').replace(/\/+$/, ''))}%`); }
1221
+ const w = `WHERE ${where.join(' AND ')}${rf.sql}`;
1222
+ const total = this.one<{ c: number }>(`SELECT count(*) c FROM merge_dirs m ${w}`, ...params, ...rf.params)!.c;
1223
+ const rows = this.all<{ repo: string; commit_hash: string; at: string; on_trunk: number; dirs: string }>(`SELECT m.* FROM merge_dirs m ${w} ORDER BY m.at DESC LIMIT ? OFFSET ?`, ...params, ...rf.params, lim(q), off(q));
1224
+ return pageOf(total, rows.map((m) => {
1225
+ const c = this.one<{ subject: string; author_at: string }>('SELECT subject, author_at FROM commits WHERE repo = ? AND hash = ?', m.repo, m.commit_hash);
1226
+ return { id: `commit:${m.commit_hash.slice(0, 12)}`, subject: c?.subject ?? '', occurred: this.commitOccurred(m.commit_hash, c?.author_at ?? m.at), onTrunk: m.on_trunk === 1, dirs: json<[string, number][]>(m.dirs, []).map(([dir, files]) => ({ dir, files })) };
1227
+ }), off(q));
1228
+ }
1229
+
1230
+ /**
1231
+ * Symbol questions on the current version — references, implementations, callers, callees — in every language whose
1232
+ * references are read: TypeScript and JavaScript by the TypeScript language service on the checkout (compiler-exact),
1233
+ * every other language by the code engine on its snapshot. Every hit says how it was resolved and how sure that is.
1234
+ */
1235
+ symbol(kind: SymbolQueryKind, input: { file: string; line?: number; character?: number; name?: string }, opts: { repo?: string } = {}): SymbolAnswer | string {
1236
+ const rf = this.repoFilter(opts.repo);
1237
+ if (typeof rf === 'string') return rf;
1238
+ const file = nameForm(input.file).replace(/\\/g, '/').replace(/^\.?\//, '');
1239
+ const row = this.one<{ repo: string; path: string; reader?: string | null }>(`SELECT * FROM code_files WHERE path = ?${rf.sql} LIMIT 1`, file, ...rf.params);
1240
+ const repo = row?.repo ?? rf.params[0] ?? this.repoId(null);
1241
+ if (!repo) return 'No repository in this ledger.';
1242
+ const reader = row ? this.readerOf(row) : compilerReads(file) ? 'compiler' : null;
1243
+ if (reader === 'engine') return engineSymbol(this.engineDir(repo), kind, { ...input, file });
1244
+ if (reader !== 'compiler') {
1245
+ return row
1246
+ ? `${file} is in a language whose references the ledger does not read (neither the TypeScript compiler nor ${ENGINE_LABEL}): ${this.levelOf(row)}.`
1247
+ : `${file} is not in the current version the ledger read.`;
1248
+ }
1249
+ const root = this.repoPath(repo);
1250
+ if (!root) return `Repository ${repo} has no checkout in the ledger.`;
1251
+ const files = this.all<{ path: string }>("SELECT path FROM code_files WHERE repo = ? AND lang IN ('typescript', 'javascript') AND generated = 0", repo).map((r) => r.path);
1252
+ const r = symbolQuery(root, files, kind, { ...input, file });
1253
+ if (typeof r === 'string') return r;
1254
+ // The language service resolves every hit itself: exact, and counted.
1255
+ const exact = { how: 'compiler', confidence: 1, counted: true } as const;
1256
+ return {
1257
+ answeredBy: 'the TypeScript language service', ...r,
1258
+ ...(r.locations ? { locations: r.locations.map((l) => ({ ...l, ...exact })) } : {}),
1259
+ ...(r.incoming ? { incoming: r.incoming.map((l) => ({ ...l, ...exact })) } : {}),
1260
+ ...(r.outgoing ? { outgoing: r.outgoing.map((l) => ({ ...l, ...exact })) } : {}),
1261
+ };
1262
+ }
1263
+
1264
+ // ───────────────────────── sessions and the owner's words (AC-9) ─────────────────────────
1265
+
1266
+ sessions(q: Paging & { host?: string; withOwnerWords?: boolean; missingOnly?: boolean; since?: string; until?: string } = {}): Page<SessionRow> {
1267
+ const where = ['1 = 1'];
1268
+ const params: (string | number)[] = [];
1269
+ if (q.host) { where.push('host = ?'); params.push(q.host); }
1270
+ if (q.withOwnerWords) where.push('owner_messages > 0');
1271
+ if (q.missingOnly) where.push('(missing = 1 OR unreadable IS NOT NULL)');
1272
+ if (q.since) { where.push('ended_at >= ?'); params.push(new Date(boundMs(q.since, false) ?? 0).toISOString()); }
1273
+ if (q.until) { where.push('started_at <= ?'); params.push(new Date(boundMs(q.until, true) ?? 0).toISOString()); }
1274
+ const w = `WHERE ${where.join(' AND ')}`;
1275
+ const total = this.one<{ c: number }>(`SELECT count(*) c FROM sessions ${w}`, ...params)!.c;
1276
+ const rows = this.all<Record<string, unknown>>(`SELECT * FROM sessions ${w} ORDER BY started_at IS NULL, started_at LIMIT ? OFFSET ?`, ...params, lim(q), off(q));
1277
+ return pageOf(total, rows.map((r) => ({
1278
+ id: String(r.key), host: String(r.host), sessionId: String(r.session_id), file: String(r.file), cwd: (r.cwd as string | null) ?? null,
1279
+ startedAt: (r.started_at as string | null) ?? null, endedAt: (r.ended_at as string | null) ?? null, messages: Number(r.messages), ownerMessages: Number(r.owner_messages),
1280
+ headless: r.headless === 1, subagent: r.subagent === 1, missing: r.missing === 1, unreadable: (r.unreadable as string | null) ?? null,
1281
+ })), off(q));
1282
+ }
1283
+
1284
+ /** A session by its entry id, its native id (or a prefix of it), or its log file. */
1285
+ findSession(input: string, host?: string): Record<string, unknown> | string {
1286
+ const v = input.trim();
1287
+ const rows = this.all<Record<string, unknown>>(`SELECT * FROM sessions WHERE (key = ? OR session_id = ? OR session_id LIKE ? OR lower(file) = lower(?))${host ? ' AND host = ?' : ''} LIMIT 3`, v, v, `${likeEscape(v)}%`, v, ...(host ? [host] : []));
1288
+ if (rows.length === 0) return `No session ${input} in the ledger.`;
1289
+ if (rows.length > 1 && !rows.some((r) => r.key === v || r.session_id === v)) return `${input} names more than one session: ${rows.map((r) => `${String(r.host)} ${String(r.session_id)}`).join(', ')}.`;
1290
+ return rows.find((r) => r.key === v || r.session_id === v) ?? rows[0]!;
1291
+ }
1292
+
1293
+ private messageRow(m: Record<string, unknown>, s: Record<string, unknown>, withAnswer: boolean): MessageRow {
1294
+ const occ = (at: unknown, key: string): Occurred => (typeof at === 'string' && at ? { at: materialTime(at) ?? at, basis: 'Session', anchor: key } : { at: String(s.first_seen), basis: 'First observed', anchor: key, undated: true });
1295
+ let answers: MessageRow['answers'] = undefined;
1296
+ if (withAnswer && m.speaker === 'owner') {
1297
+ const a = m.answers === null || m.answers === undefined ? undefined : this.one<Record<string, unknown>>('SELECT * FROM session_messages WHERE session = ? AND idx = ?', String(m.session), Number(m.answers));
1298
+ answers = a ? { id: String(a.key), index: Number(a.idx), occurred: occ(a.at, String(a.key)), text: (a.text as string | null) ?? null } : null;
1299
+ }
1300
+ return {
1301
+ id: String(m.key), session: String(s.key), index: Number(m.idx), speaker: String(m.speaker), occurred: occ(m.at, String(m.key)), text: (m.text as string | null) ?? null, chars: Number(m.chars),
1302
+ ...(m.tools ? { tools: json<string[]>(String(m.tools), []) } : {}), ...(answers !== undefined ? { answers } : {}),
1303
+ };
1304
+ }
1305
+
1306
+ /**
1307
+ * The messages of one session in order, every speaker; the text is kept for the owner's messages and the agent messages
1308
+ * they answer (the rest are recorded with speaker, time, length and tool calls).
1309
+ */
1310
+ sessionMessages(sessionInput: string, q: Paging & { host?: string; speaker?: string; fromIndex?: number } = {}) {
1311
+ const s = this.findSession(sessionInput, q.host);
1312
+ if (typeof s === 'string') return s;
1313
+ const where = ['session = ?'];
1314
+ const params: (string | number)[] = [String(s.key)];
1315
+ if (q.speaker) { where.push('speaker = ?'); params.push(q.speaker); }
1316
+ if (q.fromIndex !== undefined) { where.push('idx >= ?'); params.push(Math.floor(q.fromIndex)); }
1317
+ const w = `WHERE ${where.join(' AND ')}`;
1318
+ const total = this.one<{ c: number }>(`SELECT count(*) c FROM session_messages ${w}`, ...params)!.c;
1319
+ const rows = this.all<Record<string, unknown>>(`SELECT * FROM session_messages ${w} ORDER BY idx LIMIT ? OFFSET ?`, ...params, lim(q, 1000), off(q));
1320
+ return {
1321
+ session: { id: String(s.key), host: String(s.host), sessionId: String(s.session_id), file: String(s.file), startedAt: s.started_at, endedAt: s.ended_at, missing: s.missing === 1 },
1322
+ ...pageOf(total, rows.map((m) => this.messageRow(m, s, true)), off(q)),
1323
+ };
1324
+ }
1325
+
1326
+ /** One message by its entry id, with the agent message it answers when it is the owner's. */
1327
+ message(id: string): MessageRow | null {
1328
+ const m = this.one<Record<string, unknown>>('SELECT * FROM session_messages WHERE key = ?', id.trim());
1329
+ if (!m) return null;
1330
+ const s = this.one<Record<string, unknown>>('SELECT * FROM sessions WHERE key = ?', String(m.session))!;
1331
+ return this.messageRow(m, s, true);
1332
+ }
1333
+
1334
+ /** The owner's own words across the sessions, oldest first, each with the agent message it answered. */
1335
+ ownerWords(q: Paging & { since?: string; until?: string; keyword?: string; session?: string; host?: string; includeHeadless?: boolean } = {}) {
1336
+ const where = ["m.speaker = 'owner'"];
1337
+ const params: (string | number)[] = [];
1338
+ const since = boundMs(q.since, false);
1339
+ const until = boundMs(q.until, true);
1340
+ if (since !== null) { where.push('m.at >= ?'); params.push(new Date(since).toISOString()); }
1341
+ if (until !== null) { where.push('m.at <= ?'); params.push(new Date(until).toISOString()); }
1342
+ if (q.keyword) { where.push("m.text LIKE ? ESCAPE '\\'"); params.push(`%${likeEscape(q.keyword)}%`); }
1343
+ if (q.host) { where.push('s.host = ?'); params.push(q.host); }
1344
+ if (!q.includeHeadless) where.push('s.headless = 0');
1345
+ if (q.session) {
1346
+ const s = this.findSession(q.session);
1347
+ if (typeof s === 'string') return s;
1348
+ where.push('s.key = ?');
1349
+ params.push(String(s.key));
1350
+ }
1351
+ const from = `FROM session_messages m JOIN sessions s ON s.key = m.session WHERE ${where.join(' AND ')}`;
1352
+ const total = this.one<{ c: number; ch: number }>(`SELECT count(*) c, ifnull(sum(m.chars), 0) ch ${from}`, ...params)!;
1353
+ const rows = this.all<Record<string, unknown>>(`SELECT m.*, s.key skey ${from} ORDER BY m.at IS NULL, m.at, m.idx LIMIT ? OFFSET ?`, ...params, lim(q), off(q));
1354
+ const page = pageOf(total.c, rows.map((m) => {
1355
+ const s = this.one<Record<string, unknown>>('SELECT * FROM sessions WHERE key = ?', String(m.skey))!;
1356
+ return { ...this.messageRow(m, s, true), host: String(s.host), sessionId: String(s.session_id) };
1357
+ }), off(q));
1358
+ return { ...page, totalChars: total.ch };
1359
+ }
1360
+
1361
+ // ───────────────────────── provenance: How it got here (AC-12) ─────────────────────────
1362
+
1363
+ /**
1364
+ * The dated steps of how something got here, from the ledger alone, oldest first: where it first appeared, each version
1365
+ * that changed it (with the sections changed), lines that say it was superseded or list it as obsolete, the plans and
1366
+ * arrangements that name it, commits and merges, verdicts, the owner's words that name it, and where it stands now.
1367
+ * Steps whose material exists only in history are marked (D82). The Keeper's model steps judge what the steps mean.
1368
+ */
1369
+ provenance(input: ProvenanceInput, opts: { limitPerAnchor?: number } = {}): { steps: ProvenanceStep[]; notes: string[] } {
1370
+ const cap = Math.max(10, opts.limitPerAnchor ?? 60);
1371
+ const steps: ProvenanceStep[] = [];
1372
+ const notes: string[] = [];
1373
+ const seen = new Set<string>();
1374
+ const push = (s: ProvenanceStep) => { const k = `${s.kind}|${s.entry}`; if (!seen.has(k)) { seen.add(k); steps.push(s); } };
1375
+ const repoSql = input.repo ? this.repoFilter(input.repo) : { sql: '', params: [] as string[] };
1376
+ if (typeof repoSql === 'string') return { steps: [], notes: [repoSql] };
1377
+
1378
+ for (const raw of input.paths ?? []) {
1379
+ const path = nameForm(raw).replace(/\\/g, '/').replace(/^\.\//, '');
1380
+ const v = this.docVersions(path, { repo: input.repo });
1381
+ if (typeof v !== 'string') {
1382
+ v.versions.forEach((ver, i) => {
1383
+ const next = v.versions[i + 1];
1384
+ const diff = ver.diff ? [ver.diff.added.length ? `added ${ver.diff.added.slice(0, 4).join('; ')}${ver.diff.added.length > 4 ? ' …' : ''}` : '', ver.diff.changed.length ? `changed ${ver.diff.changed.slice(0, 4).join('; ')}${ver.diff.changed.length > 4 ? ' …' : ''}` : '', ver.diff.removed.length ? `removed ${ver.diff.removed.slice(0, 4).join('; ')}${ver.diff.removed.length > 4 ? ' …' : ''}` : ''].filter(Boolean).join(' · ') : '';
1385
+ push({
1386
+ kind: i === 0 ? 'first appeared' : 'changed', occurred: ver.occurred,
1387
+ title: `${ver.path} ${i === 0 ? (ver.importRoot ? 'entered version control with the import commit' : 'first version') : ver.change.toLowerCase()} at ${ver.commit.slice(0, 10)}${ver.onTrunk ? '' : ' (side branch)'}${diff ? ` — ${diff}` : ''}`,
1388
+ entry: ver.id, history: !ver.current, replacedBy: next?.id ?? null,
1389
+ });
1390
+ });
1391
+ for (const d of v.deletions) push({ kind: 'deleted', occurred: d.occurred, title: `${d.path} deleted in ${d.commit}${d.cleanup ? ' (a cleanup commit)' : ''}; readable in full at ${d.readableAt}`, entry: d.id, history: true });
1392
+ } else notes.push(v);
1393
+ for (const s of this.all<Record<string, unknown>>(`SELECT * FROM supersedes WHERE (path = ? OR replaced LIKE ? ESCAPE '\\' OR replacement LIKE ? ESCAPE '\\')${repoSql.sql} ORDER BY first_ms LIMIT ?`, path, `%${likeEscape(path.split('/').pop() ?? path)}%`, `%${likeEscape(path.split('/').pop() ?? path)}%`, ...repoSql.params, cap)) {
1394
+ const row = this.lineBase(s);
1395
+ push({ kind: s.obsolete_list === 1 ? 'obsolete-list row' : 'says superseded', occurred: row.occurred, title: `${String(s.path ?? `commit ${String(s.first_commit).slice(0, 10)}`)}${row.line ? `:${row.line}` : ''} reads (${String(s.pattern)}): ${clip(row.text, 160)}`, entry: row.id, history: !row.current });
1396
+ }
1397
+ for (const c of this.all<Record<string, unknown>>(`SELECT DISTINCT c.* FROM commits c JOIN commit_files cf ON cf.repo = c.repo AND cf.hash = c.hash WHERE (cf.path = ? OR cf.old_path = ?)${repoSql.sql.replace('repo', 'c.repo')} ORDER BY c.author_ms LIMIT ?`, path, path, ...repoSql.params, cap)) {
1398
+ const row = this.commitRow(c);
1399
+ push({ kind: row.merge ? 'merged' : 'commit', occurred: row.occurred, title: `${row.hash.slice(0, 10)} ${clip(row.subject, 120)}`, entry: row.id, history: !row.onTrunk });
1400
+ }
1401
+ const now = this.one<Record<string, unknown> & { repo: string; last_commit: string | null }>('SELECT * FROM code_files WHERE path = ?', path);
1402
+ // Where it stands now is dated like the file (`codeFileOccurred`); a path the current version lacks, by what took it
1403
+ // out (its deletion, else its last version) — never by the moment of the query.
1404
+ const lastStep = typeof v === 'string' ? null : v.deletions[v.deletions.length - 1] ?? v.versions[v.versions.length - 1] ?? null;
1405
+ push({ kind: 'now', occurred: now ? this.codeFileOccurred(now, `file:${path}`) : lastStep?.occurred ?? undated(this.lastRead(), null),
1406
+ title: now ? (now.last_commit ? `${path} is in the current version (last changed in ${short(now.last_commit)})` : `${path} is in the current version, outside version control`) : `${path} is not in the current version`, entry: now ? `file:${path}` : lastStep?.id ?? '', history: !now });
1407
+ }
1408
+
1409
+ for (const num of input.nums ?? []) {
1410
+ const all = this.nums({ num, limit: 5000 });
1411
+ if (typeof all === 'string') { notes.push(all); continue; }
1412
+ if (all.total === 0) { notes.push(`${num}: no occurrence in the ledger.`); continue; }
1413
+ // The first place per document, commit or name; definitions always.
1414
+ const firstPer = new Map<string, NumRow>();
1415
+ for (const r of all.rows) { const k = `${r.kind}|${r.path ?? r.firstIn ?? r.text}`; if (!firstPer.has(k) || r.place === 'definition') firstPer.set(k, r); }
1416
+ // Oldest first; at the same moment a definition (the number introduced) before a mention of it.
1417
+ const rows = [...firstPer.values()].sort((a, b) => (msOf(a.occurred.at) ?? 0) - (msOf(b.occurred.at) ?? 0) || Number(b.place === 'definition') - Number(a.place === 'definition'));
1418
+ rows.slice(0, cap).forEach((r, i) => {
1419
+ const where = r.kind === 'commit' ? `commit ${r.firstIn?.slice(7, 17)}` : r.kind === 'doc' || r.kind === 'loose' ? `${r.path}${r.line ? `:${r.line}` : ''}` : `${r.kind} ${r.path ?? r.text}`;
1420
+ const planDoc = r.kind === 'doc' && r.path !== null && /(^|\/)(plan|PLAN|INDEX|execution-plan)[^/]*\.md$/.test(r.path);
1421
+ push({
1422
+ kind: i === 0 ? 'first appeared' : r.kind === 'commit' ? 'commit' : planDoc ? 'named in plan' : 'mentioned', occurred: r.occurred,
1423
+ title: `${num} ${r.place === 'definition' ? 'defined' : 'named'} in ${where}: ${clip(r.text, 140)}`, entry: r.id, history: r.kind === 'doc' ? !r.current : r.kind === 'branch' ? false : false,
1424
+ });
1425
+ });
1426
+ if (rows.length > cap) notes.push(`${num}: ${rows.length - cap} more places (pk_ledger_numbers lists them all).`);
1427
+ const arr = this.arrangementHistory(num, input.repo);
1428
+ if (typeof arr !== 'string') {
1429
+ for (const a of arr.versions) push({ kind: 'arrangement', occurred: a.occurred, title: `${a.path}: ${[a.status && `status ${a.status}`, a.agent && `agent ${a.agent}`, a.worktree && `worktree ${a.worktree}`, a.baseline && `baseline ${a.baseline}`, a.accepted && `accepted ${a.accepted}`].filter(Boolean).join(', ') || a.kind}`, entry: a.id, history: !a.current });
1430
+ }
1431
+ // Verdicts of a report about this number (its name starts with it) or whose line names it; the owner's words that name it.
1432
+ const named = (text: unknown) => new RegExp(`(?<![A-Za-z0-9_])${num.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![A-Za-z0-9_])`).test(String(text ?? ''));
1433
+ for (const v of this.all<Record<string, unknown>>(`SELECT * FROM verdicts WHERE (instr(text, ?) > 0 OR instr(path, ?) > 0) AND kind IN ('verdict', 'count')${repoSql.sql} ORDER BY first_ms`, num, `/${num}-`, ...repoSql.params)
1434
+ .filter((v) => String(v.path).includes(`/${num}-`) || named(v.text)).slice(0, cap)) {
1435
+ const row = this.lineBase(v);
1436
+ push({ kind: 'verdict', occurred: row.occurred, title: `${String(v.path)}:${row.line}: ${String(v.kind)} ${String(v.verdict ?? '')} (${String(v.confidence)}) — ${clip(row.text, 120)}`, entry: row.id, history: !row.current });
1437
+ }
1438
+ for (const m of this.all<Record<string, unknown>>(`SELECT m.key, m.at, m.text, s.host, s.session_id, s.first_seen FROM session_messages m JOIN sessions s ON s.key = m.session WHERE m.speaker = 'owner' AND instr(m.text, ?) > 0 ORDER BY m.at`, num)
1439
+ .filter((m) => named(m.text)).slice(0, 20)) {
1440
+ push({ kind: 'owner said', occurred: m.at ? { at: materialTime(String(m.at)) ?? String(m.at), basis: 'Session', anchor: String(m.key) } : { at: String(m.first_seen), basis: 'First observed', anchor: String(m.key), undated: true }, title: `the owner (${String(m.host)} session ${String(m.session_id).slice(0, 8)}): ${clip(String(m.text), 140)}`, entry: String(m.key), history: false });
1441
+ }
1442
+ }
1443
+
1444
+ for (const h of input.commits ?? []) {
1445
+ const c = this.commit(h, { repo: input.repo, limit: 1 });
1446
+ if (typeof c === 'string') { notes.push(c); continue; }
1447
+ push({ kind: c.merge ? 'merged' : 'commit', occurred: c.occurred, title: `${c.hash.slice(0, 10)} ${clip(c.subject, 120)}`, entry: c.id, history: !c.onTrunk });
1448
+ if (c.mergedIntoTrunkBy) {
1449
+ const m = this.findCommit(c.mergedIntoTrunkBy.id);
1450
+ if (typeof m !== 'string') push({ kind: 'merged', occurred: this.commitOccurred(String(m.hash), String(m.author_at)), title: `merged into the trunk by ${String(m.hash).slice(0, 10)} ${clip(String(m.subject), 100)}`, entry: c.mergedIntoTrunkBy.id, history: false });
1451
+ }
1452
+ }
1453
+
1454
+ for (const w of input.words ?? []) {
1455
+ const span = this.wordSpan(w, { repo: input.repo });
1456
+ if (typeof span === 'string') { notes.push(span); continue; }
1457
+ if (!span.first) { notes.push(`“${w}”: not found in what the ledger read.`); continue; }
1458
+ push({ kind: 'first appeared', occurred: span.first.occurred, title: `“${w}” first appears: ${span.first.label} — ${span.first.snippet}`, entry: span.first.id, history: span.first.kind === 'doc' ? span.first.current !== true : false });
1459
+ if (span.last && span.last.id !== span.first.id) {
1460
+ const at = span.last.lastAt;
1461
+ push({ kind: 'mentioned', occurred: { at: materialTime(at) ?? at, basis: span.last.kind === 'owner' || span.last.kind === 'agent' ? 'Session' : 'Commit', anchor: span.last.id }, title: `“${w}” last appears: ${span.last.label} — ${span.last.snippet}`, entry: span.last.id, history: false });
1462
+ }
1463
+ }
1464
+
1465
+ for (const id of input.entries ?? []) {
1466
+ const e = this.resolve(id);
1467
+ if (!e) { notes.push(`${id} is not an entry of the ledger.`); continue; }
1468
+ push({ kind: e.kind === 'commit' ? 'commit' : e.kind === 'verdict' ? 'verdict' : e.kind === 'sup' ? 'says superseded' : 'mentioned', occurred: e.occurred, title: e.label, entry: e.id, history: false });
1469
+ }
1470
+
1471
+ steps.sort((a, b) => (msOf(a.occurred.at) ?? 0) - (msOf(b.occurred.at) ?? 0) || Number(a.kind === 'now') - Number(b.kind === 'now'));
1472
+ return { steps, notes };
1473
+ }
1474
+
1475
+ // ───────────────────────── coverage (§1.16 看得见, §6.7) ─────────────────────────
1476
+
1477
+ /**
1478
+ * The day a repository's document version history starts when content before it is not versioned (AC-15): the day of
1479
+ * its first import root, as `coverage()` gives it, or null when it has none. For a view that needs only this, per item.
1480
+ */
1481
+ historyStart(repo: string): string | null {
1482
+ const first = this.one<{ author_at: string }>('SELECT author_at FROM commits WHERE repo = ? AND import_root = 1 ORDER BY rowid LIMIT 1', repo);
1483
+ return first ? dayOf(first.author_at) : null;
1484
+ }
1485
+
1486
+ coverage() {
1487
+ const last = this.one<{ started_at: string; ended_at: string; ms: number; kind: string; commits_added: number; stats: string }>('SELECT * FROM rebuilds ORDER BY id DESC LIMIT 1');
1488
+ const repos = this.repos().map((r) => {
1489
+ const c = this.one<{ c: number; t: number; m: number; a: string | null; b: string | null }>('SELECT count(*) c, ifnull(sum(on_trunk), 0) t, ifnull(sum(merge), 0) m, min(author_ms) a, max(author_ms) b FROM commits WHERE repo = ?', r.id)!;
1490
+ const first = this.one<{ author_at: string }>('SELECT author_at FROM commits WHERE repo = ? ORDER BY author_ms LIMIT 1', r.id);
1491
+ const lastC = this.one<{ author_at: string }>('SELECT author_at FROM commits WHERE repo = ? ORDER BY author_ms DESC LIMIT 1', r.id);
1492
+ const imports = this.all<{ hash: string; author_at: string }>('SELECT hash, author_at FROM commits WHERE repo = ? AND import_root = 1', r.id);
1493
+ const branches = this.one<{ c: number }>('SELECT count(*) c FROM branches WHERE repo = ?', r.id)!.c;
1494
+ const docs = this.one<{ v: number; p: number; x: number }>('SELECT count(*) v, count(DISTINCT path) p, ifnull(sum(import_root), 0) x FROM docs WHERE repo = ?', r.id)!;
1495
+ const byBasis = this.all<{ b: string; c: number }>("SELECT occurred_basis b, count(*) c FROM nums WHERE repo = ? AND kind = 'doc' GROUP BY b", r.id);
1496
+ // Versions the ledger keeps without their text — over the size it reads to, or not text: what is in them (superseded
1497
+ // lines, numbers, verdicts) is not in the ledger, and that is said here rather than left out (QC AY).
1498
+ const noText = this.all<{ path: string; bytes: number }>(`SELECT d.path, d.bytes FROM docs d WHERE d.repo = ?
1499
+ AND NOT EXISTS (SELECT 1 FROM texts t WHERE t.key = 'blob:' || d.repo || ':' || d.blob) ORDER BY d.at_ms`, r.id);
1500
+ const noTextPaths = [...new Set(noText.map((d) => d.path))];
1501
+ return {
1502
+ repo: r.id, path: r.path, trunk: r.trunk, head: short(r.head), commits: c.c, trunkCommits: c.t, merges: c.m, branches,
1503
+ from: first?.author_at ?? null, to: lastC?.author_at ?? null,
1504
+ historyFrom: imports.length ? dayOf(imports[0]!.author_at) : dayOf(first?.author_at ?? null),
1505
+ importRoots: imports.map((i) => ({ id: `commit:${i.hash.slice(0, 12)}`, at: i.author_at, documents: docs.x })),
1506
+ documents: { paths: docs.p, versions: docs.v },
1507
+ documentsWithoutText: noText.length ? {
1508
+ versions: noText.length, overSizeLimit: noText.filter((d) => d.bytes > MAX_DOC_BYTES).length, sizeLimitBytes: MAX_DOC_BYTES,
1509
+ paths: noTextPaths.slice(0, 20), morePaths: Math.max(0, noTextPaths.length - 20),
1510
+ reports: noTextPaths.filter((p) => isReportLike(p, '')),
1511
+ effect: 'kept as versions without their text: the superseded lines, numbers and verdicts in them are not in the ledger; read one with read at its path, or pk_ledger_doc_text says it cannot show it',
1512
+ } : null,
1513
+ numberLinesDatedBy: Object.fromEntries(byBasis.map((x) => [x.b, x.c])),
1514
+ supersessions: this.one<{ c: number }>('SELECT count(*) c FROM supersedes WHERE repo = ?', r.id)!.c,
1515
+ numberOccurrences: this.one<{ c: number }>('SELECT count(*) c FROM nums WHERE repo = ?', r.id)!.c,
1516
+ verdicts: this.one<{ c: number }>('SELECT count(*) c FROM verdicts WHERE repo = ?', r.id)!.c,
1517
+ arrangements: ((a) => ({ versions: a.c, unreadableStructure: a.u }))(this.one<{ c: number; u: number }>('SELECT count(*) c, ifnull(sum(parsed = 0), 0) u FROM plans WHERE repo = ?', r.id)!),
1518
+ };
1519
+ });
1520
+ const code = this.codeCoverage();
1521
+ const sessions =this.one<{ c: number; m: number; o: number; x: number; u: number; a: string | null; b: string | null }>('SELECT count(*) c, ifnull(sum(messages), 0) m, ifnull(sum(owner_messages), 0) o, ifnull(sum(missing), 0) x, ifnull(sum(unreadable IS NOT NULL), 0) u, min(started_at) a, max(ended_at) b FROM sessions')!;
1522
+ const scanned = this.one<{ value: string }>("SELECT value FROM state WHERE key = 'sessionsScanned'")?.value ?? null;
1523
+ const loose = json<{ items: string[]; documents: number; untracked: number; tooLarge?: string[] }>(this.one<{ value: string }>("SELECT value FROM state WHERE key = 'looseDocuments'")?.value, { items: [], documents: 0, untracked: 0 });
1524
+ return {
1525
+ lastRebuild: last ? { at: last.ended_at, ms: last.ms, kind: last.kind, commitsAdded: last.commits_added } : null,
1526
+ repos,
1527
+ languages: code.languages,
1528
+ codeEngine: code.engine,
1529
+ generatedAndThirdParty:this.all<{ what: string; c: number; l: number }>(`SELECT CASE WHEN classification IS NOT NULL THEN 'classified: ' || classification ELSE 'generated' END what, count(*) c, ifnull(sum(lines), 0) l
1530
+ FROM code_files WHERE generated = 1 OR classification IS NOT NULL GROUP BY what ORDER BY c DESC`).map((x) => ({ what: x.what, files: x.c, lines: x.l })),
1531
+ sessions: {
1532
+ scanned: scanned !== null, lastScan: scanned, sessions: sessions.c, messages: sessions.m, ownerMessages: sessions.o, from: sessions.a, to: sessions.b,
1533
+ hosts: this.all<{ host: string; c: number }>('SELECT host, count(*) c FROM sessions GROUP BY host').map((h) => ({ host: h.host, sessions: h.c })),
1534
+ missing: this.all<{ key: string; host: string; session_id: string; started_at: string | null; ended_at: string | null; missing: number; unreadable: string | null }>('SELECT * FROM sessions WHERE missing = 1 OR unreadable IS NOT NULL ORDER BY started_at')
1535
+ .map((s) => ({ id: s.key, host: s.host, sessionId: s.session_id, from: s.started_at, to: s.ended_at, why: s.unreadable ? `cannot be read: ${s.unreadable}` : 'the log file is gone; what was read from it stays in the ledger' })),
1536
+ daysWithCommitsButNoSession: scanned !== null ? this.daysWithoutSessions() : null,
1537
+ },
1538
+ outsideVersionControl: {
1539
+ directories: loose.items, documents: loose.documents, untrackedInRepositories: loose.untracked,
1540
+ // Files too large to read (MAX_LOOSE_BYTES): listed, so nothing is left out without a word (QC AY).
1541
+ notRead: (loose.tooLarge ?? []).length ? { files: (loose.tooLarge ?? []).length, sizeLimitBytes: MAX_LOOSE_BYTES, paths: (loose.tooLarge ?? []).slice(0, 20), more: Math.max(0, (loose.tooLarge ?? []).length - 20) } : null,
1542
+ },
1543
+ rules: {
1544
+ importRoot: IMPORT_RULE, cleanup: CLEANUP_RULE, numbering: this.numRules().rule,
1545
+ verdicts: 'Verdicts are read in report-like documents only (a reports/qc/review/walkthrough/receipt folder or name, or a QC/review/report title): a verdict field (结论/判定/verdict/result/status: …) is stated; a verdict word in a heading, a table row or near QC words is a candidate for judgement; a line listing the choices (pass / fail / incomplete) is a template and is not recorded.',
1546
+ occurred: 'Commit: the author time (the committer time too when it differs). A document version: its commit, with the date the document states for itself kept as the other time. A line of a document: the first version that has it, with the date written in the text for that line kept as the other time; content of an import-style root commit by the rule above. Session: the message time. Outside version control: the written date (with the file time as the other time), else the file time the rebuild recorded; never a time the program did not read.',
1547
+ },
1548
+ };
1549
+ }
1550
+
1551
+ /** Every repository's and directory's measured code reach (code.ts `reachOf`), by id. */
1552
+ private reaches(): Map<string, CodeReach> {
1553
+ const out = new Map<string, CodeReach>();
1554
+ for (const { repo } of this.all<{ repo: string }>('SELECT DISTINCT repo FROM code_files')) {
1555
+ const r = reachOf(this.db, repo);
1556
+ if (r) out.set(repo, r);
1557
+ }
1558
+ return out;
1559
+ }
1560
+
1561
+ /**
1562
+ * How far the code structure goes, per language (§1.16 "看得见"), measured: who reads each language's references and what
1563
+ * that reached — imports resolved and not, references by resolution method (counted, and matched by a name only), files
1564
+ * nothing resolved reaches though code names them, and apart from those the named entry points (a build or manifest file
1565
+ * names them: where the code is entered, not how deep it is read). `referenceLevel` is the three-way word the views show:
1566
+ * `symbol` (the compiler; the engine where nothing was seen unresolved), `file` (the engine where something was: a residual
1567
+ * judgement there is Inferred), `file tree` (no reader). `reader` and `read` say the same as `readBy` and `level` for a
1568
+ * program to read: who reads the language's references, and how many of its files that reader read (the `Code` view).
1569
+ */
1570
+ private codeCoverage() {
1571
+ const rows = this.all<{ language: string | null; reader: string | null; c: number; l: number; sample: string }>(this.hasReader()
1572
+ ? 'SELECT ifnull(ref_lang, lang) language, reader, count(*) c, ifnull(sum(lines), 0) l, min(path) sample FROM code_files WHERE generated = 0 AND classification IS NULL GROUP BY 1, 2'
1573
+ : "SELECT lang language, CASE WHEN lang IN ('typescript', 'javascript') THEN 'compiler' END reader, count(*) c, ifnull(sum(lines), 0) l, min(path) sample FROM code_files WHERE generated = 0 AND classification IS NULL GROUP BY 1, 2");
1574
+ const reaches = this.reaches();
1575
+ const measured = new Map<string, LanguageReach[]>();
1576
+ for (const r of reaches.values()) for (const l of r.languages) measured.set(l.language, [...(measured.get(l.language) ?? []), l]);
1577
+ const sum = (xs: readonly (number | undefined)[]) => xs.reduce<number>((n, x) => n + (x ?? 0), 0);
1578
+ const merge = (xs: readonly (Readonly<Record<string, number>> | undefined)[]) => { const o: Record<string, number> = {}; for (const x of xs) for (const [k, v] of Object.entries(x ?? {})) o[k] = (o[k] ?? 0) + v; return o; };
1579
+ const byLang = new Map<string, { files: number; lines: number; read: number; reader: 'compiler' | 'engine' | null; sample: string }>();
1580
+ for (const r of rows) {
1581
+ const key = r.language ?? '(no extension)';
1582
+ const e = byLang.get(key) ?? { files: 0, lines: 0, read: 0, reader: null, sample: r.sample };
1583
+ e.files += r.c; e.lines += r.l;
1584
+ if (r.reader === 'compiler' || r.reader === 'engine') { e.read += r.c; e.reader = e.reader ?? r.reader; }
1585
+ byLang.set(key, e);
1586
+ }
1587
+ const engineErrors = [...reaches.values()].map((r) => r.error).filter((e): e is string => Boolean(e));
1588
+ const languages = [...byLang.entries()].sort((a, b) => b[1].files - a[1].files || a[0].localeCompare(b[0])).map(([language, e]) => {
1589
+ const m = measured.get(language) ?? [];
1590
+ const reach = m.length ? {
1591
+ imports: {
1592
+ resolved: sum(m.map((x) => x.imports.resolved)), unresolved: sum(m.map((x) => x.imports.unresolved)),
1593
+ ...(e.reader === 'engine' ? { unresolvedNamingRepoFiles: sum(m.map((x) => x.imports.unresolvedNamingRepoFiles)), sample: m.flatMap((x) => x.imports.sample ?? []).slice(0, 5) } : {}),
1594
+ },
1595
+ ...(e.reader === 'engine' ? { counted: merge(m.map((x) => x.counted)), nameOnly: merge(m.map((x) => x.nameOnly)), trackedOnly: sum(m.map((x) => x.trackedOnly)), parseErrors: sum(m.map((x) => x.parseErrors)) } : {}),
1596
+ namedButUnreferenced: sum(m.map((x) => x.namedButUnreferenced)), namedSample: m.flatMap((x) => x.namedSample).slice(0, 5),
1597
+ namedEntryPoints: sum(m.map((x) => x.namedEntryPoints)), entrySample: m.flatMap((x) => x.entrySample ?? []).slice(0, 5),
1598
+ } : null;
1599
+ const gaps: string[] = [];
1600
+ if (reach?.namedButUnreferenced) gaps.push(`${reach.namedButUnreferenced} file${reach.namedButUnreferenced === 1 ? '' : 's'} no counted reference reaches ${reach.namedButUnreferenced === 1 ? 'is' : 'are'} named by other files — references its reader did not resolve (${reach.namedSample.slice(0, 3).join('; ')})`);
1601
+ if (reach?.imports.unresolvedNamingRepoFiles) gaps.push(`${reach.imports.unresolvedNamingRepoFiles} import${reach.imports.unresolvedNamingRepoFiles === 1 ? '' : 's'} left unresolved name a file of the repository (${(reach.imports.sample ?? []).slice(0, 3).join('; ')})`);
1602
+ if (reach?.parseErrors) gaps.push(`${reach.parseErrors} file${reach.parseErrors === 1 ? '' : 's'} the engine could not parse in full`);
1603
+ if (e.reader === null && engineErrors.length && !compilerReads(e.sample) && engineLanguage(e.sample) !== null) gaps.push(`the code engine's references are missing: ${engineErrors[0]}`);
1604
+ const referenceLevel: 'symbol' | 'file' | 'file tree' = e.reader === 'compiler' ? 'symbol' : e.reader === 'engine' ? (gaps.length ? 'file' : 'symbol') : 'file tree';
1605
+ // Where the language's code is entered from a build or manifest file (code.ts NamingKind entry): not how deep it is
1606
+ // read, so no gap here; a residual call on those files is Inferred all the same (referenceReach).
1607
+ const entered = reach?.namedEntryPoints ? `; ${plural(reach.namedEntryPoints, 'named entry point')} no counted reference reaches, named by a build or manifest file (${reach.entrySample.slice(0, 3).join('; ')})` : '';
1608
+ const level = e.reader === 'compiler' ? `symbol level (the TypeScript compiler): file dependencies through its pre-parser, symbols on demand with pk_ledger_symbol${entered}`
1609
+ : e.reader === 'engine' ? `symbol level (${ENGINE_LABEL}): the references it resolved, symbols on demand with pk_ledger_symbol${e.read < e.files ? `; ${e.files - e.read} of the ${e.files} files not read (nothing in them to reference, or not indexed: over 1 MB, or in a directory the engine skips such as vendor/ or build/)` : ''}${gaps.length ? `; incomplete — ${gaps.join('; ')}` : ''}${entered}`
1610
+ : 'file tree and sizes only';
1611
+ return { language, files: e.files, lines: e.lines, reader: e.reader, read: e.read, readBy: e.reader === 'compiler' ? 'the TypeScript compiler' : e.reader === 'engine' ? ENGINE_LABEL : null, level, referenceLevel, gaps, ...(reach ? { reach } : {}) };
1612
+ });
1613
+ const engine = {
1614
+ engine: ENGINE_LABEL, counted: COUNTED_RULE,
1615
+ runs: [...reaches.entries()].filter(([, r]) => r.run || r.error).map(([repo, r]) => ({
1616
+ repo, snapshotFiles: r.snapshot?.files ?? 0, notCopied: r.snapshot?.failed.length ?? 0,
1617
+ lastRun: r.run ? { ran: r.run.ran, ok: r.run.ok, ms: r.run.ms, peakMemoryMB: r.run.peakMemoryMB, indexBytes: r.run.indexBytes } : null, error: r.error,
1618
+ })),
1619
+ };
1620
+ return { languages, engine };
1621
+ }
1622
+
1623
+ /**
1624
+ * Whether a computed "nothing references this" (or "this is referenced") can stand for the code under `paths` of one
1625
+ * repository (Spec §1.19; CKC-25 AC-5): `complete` when every code file there is read by a reader — the TypeScript
1626
+ * compiler, or the code engine — and none of them is a file no counted reference reaches that another file names: a build
1627
+ * or manifest file (a named entry point) or code (a reference no reader resolved); a document or other text that only
1628
+ * mentions it leaves the fact as computed. Otherwise `gaps` says why, and a residual judgement there is Inferred. Paths
1629
+ * are repository-relative, a directory or a file.
1630
+ */
1631
+ referenceReach(repoInput: string, paths: readonly string[]): { readonly complete: boolean; readonly languages: readonly string[]; readonly gaps: readonly string[] } {
1632
+ // A repository by id or path; a directory outside version control by its scope item id.
1633
+ const repo = this.one<{ repo: string }>('SELECT repo FROM code_files WHERE repo = ? LIMIT 1', repoInput)?.repo ?? this.repoId(repoInput);
1634
+ if (!repo) return { complete: false, languages: [], gaps: [`${repoInput} is not a repository of this ledger`] };
1635
+ const under = (p: string) => paths.some((raw) => { const x = nameForm(raw).replace(/\\/g, '/').replace(/\/+$/, '').replace(/^\.\//, ''); return x === '' || p === x || p.startsWith(`${x}/`); });
1636
+ const files = this.all<{ path: string; lang: string | null; reader?: string | null; ref_lang?: string | null; named_by?: string | null }>('SELECT * FROM code_files WHERE repo = ? AND generated = 0 AND classification IS NULL', repo).filter((f) => under(f.path));
1637
+ const code = files.filter((f) => this.readerOf(f) !== null || (f.lang !== null && !NOT_CODE.has(f.lang)));
1638
+ const languages = [...new Set(code.map((f) => f.ref_lang ?? f.lang ?? '(no extension)'))].sort();
1639
+ if (!code.length) return { complete: false, languages, gaps: ['these paths hold no code whose references the ledger reads'] };
1640
+ const gaps: string[] = [];
1641
+ const unread = code.filter((f) => this.readerOf(f) === null);
1642
+ if (unread.length) {
1643
+ const why = reachOf(this.db, repo)?.error;
1644
+ gaps.push(`the ledger reads no references of ${[...new Set(unread.map((f) => f.ref_lang ?? f.lang ?? '(no extension)'))].join(', ')} (${unread.length} file${unread.length === 1 ? '' : 's'} here${why ? `; ${why}` : ''})`);
1645
+ }
1646
+ // Files other files name (code.ts NamingKind): a named entry point, or a reference no reader resolved. A document or
1647
+ // other text that only mentions a file references nothing, and leaves the fact as computed.
1648
+ gaps.push(...namedGaps(code.flatMap((f) => { const by = namingThatCounts(namingOf(f.named_by))[0]; return by ? [{ path: f.path, by }] : []; }), ' here'));
1649
+ return { complete: gaps.length === 0, languages, gaps };
1650
+ }
1651
+
1652
+ /** Days with commits in any repository and no session message of any host that day (the sessions do not cover them). */
1653
+ private daysWithoutSessions(): { from: string; to: string; commitDays: number }[] {
1654
+ const commitDays = new Set(this.all<{ author_at: string }>('SELECT author_at FROM commits WHERE on_trunk = 1').map((r) => dayOf(r.author_at)!));
1655
+ const sessionDays = new Set(this.all<{ at: string }>('SELECT at FROM session_messages WHERE at IS NOT NULL').map((r) => dayOf(r.at)!));
1656
+ // Runs of consecutive commit days (in the order commits were made) that no session covers.
1657
+ const out: { from: string; to: string; commitDays: number }[] = [];
1658
+ let run: { from: string; to: string; commitDays: number } | null = null;
1659
+ for (const d of [...commitDays].sort()) {
1660
+ if (sessionDays.has(d)) { run = null; continue; }
1661
+ if (run) { run.to = d; run.commitDays += 1; } else { run = { from: d, to: d, commitDays: 1 }; out.push(run); }
1662
+ }
1663
+ return out;
1664
+ }
1665
+
1666
+ // ───────────────────────── what the ledger read last (Update pending, Spec §1.11) ─────────────────────────
1667
+
1668
+ /**
1669
+ * A document as the ledger last read it, by its path on disk: the content its repository's current version has (the
1670
+ * checkout's HEAD at the last rebuild), else the file outside version control as that rebuild read it; null when the
1671
+ * ledger keeps no text of it (not a document, never read, too large). The rebuild is a round's first step, so this is
1672
+ * the version the latest round took in; what waits for the next round is compared with it section by section
1673
+ * (keeper/organize/update-pending.ts).
1674
+ */
1675
+ currentText(absPath: string): string | null {
1676
+ const key = pathKey(absPath);
1677
+ const repo = this.repos().filter((r) => key === pathKey(r.path) || key.startsWith(`${pathKey(r.path)}${sep}`)).sort((a, b) => b.path.length - a.path.length)[0];
1678
+ if (repo) {
1679
+ const rel = relativeDisplay(repo.path, absPath);
1680
+ const f = this.one<{ blob: string | null }>('SELECT blob FROM code_files WHERE repo = ? AND path = ?', repo.id, rel);
1681
+ const text = f?.blob ? this.blobText(repo.id, f.blob) : null;
1682
+ if (text !== null) return text;
1683
+ }
1684
+ return this.one<{ content: string }>('SELECT content FROM texts WHERE key = ?', `loose:${normalizePath(absPath)}`)?.content ?? null;
1685
+ }
1686
+
1687
+ /** The branch a worktree of one of the ledger's repositories has checked out, by the worktree's path; null when unknown. */
1688
+ worktreeBranch(absPath: string): string | null {
1689
+ const key = pathKey(absPath);
1690
+ return this.all<{ path: string; branch: string | null }>('SELECT path, branch FROM worktrees').find((w) => pathKey(w.path) === key)?.branch ?? null;
1691
+ }
1692
+
1693
+ /**
1694
+ * Where the material an entry names is on disk: a document version, a deletion, an arrangement, a file of the current
1695
+ * version, a line entry (a verdict, a number, a supersession) — the file's path; a session or one of its messages — the
1696
+ * session's log. Null for what names no place (a commit, a cleanup, a rule) and for an id the ledger does not have.
1697
+ */
1698
+ entryPlace(rawId: string): { readonly file: string } | { readonly sessionFile: string } | null {
1699
+ const id = nameForm(rawId.trim()); // an id that holds a path may have been typed from a listing
1700
+ const colon = id.indexOf(':');
1701
+ if (colon <= 0) return null;
1702
+ const kind = id.slice(0, colon);
1703
+ const rest = id.slice(colon + 1);
1704
+ const table: Record<string, string> = { doc: 'docs', del: 'deleted_docs', plan: 'plans', verdict: 'verdicts', num: 'nums', sup: 'supersedes' };
1705
+ const place = (repo: string | null, path: string | null): { file: string } | null => {
1706
+ if (!path) return null;
1707
+ if (!repo) return { file: path };
1708
+ const root = this.repoPath(repo);
1709
+ return root ? { file: join(root, ...path.split('/')) } : null;
1710
+ };
1711
+ if (table[kind]) {
1712
+ const r = this.one<{ repo: string | null; path: string | null }>(`SELECT repo, path FROM ${table[kind]} WHERE key = ? LIMIT 1`, id);
1713
+ return r ? place(r.repo, r.path) : null;
1714
+ }
1715
+ if (kind === 'file') {
1716
+ const r = this.one<{ repo: string }>('SELECT repo FROM code_files WHERE path = ? LIMIT 1', rest);
1717
+ return r ? place(r.repo, rest) : null;
1718
+ }
1719
+ if (kind === 'loose') return { file: rest };
1720
+ if (kind === 'session' || kind === 'msg') {
1721
+ const s = kind === 'session'
1722
+ ? this.one<{ file: string }>('SELECT file FROM sessions WHERE key = ?', id)
1723
+ : this.one<{ file: string }>('SELECT s.file FROM session_messages m JOIN sessions s ON s.key = m.session WHERE m.key = ?', id);
1724
+ return s ? { sessionFile: s.file } : null;
1725
+ }
1726
+ return null;
1727
+ }
1728
+
1729
+ // ───────────────────────── entry ids (evidence) ─────────────────────────
1730
+
1731
+ /** Read an entry back by its id: its label, when it happened, and its text (for a cited line to be checked against). */
1732
+ resolve(rawId: string): LedgerEntry | null {
1733
+ const id = nameForm(rawId.trim()); // an id that holds a path may have been typed from a listing
1734
+ const colon = id.indexOf(':');
1735
+ if (colon <= 0) return null;
1736
+ const kind = id.slice(0, colon);
1737
+ const rest = id.slice(colon + 1);
1738
+ switch (kind) {
1739
+ case 'commit': {
1740
+ const c = this.findCommit(rest);
1741
+ if (typeof c === 'string') return null;
1742
+ const hash = String(c.hash);
1743
+ return { id: `commit:${hash.slice(0, 12)}`, kind, label: `${hash.slice(0, 7)} ${String(c.subject)}`, occurred: this.commitOccurred(hash, String(c.author_at), String(c.committer_at)), text: `${String(c.subject)}\n\n${String(c.body)}`.trim() };
1744
+ }
1745
+ case 'doc': {
1746
+ const d = this.one<Record<string, unknown>>('SELECT * FROM docs WHERE key = ? LIMIT 1', id);
1747
+ if (!d) return null;
1748
+ const text = this.blobText(String(d.repo), String(d.blob));
1749
+ return { id, kind, label: `${String(d.path)} @ ${String(d.commit_hash).slice(0, 7)} (${String(d.change)})`, occurred: this.docOccurred({ repo: String(d.repo), path: String(d.path), commit_hash: String(d.commit_hash), at: String(d.at), import_root: Number(d.import_root), blob: String(d.blob) }), ...(text !== null ? { text } : {}) };
1750
+ }
1751
+ case 'del': {
1752
+ const d = this.one<Record<string, unknown>>('SELECT * FROM deleted_docs WHERE key = ? LIMIT 1', id);
1753
+ if (!d) return null;
1754
+ const text = this.blobText(String(d.repo), (d.blob as string | null) ?? null);
1755
+ return { id, kind, label: `${String(d.path)} deleted in ${String(d.deleted_commit).slice(0, 7)}${d.cleanup === 1 ? ' (cleanup commit)' : ''}; full text at ${String(d.readable_at ?? '').slice(0, 7)}`, occurred: this.commitOccurred(String(d.deleted_commit), String(d.deleted_at)), ...(text !== null ? { text } : {}) };
1756
+ }
1757
+ case 'cleanup': {
1758
+ const c = this.one<Record<string, unknown>>('SELECT * FROM cleanups WHERE key = ? LIMIT 1', id);
1759
+ if (!c) return null;
1760
+ return { id, kind, label: `cleanup commit ${String(c.commit_hash).slice(0, 7)}: ${Number(c.deleted_docs)} documents deleted (${json<string[]>(String(c.catalog_before), []).length} → ${json<string[]>(String(c.catalog_after), []).length})`, occurred: this.commitOccurred(String(c.commit_hash), String(c.at)), text: json<string[]>(String(c.catalog_before), []).filter((p) => !json<string[]>(String(c.catalog_after), []).includes(p)).join('\n') };
1761
+ }
1762
+ case 'sup': {
1763
+ const s = this.one<Record<string, unknown>>('SELECT * FROM supersedes WHERE key = ?', id);
1764
+ if (!s) return null;
1765
+ const row = this.lineBase(s);
1766
+ const where = s.source === 'commit' ? `commit ${String(s.first_commit).slice(0, 7)}` : `${String(s.path)}${row.line ? `:${row.line}` : ''}`;
1767
+ return { id, kind, label: `${where} — ${s.obsolete_list === 1 ? 'a row of the obsolete list' : `says “${String(s.pattern)}”`}: ${String(s.replaced ?? '?')} → ${String(s.replacement ?? '(no replacement named)')}${row.current ? '' : ' (not in the current version)'}`, occurred: row.occurred, text: row.text };
1768
+ }
1769
+ case 'num': {
1770
+ const n = this.one<Record<string, unknown>>('SELECT * FROM nums WHERE key = ?', id);
1771
+ if (!n) return null;
1772
+ const row = this.lineBase(n);
1773
+ const where = n.kind === 'commit' ? `commit ${String(n.commit_hash).slice(0, 7)}` : n.kind === 'doc' || n.kind === 'loose' ? `${String(n.path)}${row.line ? `:${row.line}` : ''}` : `${String(n.kind)} ${String(n.context)}`;
1774
+ return { id, kind, label: `${String(n.num)} ${n.place === 'definition' ? 'defined' : 'named'} in ${where}`, occurred: row.occurred, text: String(n.context) };
1775
+ }
1776
+ case 'rule': {
1777
+ const r = this.one<Record<string, unknown>>('SELECT * FROM num_rules WHERE rule = ?', rest);
1778
+ if (!r) return null;
1779
+ const first = this.one<Record<string, unknown>>("SELECT * FROM nums WHERE rule = ? AND place = 'definition' ORDER BY first_ms LIMIT 1", rest);
1780
+ return { id, kind, label: `numbering rule ${rest}: ${String(r.examples)}`, occurred: first ? this.rowOccurred(first as never) : undated(this.lastRead(), id), text: String(r.basis) };
1781
+ }
1782
+ case 'verdict': {
1783
+ const v = this.one<Record<string, unknown>>('SELECT * FROM verdicts WHERE key = ?', id);
1784
+ if (!v) return null;
1785
+ const row = this.lineBase(v);
1786
+ return { id, kind, label: `${String(v.path)}${row.line ? `:${row.line}` : ''} — ${String(v.kind)} ${String(v.verdict ?? '')} (${String(v.confidence)})`, occurred: row.occurred, text: row.text };
1787
+ }
1788
+ case 'plan': {
1789
+ const p = this.one<Record<string, unknown>>('SELECT * FROM plans WHERE key = ? LIMIT 1', id);
1790
+ if (!p) return null;
1791
+ const data = json<{ status?: string | null; title?: string }>(String(p.data), {});
1792
+ const text = p.repo && p.blob ? this.blobText(String(p.repo), String(p.blob)) : null;
1793
+ return { id, kind, label: `${String(p.path)}${p.commit_hash ? ` @ ${String(p.commit_hash).slice(0, 7)}` : ''} (${String(p.kind)}${p.ident ? ` ${String(p.ident)}` : ''}${data.status ? `, status ${data.status}` : ''})`, occurred: this.rowOccurred(p as never), text: text ?? String(p.data) };
1794
+ }
1795
+ case 'session': {
1796
+ const s = this.one<Record<string, unknown>>('SELECT * FROM sessions WHERE key = ?', id);
1797
+ if (!s) return null;
1798
+ return { id, kind, label: `${String(s.host)} session ${String(s.session_id).slice(0, 8)} (${String(s.started_at ?? '?').slice(0, 16)} → ${String(s.ended_at ?? '?').slice(0, 16)})${s.missing === 1 ? ', log gone' : ''}`, occurred: s.started_at ? { at: materialTime(String(s.started_at)) ?? String(s.started_at), basis: 'Session', anchor: id } : { at: String(s.first_seen), basis: 'First observed', anchor: id, undated: true } };
1799
+ }
1800
+ case 'msg': {
1801
+ const m = this.message(id);
1802
+ if (!m) return null;
1803
+ const s = this.one<Record<string, unknown>>('SELECT host, session_id FROM sessions WHERE key = ?', m.session);
1804
+ return { id, kind, label: `${m.speaker === 'owner' ? 'the owner' : m.speaker}, ${String(s?.host ?? '')} session ${String(s?.session_id ?? '').slice(0, 8)} [${m.index}]`, occurred: m.occurred, ...(m.text !== null ? { text: m.text } : {}) };
1805
+ }
1806
+ case 'file': {
1807
+ const f = this.one<Record<string, unknown>>('SELECT * FROM code_files WHERE path = ? LIMIT 1', rest);
1808
+ if (!f) return null;
1809
+ const tracked = this.repos().some((r) => r.id === f.repo);
1810
+ return { id, kind, label: `${rest} (${tracked ? 'current version' : 'outside version control'}, ${String(f.lines ?? '?')} lines)`, occurred: this.codeFileOccurred(f, id) };
1811
+ }
1812
+ case 'loose': {
1813
+ const t = this.one<{ content: string }>('SELECT content FROM texts WHERE key = ?', id);
1814
+ if (!t) return null;
1815
+ // The document's own date, else its file time — never the moment of the query as "first seen" (QC AY).
1816
+ return { id, kind, label: `${rest} (outside version control)`, occurred: this.looseOccurred(rest, t.content, null), text: t.content };
1817
+ }
1818
+ default: return null;
1819
+ }
1820
+ }
1821
+ }
1822
+
1823
+ /** The 1-based line a word first appears on (case-insensitive, as the trigram index finds it); null when it is not there. */
1824
+ function lineOf(content: string, word: string): number | null {
1825
+ const idx = content.toLowerCase().indexOf(word.toLowerCase());
1826
+ if (idx < 0) return null;
1827
+ let line = 1;
1828
+ for (let i = content.indexOf('\n'); i >= 0 && i < idx; i = content.indexOf('\n', i + 1)) line += 1;
1829
+ return line;
1830
+ }
1831
+
1832
+ function snippetOf(content: string, word: string): string {
1833
+ const idx = content.toLowerCase().indexOf(word.toLowerCase());
1834
+ if (idx < 0) return clip(content.replace(/\s+/g, ' '), 140);
1835
+ const from = Math.max(0, idx - 60);
1836
+ const to = Math.min(content.length, idx + word.length + 60);
1837
+ return `${from > 0 ? '…' : ''}${content.slice(from, to).replace(/\s+/g, ' ')}${to < content.length ? '…' : ''}`;
1838
+ }
1839
+
1840
+ function countBy(xs: readonly string[]): Record<string, number> {
1841
+ const out: Record<string, number> = {};
1842
+ for (const x of xs) out[x] = (out[x] ?? 0) + 1;
1843
+ return out;
1844
+ }
1845
+
1846
+ export { rebuildLedger, rebuildLedgerInPlace, ledgerPath, ledgerRepos, sessionInputOf, type RebuildOptions, type RebuildStats } from './rebuild.ts';
1847
+ export type { Occurred, OccurredBasis } from './time.ts';
1848
+ export type { SymbolResult, SymbolQueryKind } from './ts-symbols.ts';
1849
+ /** A symbol answer: the TypeScript language service's (each hit exact) or the code engine's (each hit with its method). */
1850
+ export type SymbolAnswer = (SymbolResult & { readonly answeredBy: string }) | EngineSymbolResult;