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,215 @@
1
+ /**
2
+ * Watch the scope for change (Spec §3.2): files in included directories, native session logs
3
+ * for the scoped working directories, and repository heads. Changes are reported as pending
4
+ * material; a piece of work counts as "settled" after a quiet period so active writing is
5
+ * gathered instead of re-done on every keystroke. The rhythm parameters are execution-layer
6
+ * knobs (D31), not product premises.
7
+ */
8
+ import { EventEmitter } from 'node:events';
9
+ import { existsSync, statSync, watch, type FSWatcher } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { dirname, join } from 'node:path';
12
+ import type { PendingMaterial, Project, ScopeItem } from '../model/types.ts';
13
+ import { isWithin, nameForm, normalizePath, pathKey, relativeDisplay } from '../util/paths.ts';
14
+ import { gitHead } from '../util/git.ts';
15
+ import { claudeSessionFolders, codexSessionRoots, cwdMatcher, readCodexSessionHeader } from './sessions/locate.ts';
16
+ import { sessionItemForCwd, sessionStoreRootOf } from './sessions/scope.ts';
17
+ import { isDocumentPath, isReadRoot, overridesIgnoreRules, readingOf, skippedSegment, treatmentOf } from '../scope/skip.ts';
18
+ import { isIgnored } from '../scope/ignore.ts';
19
+ import { ignoredPlaceOf } from '../scope/ignored-place.ts';
20
+
21
+ export interface WatchSettings {
22
+ /** A file whose last change is older than this is considered settled. */
23
+ readonly fileQuietMs: number;
24
+ /** A session log whose last growth is older than this is considered settled. */
25
+ readonly sessionQuietMs: number;
26
+ /** How often to poll repository heads and re-check quiet periods. */
27
+ readonly pollMs: number;
28
+ }
29
+ export const DEFAULT_WATCH: WatchSettings = { fileQuietMs: 45_000, sessionQuietMs: 4 * 60_000, pollMs: 15_000 };
30
+
31
+ export interface PendingChange extends PendingMaterial {
32
+ readonly lastEventAt: number;
33
+ readonly scopeItemId: string;
34
+ }
35
+
36
+ /** Repositories and worktrees whose new commits are the project's (not a vendored library's upstream history). */
37
+ const committing = (scope: readonly ScopeItem[]) => scope.filter((i) => (i.category === 'Repository' || i.category === 'Worktree') && isReadRoot(scope, i) && ['read', 'changes'].includes(treatmentOf(i)));
38
+
39
+ export class ScopeWatcher extends EventEmitter {
40
+ private readonly watchers: FSWatcher[] = [];
41
+ private readonly pending = new Map<string, PendingChange>();
42
+ private readonly heads = new Map<string, string | null>();
43
+ /** What git's ignore rules said about a file already seen (the check runs once per path). */
44
+ private readonly ignoredCache = new Map<string, boolean>();
45
+ /** Whether a scope item lies in a place a repository around it ignores (asked once per item between starts). */
46
+ private readonly placeCache = new Map<string, boolean>();
47
+ private keep = new Set<string>();
48
+ private timer: NodeJS.Timeout | null = null;
49
+ private readonly settings: WatchSettings;
50
+ private project: Project;
51
+
52
+ constructor(project: Project, settings: Partial<WatchSettings> = {}) {
53
+ super();
54
+ this.project = project;
55
+ this.settings = { ...DEFAULT_WATCH, ...settings };
56
+ }
57
+
58
+ /**
59
+ * The scope item a changed file belongs to, when the scan would read it (Spec §1.1): not what the one skip list or
60
+ * the project's ignore rules leave out, not generated output, only documents of third-party material, and nothing
61
+ * under a location that is left out. Nor a file of a location that itself lies where a repository around it ignores —
62
+ * a worktree under an ignored directory (D105): its working files are no change to take in; its commits are polled.
63
+ */
64
+ private accept(file: string): ScopeItem | null {
65
+ const scope = this.project.scope;
66
+ const { item, treatment } = readingOf(scope, file);
67
+ if (!item || treatment === 'none' || !isReadRoot(scope, item)) return null;
68
+ const rel = relativeDisplay(item.path, file);
69
+ if (skippedSegment(rel) !== null) return null;
70
+ if (treatment === 'documents' && !isDocumentPath(file)) return null;
71
+ let inIgnoredPlace = this.placeCache.get(item.id);
72
+ if (inIgnoredPlace === undefined) { inIgnoredPlace = ignoredPlaceOf(scope, item) !== null; this.placeCache.set(item.id, inIgnoredPlace); }
73
+ if (inIgnoredPlace) return null;
74
+ if (item.versionControl !== 'none' && !overridesIgnoreRules(scope, item) && !this.keep.has(pathKey(file))) {
75
+ const key = pathKey(file);
76
+ let ignored = this.ignoredCache.get(key);
77
+ if (ignored === undefined) { ignored = isIgnored(item.path, rel); this.ignoredCache.set(key, ignored); }
78
+ if (ignored) return null;
79
+ }
80
+ return item;
81
+ }
82
+
83
+ start(): void {
84
+ this.stop();
85
+ const p = this.project;
86
+ this.ignoredCache.clear();
87
+ this.placeCache.clear();
88
+ this.keep = new Set(p.scope.filter((i) => i.relation !== 'Excluded' && i.ignoredBy?.paths).flatMap((i) => (i.ignoredBy!.paths ?? []).map((f) => pathKey(join(dirname(i.path), f.split('/').pop()!)))));
89
+ const roots = p.scope.filter((i) => i.category !== 'Session source' && isReadRoot(p.scope, i));
90
+ // One recursive watch per outermost location; each change is attributed to the deepest item that holds it (CKC-07 AC-1).
91
+ const outer = roots.filter((r) => !roots.some((o) => o !== r && isWithin(o.path, r.path) && pathKey(o.path) !== pathKey(r.path)));
92
+ for (const root of outer) {
93
+ this.watchDir(root.path, (file) => {
94
+ const owner = this.accept(file);
95
+ if (!owner) return;
96
+ this.note({ kind: 'file', ref: file, label: file, since: new Date().toISOString(), lastEventAt: Date.now(), scopeItemId: owner.id });
97
+ });
98
+ }
99
+ for (const repo of committing(p.scope)) {
100
+ this.heads.set(repo.id, gitHead(repo.path));
101
+ }
102
+ // Session logs: Claude's per-project directory and both Codex homes.
103
+ const cwds = new Set<string>();
104
+ for (const item of roots) cwds.add(item.path);
105
+ for (const item of p.scope) if (item.copyOf) cwds.add(item.copyOf);
106
+ const home = homedir();
107
+ /** The directories whose sessions one host keeps in the same native home, home by home. */
108
+ const byHome = (host: 'claude' | 'codex'): { home: string; cwds: string[] }[] => {
109
+ const groups = new Map<string, { home: string; cwds: string[] }>();
110
+ for (const cwd of cwds) {
111
+ const selected = sessionStoreRootOf(p.scope, host, cwd, home);
112
+ const key = pathKey(selected);
113
+ const group = groups.get(key) ?? { home: selected, cwds: [] };
114
+ group.cwds.push(cwd);
115
+ groups.set(key, group);
116
+ }
117
+ return [...groups.values()];
118
+ };
119
+ for (const group of byHome('claude')) {
120
+ // The folder named after the directory, and any named after another spelling of it (through a junction, say).
121
+ for (const { dir, cwd } of claudeSessionFolders(group.cwds, group.home)) {
122
+ // The full normalised path is the identity. A substring lets D:\x steal events from D:\x-y, depending on order.
123
+ const item = sessionItemForCwd(p.scope, 'claude', cwd);
124
+ // Removing a discovered session source records an owner exclusion. Keep watching a cwd with no item so its
125
+ // first new session is still caught, but an item the owner explicitly excluded stays quiet.
126
+ if (item?.relation === 'Excluded') continue;
127
+ this.watchDir(dir, (file) => {
128
+ if (!file.endsWith('.jsonl')) return;
129
+ this.note({ kind: 'session', ref: file, label: `Claude Code session ${file.split(/[\\/]/).pop()}`, since: new Date().toISOString(), lastEventAt: Date.now(), scopeItemId: item?.id ?? '' });
130
+ });
131
+ }
132
+ }
133
+ for (const group of byHome('codex')) {
134
+ const matched = cwdMatcher(group.cwds);
135
+ for (const rootDir of codexSessionRoots(group.home)) {
136
+ this.watchDir(rootDir, (file) => {
137
+ if (!file.endsWith('.jsonl')) return;
138
+ // A native home can contain every project. Only the header's complete cwd selects its session source: one of
139
+ // these directories, in whatever spelling the log records it.
140
+ const header = readCodexSessionHeader(file);
141
+ const cwd = header ? matched(header.cwd) : null;
142
+ if (!cwd) return;
143
+ const item = sessionItemForCwd(p.scope, 'codex', cwd);
144
+ if (item?.relation === 'Excluded') return;
145
+ this.note({ kind: 'session', ref: file, label: `Codex session ${file.split(/[\\/]/).pop()}`, since: new Date().toISOString(), lastEventAt: Date.now(), scopeItemId: item?.id ?? '' });
146
+ });
147
+ }
148
+ }
149
+ this.timer = setInterval(() => this.poll(), this.settings.pollMs);
150
+ this.timer.unref?.();
151
+ }
152
+
153
+ update(project: Project): void {
154
+ this.project = project;
155
+ this.start();
156
+ }
157
+
158
+ stop(): void {
159
+ for (const w of this.watchers) { try { w.close(); } catch { /* ignore */ } }
160
+ this.watchers.length = 0;
161
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
162
+ }
163
+
164
+ private watchDir(dir: string, onFile: (file: string) => void): void {
165
+ try {
166
+ const w = watch(dir, { recursive: true, persistent: false }, (_event, filename) => {
167
+ if (!filename) return;
168
+ const file = normalizePath(join(dir, nameForm(String(filename))));
169
+ onFile(file);
170
+ });
171
+ w.on('error', () => { /* a vanished directory just stops being watched */ });
172
+ this.watchers.push(w);
173
+ } catch (error) {
174
+ this.emit('warning', `Cannot watch ${dir}: ${(error as Error).message}`);
175
+ }
176
+ }
177
+
178
+ private note(change: PendingChange): void {
179
+ const key = `${change.kind}:${pathKey(change.ref)}`;
180
+ const existing = this.pending.get(key);
181
+ this.pending.set(key, existing ? { ...existing, lastEventAt: change.lastEventAt } : change);
182
+ this.emit('pending', this.list());
183
+ }
184
+
185
+ /** Everything seen changed and not yet handed to organizing. */
186
+ list(): PendingChange[] { return [...this.pending.values()]; }
187
+
188
+ /** Changes whose quiet period has passed: work that has settled (§3.2). */
189
+ settled(now = Date.now()): PendingChange[] {
190
+ return this.list().filter((c) => {
191
+ const quiet = c.kind === 'session' ? this.settings.sessionQuietMs : this.settings.fileQuietMs;
192
+ return now - c.lastEventAt >= quiet;
193
+ });
194
+ }
195
+
196
+ take(changes: readonly PendingChange[]): void {
197
+ for (const c of changes) this.pending.delete(`${c.kind}:${pathKey(c.ref)}`);
198
+ }
199
+
200
+ private poll(): void {
201
+ for (const repo of committing(this.project.scope)) {
202
+ const head = gitHead(repo.path);
203
+ if (head !== this.heads.get(repo.id)) {
204
+ this.heads.set(repo.id, head);
205
+ this.note({ kind: 'commit', ref: `${repo.path}@${head ?? 'none'}`, label: `${repo.path.split(/[\\/]/).pop()} head → ${head?.slice(0, 8) ?? 'none'}`, since: new Date().toISOString(), lastEventAt: 0, scopeItemId: repo.id });
206
+ }
207
+ }
208
+ const settled = this.settled();
209
+ if (settled.length > 0) this.emit('settled', settled);
210
+ for (const c of this.list()) {
211
+ if (c.kind === 'file' && !existsSync(c.ref)) continue;
212
+ if (c.kind === 'file') { try { const st = statSync(c.ref); if (st.mtimeMs > c.lastEventAt) this.note({ ...c, lastEventAt: st.mtimeMs }); } catch { /* removed */ } }
213
+ }
214
+ }
215
+ }
@@ -0,0 +1,50 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+
5
+ export function readJson<T>(path: string, fallback: T): T {
6
+ if (!existsSync(path)) return fallback;
7
+ try {
8
+ return JSON.parse(readFileSync(path, 'utf8')) as T;
9
+ } catch {
10
+ // A corrupt file is kept next to the fresh one instead of being silently overwritten.
11
+ try { renameSync(path, `${path}.corrupt-${Date.now()}`); } catch { /* keep going */ }
12
+ return fallback;
13
+ }
14
+ }
15
+
16
+ /** `mode`: the file's permissions on macOS and Linux when it must not be readable by other accounts (not used on Windows). */
17
+ export function writeJsonAtomic(path: string, value: unknown, mode?: number): void {
18
+ mkdirSync(dirname(path), { recursive: true });
19
+ const temp = join(dirname(path), `.${randomBytes(6).toString('hex')}.tmp`);
20
+ writeFileSync(temp, JSON.stringify(value, null, 1), { encoding: 'utf8', flag: 'wx', ...(mode === undefined ? {} : { mode }) });
21
+ for (let attempt = 0; ; attempt += 1) {
22
+ try {
23
+ renameSync(temp, path);
24
+ return;
25
+ } catch (error) {
26
+ const code = (error as NodeJS.ErrnoException).code;
27
+ if (attempt >= 5 || !['EPERM', 'EBUSY', 'EACCES'].includes(code ?? '')) {
28
+ try { unlinkSync(temp); } catch { /* nothing else to do */ }
29
+ throw error;
30
+ }
31
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * (attempt + 1));
32
+ }
33
+ }
34
+ }
35
+
36
+ export function appendJsonLine(path: string, value: unknown): void {
37
+ mkdirSync(dirname(path), { recursive: true });
38
+ appendFileSync(path, `${JSON.stringify(value)}\n`, 'utf8');
39
+ }
40
+
41
+ export function readJsonLines<T>(path: string, limit = Infinity): T[] {
42
+ if (!existsSync(path)) return [];
43
+ const lines = readFileSync(path, 'utf8').split('\n').filter((l) => l.trim());
44
+ const slice = Number.isFinite(limit) ? lines.slice(-limit) : lines;
45
+ const out: T[] = [];
46
+ for (const line of slice) {
47
+ try { out.push(JSON.parse(line) as T); } catch { /* skip broken line */ }
48
+ }
49
+ return out;
50
+ }
@@ -0,0 +1,39 @@
1
+ import { existsSync, mkdirSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { canonicalPath } from '../util/paths.ts';
5
+
6
+ /** ProjectKeeper's own home, in the file system's own spelling. Tests point `PROJECTKEEPER_HOME` at a temporary directory. */
7
+ export function projectKeeperHome(): string {
8
+ const override = process.env.PROJECTKEEPER_HOME;
9
+ return canonicalPath(override && override.trim() ? override : join(homedir(), '.projectkeeper'));
10
+ }
11
+
12
+ /**
13
+ * The home, made if it is not there — for its owner alone (0700) on macOS and Linux, where a new directory is otherwise
14
+ * open to every account of the machine: it holds the saved keys and what was read of the projects. A home that exists
15
+ * is left as it is; its owner may have placed it and shared it on purpose. On Windows the profile's own access rules
16
+ * cover it and the mode is not used.
17
+ */
18
+ export function ensureHome(home: string): void {
19
+ if (!existsSync(home)) mkdirSync(home, { recursive: true, mode: 0o700 });
20
+ }
21
+
22
+ export function workspaceFile(home = projectKeeperHome()): string {
23
+ return join(home, 'workspace.json');
24
+ }
25
+
26
+ export function projectDir(projectId: string, home = projectKeeperHome()): string {
27
+ return join(home, 'projects', projectId);
28
+ }
29
+
30
+ /**
31
+ * D107: the folder, inside a project's directory here, that holds what a takeover by the table leaves — the session
32
+ * material, the takeover's work folders and, for a public project, the table with its own git history. Clearing the
33
+ * project keeps it (project-store.ts `clearAll`).
34
+ */
35
+ export const TABLE_FOLDER = 'table';
36
+
37
+ export function tableDir(projectId: string, home = projectKeeperHome()): string {
38
+ return join(projectDir(projectId, home), TABLE_FOLDER);
39
+ }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Per-project asset store (Spec §8.5). One JSON file per collection under
3
+ * `~/.projectkeeper/projects/<id>/`, written atomically and debounced; `trace.jsonl` records
4
+ * every write with its basis so the owner can ask "why do you think so" (§3.10).
5
+ *
6
+ * The store emits `change` events ({collection, ids}) that the server forwards to open views.
7
+ */
8
+ import { EventEmitter } from 'node:events';
9
+ import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import type {
12
+ AreaUnderstanding, Authorization, ChangeRecord, ContextPackage, Coverage, EntryMark, FactRecord,
13
+ FollowUpRound, GraphNode, GraphRelation, JudgementRecord, KeeperJob, MergeRecord, ModificationRequest, Note,
14
+ ObjectJudgement, OrganizingPlan, ProjectRule, ReferenceItem, ScopeJudgement, Source, TraceEntry, WorkThread,
15
+ } from '../model/types.ts';
16
+ import type {
17
+ Breakpoint, ClerkRound, CodeTerritory, Generation, KeeperNumber, LayerEntry, ProcessLink, RoundDoc, SemanticPatch, SendBack, SessionDraft,
18
+ } from '../model/k-types.ts';
19
+ import { appendJsonLine, readJson, readJsonLines, writeJsonAtomic } from './json-file.ts';
20
+ import { ensureHome, projectKeeperHome, projectDir, TABLE_FOLDER } from './paths.ts';
21
+
22
+ export interface TraceInfo {
23
+ readonly jobId: string | null;
24
+ readonly basisSourceIds?: readonly string[];
25
+ readonly summary: string;
26
+ }
27
+
28
+ export interface Identified { readonly id: string }
29
+
30
+ export class Collection<T extends Identified> {
31
+ readonly name: string;
32
+ private readonly items = new Map<string, T>();
33
+ private readonly store: ProjectStore;
34
+
35
+ constructor(name: string, store: ProjectStore, initial: readonly T[]) {
36
+ this.name = name;
37
+ this.store = store;
38
+ for (const item of initial) this.items.set(item.id, item);
39
+ }
40
+
41
+ all(): T[] { return [...this.items.values()]; }
42
+ get(id: string): T | undefined { return this.items.get(id); }
43
+ has(id: string): boolean { return this.items.has(id); }
44
+ get size(): number { return this.items.size; }
45
+ find(predicate: (item: T) => boolean): T | undefined {
46
+ for (const item of this.items.values()) if (predicate(item)) return item;
47
+ return undefined;
48
+ }
49
+ filter(predicate: (item: T) => boolean): T[] { return this.all().filter(predicate); }
50
+
51
+ put(item: T, trace?: TraceInfo): T {
52
+ this.items.set(item.id, item);
53
+ this.store.markDirty(this.name, [item.id]);
54
+ if (trace) this.store.trace({ collection: this.name, id: item.id, op: 'put', ...trace });
55
+ return item;
56
+ }
57
+
58
+ putMany(items: readonly T[], trace?: TraceInfo): void {
59
+ if (items.length === 0) return;
60
+ for (const item of items) this.items.set(item.id, item);
61
+ this.store.markDirty(this.name, items.map((i) => i.id));
62
+ if (trace) for (const item of items) this.store.trace({ collection: this.name, id: item.id, op: 'put', ...trace });
63
+ }
64
+
65
+ remove(id: string, trace?: TraceInfo): boolean {
66
+ const existed = this.items.delete(id);
67
+ if (existed) {
68
+ this.store.markDirty(this.name, [id]);
69
+ if (trace) this.store.trace({ collection: this.name, id, op: 'remove', ...trace });
70
+ }
71
+ return existed;
72
+ }
73
+
74
+ snapshot(): T[] { return this.all(); }
75
+
76
+ /** Forget every item, without writing or tracing: the store's `clearAll` removes the files and tells the views. */
77
+ drop(): void { this.items.clear(); }
78
+ }
79
+
80
+ export interface ChangeEvent {
81
+ readonly projectId: string;
82
+ readonly collection: string;
83
+ readonly ids: readonly string[];
84
+ }
85
+
86
+ const COLLECTIONS = [
87
+ 'sources', 'facts', 'threads', 'areas', 'reference', 'nodes', 'relations', 'notes', 'changes',
88
+ 'marks', 'judgements', 'jobs', 'authorizations', 'requests', 'contexts', 'propagation', 'rounds',
89
+ 'rules', 'plans', 'merges', 'scopeJudgements',
90
+ // Increment K (Spec v3.0): the clerk method's positions and markers.
91
+ 'breakpoints', 'sendbacks', 'patches', 'numbers', 'drafts', 'territories', 'generations', 'layers', 'roundDocs', 'links', 'clerkRounds',
92
+ ] as const;
93
+ export type CollectionName = (typeof COLLECTIONS)[number];
94
+
95
+ function emptyCoverage(projectId: string): Coverage {
96
+ return {
97
+ // A project with no assets has not been organized (Spec §1.11, §6.14; D105).
98
+ projectId, state: 'Not organized yet', scopes: [], missingSourceKinds: [], processedByKind: {},
99
+ pendingByKind: {}, updatedAt: new Date().toISOString(),
100
+ };
101
+ }
102
+
103
+ export class ProjectStore extends EventEmitter {
104
+ readonly projectId: string;
105
+ readonly dir: string;
106
+ readonly sources: Collection<Source>;
107
+ readonly facts: Collection<FactRecord>;
108
+ readonly threads: Collection<WorkThread>;
109
+ readonly areas: Collection<AreaUnderstanding>;
110
+ readonly reference: Collection<ReferenceItem>;
111
+ readonly nodes: Collection<GraphNode>;
112
+ readonly relations: Collection<GraphRelation>;
113
+ readonly notes: Collection<Note>;
114
+ readonly changes: Collection<ChangeRecord>;
115
+ readonly marks: Collection<EntryMark>;
116
+ readonly judgements: Collection<JudgementRecord>;
117
+ readonly jobs: Collection<KeeperJob>;
118
+ readonly authorizations: Collection<Authorization>;
119
+ readonly requests: Collection<ModificationRequest>;
120
+ readonly contexts: Collection<ContextPackage>;
121
+ /** §2.10 one judgement per object per round; the entries inside the change records follow from these. */
122
+ readonly propagation: Collection<ObjectJudgement>;
123
+ /** §5.5 one round of Follow up, with its one result. */
124
+ readonly rounds: Collection<FollowUpRound>;
125
+ /** §1.15 the rules the project set for itself: about its material, and about how it works (D62, D64). */
126
+ readonly rules: Collection<ProjectRule>;
127
+ /** §3.7 the plan and focus of the organizing after the framing round (D62); one per project. */
128
+ readonly plans: Collection<OrganizingPlan>;
129
+ /** §1.4 duplicates merged into the work item kept (E64, E65); the merged id resolves through these. */
130
+ readonly merges: Collection<MergeRecord>;
131
+ /** §1.1 the Keeper's classification of scope locations and the owner's corrections (CKC-04 AC-13, AC-17). */
132
+ readonly scopeJudgements: Collection<ScopeJudgement>;
133
+ /** §2.12 breakpoints: a due next step without a trace, hanging on an object (CKC-24 AC-7). */
134
+ readonly breakpoints: Collection<Breakpoint>;
135
+ /** §1.18 send-backs to Work or Plan: Suggested → Returned → Closed (CKC-24 AC-9, AC-10). */
136
+ readonly sendbacks: Collection<SendBack>;
137
+ /** §1.17 semantic patches: what a docs change of meaning withdrew and what replaced it (CKC-26). */
138
+ readonly patches: Collection<SemanticPatch>;
139
+ /** §1.17 the numbers the Keeper gave to what the project did not number (D72). */
140
+ readonly numbers: Collection<KeeperNumber>;
141
+ /** §3.11 one session draft per session (D88). */
142
+ readonly drafts: Collection<SessionDraft>;
143
+ /** §1.19 the judged half of the code territories (CKC-25). */
144
+ readonly territories: Collection<CodeTerritory>;
145
+ /** §2.12 earlier generations of plans (D85). */
146
+ readonly generations: Collection<Generation>;
147
+ /** §3.3 orientation's map of which file is which layer. */
148
+ readonly layers: Collection<LayerEntry>;
149
+ /** §3.3 what a round leaves behind: history map, questions, briefs, reports, adoption record, spot check, result. */
150
+ readonly roundDocs: Collection<RoundDoc>;
151
+ /** §1.4 links between work and ledger entries the program could not make from ids alone. */
152
+ readonly links: Collection<ProcessLink>;
153
+ /** §3.3 the rounds of the clerk method, with what each produced. */
154
+ readonly clerkRounds: Collection<ClerkRound>;
155
+ private coverageDoc: Coverage;
156
+ private readonly dirty = new Map<string, Set<string>>();
157
+ private timer: NodeJS.Timeout | null = null;
158
+ private pendingFlush: Promise<void> | null = null;
159
+ private resolveFlush: (() => void) | null = null;
160
+
161
+ private constructor(projectId: string, dir: string) {
162
+ super();
163
+ this.projectId = projectId;
164
+ this.dir = dir;
165
+ const load = <T extends Identified>(name: string): T[] => readJson<T[]>(join(dir, `${name}.json`), []);
166
+ this.sources = new Collection('sources', this, load<Source>('sources'));
167
+ this.facts = new Collection('facts', this, load<FactRecord>('facts'));
168
+ this.threads = new Collection('threads', this, load<WorkThread>('threads'));
169
+ this.areas = new Collection('areas', this, load<AreaUnderstanding>('areas'));
170
+ this.reference = new Collection('reference', this, load<ReferenceItem>('reference'));
171
+ this.nodes = new Collection('nodes', this, load<GraphNode>('nodes'));
172
+ this.relations = new Collection('relations', this, load<GraphRelation>('relations'));
173
+ this.notes = new Collection('notes', this, load<Note>('notes'));
174
+ this.changes = new Collection('changes', this, load<ChangeRecord>('changes'));
175
+ this.marks = new Collection('marks', this, load<EntryMark>('marks'));
176
+ this.judgements = new Collection('judgements', this, load<JudgementRecord>('judgements'));
177
+ this.jobs = new Collection('jobs', this, load<KeeperJob>('jobs'));
178
+ this.authorizations = new Collection('authorizations', this, load<Authorization>('authorizations'));
179
+ this.requests = new Collection('requests', this, load<ModificationRequest>('requests'));
180
+ this.contexts = new Collection('contexts', this, load<ContextPackage>('contexts'));
181
+ this.propagation = new Collection('propagation', this, load<ObjectJudgement>('propagation'));
182
+ this.rounds = new Collection('rounds', this, load<FollowUpRound>('rounds'));
183
+ // Added with Spec v2.8. A home written before them has no such files and opens with them empty.
184
+ this.rules = new Collection('rules', this, load<ProjectRule>('rules'));
185
+ this.plans = new Collection('plans', this, load<OrganizingPlan>('plans'));
186
+ this.merges = new Collection('merges', this, load<MergeRecord>('merges'));
187
+ this.scopeJudgements = new Collection('scopeJudgements', this, load<ScopeJudgement>('scopeJudgements'));
188
+ // Added with Spec v3.0 (increment K). A home written before them opens with them empty.
189
+ this.breakpoints = new Collection('breakpoints', this, load<Breakpoint>('breakpoints'));
190
+ this.sendbacks = new Collection('sendbacks', this, load<SendBack>('sendbacks'));
191
+ this.patches = new Collection('patches', this, load<SemanticPatch>('patches'));
192
+ this.numbers = new Collection('numbers', this, load<KeeperNumber>('numbers'));
193
+ this.drafts = new Collection('drafts', this, load<SessionDraft>('drafts'));
194
+ this.territories = new Collection('territories', this, load<CodeTerritory>('territories'));
195
+ this.generations = new Collection('generations', this, load<Generation>('generations'));
196
+ this.layers = new Collection('layers', this, load<LayerEntry>('layers'));
197
+ this.roundDocs = new Collection('roundDocs', this, load<RoundDoc>('roundDocs'));
198
+ this.links = new Collection('links', this, load<ProcessLink>('links'));
199
+ this.clerkRounds = new Collection('clerkRounds', this, load<ClerkRound>('clerkRounds'));
200
+ this.coverageDoc = readJson<Coverage>(join(dir, 'coverage.json'), emptyCoverage(projectId));
201
+ }
202
+
203
+ static open(projectId: string, home = projectKeeperHome()): ProjectStore {
204
+ const dir = projectDir(projectId, home);
205
+ ensureHome(home);
206
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
207
+ return new ProjectStore(projectId, dir);
208
+ }
209
+
210
+ collection(name: CollectionName): Collection<Identified> {
211
+ return this[name] as unknown as Collection<Identified>;
212
+ }
213
+
214
+ get coverage(): Coverage { return this.coverageDoc; }
215
+ setCoverage(coverage: Coverage, trace?: TraceInfo): void {
216
+ this.coverageDoc = { ...coverage, updatedAt: new Date().toISOString() };
217
+ this.markDirty('coverage', ['coverage']);
218
+ if (trace) this.trace({ collection: 'coverage', id: 'coverage', op: 'put', ...trace });
219
+ }
220
+
221
+ trace(entry: { collection: string; id: string; op: 'put' | 'remove' } & TraceInfo): void {
222
+ const record: TraceEntry = {
223
+ at: new Date().toISOString(), jobId: entry.jobId, collection: entry.collection, id: entry.id,
224
+ op: entry.op, basisSourceIds: entry.basisSourceIds ?? [], summary: entry.summary,
225
+ };
226
+ appendJsonLine(join(this.dir, 'trace.jsonl'), record);
227
+ }
228
+
229
+ traceFor(collection: string, id: string, limit = 50): TraceEntry[] {
230
+ return readJsonLines<TraceEntry>(join(this.dir, 'trace.jsonl'))
231
+ .filter((e) => e.collection === collection && e.id === id).slice(-limit);
232
+ }
233
+
234
+ traceByJob(jobId: string, limit = 200): TraceEntry[] {
235
+ return readJsonLines<TraceEntry>(join(this.dir, 'trace.jsonl')).filter((e) => e.jobId === jobId).slice(-limit);
236
+ }
237
+
238
+ markDirty(collection: string, ids: readonly string[]): void {
239
+ let set = this.dirty.get(collection);
240
+ if (!set) { set = new Set(); this.dirty.set(collection, set); }
241
+ for (const id of ids) set.add(id);
242
+ if (!this.timer) {
243
+ this.timer = setTimeout(() => this.flushNow(), 40);
244
+ this.timer.unref?.();
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Remove every asset of the project (Spec §3.7 清空, §8.5): every collection, the coverage, the trace, and whatever
250
+ * else lies in the project's asset directory — the ledger, the kept versions, the code index. The store stays open,
251
+ * empty; the views are told that everything changed. The `table/` folder stays (D107): the table an agent filled, its
252
+ * history and the takeover's work folders are the record of a takeover, not the Keeper's organizing, and can be taken in
253
+ * again. Returns the entries of the directory that could not be removed
254
+ * (a file another process still holds), so the caller can say so. Nothing outside this directory is touched.
255
+ */
256
+ clearAll(): string[] {
257
+ if (this.timer) { clearTimeout(this.timer); this.timer = null; }
258
+ this.dirty.clear();
259
+ for (const name of COLLECTIONS) this.collection(name).drop();
260
+ this.coverageDoc = emptyCoverage(this.projectId);
261
+ const left: string[] = [];
262
+ for (const entry of existsSync(this.dir) ? readdirSync(this.dir) : []) {
263
+ if (entry === TABLE_FOLDER) continue;
264
+ try { rmSync(join(this.dir, entry), { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }
265
+ catch { left.push(entry); }
266
+ }
267
+ if (!existsSync(this.dir)) mkdirSync(this.dir, { recursive: true });
268
+ for (const name of [...COLLECTIONS, 'coverage']) this.emit('change', { projectId: this.projectId, collection: name, ids: [] } satisfies ChangeEvent);
269
+ return left;
270
+ }
271
+
272
+ /** Persist everything dirty. Resolves once the files are on disk. */
273
+ flush(): Promise<void> {
274
+ if (this.dirty.size === 0 && !this.timer) return Promise.resolve();
275
+ this.flushNow();
276
+ return Promise.resolve();
277
+ }
278
+
279
+ private flushNow(): void {
280
+ if (this.timer) { clearTimeout(this.timer); this.timer = null; }
281
+ const changed = [...this.dirty.entries()];
282
+ this.dirty.clear();
283
+ for (const [collection] of changed) {
284
+ if (collection === 'coverage') writeJsonAtomic(join(this.dir, 'coverage.json'), this.coverageDoc);
285
+ else if ((COLLECTIONS as readonly string[]).includes(collection)) {
286
+ writeJsonAtomic(join(this.dir, `${collection}.json`), this.collection(collection as CollectionName).snapshot());
287
+ }
288
+ }
289
+ for (const [collection, ids] of changed) {
290
+ const event: ChangeEvent = { projectId: this.projectId, collection, ids: [...ids] };
291
+ this.emit('change', event);
292
+ }
293
+ if (this.resolveFlush) { this.resolveFlush(); this.resolveFlush = null; this.pendingFlush = null; }
294
+ }
295
+ }