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,266 @@
1
+ /**
2
+ * Absence claims carry what was read, and a lane's Unsure stays unsure (CN, E152; Spec §2.12 没有痕迹,要读过才算, §3.3 抽查).
3
+ *
4
+ * The spot-check of the CM run found 3 wrong out of 29, all three absence or list claims written at the synthesis, none
5
+ * a fill error. Two notes stated as fact what a lane had marked Unsure: after the owner's D1 question 「底稿里未见后续回答」
6
+ * — the lane had not read the sessions after 09-30, and the fix shipped that evening. One cited a source that does not
7
+ * hold what it was cited for.
8
+ *
9
+ * - **An absence claim** is a note, or a six-things judgement of "dropped along the way" (3) or "let pass" (5), that says
10
+ * something has no follow-up, was let pass, or was taken up by nobody (`ABSENCE_WORDS`: the kinds the spot-check checks
11
+ * in full). It carries `looked`: where it was looked for, and up to when — the idea of `pk_record_looked` for
12
+ * breakpoint candidates. The writers refuse the claim without it (tools.ts `pk_write_note`, clerk-tools.ts `pk_tag_six`).
13
+ * - **A look that does not reach the present says so in the claim's text.** The present is the ledger's: its newest
14
+ * session message when sessions were read (`sessionsUpTo`), its newest commit otherwise (`upTo`). A claim whose look
15
+ * stops earlier is stamped `As far as read — …` on the line everyone reads, so it reads "as far as … read", never a
16
+ * bare "no follow-up" (`stampAsFarAs`).
17
+ * - **The lanes' Unsure items** are listed for the synthesis job (D103: in its task) and for the spot-check
18
+ * (`laneUnsure`, `unsureBlock`): each is read to settle it, or written as unsure; never restated as fact.
19
+ *
20
+ * The program reads and compares; whether something was handled is the model's judgement.
21
+ */
22
+ import { Ledger } from '../../ledger/index.ts';
23
+ import type { AbsenceLooked, ClerkRound, RoundDoc } from '../../model/k-types.ts';
24
+
25
+ export type { AbsenceLooked } from '../../model/k-types.ts';
26
+ import type { ProjectStore } from '../../store/project-store.ts';
27
+
28
+ // ───────────────────────── which text is an absence claim ─────────────────────────
29
+
30
+ /**
31
+ * Words that say something has no follow-up, was let pass, or was taken up by nobody. Narrower than the claim words the
32
+ * program lists later mentions for (process/claim-check.ts `CLAIM_WORDS`): "still open" and "open items" state what a
33
+ * report says, not that nothing came after.
34
+ */
35
+ export const ABSENCE_WORDS = new RegExp([
36
+ '[无没未][^。,,;;\\n]{0,4}(?:处置|处理|跟进|下文|后续|修复?|接手?|回应|回答|答复|落实)',
37
+ '无人|没人|没有人|放过去|放过了|被放过|不了了之|石沉大海',
38
+ '\\blet pass\\b|\\bnobody\\b|\\bno one\\b|\\bnot (?:handled|fixed|addressed|followed up|re-?checked|picked up|taken up|answered)\\b|\\bunhandled\\b|\\bunanswered\\b',
39
+ '\\b(?:no|without) (?:follow-?up|disposition|handling|successor|answer|reply|later (?:ticket|commit|record|answer))\\b|\\bnever (?:handled|fixed|followed up|picked up|addressed|answered|taken up)\\b',
40
+ '\\bdropped along the way\\b',
41
+ ].join('|'), 'i');
42
+
43
+ /** The lines of a text that claim an absence. */
44
+ export function absenceClaims(text: string): string[] {
45
+ return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && ABSENCE_WORDS.test(l));
46
+ }
47
+
48
+ /** The six things whose judgement is an absence claim: dropped along the way (3), let pass (5). */
49
+ export const ABSENCE_THINGS: readonly number[] = [3, 5];
50
+
51
+ // ───────────────────────── what was read ─────────────────────────
52
+
53
+ /** The newest session message and the newest commit the ledger holds (ISO times), or null where it holds none. */
54
+ export interface LedgerPresent {
55
+ readonly sessions: string | null;
56
+ readonly commits: string | null;
57
+ }
58
+
59
+ export function ledgerPresent(ledger: Ledger | null): LedgerPresent {
60
+ if (!ledger) return { sessions: null, commits: null };
61
+ const one = (sql: string): string | null => { try { return ((ledger.db.prepare(sql).get() as { t: string | null } | undefined)?.t) ?? null; } catch { return null; } };
62
+ return {
63
+ sessions: one('SELECT max(coalesce(ended_at, started_at)) AS t FROM sessions WHERE missing = 0 AND messages > 0'),
64
+ commits: one('SELECT max(committer_at) AS t FROM commits'),
65
+ };
66
+ }
67
+
68
+ /** The ledger's present for a store; nothing when the project has no ledger yet. */
69
+ export function presentOf(store: ProjectStore): LedgerPresent {
70
+ let ledger: Ledger | null = null;
71
+ try { ledger = Ledger.openDir(store.dir); } catch { ledger = null; }
72
+ try { return ledgerPresent(ledger); } finally { ledger?.close(); }
73
+ }
74
+
75
+ const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
76
+ /** A date or a date-time as milliseconds; a date alone reaches to the end of that day. Null when it is neither. */
77
+ function reachMs(value: string): number | null {
78
+ const v = value.trim();
79
+ const ms = Date.parse(DATE_ONLY.test(v) ? `${v}T23:59:59.999Z` : v);
80
+ return Number.isNaN(ms) ? null : ms;
81
+ }
82
+ /** How much later the ledger's newest entry may be and the look still reaches it: the minute the reading took. */
83
+ const REACH_SLACK_MS = 5 * 60_000;
84
+ const short = (iso: string): string => (DATE_ONLY.test(iso.trim()) ? iso.trim() : new Date(iso).toISOString().slice(0, 16).replace('T', ' ') + 'Z');
85
+
86
+ /** What lies beyond a look, in words, or null when it reaches the ledger's present. */
87
+ export function behindOf(looked: Pick<AbsenceLooked, 'upTo' | 'sessionsUpTo'>, present: LedgerPresent): string | null {
88
+ const parts: string[] = [];
89
+ const check = (read: string | null | undefined, newest: string | null, what: string) => {
90
+ if (!read || !newest) return;
91
+ const a = reachMs(read), b = reachMs(newest);
92
+ if (a === null || b === null || a + REACH_SLACK_MS >= b) return;
93
+ parts.push(`${what} to ${short(read)}; the ledger has ${what} to ${short(newest)}`);
94
+ };
95
+ check(looked.sessionsUpTo, present.sessions, 'sessions');
96
+ check(looked.upTo, present.commits, 'commits');
97
+ return parts.length ? parts.join(' · ') : null;
98
+ }
99
+
100
+ /** `where` names sessions (a session id, the word) although no `sessionsUpTo` says how far they were read. */
101
+ const NAMES_SESSIONS = /\bsessions?\b|会话|底稿|\bdrafts?\b|\btranscripts?\b/i;
102
+
103
+ /**
104
+ * `looked` as a call gives it — `{ where, upTo, sessionsUpTo? }` — checked; a string says what is wrong with it.
105
+ */
106
+ export function parseLooked(raw: unknown): { where: string[]; upTo: string; sessionsUpTo: string | null } | string {
107
+ const p = (raw ?? {}) as Record<string, unknown>;
108
+ const where = [...new Set((Array.isArray(p.where) ? p.where : []).filter((w): w is string => typeof w === 'string').map((w) => w.trim()).filter(Boolean))];
109
+ if (!where.length) return 'looked.where: each place you read for what came after (a file with its section or lines, a ledger query, a commit range, a session), so the spot-check can read it again.';
110
+ const upTo = typeof p.upTo === 'string' ? p.upTo.trim() : '';
111
+ if (!upTo || reachMs(upTo) === null) return 'looked.upTo: up to when your reading reaches — the date or time (2026-09-30, or 2026-09-30T19:53Z) of the newest commit, document version or report you read; a file read as it stands now reaches today.';
112
+ const sessionsUpTo = typeof p.sessionsUpTo === 'string' && p.sessionsUpTo.trim() ? p.sessionsUpTo.trim() : null;
113
+ if (sessionsUpTo && reachMs(sessionsUpTo) === null) return 'looked.sessionsUpTo: the time of the last session message you read (2026-09-30T19:53Z).';
114
+ if (!sessionsUpTo && where.some((w) => NAMES_SESSIONS.test(w))) return 'looked.where names sessions: give looked.sessionsUpTo, the time of the last session message you read, so the claim says how far the sessions were read.';
115
+ return { where, upTo, sessionsUpTo };
116
+ }
117
+
118
+ /** `looked` as it is kept: what the call gave, who looked and when, and what the ledger holds beyond it. */
119
+ export function lookedRecord(given: { where: string[]; upTo: string; sessionsUpTo: string | null }, present: LedgerPresent, by: { readonly roundId: string | null; readonly jobId: string | null; readonly at: string }): AbsenceLooked {
120
+ return { where: given.where, upTo: given.upTo, ...(given.sessionsUpTo ? { sessionsUpTo: given.sessionsUpTo } : {}), roundId: by.roundId, jobId: by.jobId, at: by.at, behind: behindOf(given, present) };
121
+ }
122
+
123
+ /** The refusal of a writer given an absence claim with no `looked`. */
124
+ export function absenceRefusal(what: string, claims: readonly string[]): string {
125
+ const quoted = claims.slice(0, 2).map((c) => `“${c.length > 120 ? `${c.slice(0, 119)}…` : c}”`).join(', ');
126
+ return `${what} says something has no follow-up, was let pass or was taken up by nobody${quoted ? ` (${quoted})` : ''}. Such a claim carries what was read: give looked: { where: [each place you read for what came after], upTo: the date or time your reading reaches, sessionsUpTo: the time of the last session message you read, when sessions were read }. A lane’s Unsure you did not settle by reading is written as unsure, not as a finding. Nothing was written.`;
127
+ }
128
+
129
+ // ───────────────────────── as far as read ─────────────────────────
130
+
131
+ /** The words a claim opens with when its look does not reach the ledger's present. */
132
+ export const AS_FAR_AS = 'As far as read';
133
+ const STAMP = /^\[As far as read — [^\]]*\]\s*/;
134
+
135
+ /**
136
+ * The claim's line with what its look leaves unread written in front — `[As far as read — sessions to …; the ledger has
137
+ * sessions to …] …` — or without it when the look reaches the present (an earlier stamp is taken off).
138
+ */
139
+ export function stampAsFarAs(text: string, looked: Pick<AbsenceLooked, 'behind'> | null | undefined): string {
140
+ const bare = text.replace(STAMP, '');
141
+ return looked?.behind ? `[${AS_FAR_AS} — ${looked.behind}] ${bare}` : bare;
142
+ }
143
+
144
+ /** `looked` in one line, for the spot-check's list and a tool's answer. */
145
+ export function lookedLine(l: AbsenceLooked): string {
146
+ return `looked in ${l.where.join('; ')} · up to ${short(l.upTo)}${l.sessionsUpTo ? ` · sessions to ${short(l.sessionsUpTo)}` : ''}${l.behind ? ` · does not reach the present (${l.behind})` : ''}`;
147
+ }
148
+
149
+ /**
150
+ * The absence claims this round wrote, each with what was read, for the spot-check (the list `pk_record_looked` gives it
151
+ * for breakpoint candidates): it reads past where each look stopped.
152
+ */
153
+ export function absenceLookedBlock(store: ProjectStore, round: Pick<ClerkRound, 'id'>): string {
154
+ const lines: string[] = [];
155
+ for (const n of store.notes.filter((x) => x.looked?.roundId === round.id && x.status === 'Current')) lines.push(`- note ${n.id} “${n.versions[n.versions.length - 1]?.title ?? ''}”: ${lookedLine(n.looked!)}`);
156
+ for (const m of store.marks.filter((x) => x.looked?.roundId === round.id && !x.closed)) lines.push(`- mark ${m.id} (${m.kind} on ${m.targetId}): ${lookedLine(m.looked!)}`);
157
+ for (const s of store.sendbacks.filter((x) => x.looked?.roundId === round.id)) lines.push(`- send-back ${s.id} (to ${s.to}): ${lookedLine(s.looked!)}`);
158
+ const head = `=== Absence claims this round wrote, with what was read (${lines.length}): read past where each look stopped — the later sessions, commits and tickets — before you call one Right`;
159
+ return lines.length ? [head, ...lines].join('\n') : `${head}\nNone carries a look: a "no follow-up", "let pass" or "nobody took it up" in a document of this round has to be checked from the start.`;
160
+ }
161
+
162
+ // ───────────────────────── the lanes' Unsure ─────────────────────────
163
+
164
+ export interface UnsureItem {
165
+ /** The lane whose report says it, and the report. */
166
+ readonly lane: string;
167
+ readonly reportId: string;
168
+ /** The line of the report it starts on. */
169
+ readonly line: number;
170
+ readonly text: string;
171
+ }
172
+
173
+ const UNSURE_WORD = /unsure|不确定|拿不准|没把握/i;
174
+ const HEADING = /^(#{1,6})\s+(.*)$/;
175
+ /** Another part of a report named in the same heading or label: where the Unsure part ends. */
176
+ const OTHER_PART = /looked for|not found|not read|proposed|找过|没找到|没读|未读|提议/i;
177
+ /** A bold label at the start of a line or bullet: `**Unsure**:…`, `- **Looked for, not found**: …`. */
178
+ const LABEL = /^\s*(?:[-*+]\s+)?\*\*\s*([^*]{1,60}?)\s*\*\*\s*[::]?\s*(.*)$/;
179
+ const ENUM = /[①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳]/;
180
+ const NOTHING = /^(?:无|没有|none|nothing|n\/a|—|-)[。.\s]*$/i;
181
+ const tidy = (s: string): string => s.replace(/^\s*(?:[-*+]|\d{1,2}[.)、])\s+/, '').replace(/\s+/g, ' ').trim();
182
+
183
+ /** One run of an Unsure part into its items: by its circled numbers when it counts them, else as it stands. */
184
+ function itemsOf(text: string): string[] {
185
+ const t = tidy(text);
186
+ if (!t || NOTHING.test(t)) return [];
187
+ // Counted ①②③: one item each. A lone circled number is a pointer to a section of the report, not a count.
188
+ if (!(t.includes('①') && t.includes('②'))) return [t];
189
+ return t.split(/(?=[①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳])/).map((x) => x.trim().replace(/[;;。]\s*$/, '')).filter((x) => ENUM.test(x.slice(0, 1)));
190
+ }
191
+
192
+ /**
193
+ * The Unsure items of a lane report (the skills' Report format ends with **Unsure**): what stands under a heading that
194
+ * says Unsure — the whole section when the heading names nothing else, the part labelled Unsure when it names other
195
+ * parts too (`## Unsure / Looked for, not found`) — and a bold `**Unsure**` label anywhere, with what follows it up to
196
+ * the next label, bullet of the same list, blank line or heading. A list counted ①②③ is one item each.
197
+ */
198
+ export function unsureIn(markdown: string): { line: number; text: string }[] {
199
+ const lines = markdown.split(/\r?\n/);
200
+ const out: { line: number; text: string }[] = [];
201
+ const seen = new Set<number>();
202
+ const push = (line: number, text: string) => { for (const t of itemsOf(text)) out.push({ line, text: t }); };
203
+ /** A labelled part: from its label's line to the next label, heading or blank line. */
204
+ const labelled = (i: number, rest: string): number => {
205
+ const parts = [rest];
206
+ let j = i + 1;
207
+ for (; j < lines.length; j++) {
208
+ const l = lines[j]!;
209
+ if (!l.trim() || HEADING.test(l) || LABEL.test(l)) break;
210
+ parts.push(l);
211
+ }
212
+ for (let k = i; k < j; k++) seen.add(k);
213
+ // Bullets under the label are items of their own; a run of plain lines is one.
214
+ const bullets = parts.slice(1).filter((l) => /^\s*(?:[-*+]|\d{1,2}[.)、])\s+/.test(l));
215
+ if (bullets.length && bullets.length === parts.length - 1) { if (parts[0]!.trim()) push(i + 1, parts[0]!); bullets.forEach((b, k) => push(i + 2 + k, b)); } else push(i + 1, parts.join(' '));
216
+ return j;
217
+ };
218
+ for (let i = 0; i < lines.length; i++) {
219
+ const h = HEADING.exec(lines[i]!);
220
+ if (h && UNSURE_WORD.test(h[2]!)) {
221
+ const level = h[1]!.length;
222
+ let end = i + 1;
223
+ while (end < lines.length && !(HEADING.test(lines[end]!) && HEADING.exec(lines[end]!)![1]!.length <= level)) end++;
224
+ const mixed = OTHER_PART.test(h[2]!);
225
+ for (let j = i + 1; j < end; j++) {
226
+ const l = lines[j]!;
227
+ if (seen.has(j) || !l.trim()) continue;
228
+ const label = LABEL.exec(l);
229
+ if (label) { if (UNSURE_WORD.test(label[1]!)) j = labelled(j, label[2]!) - 1; else { seen.add(j); if (mixed) { let k = j + 1; while (k < end && lines[k]!.trim() && !LABEL.test(lines[k]!) && !HEADING.test(lines[k]!)) seen.add(k++); j = k - 1; } } continue; }
230
+ if (mixed) continue; // a mixed section's unlabelled lines belong to no part the program can tell
231
+ seen.add(j);
232
+ // A bullet with its continuation lines is one item.
233
+ const parts = [l];
234
+ while (j + 1 < end && lines[j + 1]!.trim() && !/^\s*(?:[-*+]|\d{1,2}[.)、])\s+/.test(lines[j + 1]!) && !LABEL.test(lines[j + 1]!) && !HEADING.test(lines[j + 1]!)) { j++; seen.add(j); parts.push(lines[j]!); }
235
+ push(j - parts.length + 2, parts.join(' '));
236
+ }
237
+ i = end - 1;
238
+ continue;
239
+ }
240
+ }
241
+ for (let i = 0; i < lines.length; i++) {
242
+ if (seen.has(i)) continue;
243
+ const label = LABEL.exec(lines[i]!);
244
+ if (label && UNSURE_WORD.test(label[1]!)) i = labelled(i, label[2]!) - 1;
245
+ }
246
+ return out.sort((a, b) => a.line - b.line);
247
+ }
248
+
249
+ /** The Unsure items of this round's lane reports, by lane, in the order written. */
250
+ export function laneUnsure(store: ProjectStore, round: Pick<ClerkRound, 'id'>): UnsureItem[] {
251
+ const reports: RoundDoc[] = store.roundDocs.filter((d) => d.roundId === round.id && d.kind === 'Report').sort((a, b) => a.at.localeCompare(b.at));
252
+ return reports.flatMap((d) => unsureIn(d.markdown).map((u) => ({ lane: d.path ?? d.title, reportId: d.id, line: u.line, text: u.text })));
253
+ }
254
+
255
+ const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
256
+
257
+ /** The block the synthesis job gets in its task (D103), and the spot-check with its targets. */
258
+ export function unsureBlock(store: ProjectStore, round: Pick<ClerkRound, 'id'>, opts: { readonly forStep?: 'synthesis' | 'spot-check' } = {}): string {
259
+ const items = laneUnsure(store, round);
260
+ const head = `=== What the lanes marked Unsure (${items.length}): each is unsure until someone reads what settles it`;
261
+ if (!items.length) return `${head}\nNo lane report of this round has an Unsure item.`;
262
+ const rule = opts.forStep === 'spot-check'
263
+ ? 'A note, a send-back or the Result that states one of these as fact, without naming what was read to settle it, is Wrong as written: correct it to unsure, or settle it by reading.'
264
+ : 'Before you write one of these into a note, a send-back or the Result: read what settles it, and say what you read (looked) — or write it as unsure, with what was not read. Never restate it as a fact.';
265
+ return [head, rule, ...items.map((u) => `- ${u.lane} (${u.reportId}:${u.line}): ${clip(u.text, 320)}`)].join('\n');
266
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Carried on under the same number (CZ; Spec §2.12, D100 「上几代写去向、不标废弃」).
3
+ *
4
+ * An earlier generation lists the work it planned (`Generation.workIds`). Some of that work went on under its own number:
5
+ * the current plan lists the same contract, and the workbench holds one item for it. That item is current work in its
6
+ * current plan — the generation only shows it, with the destination "carried on". Everywhere else an item a generation
7
+ * lists is earlier work (the process engine lights nothing on it, its steps are history, the code it built is an
8
+ * earlier generation's), so every reader of `workIds` that means "earlier work" asks here: `earlierWork` leaves the
9
+ * carried-on items out.
10
+ *
11
+ * The rule is the item's state, not who wrote the membership (on the CQ run the main agent listed the 27 current
12
+ * contracts in the second generation by hand; from CZ the program lists them itself): the item is not Replaced,
13
+ * Abandoned or Removed, and it carries a number that a current document listing work has as a row, or that a current
14
+ * contract is named by. Which documents list work, and what a row is, are read here off the layer map, the documents'
15
+ * names and the ledger's definitions; generation-check.ts reads a candidate set's numbers with the same rules.
16
+ *
17
+ * Without a ledger nothing is known to be carried on, and every listed item is earlier work, as before.
18
+ */
19
+ import { statSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { Ledger } from '../../ledger/index.ts';
22
+ import { definitionsInPath, definitionsInText } from '../../ledger/numbering.ts';
23
+ import type { LayerKind } from '../../model/k-types.ts';
24
+ import type { ProjectStore } from '../../store/project-store.ts';
25
+ import { entryDefinitions } from './entries.ts';
26
+
27
+ export const slash = (p: string): string => p.replace(/\\/g, '/').replace(/\/+$/, '');
28
+
29
+ /** A directory name under which projects keep what was superseded. */
30
+ const ARCHIVAL = /^(?:archives?|archived|superseded.*|deprecated|obsolete|legacy|old|attic|history|_?deleted?|归档|旧版?本?|废弃|历史)$/i;
31
+
32
+ /**
33
+ * The archived set a path belongs to, or null: the archival directory and the dated or named set under it
34
+ * (`design/archive/superseded-by-v0.4-20260916/…` is one set); a document directly in the archive belongs to the archive
35
+ * itself (`subagent/archive/plan.md`). `dir` says the path is a directory, so its last segment may be the set.
36
+ */
37
+ export function archiveRootOf(path: string, dir = false): string | null {
38
+ const segs = slash(path).split('/');
39
+ const i = segs.findIndex((s) => ARCHIVAL.test(s));
40
+ if (i < 0) return null;
41
+ const hasSet = dir ? i + 1 < segs.length : i + 2 < segs.length;
42
+ return segs.slice(0, hasSet ? i + 2 : i + 1).join('/');
43
+ }
44
+
45
+ /** The layers whose documents list work as rows — a plan's tables, a task index, a contract set, an execution arrangement. */
46
+ const ROW_LAYERS: ReadonlySet<LayerKind> = new Set<LayerKind>(['Plan', 'Task index', 'Task contract', 'Execution arrangement']);
47
+ const SEP = String.raw`[\/_.\s-]`;
48
+ /** A name holding one of the words as a word of its own (between separators), or one of the CJK words anywhere. */
49
+ export const nameSays = (words: string, cjk: string): RegExp => new RegExp(`(?:^|${SEP})(?:${words})(?:${SEP}|$)|${cjk}`, 'i');
50
+ /** A name that says the document lists work: a plan, a roadmap, an index, a set of contracts or tasks. */
51
+ const ROW_NAME = nameSays('plans?|roadmap|backlog|milestones?|index|contracts?|tasks?|tickets?|issues?|todo', '计划|合同|任务|路线图|索引|清单');
52
+ /** A name that says it is something else: a readme, requirements, a specification, modules, a design, decisions, a report. */
53
+ const OTHER_NAME = nameSays('readme|prd|specs?|product|modules?|requirements?|designs?|decisions?|adrs?|notes?|reports?|reviews?|changelog', '需求|模块|决定|决策|设计|说明');
54
+
55
+ /** The layer entry over a path: the file's own, else the longest directory above it. */
56
+ export function layerEntryOver(store: ProjectStore, path: string) {
57
+ return store.layers.filter((l) => slash(l.path) === path || path.startsWith(`${slash(l.path)}/`)).sort((a, b) => slash(b.path).length - slash(a.path).length)[0] ?? null;
58
+ }
59
+
60
+ /**
61
+ * Whether a document lists work as its rows. The layer the orientation mapped the file itself to decides; else its
62
+ * name, then the names of the directories above it — inside its set when `root` is one, nearest first; else the layer of
63
+ * the directory the layer map names.
64
+ */
65
+ export function listsWork(store: ProjectStore, root: string | null, path: string): boolean {
66
+ const own = store.layers.find((l) => slash(l.path) === path && /\.[A-Za-z0-9]{1,6}$/.test(path));
67
+ if (own) return ROW_LAYERS.has(own.layer);
68
+ const inside = root === null ? path.split('/') : path === root ? path.split('/').slice(-1) : path.slice(root.length + 1).split('/');
69
+ for (const seg of [...inside].reverse()) {
70
+ if (ROW_NAME.test(seg)) return true;
71
+ if (OTHER_NAME.test(seg)) return false;
72
+ }
73
+ const over = layerEntryOver(store, path);
74
+ return over ? ROW_LAYERS.has(over.layer) : false;
75
+ }
76
+
77
+ export interface DefRow { readonly num: string; readonly rule: string; readonly repo: string | null; readonly path: string; readonly position: string | null; readonly kind: string; readonly current: number }
78
+ export interface LedgerDefs { readonly rows: readonly DefRow[]; readonly byNum: ReadonlyMap<string, readonly DefRow[]>; readonly repoPaths: ReadonlyMap<string, string>; readonly entries: Map<string, readonly { num: string; rule: string; position: string }[] | null> }
79
+ /** Every definition the ledger holds in a document or a file name, read once per opened ledger (a few thousand rows). */
80
+ const DEFS = new WeakMap<Ledger, LedgerDefs>();
81
+ export function ledgerDefs(ledger: Ledger): LedgerDefs {
82
+ let d = DEFS.get(ledger);
83
+ if (!d) {
84
+ const rows = (ledger.db.prepare("SELECT DISTINCT num, rule, repo, path, position, kind, current FROM nums WHERE place = 'definition' AND kind IN ('doc', 'file-name') AND path IS NOT NULL").all() as unknown as DefRow[]);
85
+ const byNum = new Map<string, DefRow[]>();
86
+ for (const r of rows) byNum.set(r.num, [...(byNum.get(r.num) ?? []), r]);
87
+ d = { rows, byNum, repoPaths: new Map(ledger.repos().map((r) => [r.id, r.path])), entries: new Map() };
88
+ DEFS.set(ledger, d);
89
+ }
90
+ return d;
91
+ }
92
+
93
+ /**
94
+ * A document's own entries as it stands in the checkout — its definitions at entry positions, less the points of another
95
+ * numbered entry (entries.ts) — read from the text the ledger keeps; null when the ledger keeps no current text of it.
96
+ */
97
+ export function currentEntries(ledger: Ledger, defs: LedgerDefs, repo: string | null, path: string): readonly { num: string; rule: string; position: string }[] | null {
98
+ const k = `${repo ?? ''}\u0000${path}`;
99
+ if (!defs.entries.has(k)) {
100
+ const text = repo ? ledger.currentText(`${defs.repoPaths.get(repo) ?? ''}/${path}`) : null;
101
+ defs.entries.set(k, text === null ? null : entryDefinitions(
102
+ definitionsInText(text).filter((d) => d.position === 'heading' || d.position === 'bold entry' || d.position === 'table first column').map((d) => ({ num: d.num, rule: d.family, position: d.position, line: d.line ?? 0 })),
103
+ text.split(/\r?\n/),
104
+ ));
105
+ }
106
+ return defs.entries.get(k)!;
107
+ }
108
+
109
+ /** The positions a row of a plan stands at: a heading, or the first cell of a table row (a bold line is a log's entry). */
110
+ export const ROW_POSITIONS: ReadonlySet<string> = new Set(['heading', 'table first column']);
111
+
112
+ export interface CurrentRows {
113
+ readonly defs: LedgerDefs;
114
+ /** A document that is not current: under an archive, mapped not current, or under one of the roots given. */
115
+ readonly setAside: (path: string) => boolean;
116
+ /** A document named by a number: that item's own document, whose rows are its points. */
117
+ readonly numberNamed: (path: string) => boolean;
118
+ /** A file-name definition that is the file's own name (not a directory above it). */
119
+ readonly namedByOwnNumber: (r: DefRow) => boolean;
120
+ /** The current documents that list a number as a row of their own. */
121
+ readonly currentRowsOf: (n: string) => DefRow[];
122
+ /**
123
+ * Carried on: a current document that lists work has the number as a row, or a current file among such documents is
124
+ * named by it (a contract in the contract set). A report that tabulates it, or one named after it, does not carry it on.
125
+ */
126
+ readonly isCurrent: (n: string) => boolean;
127
+ }
128
+
129
+ /** What the current documents list as rows, read off the ledger. `alsoAside` are roots to treat as set aside besides the archives. */
130
+ export function currentRows(store: ProjectStore, ledger: Ledger, alsoAside: readonly string[] = []): CurrentRows {
131
+ const defs = ledgerDefs(ledger);
132
+ const aside = new Map<string, boolean>();
133
+ const setAside = (path: string): boolean => {
134
+ if (!aside.has(path)) aside.set(path, alsoAside.some((x) => path === x || path.startsWith(`${x}/`)) || archiveRootOf(path) !== null || layerEntryOver(store, path)?.current === false);
135
+ return aside.get(path)!;
136
+ };
137
+ const namedByOwnNumber = (r: DefRow): boolean => r.kind === 'file-name' && definitionsInPath(r.path.split('/').pop() ?? r.path).some((d) => d.num === r.num);
138
+ const numbered = new Map<string, boolean>();
139
+ const numberNamed = (path: string): boolean => {
140
+ if (!numbered.has(path)) numbered.set(path, definitionsInPath(path.split('/').pop() ?? path).some((d) => (defs.byNum.get(d.num) ?? []).some((r) => r.kind === 'file-name' && r.path === path)));
141
+ return numbered.get(path)!;
142
+ };
143
+ const currentRowsOf = (n: string): DefRow[] => (defs.byNum.get(n) ?? []).filter((d) => d.kind === 'doc' && d.current === 1 && !setAside(d.path) && ROW_POSITIONS.has(d.position ?? '')
144
+ && (currentEntries(ledger, defs, d.repo, d.path)?.some((e) => e.num === n && ROW_POSITIONS.has(e.position)) ?? true));
145
+ const listed = new Map<string, boolean>();
146
+ const isCurrent = (n: string): boolean => {
147
+ if (!listed.has(n)) listed.set(n, currentRowsOf(n).some((d) => !numberNamed(d.path) && listsWork(store, null, d.path))
148
+ || (defs.byNum.get(n) ?? []).some((d) => d.kind === 'file-name' && d.current === 1 && !setAside(d.path) && namedByOwnNumber(d) && listsWork(store, null, d.path)));
149
+ return listed.get(n)!;
150
+ };
151
+ return { defs, setAside, numberNamed, namedByOwnNumber, currentRowsOf, isCurrent };
152
+ }
153
+
154
+ const ENDED: ReadonlySet<string> = new Set(['Replaced', 'Abandoned', 'Removed']);
155
+
156
+ /**
157
+ * The work items a generation lists that were carried on under the same number (see the module comment): current work,
158
+ * whatever generation lists them and whoever listed them. Empty without a ledger.
159
+ */
160
+ export function carriedOnItems(store: ProjectStore, ledger: Ledger | null): Set<string> {
161
+ const out = new Set<string>();
162
+ const listed = new Set(store.generations.all().flatMap((g) => g.workIds));
163
+ if (!ledger || !listed.size) return out;
164
+ let rows: CurrentRows;
165
+ try { rows = currentRows(store, ledger); } catch { return out; }
166
+ for (const id of listed) {
167
+ const t = store.threads.get(id);
168
+ if (!t || ENDED.has(t.validity)) continue;
169
+ if (t.ids.some((i) => rows.isCurrent(i.trim().toUpperCase()) || rows.isCurrent(i.trim()))) out.add(id);
170
+ }
171
+ return out;
172
+ }
173
+
174
+ const CACHE = new WeakMap<ProjectStore, { mark: string; carried: Set<string> }>();
175
+ const fileMark = (path: string): string => { try { const s = statSync(path); return `${s.size}:${Math.round(s.mtimeMs)}`; } catch { return '-'; } };
176
+
177
+ /**
178
+ * `carriedOnItems` for a reader that has only the store: the project's ledger is opened here, and the answer is kept
179
+ * until the generations, their items, the layer map or the ledger change.
180
+ */
181
+ export function carriedOn(store: ProjectStore): Set<string> {
182
+ const gens = store.generations.all();
183
+ if (!gens.some((g) => g.workIds.length)) return new Set();
184
+ const mark = [
185
+ fileMark(join(store.dir, 'ledger.sqlite')), fileMark(join(store.dir, 'ledger.sqlite-wal')),
186
+ gens.map((g) => `${g.id}:${g.workIds.map((id) => { const t = store.threads.get(id); return t ? `${id}/${t.validity}/${t.ids.join(',')}` : id; }).join(';')}`).join('|'),
187
+ store.layers.all().map((l) => `${l.path}/${l.layer}/${l.current ? 1 : 0}`).join('|'),
188
+ ].join('#');
189
+ const hit = CACHE.get(store);
190
+ if (hit && hit.mark === mark) return hit.carried;
191
+ let ledger: Ledger | null = null;
192
+ try { ledger = Ledger.openDir(store.dir); } catch { ledger = null; }
193
+ let carried = new Set<string>();
194
+ try { carried = carriedOnItems(store, ledger); } catch { carried = new Set(); } finally { ledger?.close(); }
195
+ CACHE.set(store, { mark, carried });
196
+ return carried;
197
+ }
198
+
199
+ /**
200
+ * The work of earlier generations: what the generations list, less what was carried on under the same number. This is
201
+ * what "in an earlier generation" means to every reader that treats such work as history.
202
+ */
203
+ export function earlierWork(store: ProjectStore): Set<string> {
204
+ const listed = store.generations.all().flatMap((g) => g.workIds);
205
+ if (!listed.length) return new Set();
206
+ const carried = carriedOn(store);
207
+ return new Set(listed.filter((id) => !carried.has(id)));
208
+ }
209
+
210
+ /** The earlier generation a work item belongs to as earlier work, or undefined: a carried-on item belongs to none. */
211
+ export function earlierGenerationOf(store: ProjectStore, workId: string) {
212
+ const g = store.generations.find((x) => x.workIds.includes(workId));
213
+ return g && !carriedOn(store).has(workId) ? g : undefined;
214
+ }