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,166 @@
1
+ /**
2
+ * The project's material rules applied to the sources they cover (Spec §1.15 "怎样施行"; CKC-04 AC-14, CKC-02 AC-23):
3
+ * what a `Recovery only` rule covers — directories, files or branches — is `History only` (§1.2), what a `Reference
4
+ * only` rule covers is `Reference only`, and the source names the rule (`usedAsByRuleId`). Rules are usually written by
5
+ * the framing round after intake, so this runs after every intake and whenever the scope is decided again (a rule or a
6
+ * scope classification written or changed), after the scope decision.
7
+ *
8
+ * Which rule: rules in force, of either basis. The project's written rule (`Explicit`) prevails over one the Keeper
9
+ * inferred (`Inferred`) wherever both cover a source; the Keeper's inference fills in only where the project's rules say
10
+ * nothing (§1.15), and the rule the source names says it is inferred. Two rules of different kinds covering a source
11
+ * equally are a conflict between the project's rules, which the program does not settle (§1.15: both stay). `Other`
12
+ * rules say in words what they do, so they are left to the Keeper.
13
+ *
14
+ * What it does with a `Used as` someone already gave — the most conservative reading of the Spec, as the instructions
15
+ * for this work asked:
16
+ * - the owner's own judgement stands: of the source (`usedAsBy: 'owner'`), and of its location — a location the owner
17
+ * classified is what the owner said, so a `Recovery only` rule does not take it out of the current material;
18
+ * - the Keeper's own judgement *by a rule* (it named one) stands: the program does not argue with it;
19
+ * - what the scope set by the location's classification (§1.1: third-party documents are `Reference only`, marked by
20
+ * `usedAsByScopeItemId`) stays when the rule says the same; where the rule says otherwise, the project's rule prevails
21
+ * over the Keeper's classification, and the scope's mark goes so the scope does not take it back;
22
+ * - the Keeper's own inference gives way to the project's rule (§1.15); the trace keeps what it was;
23
+ * - what the program itself set by a rule that no longer covers the source (replaced, withdrawn, narrowed) goes back to
24
+ * what its location sets, else `Not yet judged`, so the source is judged again.
25
+ *
26
+ * A source the program judged carries `usedAsBy: null` with `usedAsByRuleId` set; the Keeper's own carry `keeper`.
27
+ */
28
+ import type { Project, ProjectRule, Source } from '../model/types.ts';
29
+ import type { UsedAs } from '../model/vocab.ts';
30
+ import type { ProjectStore } from '../store/project-store.ts';
31
+ import { innermostItem, readingOf } from '../scope/skip.ts';
32
+ import { gitBranch } from '../util/git.ts';
33
+ import { sep } from 'node:path';
34
+ import { isWithin, nameForm, relativeDisplay } from '../util/paths.ts';
35
+
36
+ const TREATMENT: Partial<Record<string, UsedAs>> = { 'Recovery only': 'History only', 'Reference only': 'Reference only' };
37
+
38
+ interface Entry { readonly kind: 'path' | 'branch' | 'absolute'; readonly value: string; readonly glob: RegExp | null }
39
+
40
+ /**
41
+ * What a rule applies to, entry by entry. `locations`: the project's own, by which an absolute path of a system whose
42
+ * paths start at `/` is told from a path written from the project's root with a leading slash — `/Users/sam/orchard/attic`
43
+ * lies in the project, `/attic` does not. (A Windows path says it is absolute by its drive.)
44
+ */
45
+ function entriesOf(rule: ProjectRule, locations: readonly string[]): Entry[] {
46
+ const out: Entry[] = [];
47
+ for (const raw of rule.appliesTo) {
48
+ let v = nameForm(raw.trim().replace(/^[`'"“”‘’]+|[`'"“”‘’]+$/g, '').trim());
49
+ if (!v) continue;
50
+ const branch = /^(?:(?:branch(?:es)?|分支)\s*[::]?\s*|refs\/heads\/)(.+)$/i.exec(v);
51
+ if (branch) { const name = branch[1]!.trim().replace(/^[`'"“”‘’]+|[`'"“”‘’]+$/g, '').toLowerCase(); out.push({ kind: 'branch', value: name, glob: globOf(name) }); continue; }
52
+ if (/^[A-Za-z]:[\\/]/.test(v) || v.startsWith('\\\\') || (sep === '/' && v.startsWith('/') && !/[*?]/.test(v) && locations.some((l) => isWithin(l, v)))) { out.push({ kind: 'absolute', value: v, glob: null }); continue; }
53
+ v = v.split('\\').join('/').replace(/^\.\/+/, '').replace(/\/(\*\*?)?$/, '').replace(/\/+$/, '').toLowerCase();
54
+ if (!v || v === '.' || v === '*' || v === '**') continue;
55
+ out.push({ kind: 'path', value: v, glob: globOf(v) });
56
+ }
57
+ return out;
58
+ }
59
+
60
+ function globOf(v: string): RegExp | null {
61
+ if (!/[*?]/.test(v)) return null;
62
+ const re = v.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '\u0000').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]').replace(/\u0000/g, '.*');
63
+ return new RegExp(`^${re}$`);
64
+ }
65
+
66
+ /** How specifically an entry covers a source: a branch covers the whole checkout (it wins); a longer path is more specific. */
67
+ function coverage(e: Entry, paths: readonly string[], abs: string, branch: string | null): number | null {
68
+ if (e.kind === 'branch') return branch && (branch === e.value || e.glob?.test(branch)) ? Number.MAX_SAFE_INTEGER : null;
69
+ if (e.kind === 'absolute') return isWithin(e.value, abs) ? e.value.length : null;
70
+ for (const p of paths) {
71
+ if (p === e.value || p.startsWith(`${e.value}/`)) return e.value.length;
72
+ if (e.glob) {
73
+ const segs = p.split('/');
74
+ for (let i = 1; i <= segs.length; i++) if (e.glob.test(segs.slice(0, i).join('/'))) return e.value.length;
75
+ }
76
+ }
77
+ // A bare name may be a branch (the project names its branches as it names its folders).
78
+ if (branch && !e.value.includes('/') && branch === e.value) return Number.MAX_SAFE_INTEGER;
79
+ return null;
80
+ }
81
+
82
+ type Covering = { readonly rule: ProjectRule; readonly usedAs: UsedAs };
83
+
84
+ /** The most specific of these rules covering the source; null when none does or two kinds cover it equally. */
85
+ function mostSpecific(rules: readonly ProjectRule[], locations: readonly string[], paths: readonly string[], abs: string, branch: string | null): { found: Covering | null; covered: boolean } {
86
+ let best: (Covering & { score: number }) | null = null;
87
+ let tied = false;
88
+ for (const rule of rules) {
89
+ const usedAs = TREATMENT[rule.category ?? '']!;
90
+ for (const e of entriesOf(rule, locations)) {
91
+ const score = coverage(e, paths, abs, branch);
92
+ if (score === null) continue;
93
+ if (!best || score > best.score) { best = { rule, usedAs, score }; tied = false; }
94
+ else if (score === best.score && usedAs !== best.usedAs) tied = true;
95
+ }
96
+ }
97
+ return { found: best && !tied ? { rule: best.rule, usedAs: best.usedAs } : null, covered: best !== null };
98
+ }
99
+
100
+ /**
101
+ * The rule that covers this source and what it makes of it, or null: no rule, or rules of different kinds covering it
102
+ * equally. The project's written rules are asked first; the Keeper's inferred rules only where no written rule covers
103
+ * the source at all — a conflict between written rules is not settled by an inference.
104
+ */
105
+ function coveringRule(rules: readonly ProjectRule[], s: Source, project: Project, branchOf: (scopeItemId: string) => string | null): Covering | null {
106
+ if (s.anchor.kind !== 'file') return null;
107
+ const abs = s.anchor.path;
108
+ const roots = [...project.locations, ...project.scope.filter((i) => i.id === s.scopeItemId).map((i) => i.path)].filter((r) => isWithin(r, abs));
109
+ const paths = [...new Set(roots.map((r) => relativeDisplay(r, abs).toLowerCase()))];
110
+ const branch = branchOf(s.scopeItemId);
111
+ const written = mostSpecific(rules.filter((r) => r.basis === 'Explicit'), project.locations, paths, abs, branch);
112
+ if (written.covered) return written.found;
113
+ return mostSpecific(rules.filter((r) => r.basis !== 'Explicit'), project.locations, paths, abs, branch).found;
114
+ }
115
+
116
+ /** The owner classified the source's location as something other than left out: a `Recovery only` rule does not take it out. */
117
+ function ownerKeepsLocation(project: Project, path: string): boolean {
118
+ const item = innermostItem(project.scope, path);
119
+ return item?.classification?.by === 'owner' && item.relation !== 'Excluded';
120
+ }
121
+
122
+ /** Apply the material rules in force to every file source; returns how many sources changed. */
123
+ export function applyMaterialRules(store: ProjectStore, project: Project): number {
124
+ const rules = store.rules.filter((r) => r.group === 'Material rules' && r.validity === 'Current' && TREATMENT[r.category ?? ''] !== undefined);
125
+ const branches = new Map<string, string | null>();
126
+ const byBranch = rules.some((r) => entriesOf(r, project.locations).some((e) => e.kind === 'branch' || (e.kind === 'path' && !e.value.includes('/'))));
127
+ const branchOf = (scopeItemId: string): string | null => {
128
+ if (!byBranch) return null;
129
+ if (!branches.has(scopeItemId)) {
130
+ const item = project.scope.find((i) => i.id === scopeItemId);
131
+ branches.set(scopeItemId, item && (item.category === 'Repository' || item.category === 'Worktree') && !item.missing ? gitBranch(item.path)?.toLowerCase() ?? null : null);
132
+ }
133
+ return branches.get(scopeItemId) ?? null;
134
+ };
135
+ let changed = 0;
136
+ for (const s of store.sources.all()) {
137
+ if (s.anchor.kind !== 'file') continue;
138
+ const path = s.anchor.path;
139
+ const found = coveringRule(rules, s, project, branchOf);
140
+ const by = found && !(found.usedAs === 'History only' && ownerKeepsLocation(project, path)) ? found : null;
141
+ const scopeMark = s.usedAsByScopeItemId ?? null;
142
+ if (by) {
143
+ if (s.usedAsBy === 'owner') continue; // the owner judged this source, or classified its location
144
+ if (scopeMark && s.usedAs === by.usedAs) continue; // its location makes it the same already: that mark stays
145
+ if (!scopeMark && s.usedAsBy === 'keeper' && s.usedAsByRuleId) continue; // the Keeper judged it by a rule it named
146
+ if (s.usedAs === by.usedAs && s.usedAsByRuleId === by.rule.id && s.usedAsBy === null && !scopeMark) continue;
147
+ const was = s.usedAs && s.usedAs !== by.usedAs
148
+ ? `; it was ${s.usedAs}${scopeMark ? ' by its location’s classification' : s.usedAsBy === 'keeper' ? ' by the Keeper’s own judgement' : ''}`
149
+ : '';
150
+ store.sources.put({ ...s, usedAs: by.usedAs, usedAsBy: null, usedAsByRuleId: by.rule.id, usedAsByScopeItemId: null }, {
151
+ jobId: null, basisSourceIds: [s.id, ...by.rule.sourceIds],
152
+ summary: `Used as ${by.usedAs} by rule ${by.rule.id} (${by.rule.category}, ${by.rule.basis}: ${by.rule.summary.slice(0, 80)})${was}`,
153
+ });
154
+ changed++;
155
+ } else if (s.usedAsBy === null && s.usedAsByRuleId && !scopeMark) {
156
+ // What its location sets comes back (third-party documents are Reference only, §1.1); otherwise it is judged again.
157
+ const located = readingOf(project.scope, path).usedAs;
158
+ store.sources.put({ ...s, usedAs: located?.value ?? null, usedAsBy: located?.by ?? null, usedAsByRuleId: null, usedAsByScopeItemId: located?.scopeItemId ?? null }, {
159
+ jobId: null, basisSourceIds: [s.id],
160
+ summary: `${located ? `Used as ${located.value} by its location again` : 'Not yet judged again'}: rule ${s.usedAsByRuleId}, by which it was ${s.usedAs}, no longer covers it (replaced, withdrawn or narrowed, or the owner kept the location)`,
161
+ });
162
+ changed++;
163
+ }
164
+ }
165
+ return changed;
166
+ }
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Deletion means "no longer needed" (D61; Spec §2.1, §2.6, §7.1; CKC-02 AC-5, AC-23), recorded by the program when
3
+ * intake finds a file deleted from the project's current version — not moved:
4
+ *
5
+ * - an object whose material is now all gone becomes `Removed`, and one change record says what was removed and why;
6
+ * - an object that still has other material keeps its validity, and is marked `Suspected stale` on itself, because
7
+ * part of what it rests on is gone;
8
+ * - a current object that relies on something removed is marked on itself, never the removed object (§1.11);
9
+ * - when the material comes back, what the program removed returns to the validity it had, and the program's marks
10
+ * close. What the Keeper or the owner judged `Removed` is theirs and is not touched.
11
+ *
12
+ * Moves into a place the project keeps for archive, recovery or deletion are judged by the round's main job by the
13
+ * project's rules (§1.15), not here. A whole scope location that vanished — a drive, a removed worktree — is not a
14
+ * deletion of the project's content, so nothing is removed for it. No model is involved.
15
+ */
16
+ import { existsSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import type { Attribution, ChangeItem, ChangeRecord, EntryMark, Project, ProjectRule, ReferenceItem, Source, WorkThread } from '../model/types.ts';
19
+ import { VALIDITY, isOneOf, type Validity } from '../model/vocab.ts';
20
+ import { stableId } from '../model/ids.ts';
21
+ import type { ProjectStore } from '../store/project-store.ts';
22
+ import { markRefusal } from '../keeper/adjustment.ts';
23
+ import { commitSourceId } from '../sources/anchor.ts';
24
+ import { isHistoryOnly } from '../sources/history.ts';
25
+ import { gitPathFates, gitToplevel, type CommitMeta, type PathFate } from '../util/git.ts';
26
+ import { isWithin, relativeDisplay } from '../util/paths.ts';
27
+
28
+ export interface DeletedFile {
29
+ readonly path: string;
30
+ readonly scopeItemId: string;
31
+ readonly sourceIds: readonly string[];
32
+ /** The commit that deleted it, when git knows one; null for a deletion in the working tree; absent: not asked yet. */
33
+ readonly deletedIn?: CommitMeta | null;
34
+ }
35
+
36
+ /**
37
+ * What git says became of files that are gone from disk, for the ones inside a repository of the scope: deleted by a
38
+ * commit, or moved away by a commit to a file that is still there (where, absolute) — also outside the scope. A file
39
+ * moved and then deleted, or moved on again, is followed to what finally became of it. No entry: nothing committed says
40
+ * (a change in the working tree). A few questions per repository, however many files.
41
+ */
42
+ export function gitFates(project: Project, paths: readonly string[]): Map<string, { fate: PathFate; movedTo: string | null }> {
43
+ const out = new Map<string, { fate: PathFate; movedTo: string | null }>();
44
+ const tops = new Map<string, string | null>();
45
+ const byTop = new Map<string, string[]>();
46
+ for (const path of paths) {
47
+ const item = project.scope.filter((i) => i.category !== 'Session source' && i.relation !== 'Excluded' && i.versionControl !== 'none' && isWithin(i.path, path)).sort((a, b) => b.path.length - a.path.length)[0];
48
+ if (!item || !existsSync(item.path)) continue;
49
+ if (!tops.has(item.path)) tops.set(item.path, gitToplevel(item.path));
50
+ const top = tops.get(item.path);
51
+ if (!top || !isWithin(top, path)) continue;
52
+ const list = byTop.get(top) ?? [];
53
+ list.push(path);
54
+ byTop.set(top, list);
55
+ }
56
+ for (const [top, list] of byTop) {
57
+ const origin = new Map(list.map((p) => [relativeDisplay(top, p), p])); // the path asked about → the file that is gone
58
+ const seen = new Set(origin.keys());
59
+ let ask = [...origin.keys()];
60
+ while (ask.length) {
61
+ const next: string[] = [];
62
+ for (const [rel, fate] of gitPathFates(top, ask)) {
63
+ const gone = origin.get(rel);
64
+ if (!fate || gone === undefined) continue;
65
+ if (fate.kind === 'deleted') { out.set(gone, { fate, movedTo: null }); continue; }
66
+ const to = join(top, ...fate.to.split('/'));
67
+ if (existsSync(to)) { out.set(gone, { fate, movedTo: to }); continue; }
68
+ if (seen.has(fate.to)) continue;
69
+ seen.add(fate.to);
70
+ origin.set(fate.to, gone);
71
+ next.push(fate.to);
72
+ }
73
+ ask = next;
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+
79
+ export interface RemovalOutcome {
80
+ readonly removed: readonly string[];
81
+ readonly marked: readonly string[];
82
+ readonly changeIds: readonly string[];
83
+ }
84
+
85
+ /** Every mark the program writes about a deletion says this, and nothing the Keeper writes is expected to. */
86
+ const DELETED = 'deleted from the project’s current version';
87
+ /** A current object relies on another through these (§1.6): it serves, refines, implements, verifies, depends on or carries it out. */
88
+ const RELIES = ['serves', 'refines', 'implements', 'verifies', 'depends on', 'carries out'];
89
+ /** What a deletion turns into `Removed`: what was in force, proposed, deferred or not yet judged. What was already replaced or
90
+ * abandoned keeps saying by what and why. */
91
+ const REMOVABLE: readonly Validity[] = ['Current', 'Proposed', 'Deferred', 'Unjudged'];
92
+ const PROGRAM: Attribution = { author: { kind: 'unknown', name: null, window: null, host: null, model: null }, holder: null, identity: 'Artifact' };
93
+
94
+ type Obj =
95
+ | { readonly kind: 'reference'; readonly item: ReferenceItem }
96
+ | { readonly kind: 'thread'; readonly item: WorkThread }
97
+ | { readonly kind: 'rule'; readonly item: ProjectRule };
98
+
99
+ const nameOf = (o: Obj): string => (o.kind === 'reference' ? o.item.name : o.kind === 'thread' ? o.item.title : o.item.summary);
100
+
101
+ function objects(store: ProjectStore): Obj[] {
102
+ return [
103
+ ...store.reference.all().map((item) => ({ kind: 'reference' as const, item })),
104
+ ...store.threads.all().map((item) => ({ kind: 'thread' as const, item })),
105
+ ...store.rules.all().map((item) => ({ kind: 'rule' as const, item })),
106
+ ];
107
+ }
108
+
109
+ /** Every source an object rests on: the ones it cites, and for a work item those of its fact records, results and checks. */
110
+ function restsOn(store: ProjectStore, o: Obj): string[] {
111
+ if (o.kind !== 'thread') return [...o.item.sourceIds];
112
+ const t = o.item;
113
+ return [...new Set([
114
+ ...t.factRecordIds.flatMap((f) => store.facts.get(f)?.aboutSourceIds ?? []),
115
+ ...[...t.executionFacts, ...t.qcFacts].flatMap((s) => s.sourceIds),
116
+ ])];
117
+ }
118
+
119
+ /** The sources that can carry it as current: history never does, and reference-only material makes no requirement (§1.2). */
120
+ function currentBasis(store: ProjectStore, ids: readonly string[]): Source[] {
121
+ return ids.map((id) => store.sources.get(id)).filter((s): s is Source => s !== undefined && !isHistoryOnly(s) && s.usedAs !== 'Reference only');
122
+ }
123
+
124
+ function put(store: ProjectStore, o: Obj, validity: Validity, at: string, summary: string, basis: readonly string[]): void {
125
+ const trace = { jobId: null, basisSourceIds: basis, summary };
126
+ if (o.kind === 'reference') store.reference.put({ ...o.item, validity, validityByRuleId: null, updatedAt: at }, trace);
127
+ else if (o.kind === 'thread') store.threads.put({ ...o.item, validity, validityByRuleId: null, updatedAt: at }, trace);
128
+ else store.rules.put({ ...o.item, validity, updatedAt: at }, trace);
129
+ }
130
+
131
+ /** Write or extend the program's `Suspected stale` on a current object; a mark the Keeper wrote there stands as it is. */
132
+ function programMark(store: ProjectStore, project: Project, targetId: string, clue: string, clueSourceIds: readonly string[], at: string): boolean {
133
+ if (markRefusal(store, 'Suspected stale', targetId, clue, clueSourceIds, null)) return false;
134
+ const id = stableId('mark', 'Suspected stale', targetId);
135
+ const existing = store.marks.get(id);
136
+ if (existing && existing.closed === null && !existing.clue.includes(DELETED)) return false;
137
+ const open = existing && existing.closed === null ? existing : null;
138
+ const mark: EntryMark = {
139
+ id, projectId: project.id, kind: 'Suspected stale', targetId,
140
+ clueSourceIds: [...new Set([...(open?.clueSourceIds ?? []), ...clueSourceIds])],
141
+ clue: open ? (open.clue.includes(clue) ? open.clue : `${open.clue} ${clue}`) : clue,
142
+ since: open?.since ?? at, noteId: existing?.noteId ?? null, closed: null,
143
+ };
144
+ store.marks.put(mark, { jobId: null, basisSourceIds: clueSourceIds, summary: `Mark Suspected stale on ${targetId}: ${clue.slice(0, 120)}` });
145
+ return true;
146
+ }
147
+
148
+ interface Deletion { readonly key: string; readonly commit: CommitMeta | null; readonly at: string }
149
+
150
+ /**
151
+ * Record what a batch of file deletions means for the assets. `deleted` lists the files intake found gone (not moved),
152
+ * each with the sources it had; their availability is already `No longer available`.
153
+ */
154
+ export function recordDeletions(store: ProjectStore, project: Project, deleted: readonly DeletedFile[], now = new Date().toISOString()): RemovalOutcome {
155
+ // A location that is gone as a whole is not a deletion of content (a drive, a removed worktree): skip its files.
156
+ const items = new Map(project.scope.map((i) => [i.id, i]));
157
+ const files = deleted.filter((d) => {
158
+ const item = items.get(d.scopeItemId) ?? project.scope.filter((i) => i.category !== 'Session source' && isWithin(i.path, d.path)).sort((a, b) => b.path.length - a.path.length)[0];
159
+ return item !== undefined && item.relation !== 'Excluded' && existsSync(item.path);
160
+ });
161
+ if (files.length === 0) return { removed: [], marked: [], changeIds: [] };
162
+
163
+ // How each file was deleted: by a commit git knows (dated by it, and saying why in its own words), or in the working tree.
164
+ const unasked = files.filter((f) => f.deletedIn === undefined).map((f) => f.path);
165
+ const asked = unasked.length ? gitFates(project, unasked) : new Map<string, { fate: PathFate }>();
166
+ const deletionOf = new Map<string, Deletion>();
167
+ for (const f of files) {
168
+ const fate = f.deletedIn === undefined ? asked.get(f.path)?.fate : undefined;
169
+ const commit = f.deletedIn !== undefined ? f.deletedIn : fate?.kind === 'deleted' ? fate.commit : null;
170
+ deletionOf.set(f.path, commit ? { key: commit.hash, commit, at: new Date(commit.at).toISOString() } : { key: `working tree ${now}`, commit: null, at: now });
171
+ }
172
+ const deletedIds = new Set(files.flatMap((f) => f.sourceIds));
173
+ const fileOf = new Map(files.flatMap((f) => f.sourceIds.map((id) => [id, f] as const)));
174
+ const pathsOf = (ids: readonly string[]) => [...new Set(ids.map((id) => fileOf.get(id)?.path).filter((p): p is string => p !== undefined))].map((p) => {
175
+ const root = project.locations.find((l) => isWithin(l, p));
176
+ return root ? relativeDisplay(root, p) : p;
177
+ });
178
+
179
+ const removed: { o: Obj; before: Validity; gone: string[] }[] = [];
180
+ const partial: { o: Obj; gone: string[] }[] = [];
181
+ for (const o of objects(store)) {
182
+ const rests = restsOn(store, o);
183
+ const gone = rests.filter((id) => deletedIds.has(id));
184
+ if (gone.length === 0) continue;
185
+ const basis = currentBasis(store, rests);
186
+ if (basis.length === 0) continue;
187
+ if (basis.every((s) => s.availability === 'No longer available')) {
188
+ if (REMOVABLE.includes(o.item.validity)) removed.push({ o, before: o.item.validity, gone });
189
+ } else if (o.item.validity === 'Current' || o.item.validity === 'Proposed' || o.item.validity === 'Deferred') {
190
+ partial.push({ o, gone });
191
+ }
192
+ }
193
+
194
+ for (const r of removed) put(store, r.o, 'Removed', now, `Removed: its material was ${DELETED} (${pathsOf(r.gone).join(', ')})`, r.gone);
195
+
196
+ // One change record per deletion — the commit that deleted the files, or what the working tree lost — with an item for
197
+ // each object it removed (§1.8, §2.1).
198
+ const changeIds: string[] = [];
199
+ const groups = new Map<string, { deletion: Deletion; entries: typeof removed }>();
200
+ for (const r of removed) {
201
+ const d = r.gone.map((id) => deletionOf.get(fileOf.get(id)!.path)!).sort((a, b) => b.at.localeCompare(a.at))[0]!;
202
+ const g = groups.get(d.key) ?? { deletion: d, entries: [] };
203
+ g.entries.push(r);
204
+ groups.set(d.key, g);
205
+ }
206
+ for (const { deletion, entries } of groups.values()) {
207
+ const c = deletion.commit;
208
+ const recordId = stableId('chg', project.id, 'Removed', deletion.key);
209
+ const commitSource = c ? project.scope.map((i) => commitSourceId(i.path, c.hash)).find((id) => store.sources.has(id)) : undefined;
210
+ const by: Attribution = c ? { ...PROGRAM, author: { ...PROGRAM.author, name: c.author || null } } : PROGRAM;
211
+ const items: ChangeItem[] = entries.map((r) => {
212
+ const paths = pathsOf(r.gone).join(', ');
213
+ return {
214
+ id: stableId('item', recordId, 'Removed', r.o.item.id), at: deletion.at, atSource: c ? 'material' : 'observed',
215
+ material: 'Other', effect: 'Removed', title: `${nameOf(r.o)} removed`,
216
+ summary: `Its material was ${DELETED}: ${paths}${c ? ` (commit ${c.hash.slice(0, 8)}, ${c.author}, ${c.at.slice(0, 10)})` : ' (seen missing from the working tree; no commit records it yet)'}. Deletion means it is no longer needed.`,
217
+ before: r.before, after: 'Removed', sourceIds: [...r.gone, ...(commitSource ? [commitSource] : [])], by, why: c ? c.subject || null : null,
218
+ affects: [r.o.item.id],
219
+ };
220
+ });
221
+ const previous = store.changes.get(recordId);
222
+ const all = [...(previous?.items ?? []).filter((old) => !items.some((i) => i.id === old.id)), ...items].sort((a, b) => a.at.localeCompare(b.at));
223
+ const head = all[0]!;
224
+ const record: ChangeRecord = {
225
+ id: recordId, projectId: project.id, at: head.at, atSource: head.atSource, material: head.material, effect: head.effect,
226
+ title: c ? `Commit ${c.hash.slice(0, 8)} · ${c.subject}` : `Files missing from the working tree (seen ${deletion.at.slice(0, 16).replace('T', ' ')})`,
227
+ summary: all.length > 1 ? all.map((i) => `${i.effect}: ${i.title}`).join('; ') : head.summary, before: head.before, after: head.after,
228
+ sourceIds: [...new Set(all.flatMap((i) => i.sourceIds))], by, affects: [...new Set(all.flatMap((i) => i.affects))],
229
+ propagation: previous?.propagation ?? [], notJudged: previous?.notJudged ?? [], segment: null, createdInJobId: null, updatedAt: now,
230
+ work: { kind: 'Time range', label: c ? `Commit ${c.hash.slice(0, 8)}` : 'Deletions seen in the working tree', sessionId: null, startedAt: deletion.at, endedAt: deletion.at, openEnded: false },
231
+ items: all,
232
+ };
233
+ store.changes.put(record, { jobId: null, basisSourceIds: record.sourceIds, summary: `Change record (deletion): ${items.length} object${items.length === 1 ? '' : 's'} removed` });
234
+ changeIds.push(recordId);
235
+ }
236
+
237
+ // Marks go on the current objects that still rely on what was deleted (§2.1, §2.6, §7.1; §1.11 says where).
238
+ const marked = new Set<string>();
239
+ const removedIds = new Set(removed.map((r) => r.o.item.id));
240
+ for (const p of partial) {
241
+ if (programMark(store, project, p.o.item.id, `Part of the material it rests on was ${DELETED}: ${pathsOf(p.gone).join(', ')}. What it says may no longer hold.`, p.gone, now)) marked.add(p.o.item.id);
242
+ }
243
+ for (const r of removed) {
244
+ const id = r.o.item.id;
245
+ const dependents = new Set<string>([
246
+ ...store.relations.filter((rel) => rel.to === id && RELIES.includes(rel.type)).map((rel) => rel.from),
247
+ ...store.reference.filter((x) => x.refines.includes(id)).map((x) => x.id),
248
+ ...store.threads.filter((t) => t.serves.some((s) => s.referenceId === id) || t.dependsOn.some((d) => d.threadId === id)).map((t) => t.id),
249
+ ...(r.o.kind === 'rule' ? [...store.reference.filter((x) => x.validityByRuleId === id).map((x) => x.id), ...store.threads.filter((t) => t.validityByRuleId === id || t.progressByRuleId === id).map((t) => t.id)] : []),
250
+ ]);
251
+ for (const x of dependents) {
252
+ if (removedIds.has(x)) continue;
253
+ const target = store.reference.get(x) ?? store.threads.get(x);
254
+ if (!target || !['Current', 'Proposed', 'Deferred'].includes(target.validity)) continue;
255
+ const how = r.o.kind === 'rule' ? `It is judged by the project’s rule “${nameOf(r.o)}”, which was removed` : `It relies on “${nameOf(r.o)}”, which was removed`;
256
+ if (programMark(store, project, x, `${how}: its material was ${DELETED} (${pathsOf(r.gone).join(', ')}).`, r.gone, now)) marked.add(x);
257
+ }
258
+ }
259
+ return { removed: [...removedIds], marked: [...marked], changeIds };
260
+ }
261
+
262
+ /**
263
+ * Material the program saw deleted is back (the same file, at the same place): what the program removed because of it
264
+ * returns to the validity it had, the return is recorded, and the program's marks whose material is all back close.
265
+ */
266
+ export function recordReturns(store: ProjectStore, project: Project, returnedIds: readonly string[], now = new Date().toISOString()): { restored: readonly string[]; changeIds: readonly string[] } {
267
+ if (returnedIds.length === 0) return { restored: [], changeIds: [] };
268
+ const back = new Set(returnedIds);
269
+ const removedBy = (id: string): ChangeItem | null => {
270
+ let found: ChangeItem | null = null;
271
+ for (const c of store.changes.all()) {
272
+ if (c.createdInJobId !== null) continue;
273
+ for (const i of c.items ?? []) if (i.effect === 'Removed' && i.affects.includes(id) && (!found || i.at >= found.at)) found = i;
274
+ }
275
+ return found;
276
+ };
277
+ const items: ChangeItem[] = [];
278
+ const recordId = stableId('chg', project.id, 'Returned', now);
279
+ for (const o of objects(store)) {
280
+ if (o.item.validity !== 'Removed') continue;
281
+ const rests = restsOn(store, o);
282
+ const returned = rests.filter((id) => back.has(id));
283
+ if (returned.length === 0) continue;
284
+ if (!currentBasis(store, rests).some((s) => s.availability !== 'No longer available')) continue;
285
+ const item = removedBy(o.item.id);
286
+ if (!item) continue; // the Keeper or the owner judged it Removed; that judgement is not the program's to undo
287
+ const before: Validity = isOneOf(VALIDITY, item.before) && item.before !== 'Removed' ? item.before : 'Current';
288
+ const paths = returned.map((id) => store.sources.get(id)).map((s) => (s && s.anchor.kind === 'file' ? s.anchor.path : '')).filter(Boolean);
289
+ const shown = [...new Set(paths)].map((p) => { const root = project.locations.find((l) => isWithin(l, p)); return root ? relativeDisplay(root, p) : p; });
290
+ put(store, o, before, now, `Back: its material is in the project’s current version again (${shown.join(', ')})`, returned);
291
+ items.push({
292
+ id: stableId('item', recordId, 'Corrected', o.item.id), at: now, atSource: 'observed', material: 'Other', effect: 'Corrected',
293
+ title: `${nameOf(o)} is back`, summary: `Its material is in the project’s current version again: ${shown.join(', ')}. The removal recorded when it went missing no longer holds.`,
294
+ before: 'Removed', after: before, sourceIds: returned, by: PROGRAM, why: null, affects: [o.item.id],
295
+ });
296
+ }
297
+ // The program's marks about a deletion close once everything they named is back.
298
+ for (const m of store.marks.all()) {
299
+ if (m.closed !== null || !m.clue.includes(DELETED) || !m.clueSourceIds.some((id) => back.has(id))) continue;
300
+ if (m.clueSourceIds.some((id) => store.sources.get(id)?.availability === 'No longer available')) continue;
301
+ store.marks.put({ ...m, closed: { result: 'No longer relevant', at: now, reason: 'The deleted material it named is back in the project’s current version.' } }, { jobId: null, basisSourceIds: m.clueSourceIds, summary: `Mark closed: ${m.targetId}'s deleted material is back` });
302
+ }
303
+ if (items.length === 0) return { restored: [], changeIds: [] };
304
+ const head = items[0]!;
305
+ const record: ChangeRecord = {
306
+ id: recordId, projectId: project.id, at: now, atSource: 'observed', material: 'Other', effect: 'Corrected',
307
+ title: `Files back in the working tree (seen ${now.slice(0, 16).replace('T', ' ')})`,
308
+ summary: items.length > 1 ? items.map((i) => `${i.effect}: ${i.title}`).join('; ') : head.summary, before: head.before, after: head.after,
309
+ sourceIds: [...new Set(items.flatMap((i) => i.sourceIds))], by: PROGRAM, affects: [...new Set(items.flatMap((i) => i.affects))],
310
+ propagation: [], notJudged: [], segment: null, createdInJobId: null, updatedAt: now,
311
+ work: { kind: 'Time range', label: 'Files back in the working tree', sessionId: null, startedAt: now, endedAt: now, openEnded: false },
312
+ items,
313
+ };
314
+ store.changes.put(record, { jobId: null, basisSourceIds: record.sourceIds, summary: `Change record: ${items.length} removed object${items.length === 1 ? '' : 's'} back` });
315
+ return { restored: items.map((i) => i.affects[0]!), changeIds: [recordId] };
316
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `Skipped: too large` (Spec §1.11, §3.10, §6.7; D105; CKC-04 AC-18, CKC-07 AC-10): a file over the size intake reads
3
+ * of one file is not read, and that is not a failure. It is recorded with its size and the limit, listed in Project
4
+ * scope, and counted nowhere in the top bar. The owner, of an archived export page that stayed red: 「超过 2 MB 的上限
5
+ * 这个不要红色的」.
6
+ *
7
+ * The reader still reports it the way it always did (`larger than N bytes`, sources/files.ts); here that report is told
8
+ * apart from what really failed — unreadable, a broken session log, a step of a round that did not finish. A home written
9
+ * before D105 holds such a file among its failures: every pass that writes the coverage moves it over, so it shows as
10
+ * skipped with no step by the owner.
11
+ */
12
+ import { statSync } from 'node:fs';
13
+ import type { CoverageScope, Project, SkippedMaterial } from '../model/types.ts';
14
+ import { isWithin } from '../util/paths.ts';
15
+
16
+ type Failure = CoverageScope['failed'][number];
17
+
18
+ const TOO_LARGE = /^larger than (\d+) bytes$/;
19
+
20
+ /** The limit a `larger than N bytes` report names, or null for any other reason. */
21
+ export function tooLargeLimit(reason: string): number | null {
22
+ const m = TOO_LARGE.exec(reason.trim());
23
+ return m ? Number(m[1]) : null;
24
+ }
25
+
26
+ function sizeOf(path: string): number | null {
27
+ try { return statSync(path).size; } catch { return null; }
28
+ }
29
+
30
+ /** The locations the scope leaves out: a skipped file under one is no longer listed (the owner can exclude it, §6.7). */
31
+ export function excludedLocations(project: Project): string[] {
32
+ return project.scope.filter((i) => i.relation === 'Excluded' && i.category !== 'Session source').map((i) => i.path);
33
+ }
34
+
35
+ /** One entry per file, the latest, in the order the kept ones came. */
36
+ function latestSkipped(list: readonly SkippedMaterial[]): SkippedMaterial[] {
37
+ const byRef = new Map<string, SkippedMaterial>();
38
+ for (const s of list) {
39
+ const before = byRef.get(s.ref);
40
+ if (before && before.at > s.at) continue;
41
+ byRef.delete(s.ref);
42
+ byRef.set(s.ref, s);
43
+ }
44
+ return [...byRef.values()];
45
+ }
46
+
47
+ /**
48
+ * What intake could not take in, told apart: `failed` keeps what went wrong; a file too large to read goes to `skipped`
49
+ * with its size now and the limit. `before` is what was skipped already; an entry of the same file is replaced by the
50
+ * later one. `excluded`: locations the owner left out of the scope — a skipped file under one is no longer listed.
51
+ */
52
+ export function splitTooLarge(failed: readonly Failure[], before: readonly SkippedMaterial[] = [], excluded: readonly string[] = []): { failed: Failure[]; skipped: SkippedMaterial[] } {
53
+ const real: Failure[] = [];
54
+ const moved: SkippedMaterial[] = [];
55
+ for (const f of failed) {
56
+ const limit = tooLargeLimit(f.reason);
57
+ if (limit === null) real.push(f);
58
+ else moved.push({ ref: f.ref, bytes: sizeOf(f.ref), limit, at: f.at });
59
+ }
60
+ const skipped = latestSkipped([...before, ...moved]).filter((s) => !excluded.some((root) => isWithin(root, s.ref)));
61
+ return { failed: real, skipped };
62
+ }
63
+
64
+ /** Everything skipped as too large across the coverage's scopes, also what an older home still holds among its failures. */
65
+ export function skippedOf(scopes: readonly CoverageScope[]): SkippedMaterial[] {
66
+ return latestSkipped(scopes.flatMap((s) => splitTooLarge(s.failed, s.skipped ?? []).skipped));
67
+ }
68
+
69
+ /** What really failed across the coverage's scopes: not a file too large to read. */
70
+ export function failuresOf(scopes: readonly CoverageScope[]): Failure[] {
71
+ return scopes.flatMap((s) => s.failed.filter((f) => tooLargeLimit(f.reason) === null));
72
+ }