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,160 @@
1
+ /**
2
+ * `How this project works` in the context packs (Spec §1.15, §7.3 item 3, §7.4 item 10; CKC-21 AC-9, AC-10; CKC-12
3
+ * AC-18, AC-39), and the page an agent reads for one rule by its id (§7.10). Deterministic; no model.
4
+ *
5
+ * The start pack says where the rules come from — the owner's own summary of how work runs, by name and id, or what the
6
+ * Keeper dug out of the records — then gives the rules in force in the three fixed groups, one line each with its id.
7
+ * An inferred rule says on its line that it waits for the owner; where practice differs from the owner's summary, the
8
+ * line says so; a rule a role set without the owner says that too (and sits in `Pending owner decisions`). The project's
9
+ * own wording is not pasted (it is read by id), obsolete material is in `Do not revive`, replaced rules are not given.
10
+ *
11
+ * A work pack carries the rules that bear on the work. What a rule applies to is what the rule itself records
12
+ * (`appliesTo`): a material rule bears on the work when it covers material the work rests on or decided something in
13
+ * it; a rule about how work is done bears on every piece of work unless it names a role, a kind of work or another work
14
+ * item that this one is not.
15
+ */
16
+ import type { EntryMark, ProjectRule } from '../model/types.ts';
17
+ import { WORK_KIND } from '../model/vocab.ts';
18
+ import { rulesByGroup } from '../keeper/rules.ts';
19
+ import { dayOf } from './owner-words.ts';
20
+
21
+ const text = (s: string | null | undefined) => (s ?? '').trim();
22
+ const oneLine = (s: string | null | undefined) => text(s).replace(/\s*\n+\s*/g, ' ');
23
+
24
+ export interface RuleRowHelp {
25
+ /** Citation numbers for sources, as the pack writes them. */
26
+ cite(ids: readonly string[]): string;
27
+ /** The open `Decided without owner` mark on this rule, when a role set it in the owner's place. */
28
+ withoutOwner(ruleId: string): EntryMark | undefined;
29
+ }
30
+
31
+ /** One rule on one line: what it says, its id, and what the reader has to know about it (§7.3). */
32
+ export function ruleRow(rule: ProjectRule, help: RuleRowHelp): string {
33
+ const summary = oneLine(rule.summary);
34
+ const prefix = rule.group === 'Material rules' && rule.category ? `${rule.category} · ` : '';
35
+ const parts = [`- ${prefix}${summary} (\`${rule.id}\`)`];
36
+ if (rule.basis === 'Inferred') parts.push(' · Inferred, awaiting the owner’s confirmation');
37
+ const beyond = rule.appliesTo.filter((t) => text(t) && !summary.toLowerCase().includes(text(t).toLowerCase()));
38
+ if (beyond.length) parts.push(` — applies to: ${beyond.map(text).join(', ')}`);
39
+ const without = help.withoutOwner(rule.id);
40
+ if (without) parts.push(` — set by ${without.decidedBy?.who ?? 'a role'}${without.decidedBy?.at ? ` on ${dayOf(without.decidedBy.at)}` : ''} without the owner: followed while it stands, see \`Pending owner decisions\``);
41
+ for (const d of rule.differsInPractice) parts.push(` — in practice: ${oneLine(d.text)}${help.cite(d.sourceIds)}`);
42
+ parts.push(help.cite(rule.sourceIds));
43
+ return parts.join('');
44
+ }
45
+
46
+ /** The rules a pack gives: in force, obsolete ones aside (they are in `Do not revive`). */
47
+ export function rulesInForce(rules: readonly ProjectRule[]): ProjectRule[] {
48
+ return rules.filter((r) => r.validity === 'Current' && r.category !== 'Obsolete');
49
+ }
50
+
51
+ /** The section's lines: optionally an opening saying where the rules come from, then the three groups. */
52
+ export function rulesLines(rules: readonly ProjectRule[], help: RuleRowHelp, opening: string | null): string[] {
53
+ const shown = rulesInForce(rules);
54
+ if (!shown.length) return [];
55
+ const lines: string[] = opening ? [opening] : [];
56
+ for (const g of rulesByGroup(shown)) {
57
+ if (!g.rules.length) continue;
58
+ lines.push(`${g.group}:`);
59
+ for (const r of g.rules) lines.push(ruleRow(r, help));
60
+ }
61
+ return lines;
62
+ }
63
+
64
+ /** Where the rules come from (§7.3 item 3): the owner's summarized way of working by name and id, or the Keeper's digging. */
65
+ export function rulesOpening(rules: readonly ProjectRule[], help: RuleRowHelp, obsolete: number): string {
66
+ const shown = rulesInForce(rules);
67
+ const systems = [...new Set(shown.map((r) => text(r.ownerSystem)).filter((s) => s.length))];
68
+ const dug = shown.filter((r) => !text(r.ownerSystem));
69
+ const inferred = shown.filter((r) => r.basis === 'Inferred').length;
70
+ const parts: string[] = [];
71
+ if (systems.length) {
72
+ parts.push(systems.map((s) => `From the owner’s own summary of how work runs here, **${s}**${help.cite([...new Set(shown.filter((r) => text(r.ownerSystem) === s).flatMap((r) => r.sourceIds))])}`).join('; '));
73
+ if (dug.length) parts.push(`the other ${dug.length} the Keeper dug out of the project’s records`);
74
+ } else {
75
+ parts.push('The Keeper dug these out of the project’s own records; the project has no way of working the owner summarized');
76
+ }
77
+ const tail = [
78
+ inferred ? `${inferred} inferred, awaiting the owner’s confirmation` : '',
79
+ obsolete ? `what the project marks obsolete is in \`Do not revive\`` : '',
80
+ 'each rule in full, with where it is written: `pk get <rule id>`',
81
+ ].filter((x) => x.length);
82
+ return `${parts.join('; ')}. ${tail.join('; ')}.`;
83
+ }
84
+
85
+ export interface WorkFacts {
86
+ /** Names and numbers of the work, its areas and what it serves. */
87
+ readonly words: readonly string[];
88
+ /** The work's holder and the pack's recipient. */
89
+ readonly roles: readonly string[];
90
+ readonly kind: string;
91
+ /** Normalized paths of the material the work rests on. */
92
+ readonly paths: readonly string[];
93
+ /** Rules that decided something about the work or its material (validity, progress, `Used as`, an untrusted claim). */
94
+ readonly decidedBy: ReadonlySet<string>;
95
+ /** The project's recognized roles, and every work item's own number. */
96
+ readonly knownRoles: readonly string[];
97
+ readonly workNumbers: ReadonlySet<string>;
98
+ }
99
+
100
+ const norm = (s: string) => text(s).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '').toLowerCase();
101
+ /** Singular and case-folded, so `Reviews` names `Review`. */
102
+ const word = (s: string) => text(s).toLowerCase().replace(/s$/, '');
103
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
104
+ const mentions = (hay: string, needle: string) => {
105
+ const h = hay.toLowerCase();
106
+ const n = needle.toLowerCase().trim();
107
+ if (!n) return false;
108
+ return n.length > 3 ? h.includes(n) : new RegExp(`(^|[^a-z0-9])${escape(n)}([^a-z0-9]|$)`).test(h);
109
+ };
110
+
111
+ /** Whether a path the rule names covers one of the work's paths: the file itself, or a directory it is under. */
112
+ export function pathCovers(target: string, paths: readonly string[]): boolean {
113
+ const t = norm(target);
114
+ if (!t || /\s/.test(t)) return false;
115
+ return paths.some((p) => p === t || p.endsWith(`/${t}`) || p.startsWith(`${t}/`) || p.includes(`/${t}/`));
116
+ }
117
+
118
+ export function ruleBearsOnWork(rule: ProjectRule, w: WorkFacts): boolean {
119
+ if (rule.validity !== 'Current') return false;
120
+ const namesWork = (t: string) => w.words.some((x) => mentions(t, x) || mentions(x, t));
121
+ if (rule.group === 'Material rules') {
122
+ if (w.decidedBy.has(rule.id)) return true;
123
+ return rule.appliesTo.some((t) => pathCovers(t, w.paths) || namesWork(t));
124
+ }
125
+ if (rule.appliesTo.length === 0) return true;
126
+ const isRole = (t: string) => w.knownRoles.some((r) => word(r) === word(t));
127
+ const isKind = (t: string) => WORK_KIND.some((k) => word(k) === word(t));
128
+ const isWork = (t: string) => w.workNumbers.has(text(t).toUpperCase());
129
+ const restricting = rule.appliesTo.filter((t) => isRole(t) || isKind(t) || isWork(t));
130
+ // Branches, directories and plain words say what the rule is about, not whom it is for.
131
+ if (restricting.length === 0) return true;
132
+ return restricting.some((t) => w.roles.some((r) => word(r) === word(t)) || word(w.kind) === word(t) || namesWork(t));
133
+ }
134
+
135
+ export interface RulePage {
136
+ readonly rule: ProjectRule;
137
+ readonly sources: readonly { readonly id: string; readonly title: string; readonly label: string }[];
138
+ readonly replacedBy: { readonly id: string; readonly summary: string } | null;
139
+ readonly withoutOwner: EntryMark | null;
140
+ }
141
+
142
+ /** What `pk get <rule id>` reads: the rule whole, with where it is written (Spec §7.10, §1.15). */
143
+ export function rulePage(p: RulePage): string {
144
+ const r = p.rule;
145
+ const head = [r.group, r.category ?? '', r.basis === 'Inferred' ? 'Inferred, awaiting the owner’s confirmation' : '', r.validity !== 'Current' ? r.validity : ''].filter((x) => x.length).join(' · ');
146
+ const lines = [
147
+ `# ${oneLine(r.summary)} (\`${r.id}\`)`,
148
+ head,
149
+ '',
150
+ r.excerpt ? `The project’s own words: “${text(r.excerpt)}”` : 'The project gives no wording of its own for this rule; it was inferred from the records below.',
151
+ r.appliesTo.length ? `Applies to: ${r.appliesTo.join(', ')}` : '',
152
+ r.ownerSystem ? `Part of the owner’s own summary of how work runs here: ${r.ownerSystem}` : '',
153
+ ...r.differsInPractice.map((d) => `In practice it differs: ${oneLine(d.text)}${d.sourceIds.length ? ` (${d.sourceIds.map((s) => `\`${s}\``).join(', ')})` : ''}`),
154
+ r.ownerConfirmation ? `The owner confirmed it on ${dayOf(r.ownerConfirmation.at)}: “${text(r.ownerConfirmation.quote)}” (\`${r.ownerConfirmation.sourceId}\`)` : '',
155
+ p.withoutOwner ? `Decided without owner (\`${p.withoutOwner.id}\`): set by ${p.withoutOwner.decidedBy?.who ?? 'a role'}${p.withoutOwner.decidedBy?.at ? ` on ${dayOf(p.withoutOwner.decidedBy.at)}` : ''}; ${oneLine(p.withoutOwner.clue)}` : '',
156
+ p.replacedBy ? `Replaced by ${oneLine(p.replacedBy.summary)} (\`${p.replacedBy.id}\`) — \`pk get ${p.replacedBy.id}\` reads it.` : '',
157
+ p.sources.length ? `\n## Where it is written (${p.sources.length})\n${p.sources.map((s) => `- \`${s.id}\` ${s.title} — ${s.label}`).join('\n')}\nEach source by id with \`pk get\`.` : '',
158
+ ];
159
+ return lines.filter((l, i) => l.length || i === 2).join('\n');
160
+ }
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Intake: read the scope's material into sources and keep coverage honest (Spec §1.11,
3
+ * §3.2, §3.7 stages 1–3). Reading is not judging: new sources carry `usedAs: null`
4
+ * (`Not yet judged`) until an organizing job forms fact records from them.
5
+ */
6
+ import { existsSync, statSync } from 'node:fs';
7
+ import type { Coverage, CoverageScope, PendingMaterial, Project, ScopeItem, Source } from '../model/types.ts';
8
+ import { fingerprint } from '../model/ids.ts';
9
+ import type { ProjectStore } from '../store/project-store.ts';
10
+ import { gitHead } from '../util/git.ts';
11
+ import { isWithin, normalizePath, pathKey, samePath } from '../util/paths.ts';
12
+ import { readFileSources, scanFiles, scanFilesAsync, type FileEntry } from '../sources/files.ts';
13
+ import { readsCommits } from '../scope/skip.ts';
14
+ import { ignoredPlaceOf } from '../scope/ignored-place.ts';
15
+ import { commitSources, statusSource } from '../sources/gitobs.ts';
16
+ import { isHistoryOnly } from '../sources/history.ts';
17
+ import { locateSessionsForHomes, type LocatedSession } from '../sources/sessions/locate.ts';
18
+ import { sessionStoreRootOf } from '../sources/sessions/scope.ts';
19
+ import { sessionSources } from '../sources/sessions/read.ts';
20
+ import type { PendingChange } from '../sources/watch.ts';
21
+ import { clerkPending } from '../keeper/organize/clerk-coverage.ts';
22
+ import { applyMaterialRules } from './material-rules.ts';
23
+ import { gitFates, recordDeletions, recordReturns, type DeletedFile } from './removal.ts';
24
+ import { excludedLocations, splitTooLarge } from './skipped.ts';
25
+
26
+ export interface IntakeResult {
27
+ readonly filesRead: number;
28
+ readonly sourcesUpserted: number;
29
+ readonly sourcesChanged: number;
30
+ readonly sourcesGone: number;
31
+ readonly sessionsRead: number;
32
+ readonly segments: number;
33
+ readonly commits: number;
34
+ readonly skipped: readonly { readonly path: string; readonly reason: string }[];
35
+ readonly durationMs: number;
36
+ /** Objects the program recorded `Removed` because their material was deleted (D61), and ones back because it returned. */
37
+ readonly objectsRemoved?: number;
38
+ readonly objectsBack?: number;
39
+ }
40
+
41
+ /** A source's `Used as` with who set it and by what — kept together, so one judgement's value never carries another's mark. */
42
+ const usedAsOf = (s: Source) => ({ usedAs: s.usedAs, usedAsBy: s.usedAsBy, usedAsByRuleId: s.usedAsByRuleId ?? null, usedAsByScopeItemId: s.usedAsByScopeItemId ?? null });
43
+
44
+ /**
45
+ * Upsert sources, keeping the owner's, the Keeper's, a rule's or the scope's `Used as` and reporting real change, and
46
+ * which sources came back after being gone (the same file at the same place again). A source not judged yet takes what
47
+ * its location sets as it is read now (§1.1: third-party documents are `Reference only`).
48
+ */
49
+ function upsert(store: ProjectStore, incoming: readonly Source[], summary: string): { upserted: number; changed: string[]; returned: string[] } {
50
+ const changed: string[] = [];
51
+ const returned: string[] = [];
52
+ const toPut: Source[] = [];
53
+ for (const s of incoming) {
54
+ const existing = store.sources.get(s.id);
55
+ if (existing) {
56
+ if (existing.version.fingerprint === s.version.fingerprint && existing.availability === null) continue;
57
+ if (existing.version.fingerprint !== s.version.fingerprint) changed.push(s.id);
58
+ if (existing.availability === 'No longer available') returned.push(s.id);
59
+ toPut.push({ ...s, ...usedAsOf(existing.usedAs !== null ? existing : s), availability: null, movedTo: null });
60
+ } else {
61
+ toPut.push(s);
62
+ }
63
+ }
64
+ store.sources.putMany(toPut, { jobId: null, summary });
65
+ return { upserted: toPut.length, changed, returned };
66
+ }
67
+
68
+ /**
69
+ * Where a vanished file's content is now, or null when it is nowhere: a move (§1.2 `Moved`), not a deletion. The same
70
+ * whole file elsewhere is a move; so is a file elsewhere that holds at least half of its sections word for word, which
71
+ * is a move with an edit made in the same stretch of work (an archived document given a new header line).
72
+ */
73
+ function movedTo(sources: readonly Source[], wholeFile: ReadonlyMap<string, string>, sections: ReadonlyMap<string, readonly string[]>, from: string): string | null {
74
+ for (const s of sources) {
75
+ const twin = wholeFile.get(s.version.fingerprint);
76
+ if (twin && !samePath(twin, from)) return twin;
77
+ }
78
+ const hits = new Map<string, number>();
79
+ for (const s of sources) {
80
+ for (const path of new Set(sections.get(fingerprint(s.excerpt)) ?? [])) if (!samePath(path, from)) hits.set(path, (hits.get(path) ?? 0) + 1);
81
+ }
82
+ const best = [...hits].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0];
83
+ return best && best[1] * 2 >= sources.length ? best[0] : null;
84
+ }
85
+
86
+ /** The file content the project has now, by whole-file and by section fingerprint, for telling a move from a deletion. */
87
+ function contentIndex(current: readonly Source[]): { wholeFile: Map<string, string>; sections: Map<string, string[]> } {
88
+ const wholeFile = new Map<string, string>();
89
+ const sections = new Map<string, string[]>();
90
+ for (const s of current) {
91
+ if (s.anchor.kind !== 'file') continue;
92
+ wholeFile.set(s.version.fingerprint, s.anchor.path);
93
+ const key = fingerprint(s.excerpt);
94
+ const list = sections.get(key) ?? [];
95
+ list.push(s.anchor.path);
96
+ sections.set(key, list);
97
+ }
98
+ return { wholeFile, sections };
99
+ }
100
+
101
+ /**
102
+ * Mark the sources of files that are gone: `Moved` (with where) when their content is elsewhere in the project, or
103
+ * when a commit moved them away — also to a place outside the scope — and `No longer available` otherwise. Returns the
104
+ * files that were deleted, with the commit that deleted them when git knows it, for `recordDeletions`.
105
+ */
106
+ function markGone(store: ProjectStore, project: Project, gone: readonly Source[], current: readonly Source[]): { deleted: DeletedFile[]; count: number } {
107
+ const byFile = new Map<string, Source[]>();
108
+ for (const s of gone) {
109
+ if (s.anchor.kind !== 'file') continue;
110
+ const key = pathKey(s.anchor.path);
111
+ const list = byFile.get(key) ?? [];
112
+ list.push(s);
113
+ byFile.set(key, list);
114
+ }
115
+ const { wholeFile, sections } = contentIndex(current);
116
+ const files = [...byFile.values()].map((sources) => {
117
+ const from = (sources[0]!.anchor as { path: string }).path;
118
+ return { sources, from, twin: movedTo(sources, wholeFile, sections, from) };
119
+ });
120
+ const fates = gitFates(project, files.filter((f) => !f.twin).map((f) => f.from));
121
+ const deleted: DeletedFile[] = [];
122
+ let count = 0;
123
+ for (const { sources, from, twin } of files) {
124
+ const fate = fates.get(from);
125
+ const moved = twin ?? fate?.movedTo ?? null;
126
+ for (const s of sources) store.sources.put({ ...s, availability: moved ? 'Moved' : 'No longer available', movedTo: moved }, { jobId: null, summary: moved ? `File moved to ${moved}` : 'File no longer available' });
127
+ count += sources.length;
128
+ if (!moved) deleted.push({ path: from, scopeItemId: sources[0]!.scopeItemId, sourceIds: sources.map((s) => s.id), deletedIn: fate?.fate.kind === 'deleted' ? fate.fate.commit : null });
129
+ }
130
+ return { deleted, count };
131
+ }
132
+
133
+ /**
134
+ * What was taken in by fact records, by kind of material (the coverage's `Processed` line). The clerk method's rounds write
135
+ * none (D88), so for them this stays empty and the coverage's organizing levels say what was read and how (§1.11).
136
+ */
137
+ function processedByKindOf(store: ProjectStore): Record<string, number> {
138
+ const used = new Set<string>();
139
+ for (const f of store.facts.all()) for (const id of f.aboutSourceIds) used.add(id);
140
+ const byKind: Record<string, number> = {};
141
+ for (const s of store.sources.all()) {
142
+ if (!used.has(s.id) || s.availability === 'No longer available' || isHistoryOnly(s)) continue;
143
+ const kind = s.anchor.kind === 'file' || s.anchor.kind === 'session' || s.anchor.kind === 'commit' ? s.anchor.kind : 'other';
144
+ byKind[kind] = (byKind[kind] ?? 0) + 1;
145
+ }
146
+ return byKind;
147
+ }
148
+
149
+ /**
150
+ * The coverage with what waits for the next round listed the one way the organizing service lists it (Spec §1.11, §3.2;
151
+ * keeper/organize/clerk-coverage.ts `clerkPending`): what was read since the latest round started, less what the project's
152
+ * rules settle, what is history or reference only (§1.2, §1.15; CKC-02 AC-23), what the scope does not read, and the
153
+ * Keeper's own commits of its project folder — with the changes the watcher saw and has not settled (`watcher`), when
154
+ * the caller has them. For a caller without the App; intake itself leaves the list to the App (`recordIntake`).
155
+ */
156
+ export function recomputeCoverage(store: ProjectStore, project: Project, extra: { organizing?: readonly PendingMaterial[]; failed?: CoverageScope['failed']; watcher?: readonly PendingMaterial[] } = {}): Coverage {
157
+ const { pending } = clerkPending(store, project, extra.watcher ?? []);
158
+ const pendingByKind: Record<string, number> = {};
159
+ for (const p of pending) pendingByKind[p.kind] = (pendingByKind[p.kind] ?? 0) + 1;
160
+ const previous = store.coverage.scopes.find((s) => s.id === 'project');
161
+ const mainRepo = project.scope.find((i) => i.category === 'Repository' && i.relation !== 'Excluded' && !i.missing);
162
+ const organizing = extra.organizing ?? previous?.organizing ?? [];
163
+ const label: CoverageScope['coverage'] = project.organizingPaused ? 'Organizing paused' : organizing.length > 0 ? 'Organizing' : pending.length > 0 ? 'Changes pending' : 'Up to date';
164
+ // A file too large to read is skipped, not failed (D105): also one an older home still holds among its failures.
165
+ const told = splitTooLarge(latestFailures(extra.failed ?? previous?.failed ?? []), previous?.skipped ?? [], excludedLocations(project));
166
+ const projectScope: CoverageScope = {
167
+ id: 'project', kind: 'project', label: project.name, coverage: label, asOf: previous?.asOf ?? null,
168
+ commit: mainRepo ? gitHead(mainRepo.path) : null, pending, organizing,
169
+ failed: told.failed, skipped: told.skipped, lastRelookAt: previous?.lastRelookAt ?? null,
170
+ };
171
+ const others = store.coverage.scopes.filter((s) => s.id !== 'project');
172
+ return {
173
+ ...store.coverage, scopes: [projectScope, ...others], processedByKind: processedByKindOf(store), pendingByKind,
174
+ state: store.coverage.state, updatedAt: new Date().toISOString(),
175
+ };
176
+ }
177
+
178
+ /**
179
+ * What could not be taken in, one entry per ref: the latest of each (by `at`, the later one on a tie), in the order the
180
+ * kept entries came. Every incremental intake appends what it skipped, so a file that stays too large used to be counted
181
+ * again at every pass (owner 2026-09-30: "2 failed" was one archive page recorded twice).
182
+ */
183
+ export function latestFailures(failed: CoverageScope['failed']): CoverageScope['failed'] {
184
+ const byRef = new Map<string, CoverageScope['failed'][number]>();
185
+ for (const f of failed) {
186
+ const before = byRef.get(f.ref);
187
+ if (before && before.at > f.at) continue;
188
+ byRef.delete(f.ref);
189
+ byRef.set(f.ref, f);
190
+ }
191
+ return [...byRef.values()];
192
+ }
193
+
194
+ /**
195
+ * What intake records in the coverage: what it could not take in, with the reason (CKC-07 AC-10) — a file over the size
196
+ * limit apart, as skipped with its size and the limit, not as a failure (D105; ./skipped.ts) — when it read, the
197
+ * point the next intake reads new commits from, the head of the main repository, and what fact records took in. What
198
+ * waits for the next round is not intake's to list: the organizing service's list is the only one, and the App writes it
199
+ * right after every intake (`App.refreshCoverage`, which carries these failures over). Intake used to write a list of its
200
+ * own — every source no fact record was about, which the clerk method's rounds never write — so until the organizing
201
+ * service's next pass the coverage listed nearly every source as pending.
202
+ */
203
+ function recordIntake(store: ProjectStore, project: Project, failed: CoverageScope['failed'], summary: string, skippedBefore: NonNullable<CoverageScope['skipped']> = []): void {
204
+ const previous = store.coverage.scopes.find((s) => s.id === 'project');
205
+ const mainRepo = project.scope.find((i) => i.category === 'Repository' && i.relation !== 'Excluded' && !i.missing);
206
+ const base: CoverageScope = previous ?? { id: 'project', kind: 'project', label: project.name, coverage: 'Changes pending', asOf: null, commit: null, pending: [], organizing: [], failed: [], lastRelookAt: null };
207
+ const told = splitTooLarge(latestFailures(failed), skippedBefore, excludedLocations(project));
208
+ const scope: CoverageScope = { ...base, asOf: new Date().toISOString(), commit: mainRepo ? gitHead(mainRepo.path) : null, failed: told.failed.slice(-100), skipped: told.skipped.slice(-100) };
209
+ store.setCoverage({ ...store.coverage, scopes: [scope, ...store.coverage.scopes.filter((s) => s.id !== 'project')], processedByKind: processedByKindOf(store), updatedAt: new Date().toISOString() }, { jobId: null, summary });
210
+ }
211
+
212
+ /**
213
+ * The directory a session item recorded before items named theirs gives in its reason — "… sessions whose working
214
+ * directory is <dir>: 3 found", also inside "Excluded by the owner (was: …)". Read only for such an item.
215
+ */
216
+ const REASON_CWD = /sessions whose working directory is (.+?)(?: \(the original of a copy; read-only\))?: \d+ found/;
217
+
218
+ /**
219
+ * The scope item a session is filed under (§1.1): its host's item for the session's own working directory, the two
220
+ * compared as paths, with the case and slashes the system allows. It used to look for the directory inside each item's
221
+ * reason text, so of `D:\x` and `D:\x-y` a session of the first went to whichever item came first, and a directory the
222
+ * log spelt otherwise matched nothing. A session no item names goes to its host's first item, as before.
223
+ * `cwd` is the directory of the scope the session was located for (`LocatedSession.matchedCwd`): the log itself may
224
+ * record that directory in another spelling — through a junction, a `subst` drive, a short name.
225
+ */
226
+ function sessionScopeItem(project: Project, host: string, cwd: string | null): string {
227
+ const byHost = project.scope.filter((i) => i.sessionHost === host);
228
+ const directoryOf = (i: ScopeItem) => i.sessionCwd ?? REASON_CWD.exec(i.reason)?.[1] ?? null;
229
+ const match = cwd ? byHost.find((i) => { const dir = directoryOf(i); return dir !== null && samePath(dir, cwd); }) : undefined;
230
+ return (match ?? byHost[0])?.id ?? project.scope[0]?.id ?? '';
231
+ }
232
+
233
+ function scopeCwds(project: Project): string[] {
234
+ const out = new Set<string>();
235
+ for (const i of project.scope) {
236
+ if ((i.category === 'Directory' || i.category === 'Repository' || i.category === 'Worktree') && i.relation !== 'Excluded' && !i.missing) out.add(i.path);
237
+ if (i.copyOf) out.add(i.copyOf);
238
+ }
239
+ return [...out];
240
+ }
241
+
242
+ const yieldNow = () => new Promise<void>((resolve) => setImmediate(resolve));
243
+
244
+ /** Read everything in scope. Files are scanned on a worker thread and the rest yields between items, so the workbench keeps answering during a large intake (CKC-09 AC-1). */
245
+ export async function fullIntake(store: ProjectStore, project: Project): Promise<IntakeResult> {
246
+ const started = Date.now();
247
+ const skipped: { path: string; reason: string }[] = [];
248
+ let changed = 0;
249
+ let upserted = 0;
250
+ const returned: string[] = [];
251
+
252
+ // Files
253
+ const scan = await scanFilesAsync(project.id, project.scope);
254
+ skipped.push(...scan.skipped);
255
+ for (let i = 0; i < scan.sources.length; i += 400) {
256
+ const files = upsert(store, scan.sources.slice(i, i + 400), 'Read project files');
257
+ upserted += files.upserted;
258
+ changed += files.changed.length;
259
+ returned.push(...files.returned);
260
+ await yieldNow();
261
+ }
262
+
263
+ // Sources whose file is gone: moved when the content appears elsewhere, otherwise deleted — and a deletion means the
264
+ // objects resting only on it are no longer needed (D61).
265
+ const vanished = store.sources.filter((s) => s.anchor.kind === 'file' && !s.availability && !existsSync(s.anchor.path));
266
+ const { deleted, count: gone } = markGone(store, project, vanished, scan.sources);
267
+
268
+ // Sessions
269
+ const cwds = scopeCwds(project);
270
+ const located = locateSessionsForHomes(cwds, (host, cwd) => sessionStoreRootOf(project.scope, host, cwd));
271
+ let sessionsRead = 0;
272
+ let segments = 0;
273
+ for (const s of located) {
274
+ try {
275
+ const read = sessionSources(project.id, s, sessionScopeItem(project, s.host, s.matchedCwd));
276
+ const r = upsert(store, read.sources, `Read ${s.host} session ${s.sessionId.slice(0, 8)}`);
277
+ upserted += r.upserted;
278
+ changed += r.changed.length;
279
+ segments += read.sources.length;
280
+ sessionsRead += 1;
281
+ } catch (error) {
282
+ skipped.push({ path: s.file, reason: `session unreadable: ${(error as Error).message}` });
283
+ }
284
+ await yieldNow();
285
+ }
286
+
287
+ // Git
288
+ let commits = 0;
289
+ // The project's repositories and the worktrees that add something to the trunk; not a vendored library's upstream
290
+ // history, not a merged worktree with nothing uncommitted (Spec §1.1, E60).
291
+ for (const repo of project.scope.filter(readsCommits)) {
292
+ const previous = store.coverage.scopes.find((s) => s.id === 'project')?.asOf ?? null;
293
+ // A worktree contributes only the commits its main repository's HEAD does not already reach.
294
+ const cs = commitSources(project.id, repo, previous, null, repo.category === 'Worktree' && repo.worktreeOf ? gitHead(repo.worktreeOf) : null);
295
+ const r = upsert(store, cs, `Read commits of ${repo.path}`);
296
+ upserted += r.upserted;
297
+ commits += cs.length;
298
+ const st = statusSource(project.id, repo);
299
+ if (st) store.sources.put(st, { jobId: null, summary: `Observed repository status of ${repo.path}` });
300
+ await yieldNow();
301
+ }
302
+
303
+ // What the deletions and returns mean for the assets, once the commits that made them are read too (D61).
304
+ const removal = recordDeletions(store, project, deleted);
305
+ const back = recordReturns(store, project, returned);
306
+ // Material in a place a project rule covers is judged by that rule, also material read just now (§1.15).
307
+ applyMaterialRules(store, project);
308
+ recordIntake(store, project, skipped.map((s) => ({ ref: s.path, reason: s.reason, at: new Date().toISOString() })), 'Intake completed');
309
+ return { filesRead: scan.files.length, sourcesUpserted: upserted, sourcesChanged: changed, sourcesGone: gone, sessionsRead, segments, commits, skipped, durationMs: Date.now() - started, objectsRemoved: removal.removed.length, objectsBack: back.restored.length };
310
+ }
311
+
312
+ /** Re-read only what the watcher reported as settled. */
313
+ export function incrementalIntake(store: ProjectStore, project: Project, changes: readonly PendingChange[]): IntakeResult {
314
+ const started = Date.now();
315
+ const skipped: { path: string; reason: string }[] = [];
316
+ let upserted = 0;
317
+ let changed = 0;
318
+ let gone = 0;
319
+ let sessionsRead = 0;
320
+ let segments = 0;
321
+ let commits = 0;
322
+ const vanishedPaths: string[] = [];
323
+ const returned: string[] = [];
324
+ /** Files this pass read, or found gone: what was recorded about one of them before no longer stands. */
325
+ const settled = new Set<string>();
326
+ // The scope's session logs, located at the pass's first session change and kept for the rest: locating walks the
327
+ // hosts' whole session stores, and the pass does nothing between two changes that could add a log to them.
328
+ let scopeSessions: LocatedSession[] | null = null;
329
+ for (const c of changes) {
330
+ if (c.kind === 'file') {
331
+ const path = normalizePath(c.ref);
332
+ // A path that is gone — a file, or a whole directory the watcher reported once — is handled after every file of
333
+ // this batch is read, so a file moved within the batch is found at its new place.
334
+ if (!existsSync(path)) { vanishedPaths.push(path); settled.add(pathKey(path)); continue; }
335
+ const owner = project.scope.filter((i) => i.category !== 'Session source' && i.relation !== 'Excluded' && isWithin(i.path, path)).sort((a, b) => b.path.length - a.path.length)[0];
336
+ if (!owner) continue;
337
+ // A location in a place the project's ignore rules leave out: its working files are no change to take in (D105).
338
+ if (ignoredPlaceOf(project.scope, owner)) continue;
339
+ let entry: FileEntry;
340
+ try { const st = statSync(path); entry = { path, scopeItemId: owner.id, bytes: st.size, mtimeMs: st.mtimeMs }; } catch { continue; }
341
+ const read = readFileSources(project.id, entry, null, project.scope);
342
+ if (read.skipped) { if (!/not a text file/.test(read.skipped)) skipped.push({ path, reason: read.skipped }); continue; }
343
+ // Sections that disappeared from the file are no longer available.
344
+ const ids = new Set(read.sources.map((s) => s.id));
345
+ for (const s of store.sources.all()) if (s.anchor.kind === 'file' && samePath(s.anchor.path, path) && !ids.has(s.id) && !s.availability) store.sources.put({ ...s, availability: 'No longer available' }, { jobId: null, summary: 'Section no longer in the file' });
346
+ const r = upsert(store, read.sources, `Re-read ${path}`);
347
+ upserted += r.upserted; changed += r.changed.length;
348
+ returned.push(...r.returned);
349
+ settled.add(pathKey(path));
350
+ } else if (c.kind === 'session') {
351
+ scopeSessions ??= locateSessionsForHomes(scopeCwds(project), (host, cwd) => sessionStoreRootOf(project.scope, host, cwd));
352
+ const located = scopeSessions.find((s) => samePath(s.file, c.ref));
353
+ if (!located) continue; // a log for some other directory
354
+ if (located.cwd && !located.matchedCwd) continue;
355
+ try {
356
+ const read = sessionSources(project.id, located, sessionScopeItem(project, located.host, located.matchedCwd));
357
+ const r = upsert(store, read.sources, `Re-read ${located.host} session ${located.sessionId.slice(0, 8)}`);
358
+ upserted += r.upserted; changed += r.changed.length; segments += read.sources.length; sessionsRead += 1;
359
+ } catch (error) { skipped.push({ path: c.ref, reason: `session unreadable: ${(error as Error).message}` }); }
360
+ } else if (c.kind === 'commit') {
361
+ const repo = project.scope.find((i) => i.id === c.scopeItemId);
362
+ if (!repo) continue;
363
+ const previous = store.coverage.scopes.find((s) => s.id === 'project')?.asOf ?? null;
364
+ const cs = commitSources(project.id, repo, previous, null, repo.category === 'Worktree' && repo.worktreeOf ? gitHead(repo.worktreeOf) : null);
365
+ const r = upsert(store, cs, `Read new commits of ${repo.path}`);
366
+ upserted += r.upserted; commits += cs.length;
367
+ const st = statusSource(project.id, repo);
368
+ if (st) store.sources.put(st, { jobId: null, summary: `Observed repository status of ${repo.path}` });
369
+ }
370
+ }
371
+ let deleted: DeletedFile[] = [];
372
+ if (vanishedPaths.length) {
373
+ const vanished = store.sources.filter((s) => s.anchor.kind === 'file' && !s.availability && vanishedPaths.some((p) => isWithin(p, (s.anchor as { path: string }).path)) && !existsSync(s.anchor.path));
374
+ const vanishedIds = new Set(vanished.map((s) => s.id));
375
+ const current = store.sources.filter((s) => s.anchor.kind === 'file' && !s.availability && !vanishedIds.has(s.id));
376
+ const marked = markGone(store, project, vanished, current);
377
+ deleted = marked.deleted;
378
+ gone += marked.count;
379
+ }
380
+ const removal = recordDeletions(store, project, deleted);
381
+ const back = recordReturns(store, project, returned);
382
+ applyMaterialRules(store, project);
383
+ // What was recorded before stands, except for a file this pass read or found gone: it is no longer failed or skipped.
384
+ const previous = store.coverage.scopes.find((s) => s.id === 'project');
385
+ const stillStands = (ref: string) => !settled.has(pathKey(ref));
386
+ const previousFailed = (previous?.failed ?? []).filter((f) => stillStands(f.ref));
387
+ const previousSkipped = (previous?.skipped ?? []).filter((f) => stillStands(f.ref));
388
+ recordIntake(store, project, [...previousFailed, ...skipped.map((s) => ({ ref: s.path, reason: s.reason, at: new Date().toISOString() }))], 'Incremental intake', previousSkipped);
389
+ return { filesRead: changes.filter((c) => c.kind === 'file').length, sourcesUpserted: upserted, sourcesChanged: changed, sourcesGone: gone, sessionsRead, segments, commits, skipped, durationMs: Date.now() - started, objectsRemoved: removal.removed.length, objectsBack: back.restored.length };
390
+ }
391
+
392
+ export type { LocatedSession };