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,74 @@
1
+ /**
2
+ * Number ranges as projects write them (CM, E151; CK fix 14): `AC-25-AC-31`, `AC-25–AC-31`, `E95~E101`, `CKC-22~27`,
3
+ * `D13–D18`, `R-35 到 R-39`. The ledger indexes a number where it stands alone, so the members inside a range — and the
4
+ * last one, when a hyphen joins it to the first (`AC-25-AC-31`) — were found nowhere: the gated run's spot-check wrote
5
+ * that nobody took up CKC-03 AC-31 although `3431e93` is titled 「(CKC-03 AC-25-AC-31)」.
6
+ *
7
+ * A range is two numbers of one family (the same letters, the second may leave them out) joined by a dash, a tilde, 至 or
8
+ * 到, the second larger than the first and at most `MAX_RANGE` apart. Lookups that read what later names a number expand
9
+ * them (`expandRanges`, `rangeMembers`); the ledger's own index is left as it is.
10
+ */
11
+
12
+ /** The widest range read as one: a span wider than this is two numbers, not a list. */
13
+ export const MAX_RANGE = 300;
14
+
15
+ const RANGE = /(?<![A-Za-z0-9_])([A-Z]{1,6}-?[A-Z]?)(\d{1,4})\s*(?:-|–|—|~|~|〜|至|到|\.\.)\s*(?:([A-Z]{1,6}-?[A-Z]?)(?=\d))?(\d{1,4})(?![A-Za-z0-9_])/g;
16
+
17
+ export interface NumberRange {
18
+ /** Where it stands in the text, and as written. */
19
+ readonly index: number;
20
+ readonly written: string;
21
+ /** The family's letters (`AC-`, `E`, `CKC-`) and every member, both ends included, as the project writes them. */
22
+ readonly prefix: string;
23
+ readonly members: readonly string[];
24
+ }
25
+
26
+ /** The ranges a text writes, each with its members. */
27
+ export function rangesIn(text: string): NumberRange[] {
28
+ const out: NumberRange[] = [];
29
+ for (const m of text.matchAll(RANGE)) {
30
+ const prefix = m[1]!;
31
+ if (m[3] !== undefined && m[3] !== prefix) continue;
32
+ const from = Number(m[2]);
33
+ const to = Number(m[4]);
34
+ if (!(to > from) || to - from > MAX_RANGE) continue;
35
+ // Keep the width the project writes (`AC-05` stays two digits).
36
+ const width = m[2]!.startsWith('0') ? m[2]!.length : 0;
37
+ const members: string[] = [];
38
+ for (let n = from; n <= to; n++) members.push(`${prefix}${width ? String(n).padStart(width, '0') : String(n)}`);
39
+ out.push({ index: m.index!, written: m[0], prefix, members });
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /** Every number the ranges of a text name, the ends included. */
45
+ export function rangeMembers(text: string): string[] {
46
+ return [...new Set(rangesIn(text).flatMap((r) => r.members))];
47
+ }
48
+
49
+ /** Whether a range in the text names this number. */
50
+ export function rangeCovers(text: string, num: string): boolean {
51
+ return rangesIn(text).some((r) => r.members.includes(num));
52
+ }
53
+
54
+ /**
55
+ * The text with each range written out as a list (`AC-25, AC-26, …, AC-31`), for readers that tokenize numbers one by one.
56
+ * A range wider than `maxMembers` stays as written: `CKC-01~CKC-27` in a sentence names the whole set, not 27 items.
57
+ */
58
+ export function expandRanges(text: string, maxMembers = MAX_RANGE + 1): string {
59
+ const ranges = rangesIn(text).filter((r) => r.members.length <= maxMembers);
60
+ if (!ranges.length) return text;
61
+ let out = '';
62
+ let at = 0;
63
+ for (const r of ranges) {
64
+ out += text.slice(at, r.index) + r.members.join(', ');
65
+ at = r.index + r.written.length;
66
+ }
67
+ return out + text.slice(at);
68
+ }
69
+
70
+ /** The letters of a number's family, as a range writes them (`AC-` of AC-31, `E` of E101), or null for two capitals alone. */
71
+ export function familyPrefix(num: string): string | null {
72
+ const m = /^([A-Z]{1,6}-?[A-Z]?)(\d{1,4})$/.exec(num);
73
+ return m ? m[1]! : null;
74
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Worker-thread entry for `rebuildLedger`: the recompute runs off the main thread so the workbench
3
+ * keeps answering while a large project's ledger is rebuilt (CKC-22 AC-16: no model involved).
4
+ */
5
+ import { parentPort, workerData } from 'node:worker_threads';
6
+ import type { Project } from '../model/types.ts';
7
+ import { rebuildLedgerInPlace, type RebuildOptions } from './rebuild.ts';
8
+
9
+ const data = workerData as { dbPath: string; project: Project; opts: RebuildOptions };
10
+ try {
11
+ const stats = rebuildLedgerInPlace(data.dbPath, data.project, data.opts);
12
+ parentPort!.postMessage({ ok: true, stats });
13
+ } catch (error) {
14
+ parentPort!.postMessage({ ok: false, error: (error as Error).message });
15
+ }
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Recompute the ledger (Spec §1.16; CKC-22 AC-2, AC-16). No model is involved and nothing here touches the network; git
3
+ * is only read. `rebuildLedger` runs the work on a worker thread so the workbench's event loop keeps answering; queries go
4
+ * through their own read-only connections (WAL), which a rebuild never blocks.
5
+ *
6
+ * Incremental (AC-2): a ref's recorded tip, each distinct document content, each loose file's size and time and each
7
+ * session log's size and time decide what is read again; the first build of a ledger reads everything.
8
+ *
9
+ * Which repositories: every `Repository` item of the scope the project reads (its own repository, a nested independent
10
+ * repository such as ContextKeeper's `app/`, a copy), each on its own; a worktree folds into its main repository, whose
11
+ * worktree list the ledger reads. Material outside version control — `Directory` items, and documents a repository's
12
+ * checkout has but has never committed — is read from disk and dated by its written dates, else its file time (§3.7).
13
+ *
14
+ * The code engine (code-engine.ts) keeps its snapshots and indexes next to the ledger, in `code-index/` of the project's
15
+ * ProjectKeeper directory; the one of a repository or directory the scope no longer holds is removed with its entries.
16
+ */
17
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { dirname, join } from 'node:path';
20
+ import { Worker } from 'node:worker_threads';
21
+ import type { DatabaseSync } from 'node:sqlite';
22
+ import type { Project, ScopeItem } from '../model/types.ts';
23
+ import { projectDir } from '../store/paths.ts';
24
+ import { isSkippedName, treatmentOf } from '../scope/skip.ts';
25
+ import { sessionCwdOf, sessionStoreRootOf } from '../sources/sessions/scope.ts';
26
+ import { isDocumentPath } from '../sources/history.ts';
27
+ import { isWithin, nameForm, normalizePath, pathKey } from '../util/paths.ts';
28
+ import { getState, openLedgerForWrite, putText, redact, setState, tx } from './schema.ts';
29
+ import { scanRepo, type RepoHandle, type RepoScanStats } from './repo-scan.ts';
30
+ import { MAX_DOC_BYTES, scanDocs, type DocScanStats } from './docs.ts';
31
+ import { computeNumRules, numKey, scanLooseDocument, scanTextsFirstPass, scanTextsSecondPass, textStats, type NameItem, type TextScanStats } from './text-scan.ts';
32
+ import { definitionsInText, familyOf } from './numbering.ts';
33
+ import { msOf } from './time.ts';
34
+ import { arrangementKind, scanArrangements, scanLooseArrangement, type ArrangementScanStats } from './arrangements.ts';
35
+ import { reachOf, scanCode, scanLooseCode, type CodeScanStats } from './code.ts';
36
+ import { dropEngineDir, engineDirOf } from './code-engine.ts';
37
+ import { scanBlame, type BlameScanStats } from './blame.ts';
38
+ import { scanSessions, type SessionScanInput, type SessionScanStats } from './sessions.ts';
39
+ import { ledgerGit } from './git-read.ts';
40
+ import { OWN_TS_WORKER_OPTIONS } from '../util/own-ts.ts';
41
+
42
+ export function ledgerPath(projectId: string, home?: string): string {
43
+ return join(projectDir(projectId, home), 'ledger.sqlite');
44
+ }
45
+
46
+ /** Where the code engine keeps a project's snapshots and indexes: `code-index/` next to its ledger (never in the project). */
47
+ export const engineRootOf = (dbPath: string): string => join(dirname(dbPath), 'code-index');
48
+
49
+ export interface RebuildOptions {
50
+ /** Session logs to read; null or absent: sessions are not part of this rebuild (the coverage says so). */
51
+ readonly sessions?: SessionScanInput | null;
52
+ /** Recompute the parts whose inputs did not move too. */
53
+ readonly force?: boolean;
54
+ /**
55
+ * The numbers the Keeper gave to what the project did not number (Spec §1.17, D72; CKC-22 AC-5: "Keeper 编的号也在账本
56
+ * 里"). Recorded as the ledger's `keeper` occurrences, so such a number is found in the ledger whether or not the
57
+ * project folder holds it — before, it reached the ledger only once `projectkeeper/keeper-numbers.md` was authorized,
58
+ * committed and read (QC AY). Absent or null: the Keeper's rows stay as the last rebuild that had them recorded them.
59
+ */
60
+ readonly keeperNumbers?: readonly KeeperNumberInput[] | null;
61
+ }
62
+
63
+ /** A Keeper number as the ledger records it (model/k-types.ts `KeeperNumber`). */
64
+ export interface KeeperNumberInput {
65
+ readonly number: string;
66
+ readonly objectId: string;
67
+ readonly objectKind: string;
68
+ /** The project's own number, once it numbers the object itself: the Keeper's stays an alias. */
69
+ readonly projectNumber: string | null;
70
+ /** When the Keeper gave it. */
71
+ readonly at: string;
72
+ }
73
+
74
+ /** The Keeper's numbers, all of them, in place of the ones the last rebuild recorded. */
75
+ function recordKeeperNumbers(db: DatabaseSync, numbers: readonly KeeperNumberInput[]): number {
76
+ let n = 0;
77
+ tx(db, () => {
78
+ db.prepare("DELETE FROM nums WHERE kind = 'keeper'").run();
79
+ const ins = db.prepare(`INSERT OR IGNORE INTO nums (key, num, rule, kind, place, position, repo, path, commit_hash, line, context, ident, confidence, first_ms, occurred_at, occurred_basis, occurred_anchor, undated, current)
80
+ VALUES (?, ?, ?, 'keeper', 'definition', 'Keeper number', NULL, NULL, NULL, NULL, ?, ?, 'stated', ?, ?, 'First observed', ?, 0, 1)`);
81
+ for (const k of numbers) {
82
+ const context = `The Keeper's number for ${k.objectKind} ${k.objectId}${k.projectNumber ? `; the project numbers it ${k.projectNumber}, which comes first` : ''}`;
83
+ // When the Keeper gave the number is when the program first saw it: it recorded it then.
84
+ if (ins.run(numKey(null, 'keeper', k.objectId, k.number, k.number), k.number, familyOf(k.number)?.family ?? 'Keeper number', redact(context), k.number, msOf(k.at), k.at, `keeper-number:${k.number}`).changes > 0) n += 1;
85
+ }
86
+ });
87
+ return n;
88
+ }
89
+
90
+ export interface RepoRebuildStats extends RepoScanStats {
91
+ readonly docs: DocScanStats;
92
+ readonly texts: TextScanStats;
93
+ readonly arrangements: ArrangementScanStats;
94
+ readonly code: CodeScanStats;
95
+ readonly blame: BlameScanStats;
96
+ readonly ms: number;
97
+ }
98
+
99
+ export interface RebuildStats {
100
+ readonly kind: 'full' | 'incremental';
101
+ readonly startedAt: string;
102
+ readonly endedAt: string;
103
+ readonly ms: number;
104
+ readonly commitsAdded: number;
105
+ readonly repos: readonly RepoRebuildStats[];
106
+ readonly loose: { readonly items: number; readonly files: number; readonly documents: number; readonly read: number; readonly untrackedDocuments: number };
107
+ readonly sessions: SessionScanStats | 'not scanned';
108
+ readonly numRules: number;
109
+ /** What could not be read, in words (a repository git cannot open, a session log that fails to parse …). */
110
+ readonly notes: readonly string[];
111
+ }
112
+
113
+ /** The repositories whose history the ledger records (see the module comment). */
114
+ export function ledgerRepos(project: Project): RepoHandle[] {
115
+ const out: RepoHandle[] = [];
116
+ const seen = new Set<string>();
117
+ const add = (id: string, path: string) => {
118
+ const key = pathKey(path);
119
+ if (seen.has(key)) return;
120
+ seen.add(key);
121
+ out.push({ id, path: normalizePath(path) });
122
+ };
123
+ const repos = project.scope.filter((i) => i.category === 'Repository' && !i.missing && i.versionControl !== 'none' && treatmentOf(i) === 'read');
124
+ for (const i of [...repos].sort((a, b) => Number(b.relation === 'Main project') - Number(a.relation === 'Main project'))) add(i.id, i.path);
125
+ // A worktree whose main repository is not itself a scope item: the main repository is read in its place.
126
+ for (const w of project.scope.filter((i) => i.category === 'Worktree' && !i.missing && i.worktreeOf && i.relation !== 'Excluded')) {
127
+ if (!repos.some((r) => pathKey(r.path) === pathKey(w.worktreeOf!))) add(w.id, w.worktreeOf!);
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /** Scope items outside version control whose material the ledger reads from disk. */
133
+ export function looseItems(project: Project): ScopeItem[] {
134
+ return project.scope.filter((i) => i.category === 'Directory' && !i.missing && treatmentOf(i) === 'read' && i.versionControl !== 'git');
135
+ }
136
+
137
+ /** The sessions of a project: the working directories of its scope and of its session items, each in its own native home. */
138
+ export function sessionInputOf(project: Project): SessionScanInput {
139
+ const cwds = new Set<string>();
140
+ for (const i of project.scope) {
141
+ if ((i.category === 'Directory' || i.category === 'Repository' || i.category === 'Worktree') && i.relation !== 'Excluded' && !i.missing) cwds.add(i.path);
142
+ if (i.copyOf) cwds.add(i.copyOf);
143
+ const c = sessionCwdOf(i);
144
+ if (i.category === 'Session source' && c && i.relation !== 'Excluded') cwds.add(c);
145
+ }
146
+ const homes: { host: 'claude' | 'codex'; cwd: string; home: string }[] = [];
147
+ for (const cwd of cwds) for (const host of ['claude', 'codex'] as const) {
148
+ const home = sessionStoreRootOf(project.scope, host, cwd, homedir());
149
+ if (pathKey(home) !== pathKey(homedir())) homes.push({ host, cwd, home });
150
+ }
151
+ return { cwds: [...cwds], homes };
152
+ }
153
+
154
+ /** The scope's classification of the location holding a path (innermost wins), for the code structure. */
155
+ function classifier(scope: readonly ScopeItem[]): (abs: string) => string | null {
156
+ const classified = scope.filter((i) => i.category !== 'Session source' && (i.classification?.kind || i.relation === 'Third-party material' || i.relation === 'Generated'));
157
+ return (abs) => {
158
+ const hit = classified.filter((i) => isWithin(i.path, abs)).sort((a, b) => b.path.length - a.path.length)[0];
159
+ return hit ? (hit.classification?.kind ?? hit.relation) : null;
160
+ };
161
+ }
162
+
163
+ /** A file outside version control larger than this is not read; the rebuild says which (`notes`, the coverage). */
164
+ export const MAX_LOOSE_BYTES = 2_000_000;
165
+
166
+ /** The first few of a list, and how many more: what a note says without printing every path. */
167
+ function listSome(items: readonly string[], n = 12): string {
168
+ return `${items.slice(0, n).join(', ')}${items.length > n ? `, and ${items.length - n} more` : ''}`;
169
+ }
170
+
171
+ /** What the code engine could not read in one repository or directory, for the rebuild's notes (the coverage says it too). */
172
+ function codeEngineNote(db: DatabaseSync, id: string, path: string): string | null {
173
+ const reach = reachOf(db, id);
174
+ if (!reach) return null;
175
+ const parts: string[] = [];
176
+ if (reach.error) parts.push(`the code engine's references are missing: ${reach.error}`);
177
+ const failed = reach.snapshot?.failed ?? [];
178
+ if (failed.length) parts.push(`${failed.length} file${failed.length === 1 ? '' : 's'} could not be copied into the engine's snapshot, so ${failed.length === 1 ? 'its' : 'their'} references are not read: ${listSome(failed, 5)}`);
179
+ return parts.length ? `${path}: ${parts.join('; ')}.` : null;
180
+ }
181
+
182
+ interface LooseFile { readonly abs: string; readonly rel: string; readonly size: number; readonly mtimeMs: number }
183
+
184
+ function walkDocs(root: string, skip: (abs: string) => boolean, into: LooseFile[], rel = '', depth = 0): void {
185
+ if (depth > 20) return;
186
+ let entries;
187
+ try { entries = readdirSync(join(root, rel), { withFileTypes: true }); } catch { return; }
188
+ for (const e of entries) {
189
+ if (e.isSymbolicLink()) continue;
190
+ const name = nameForm(e.name);
191
+ const r = rel ? `${rel}/${name}` : name;
192
+ const abs = join(root, ...r.split('/'));
193
+ if (e.isDirectory()) { if (!isSkippedName(name) && name !== '.git' && !skip(abs)) walkDocs(root, skip, into, r, depth + 1); continue; }
194
+ if (!e.isFile() || !(isDocumentPath(r) || arrangementKind(r) !== null)) continue;
195
+ try { const st = statSync(abs); into.push({ abs, rel: r, size: st.size, mtimeMs: st.mtimeMs }); } catch { continue; }
196
+ }
197
+ }
198
+
199
+ /** Documents a checkout has and has never committed (untracked, not ignored): outside version control too. */
200
+ function untrackedDocs(repo: RepoHandle): LooseFile[] {
201
+ const r = ledgerGit(repo.path, ['status', '--porcelain', '-z', '--untracked-files=all'], { timeoutMs: 120_000 });
202
+ if (!r.ok) return [];
203
+ const out: LooseFile[] = [];
204
+ for (const entry of r.out.split('\0')) {
205
+ if (!entry.startsWith('?? ')) continue;
206
+ const rel = entry.slice(3);
207
+ if (!(isDocumentPath(rel) || arrangementKind(rel) !== null)) continue;
208
+ const abs = join(repo.path, ...rel.split('/'));
209
+ try { const st = statSync(abs); if (st.isFile()) out.push({ abs, rel, size: st.size, mtimeMs: st.mtimeMs }); } catch { continue; }
210
+ }
211
+ return out;
212
+ }
213
+
214
+ /** Everything the ledger recorded of one repository (or one directory outside version control) removed, its code snapshot too. */
215
+ function dropRepo(db: DatabaseSync, id: string, engineRoot: string): void {
216
+ tx(db, () => {
217
+ for (const t of ['refs', 'commits', 'commit_files', 'branches', 'worktrees', 'docs', 'deleted_docs', 'cleanups', 'doc_items', 'doc_scans', 'supersedes', 'nums', 'verdicts', 'plans', 'code_files', 'code_deps', 'code_blame', 'dir_stats', 'merge_dirs']) {
218
+ db.prepare(`DELETE FROM ${t} WHERE repo = ?`).run(id);
219
+ }
220
+ for (const { rowid } of db.prepare('SELECT id AS rowid FROM texts WHERE repo = ?').all(id) as { rowid: number }[]) {
221
+ db.prepare('DELETE FROM texts_fts WHERE rowid = ?').run(rowid);
222
+ db.prepare('DELETE FROM texts WHERE id = ?').run(rowid);
223
+ }
224
+ db.prepare('DELETE FROM repos WHERE id = ?').run(id);
225
+ db.prepare("DELETE FROM state WHERE key LIKE ? OR key LIKE ? OR key = ? OR key = ?").run(`ref:${id}:%`, `importRoots:${id}`, `code:${id}`, `codeReach:${id}`);
226
+ });
227
+ dropEngineDir(engineDirOf(engineRoot, id));
228
+ }
229
+
230
+ /** The whole recompute, synchronously, on whatever thread calls it. */
231
+ export function rebuildLedgerInPlace(dbPath: string, project: Project, opts: RebuildOptions = {}): RebuildStats {
232
+ const startedAt = new Date().toISOString();
233
+ const started = Date.now();
234
+ const db = openLedgerForWrite(dbPath);
235
+ const notes: string[] = [];
236
+ const engineRoot = engineRootOf(dbPath);
237
+ try {
238
+ const hadCommits = (db.prepare('SELECT count(*) c FROM commits').get() as { c: number }).c > 0;
239
+ const classify = classifier(project.scope);
240
+ const repos = ledgerRepos(project);
241
+ // A repository the scope no longer holds (excluded, gone) leaves the ledger with everything recorded of it.
242
+ for (const { id } of db.prepare('SELECT id FROM repos').all() as { id: string }[]) {
243
+ if (!repos.some((r) => r.id === id)) { dropRepo(db, id, engineRoot); notes.push(`${id}: no longer a repository of the scope; its history left the ledger.`); }
244
+ }
245
+ const perRepo: { repo: RepoHandle; scan: RepoScanStats; docs: DocScanStats; code: CodeScanStats; blame: BlameScanStats; contents: number; ms: number }[] = [];
246
+ const names: NameItem[] = [];
247
+ for (const repo of repos) {
248
+ const t0 = Date.now();
249
+ const scan = scanRepo(db, repo, startedAt);
250
+ if (scan.refs === 0 && scan.commits === 0) notes.push(`${repo.path}: git shows no ref and no commit there (not a repository, or git cannot open it).`);
251
+ if (scan.unreadCommits > 0) notes.push(`${repo.path}: ${scan.unreadCommits} commits could not be read.`);
252
+ const docs = scanDocs(db, repo, startedAt);
253
+ const unread = docs.withoutTextPaths ?? [];
254
+ if (unread.length) notes.push(`${repo.path}: ${unread.length} document version${unread.length === 1 ? '' : 's'} recorded without text (over ${MAX_DOC_BYTES / 1_000_000} MB, or not text), so the superseded lines, numbers and verdicts in ${unread.length === 1 ? 'it' : 'them'} are not in the ledger: ${listSome(unread.map((u) => `${u.path}@${u.commit.slice(0, 7)} (${Math.round(u.bytes / 1000)} kB)`))}.`);
255
+ const code = scanCode(db, repo, { classify, engineRoot }, { force: opts.force, now: startedAt });
256
+ const reachNote = codeEngineNote(db, repo.id, repo.path);
257
+ if (reachNote) notes.push(reachNote);
258
+ const blame = scanBlame(db, repo, opts.force === true);
259
+ if (blame.failed) notes.push(`${repo.path}: git blame could not cover ${blame.failed} current source files.`);
260
+ const first = scanTextsFirstPass(db, repo);
261
+ names.push(...first.names);
262
+ perRepo.push({ repo, scan, docs, code, blame, contents: first.contents, ms: Date.now() - t0 });
263
+ }
264
+
265
+ // Material outside version control: directories of the scope, and documents a checkout never committed.
266
+ const loose: LooseFile[] = [];
267
+ const items = looseItems(project);
268
+ const otherItems = project.scope.filter((i) => i.category !== 'Session source');
269
+ for (const item of items) {
270
+ // Another scope item inside this directory (a nested repository, an excluded folder) is left to itself.
271
+ const skip = (abs: string) => otherItems.some((o) => o.id !== item.id && pathKey(o.path) === pathKey(abs));
272
+ walkDocs(item.path, skip, loose);
273
+ scanLooseCode(db, item.id, item.path, skip, { classify, engineRoot }, opts.force === true, startedAt);
274
+ const reachNote = codeEngineNote(db, item.id, item.path);
275
+ if (reachNote) notes.push(reachNote);
276
+ }
277
+ // A directory the scope no longer holds leaves the code structure, its code snapshot with it.
278
+ const held = new Set([...repos.map((r) => r.id), ...items.map((i) => i.id)]);
279
+ for (const { repo } of db.prepare('SELECT DISTINCT repo FROM code_files').all() as { repo: string }[]) {
280
+ if (held.has(repo)) continue;
281
+ tx(db, () => { for (const t of ['code_files', 'code_deps', 'dir_stats']) db.prepare(`DELETE FROM ${t} WHERE repo = ?`).run(repo); db.prepare('DELETE FROM state WHERE key = ? OR key = ?').run(`loosecode:${repo}`, `codeReach:${repo}`); });
282
+ dropEngineDir(engineDirOf(engineRoot, repo));
283
+ }
284
+ const untracked = repos.flatMap((r) => untrackedDocs(r));
285
+ const allLoose = [...loose, ...untracked];
286
+ // Too large to read: listed and said, not left out without a word (QC AY).
287
+ const tooLarge = allLoose.filter((f) => f.size > MAX_LOOSE_BYTES).map((f) => normalizePath(f.abs));
288
+ if (tooLarge.length) notes.push(`${tooLarge.length} file${tooLarge.length === 1 ? '' : 's'} outside version control over ${MAX_LOOSE_BYTES / 1_000_000} MB not read (their dates, superseded lines, numbers and verdicts are not in the ledger): ${listSome(tooLarge)}.`);
289
+ const looseTexts = new Map<string, string>();
290
+ for (const f of allLoose) {
291
+ if (f.size > MAX_LOOSE_BYTES) continue;
292
+ try {
293
+ const text = readFileSync(f.abs, 'utf8');
294
+ if (!text.slice(0, 8000).includes('\0')) looseTexts.set(f.abs, text);
295
+ } catch { continue; }
296
+ }
297
+ for (const f of allLoose) {
298
+ const text = looseTexts.get(f.abs);
299
+ if (text === undefined || !isDocumentPath(f.rel)) continue;
300
+ for (const d of definitionsInText(text)) names.push({ kind: 'loose', where: normalizePath(f.abs), def: d });
301
+ }
302
+
303
+ const { rules, changed } = computeNumRules(db, names);
304
+ const repoStats: RepoRebuildStats[] = [];
305
+ for (const r of perRepo) {
306
+ const t0 = Date.now();
307
+ scanTextsSecondPass(db, r.repo, rules, changed, names.filter((n) => n.kind !== 'loose'), startedAt);
308
+ const arrangements = scanArrangements(db, r.repo, startedAt);
309
+ repoStats.push({ ...r.scan, docs: r.docs, code: r.code, blame: r.blame, texts: textStats(db, r.repo.id, r.contents, changed), arrangements, ms: r.ms + Date.now() - t0 });
310
+ }
311
+
312
+ let read = 0;
313
+ tx(db, () => {
314
+ const seen = new Set<string>();
315
+ for (const f of allLoose) {
316
+ const path = normalizePath(f.abs);
317
+ seen.add(path);
318
+ const text = looseTexts.get(f.abs);
319
+ if (text === undefined) continue;
320
+ const stamp = `${f.size}:${Math.round(f.mtimeMs)}`;
321
+ if (!changed && !opts.force && getState(db, `loose:${path}`) === stamp) continue;
322
+ const fileTime = new Date(f.mtimeMs).toISOString();
323
+ if (isDocumentPath(f.rel)) {
324
+ putText(db, `loose:${path}`, 'loose', null, text);
325
+ scanLooseDocument(db, path, text, fileTime, rules);
326
+ }
327
+ if (arrangementKind(f.rel)) scanLooseArrangement(db, path, f.rel, text, fileTime);
328
+ setState(db, `loose:${path}`, stamp);
329
+ // When the program first read it: the `first seen` of a document that gives no time of its own (§2.11).
330
+ if (getState(db, `loosefirst:${path}`) === null) setState(db, `loosefirst:${path}`, startedAt);
331
+ read += 1;
332
+ }
333
+ // A loose file gone from disk: its rows go (the file never had a version to keep).
334
+ for (const { key } of db.prepare("SELECT key FROM state WHERE key LIKE 'loose:%'").all() as { key: string }[]) {
335
+ const path = key.slice('loose:'.length);
336
+ if (seen.has(path)) continue;
337
+ db.prepare("DELETE FROM supersedes WHERE repo IS NULL AND source = 'loose' AND path = ?").run(path);
338
+ db.prepare('DELETE FROM verdicts WHERE repo IS NULL AND path = ?').run(path);
339
+ db.prepare("DELETE FROM nums WHERE repo IS NULL AND kind = 'loose' AND path = ?").run(path);
340
+ db.prepare('DELETE FROM plans WHERE repo IS NULL AND path = ?').run(path);
341
+ db.prepare('DELETE FROM state WHERE key = ? OR key = ?').run(key, `loosefirst:${path}`);
342
+ const t = db.prepare('SELECT id FROM texts WHERE key = ?').get(`loose:${path}`) as { id: number } | undefined;
343
+ if (t) { db.prepare('DELETE FROM texts_fts WHERE rowid = ?').run(t.id); db.prepare('DELETE FROM texts WHERE id = ?').run(t.id); }
344
+ }
345
+ setState(db, 'looseDocuments', JSON.stringify({ items: items.map((i) => i.path), documents: allLoose.filter((f) => isDocumentPath(f.rel)).length, untracked: untracked.filter((f) => isDocumentPath(f.rel)).length, tooLarge }));
346
+ });
347
+
348
+ const sessions = opts.sessions ? scanSessions(db, opts.sessions, startedAt) : ('not scanned' as const);
349
+ if (opts.sessions) setState(db, 'sessionsScanned', startedAt);
350
+ if (opts.keeperNumbers) recordKeeperNumbers(db, opts.keeperNumbers);
351
+ if (sessions !== 'not scanned' && sessions.unreadable > 0) notes.push(`${sessions.unreadable} session logs could not be read (listed with their reason).`);
352
+
353
+ const endedAt = new Date().toISOString();
354
+ const commitsAdded = repoStats.reduce((n, r) => n + r.commitsAdded, 0);
355
+ const stats: RebuildStats = {
356
+ kind: hadCommits && !opts.force ? 'incremental' : 'full', startedAt, endedAt, ms: Date.now() - started, commitsAdded,
357
+ repos: repoStats,
358
+ loose: { items: items.length, files: allLoose.length, documents: allLoose.filter((f) => isDocumentPath(f.rel)).length, read, untrackedDocuments: untracked.filter((f) => isDocumentPath(f.rel)).length },
359
+ sessions, numRules: rules.length, notes,
360
+ };
361
+ db.prepare('INSERT INTO rebuilds (started_at, ended_at, ms, kind, commits_added, stats) VALUES (?, ?, ?, ?, ?, ?)')
362
+ .run(startedAt, endedAt, stats.ms, stats.kind, commitsAdded, JSON.stringify(stats));
363
+ setState(db, 'lastRebuildAt', endedAt);
364
+ setState(db, 'repos', JSON.stringify(repos));
365
+ return stats;
366
+ } finally {
367
+ db.close();
368
+ }
369
+ }
370
+
371
+ /** The same recompute on a worker thread, so the workbench keeps answering (CKC-22 AC-16: no model involved). */
372
+ export function rebuildLedger(dbPath: string, project: Project, opts: RebuildOptions = {}): Promise<RebuildStats> {
373
+ return new Promise((resolve, reject) => {
374
+ const worker = new Worker(new URL('./rebuild-worker.ts', import.meta.url), { workerData: { dbPath, project, opts }, ...OWN_TS_WORKER_OPTIONS });
375
+ let settled = false;
376
+ worker.once('message', (msg: { ok: true; stats: RebuildStats } | { ok: false; error: string }) => {
377
+ settled = true;
378
+ if (msg.ok) resolve(msg.stats);
379
+ else reject(new Error(msg.error));
380
+ void worker.terminate();
381
+ });
382
+ worker.once('error', (error) => { if (!settled) { settled = true; reject(error); } });
383
+ worker.once('exit', (code) => { if (!settled) { settled = true; reject(new Error(`the ledger's rebuild worker exited with code ${code}`)); } });
384
+ });
385
+ }
386
+
387
+ export type { DatabaseSync };