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,832 @@
1
+ /**
2
+ * The takeover by the clerk method (Spec v3.0 §3.7): the first usable round's own figures, what a deepening reads path
3
+ * by path — counted from the ledger, by material category, with what the organizing plan says to read closely (D62) —
4
+ * the three depth options with an estimate on a stated basis, the depth question's note, and the deepening's progress
5
+ * path by path with what each path actually read against its plan (CKC-13 AC-8). Every number comes from the ledger, the
6
+ * jobs or the assets; no model writes one.
7
+ */
8
+ import { statSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import type { ClerkRound, ReadingCategory, ReadingMaterial, RoundDoc, StageEntry } from '../../model/k-types.ts';
11
+ import type { DeepeningActual, DeepeningPlan, DeepeningReads, DepthOption, KeeperJob, OwnerWordsStep, Project, TraceEntry } from '../../model/types.ts';
12
+ import { OWNER_WORDS } from '../../model/vocab.ts';
13
+ import type { ProjectStore } from '../../store/project-store.ts';
14
+ import { readJsonLines } from '../../store/json-file.ts';
15
+ import type { Ledger } from '../../ledger/index.ts';
16
+ import { ownerUtterances } from '../../sources/sessions/utterances.ts';
17
+ import { isWithin, pathKey } from '../../util/paths.ts';
18
+ import { DEEPENING_PATHS, sweepKindsOf, type SweepKind } from './clerk-prompts.ts';
19
+ import { roundDepth } from './takeover-state.ts';
20
+ import { extentOf, tallyReads, type ReadTally } from './clerk-coverage.ts';
21
+ import { currentDocuments, oneVersionPerDocument } from './coverage-tools.ts';
22
+ import {
23
+ closelyPath, KIND_CATEGORY, namedInBrief, ORGANIZING_PLAN_ID, pathJobs, pathReadingOf, readCloselyNow, readsOf as materialReads, sameCommit, settlingDirs, underAny,
24
+ type MaterialRead, type PathSlot, type ReadClosely,
25
+ } from './reading.ts';
26
+
27
+ /** §3.7 stage 4: the four paths a deepening digs, each over the whole history (the four kinds of question, clerk-prompts.ts). */
28
+ export { DEEPENING_PATHS };
29
+ /** The ledger's file languages that are documents: counted with the document chain, not as code. */
30
+ const DOCUMENT_LANGUAGES = new Set(['markdown', 'text']);
31
+
32
+ const minutesOf = (from: string | null | undefined, to: string | null | undefined): number | null =>
33
+ from && to ? Math.max(0, Math.round((Date.parse(to) - Date.parse(from)) / 60_000)) : null;
34
+
35
+ /**
36
+ * What a round's jobs read, from the calls the runtime recorded on each job (`JobStep.reads`; a step recorded before
37
+ * reads were kept counts by its target): files read whole and in part — by the file tool, the shell or the ledger's
38
+ * document tools — sources, ledger queries.
39
+ */
40
+ function readsOf(jobs: readonly KeeperJob[], project: Project): { whole: number; part: number; sources: number; ledgerQueries: number } {
41
+ const tally = tallyReads(jobs, project);
42
+ let whole = 0, part = 0;
43
+ for (const f of tally.files.values()) { const e = extentOf(f); if (e === 'whole') whole += 1; else if (e === 'part') part += 1; }
44
+ let ledgerQueries = 0;
45
+ for (const j of jobs) for (const s of j.steps) if (!s.isError && s.tool.startsWith('pk_ledger_')) ledgerQueries++;
46
+ return { whole, part, sources: tally.sources.size, ledgerQueries };
47
+ }
48
+
49
+ /** The first usable round's own figures: its time, its cost, what it read, and its owner's-words step (D37). */
50
+ export function firstUsableFigures(store: ProjectStore, round: ClerkRound | undefined, ledger: Ledger | null, project?: Project): {
51
+ minutes: number | null; cost: number | null; byKind: Record<string, number>; itemsRead: number; ownerWords: OwnerWordsStep | null;
52
+ } {
53
+ if (!round) return { minutes: null, cost: null, byKind: {}, itemsRead: 0, ownerWords: null };
54
+ const jobs = store.jobs.filter((j) => j.step?.roundId === round.id);
55
+ const model = jobs.filter((j) => j.agent !== 'program');
56
+ const costs = model.map((j) => j.usage.cost);
57
+ const cost = costs.length && costs.every((c) => c != null) ? costs.reduce((n, c) => n + c!, 0) : null;
58
+ const reads = readsOf(model, project ?? { locations: [] } as unknown as Project);
59
+ const draftJobs = model.filter((j) => j.step!.kind === 'session-drafts');
60
+ const draftJobIds = new Set(draftJobs.map((j) => j.id));
61
+ const drafts = store.drafts.filter((d) => d.jobId !== null && draftJobIds.has(d.jobId));
62
+ const lines = drafts.flatMap((d) => d.ownerLines);
63
+ const byKind: Record<string, number> = {};
64
+ if (reads.whole) byKind['files read in full'] = reads.whole;
65
+ if (reads.part) byKind['files read in part'] = reads.part;
66
+ if (reads.sources) byKind['sources read'] = reads.sources;
67
+ if (reads.ledgerQueries) byKind['ledger queries'] = reads.ledgerQueries;
68
+ if (lines.length) byKind["owner's lines drafted"] = lines.length;
69
+ let ownerWords: OwnerWordsStep | null = null;
70
+ if (draftJobs.length) {
71
+ const startedAt = draftJobs.map((j) => j.startedAt ?? j.queuedAt).sort()[0]!;
72
+ const endedAt = draftJobs.map((j) => j.endedAt ?? j.startedAt ?? j.queuedAt).sort().pop()!;
73
+ const { total, totalChars } = ownerMessagesTotal(store, ledger);
74
+ ownerWords = {
75
+ startedAt, endedAt, minutes: minutesOf(startedAt, endedAt) ?? 0, calls: draftJobs.length,
76
+ utterances: lines.length, chars: lines.reduce((n, l) => n + l.text.length, 0), total, totalChars,
77
+ items: ownerWordsItemsOf(store, round, jobs),
78
+ };
79
+ }
80
+ return { minutes: minutesOf(round.startedAt, round.endedAt), cost, byKind, itemsRead: reads.whole + reads.part + reads.sources + lines.length, ownerWords };
81
+ }
82
+
83
+ /**
84
+ * How many messages the owner wrote in the project's sessions, and their characters — the "of Y" of the owner's-words
85
+ * step (D37). The ledger's count when it has read the sessions; else the program's own reading of the same session
86
+ * logs, the one the drafts step takes the owner's words from (`ownerUtterances`), without the Keeper conversation, which
87
+ * the ledger does not hold either. It used to be 0 whenever the ledger was not there, which read "12 of 0" (QC AY).
88
+ */
89
+ function ownerMessagesTotal(store: ProjectStore, ledger: Ledger | null): { total: number; totalChars: number } {
90
+ const all = ledger ? ledger.ownerWords({ limit: 1 }) : null;
91
+ if (all && typeof all !== 'string' && all.total > 0) return { total: all.total, totalChars: all.totalChars };
92
+ const set = ownerUtterances(store);
93
+ if (typeof set === 'string') return { total: 0, totalChars: 0 };
94
+ const own = set.utterances.filter((u) => !u.headless && u.host !== 'pi');
95
+ return { total: own.length, totalChars: own.reduce((n, u) => n + u.text.length, 0) };
96
+ }
97
+
98
+ /** The Owner's words items a round's jobs wrote, from the store's trace, read again only when the trace grew. */
99
+ const ownerItemsSeen = new Map<string, { size: number; n: number }>();
100
+
101
+ /**
102
+ * How many `Owner's words` items the round wrote (the skeleton writes them from the drafts' decisions and
103
+ * confirmations, §3.3): what the owner's-words step's "wrote N Owner's words items" says. It used to count the session
104
+ * drafts instead, which are the step's ground, not its items (QC AY).
105
+ */
106
+ function ownerWordsItemsOf(store: ProjectStore, round: ClerkRound, jobs: readonly KeeperJob[]): number {
107
+ const file = join(store.dir, 'trace.jsonl');
108
+ let size = 0;
109
+ try { size = statSync(file).size; } catch { return 0; }
110
+ const key = `${store.dir}\0${round.id}`;
111
+ const seen = ownerItemsSeen.get(key);
112
+ if (seen && seen.size === size) return seen.n;
113
+ const jobIds = new Set([round.rootJobId, ...jobs.map((j) => j.id)]);
114
+ const ids = new Set<string>();
115
+ for (const e of readJsonLines<TraceEntry>(file)) if (e.op === 'put' && e.collection === 'reference' && e.jobId && jobIds.has(e.jobId)) ids.add(e.id);
116
+ const n = [...ids].filter((id) => store.reference.get(id)?.category === OWNER_WORDS).length;
117
+ ownerItemsSeen.set(key, { size, n });
118
+ return n;
119
+ }
120
+
121
+ /**
122
+ * The project's documents as they are now, one material each (Spec §3.3: 一份文档的各个旧版本不单列; §3.7: 材料按文件、提交、
123
+ * 会话算), and those the project's own material rules settle (§1.15: obsolete, reference only, recovery only): they are
124
+ * judged by the rule, not read closely, so the depth question counts them apart.
125
+ */
126
+ function documentTotals(ledger: Ledger, store: ProjectStore): { documents: number; settled: number } {
127
+ const settling = settlingDirs(store);
128
+ const docs = currentDocuments(ledger);
129
+ const settled = docs.filter((d) => underAny(settling, d.row.path)).length;
130
+ return { documents: docs.length - settled, settled };
131
+ }
132
+
133
+ /** One path of a deepening with what it reads, by material category; `materials`: how many of them are read closely. */
134
+ type PathReads = DeepeningPlan['paths'][number] & { readonly materials: number };
135
+
136
+ /** §3.7: what each of the four kinds of question reads across the whole history, counted from the ledger's totals. */
137
+ function kindTotals(ledger: Ledger, store: ProjectStore): Record<(typeof DEEPENING_PATHS)[number], PathReads> {
138
+ const cov = ledger.coverage();
139
+ const docs = documentTotals(ledger, store);
140
+ const sum = (f: (r: (typeof cov.repos)[number]) => number) => cov.repos.reduce((n, r) => n + f(r), 0);
141
+ const deleted = ledger.deletedDocs({ limit: 1 });
142
+ const deletedCount = typeof deleted === 'string' ? 0 : deleted.total;
143
+ const code = cov.languages.filter((l) => !DOCUMENT_LANGUAGES.has(l.language));
144
+ const codeFiles = code.reduce((n, l) => n + l.files, 0);
145
+ const codeLines = code.reduce((n, l) => n + l.lines, 0);
146
+ const keep = (reads: { category: string; count: number }[]) => reads.filter((r) => r.count > 0);
147
+ const basis = "the ledger's totals for this kind of question";
148
+ // What is read closely, kind by kind — together the materials a Full deepening reads: the documents as they are now
149
+ // (a document is one material; its older versions are looked up by question) less what the rules settle, commits on
150
+ // every ref, sessions, code files.
151
+ return {
152
+ [DEEPENING_PATHS[0]]: { path: DEEPENING_PATHS[0], basis, materials: cov.sessions.sessions, reads: keep([
153
+ { category: 'sessions', count: cov.sessions.sessions },
154
+ { category: "owner's messages", count: cov.sessions.ownerMessages },
155
+ { category: 'deleted documents', count: deletedCount },
156
+ { category: 'commits off the trunk', count: sum((r) => r.commits - r.trunkCommits) },
157
+ ]) },
158
+ [DEEPENING_PATHS[1]]: { path: DEEPENING_PATHS[1], basis, materials: docs.documents, reads: keep([
159
+ { category: 'documents', count: docs.documents },
160
+ { category: "documents settled by the project's rules (judged by the rule, not read closely)", count: docs.settled },
161
+ { category: 'written supersessions', count: sum((r) => r.supersessions) },
162
+ ]) },
163
+ [DEEPENING_PATHS[2]]: { path: DEEPENING_PATHS[2], basis, materials: sum((r) => r.commits), reads: keep([
164
+ { category: 'execution arrangements (versions)', count: sum((r) => r.arrangements.versions) },
165
+ { category: 'verdicts in reports', count: sum((r) => r.verdicts) },
166
+ { category: 'trunk commits', count: sum((r) => r.trunkCommits) },
167
+ { category: 'merges', count: sum((r) => r.merges) },
168
+ ]) },
169
+ [DEEPENING_PATHS[3]]: { path: DEEPENING_PATHS[3], basis, materials: codeFiles, reads: keep([
170
+ { category: `code files (${codeLines} lines)`, count: codeFiles },
171
+ ]) },
172
+ } as Record<(typeof DEEPENING_PATHS)[number], PathReads>;
173
+ }
174
+
175
+ /** The paths and commits a brief names (reading.ts, where the organizing plan's entries are read the same way). */
176
+ export { namedInBrief };
177
+
178
+ /** The sessions a brief names (`namedInBrief`): their logs under an absolute path it names, or their native id's start. */
179
+ function sessionsNamed(ledger: Ledger, n: ReturnType<typeof namedInBrief>): string[] {
180
+ try { return ledger.namedSessions(n.absolute, n.commits); } catch { return []; }
181
+ }
182
+
183
+ /** The kinds of question the briefs cover, by their names (`sweepKindsOf`), and the kinds none of them does. */
184
+ function coveredKinds(briefs: readonly Pick<RoundDoc, 'path' | 'title'>[]): { covered: Set<SweepKind>; missing: SweepKind[] } {
185
+ const covered = new Set<SweepKind>();
186
+ for (const b of briefs) for (const k of sweepKindsOf(b.path ?? b.title)) covered.add(k);
187
+ return { covered, missing: DEEPENING_PATHS.filter((k) => !covered.has(k)) };
188
+ }
189
+
190
+ /** The briefs a deepening is counted by: the latest finished first usable round's. */
191
+ function deepeningBriefs(store: ProjectStore) {
192
+ const first = store.clerkRounds.filter((r) => r.kind === 'First usable' && r.status === 'Done').sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0];
193
+ return first ? store.roundDocs.filter((d) => d.roundId === first.id && d.kind === 'Brief').sort((a, b) => a.at.localeCompare(b.at)) : [];
194
+ }
195
+
196
+ /**
197
+ * §3.7: what a Full deepening reads, path by path, by material category — "按问题清单算" (QC AY). Each sweep orientation
198
+ * set is counted by what its brief names: the documents at or under the paths it names, each once however many versions
199
+ * it has (D99; less what the rules settle, said apart), the source files there, the commits it names. A brief that names nothing the ledger can
200
+ * count is counted by the ledger's totals for its kind of question; a kind no brief covers is counted by its totals too,
201
+ * since the deepening answers each of the four kinds with a lane (CKC-23 AC-4; `pk_stage` holds the dig to it). With no
202
+ * brief yet, the four kinds by the ledger's totals.
203
+ *
204
+ * `materials`, what Full reads closely, counts each material once: what several briefs name, once; and a category a
205
+ * kind is counted whole for (all current documents, all commits, all sessions, all code files) as its ledger total,
206
+ * which holds whatever the briefs named of it.
207
+ */
208
+ export function deepeningPlanPaths(ledger: Ledger | null, store: ProjectStore, plan: DeepeningPlanned = deepeningPlanned(ledger, store)): { paths: PathReads[]; materials: number } {
209
+ return { paths: plan.paths, materials: plan.materials };
210
+ }
211
+
212
+ /** What a brief names that its path reads closely: documents, one version each (less what the rules settle), code files, commits, sessions. */
213
+ interface Named {
214
+ readonly versions: readonly { readonly key: string; readonly path: string }[];
215
+ readonly codeFiles: readonly { readonly key: string; readonly lines: number }[];
216
+ readonly commits: readonly string[];
217
+ /** The sessions it names (their logs under a path it names, or their native id): the ledger's session ids. */
218
+ readonly sessions?: readonly string[];
219
+ }
220
+ /** One path of the plan: counted from what its brief names (`named`), or from the ledger's totals for its kinds of question. */
221
+ type PlannedPath = PathReads & {
222
+ readonly named: Named | null;
223
+ readonly kinds: readonly SweepKind[];
224
+ /**
225
+ * What the organizing plan says to read closely that goes on this path (§3.7, D62; reading.ts `closelyPath`): `all` of
226
+ * it — what a Focused deepening plans here — and `added`, what of it the path did not plan already, which Full reads too.
227
+ */
228
+ readonly closely?: { readonly all: Named; readonly added: Named } | null;
229
+ };
230
+ type NamedSets = { readonly versions: ReadonlySet<string>; readonly codeFiles: ReadonlySet<string>; readonly commits: ReadonlySet<string>; readonly sessions?: ReadonlySet<string> };
231
+ export interface DeepeningPlanned {
232
+ readonly paths: PlannedPath[];
233
+ readonly materials: number;
234
+ readonly totals: Record<SweepKind, PathReads> | null;
235
+ /** The kinds counted whole, by the ledger's totals: their category is planned whole. */
236
+ readonly wholeKinds: ReadonlySet<SweepKind>;
237
+ /** What the briefs named, and what the organizing plan adds to read closely, each once. */
238
+ readonly named: NamedSets;
239
+ /**
240
+ * What the organizing plan says to read closely (reading.ts `readCloselyOf`): each material's entry in the plan's words,
241
+ * the targets the ledger has nothing for, and what it adds to the paths — for a kind counted whole, what its rules settle.
242
+ */
243
+ readonly closely: Pick<ReadClosely, 'what' | 'unresolved'> & { readonly added: NamedSets };
244
+ }
245
+
246
+ const NO_NAMED: NamedSets = { versions: new Set(), codeFiles: new Set(), commits: new Set(), sessions: new Set() };
247
+ const namedSize = (n: Named | null | undefined): number => (n ? n.versions.length + n.codeFiles.length + n.commits.length + (n.sessions?.length ?? 0) : 0);
248
+
249
+ /** Two lists of what a path reads, as one, each material once. */
250
+ function mergeNamed(a: Named | null, b: Named | null | undefined): Named | null {
251
+ if (!namedSize(b)) return a;
252
+ if (!a) return b!;
253
+ const once = <T>(xs: readonly T[], key: (x: T) => string): T[] => [...new Map(xs.map((x) => [key(x), x] as const)).values()];
254
+ return {
255
+ versions: once([...a.versions, ...b!.versions], (v) => v.key), codeFiles: once([...a.codeFiles, ...b!.codeFiles], (f) => f.key),
256
+ commits: [...new Set([...a.commits, ...b!.commits])], sessions: [...new Set([...(a.sessions ?? []), ...(b!.sessions ?? [])])],
257
+ };
258
+ }
259
+
260
+ /**
261
+ * What the organizing plan says to read closely, put on the plan's paths (Spec §3.7, D62; reading.ts `closelyPath`): the
262
+ * path that plans it already — its brief names it, or it plans the whole category (what the rules settle is outside
263
+ * that) — else the first path of its kind of question. What of it a path did not plan already is added to what it reads:
264
+ * its count, and a row of its own among its reads.
265
+ */
266
+ function withReadClosely(paths: readonly PlannedPath[], closely: ReadClosely, settling: readonly string[]): PlannedPath[] {
267
+ if (!closely.what.size) return [...paths];
268
+ const keysOf = (n: Named | null) => new Set(n ? [...n.versions.map((v) => v.key), ...n.codeFiles.map((f) => f.key), ...n.commits.map((c) => `commit:${c}`), ...(n.sessions ?? [])] : []);
269
+ const slots: PathSlot[] = paths.map((p) => ({ kinds: p.kinds.length ? p.kinds : sweepKindsOf(p.path), named: keysOf(p.named), whole: new Set(p.kinds.map((k) => KIND_CATEGORY[k])) }));
270
+ type Lists = { versions: { key: string; path: string }[]; codeFiles: { key: string; lines: number }[]; commits: string[]; sessions: string[] };
271
+ const lists = (): Lists => ({ versions: [], codeFiles: [], commits: [], sessions: [] });
272
+ const all = paths.map(lists);
273
+ const added = paths.map(lists);
274
+ const put = (key: string, category: ReadingCategory, settled: boolean, into: (l: Lists) => void) => {
275
+ const i = closelyPath(slots, { key, category, settled });
276
+ if (i < 0) return;
277
+ into(all[i]!);
278
+ if (!slots[i]!.named.has(key) && (settled || !slots[i]!.whole.has(category))) into(added[i]!);
279
+ };
280
+ for (const v of closely.versions) put(v.key, 'document versions', underAny(settling, v.path), (l) => l.versions.push(v));
281
+ for (const f of closely.codeFiles) put(f.key, 'code files', false, (l) => l.codeFiles.push(f));
282
+ for (const c of closely.commits) put(`commit:${c}`, 'commits', false, (l) => l.commits.push(c));
283
+ for (const s of closely.sessions) put(s, 'sessions', false, (l) => l.sessions.push(s));
284
+ return paths.map((p, i) => {
285
+ if (!namedSize(all[i]!)) return p;
286
+ const n = namedSize(added[i]!);
287
+ return { ...p, closely: { all: all[i]!, added: added[i]! }, materials: p.materials + n, reads: n ? [...p.reads, { category: 'materials the organizing plan says to read closely', count: n }] : p.reads };
288
+ });
289
+ }
290
+
291
+ /** What the organizing plan adds to the paths, each once, by category. */
292
+ function addedByPlan(paths: readonly PlannedPath[]): NamedSets {
293
+ const of = (pick: (n: Named) => readonly string[]) => new Set(paths.flatMap((p) => (p.closely ? pick(p.closely.added) : [])));
294
+ return { versions: of((n) => n.versions.map((v) => v.key)), codeFiles: of((n) => n.codeFiles.map((f) => f.key)), commits: of((n) => n.commits), sessions: of((n) => n.sessions ?? []) };
295
+ }
296
+
297
+ /** `deepeningPlanPaths`, keeping what each path names so what was read can be held against it. */
298
+ /**
299
+ * The plan a deepening is counted and held against, computed once per coverage pass and handed to what needs it: counting
300
+ * it reads the ledger's totals, which is the slow part (about half a second on a project the size of ContextKeeper).
301
+ */
302
+ export function deepeningPlanned(ledger: Ledger | null, store: ProjectStore): DeepeningPlanned {
303
+ if (!ledger) return { paths: [], materials: 0, totals: null, wholeKinds: new Set(), named: NO_NAMED, closely: { what: new Map(), unresolved: [], added: NO_NAMED } };
304
+ const totals = kindTotals(ledger, store);
305
+ const briefs = deepeningBriefs(store);
306
+ const settling = settlingDirs(store);
307
+ // What the organizing plan says to read closely goes on the paths too (§3.7, D62): Full reads it with the rest. A
308
+ // document is one material, however many of its versions a target reaches (§3.3).
309
+ const closelyAll = readCloselyNow(ledger, store);
310
+ const closely: ReadClosely = { ...closelyAll, versions: oneVersionPerDocument(ledger, closelyAll.versions) };
311
+ const closelyOf = (paths: readonly PlannedPath[]) => ({ what: closely.what, unresolved: closely.unresolved, added: addedByPlan(paths) });
312
+ if (briefs.length === 0) {
313
+ const paths = withReadClosely(DEEPENING_PATHS.map((k) => ({ ...totals[k], named: null, kinds: [k] })), closely, settling);
314
+ const added = addedByPlan(paths);
315
+ return { paths, materials: paths.reduce((n, p) => n + p.materials, 0), totals, wholeKinds: new Set(DEEPENING_PATHS), named: added, closely: closelyOf(paths) };
316
+ }
317
+ const versions = new Set<string>(), files = new Set<string>(), commits = new Set<string>(), sessions = new Set<string>();
318
+ const paths: PlannedPath[] = [];
319
+ const counted = new Set<SweepKind>();
320
+ for (const b of briefs) {
321
+ const name = b.path ?? b.title;
322
+ const inBrief = namedInBrief(b.markdown);
323
+ const named = ledger.named(inBrief);
324
+ const namedSessions = sessionsNamed(ledger, inBrief);
325
+ // A document is one material, however many of its versions the brief's paths reach (§3.3).
326
+ const documents = oneVersionPerDocument(ledger, named.versions);
327
+ const close = documents.filter((v) => !underAny(settling, v.path));
328
+ const settled = documents.length - close.length;
329
+ const lines = named.codeFiles.reduce((n, f) => n + f.lines, 0);
330
+ const reads = [
331
+ { category: 'documents', count: close.length },
332
+ { category: "documents settled by the project's rules (judged by the rule, not read closely)", count: settled },
333
+ { category: `code files (${lines} lines)`, count: named.codeFiles.length },
334
+ { category: 'commits named', count: named.commits.length },
335
+ { category: 'sessions named', count: namedSessions.length },
336
+ ].filter((r) => r.count > 0);
337
+ if (reads.length) {
338
+ for (const v of close) versions.add(v.key);
339
+ for (const f of named.codeFiles) files.add(f.key);
340
+ for (const c of named.commits) commits.add(c);
341
+ for (const s of namedSessions) sessions.add(s);
342
+ paths.push({ path: name, reads, basis: 'counted from what its brief names', materials: close.length + named.codeFiles.length + named.commits.length + namedSessions.length, named: { versions: close, codeFiles: named.codeFiles, commits: named.commits, sessions: namedSessions }, kinds: [] });
343
+ continue;
344
+ }
345
+ // Named in words only: the ledger's totals for the kind its name says, once per kind.
346
+ const kinds = sweepKindsOf(name).filter((k) => !counted.has(k));
347
+ for (const k of kinds) counted.add(k);
348
+ const fromTotals = kinds.map((k) => totals[k]);
349
+ const materials = fromTotals.reduce((n, p) => n + p.materials, 0);
350
+ paths.push({ path: name, reads: fromTotals.flatMap((p) => p.reads), basis: fromTotals.length ? "the ledger's totals for its kind of question: its brief names nothing the ledger can count" : 'its brief names nothing the ledger can count', materials, named: null, kinds });
351
+ }
352
+ for (const k of coveredKinds(briefs).missing) {
353
+ if (counted.has(k)) continue;
354
+ counted.add(k);
355
+ const t = totals[k];
356
+ paths.push({ ...t, path: `${k}${ADDED_SWEEP}`, basis: `${t.basis}: no brief covers it yet, and the deepening sends a lane for each kind of question`, named: null, kinds: [k] });
357
+ }
358
+ const placed = withReadClosely(paths, closely, settling);
359
+ const added = addedByPlan(placed);
360
+ // Each kind counted whole stands for its whole category; what the briefs named of that category is inside it, and what
361
+ // the organizing plan adds to it is what the rules settle, outside it.
362
+ const whole = (k: SweepKind, named: ReadonlySet<string>, add: ReadonlySet<string>) => (counted.has(k) ? totals[k].materials + add.size : new Set([...named, ...add]).size);
363
+ const materials = whole(DEEPENING_PATHS[0], sessions, added.sessions!) + whole(DEEPENING_PATHS[1], versions, added.versions) + whole(DEEPENING_PATHS[2], commits, added.commits) + whole(DEEPENING_PATHS[3], files, added.codeFiles);
364
+ const union = (a: ReadonlySet<string>, b: ReadonlySet<string>) => new Set([...a, ...b]);
365
+ return {
366
+ paths: placed, materials, totals, wholeKinds: counted, closely: closelyOf(placed),
367
+ named: { versions: union(versions, added.versions), codeFiles: union(files, added.codeFiles), commits: union(commits, added.commits), sessions: union(sessions, added.sessions!) },
368
+ };
369
+ }
370
+
371
+ /** How the plan names a path for a kind of question no brief covers yet: the deepening's lane for it goes by the kind's name. */
372
+ export const ADDED_SWEEP = ' (no brief covers it yet)';
373
+
374
+ /** §3.7: what a Full deepening reads, path by path, by material category (`deepeningPlanPaths`). */
375
+ export function deepeningReads(ledger: Ledger | null, store: ProjectStore, plan?: DeepeningPlanned): DeepeningPlan['paths'] {
376
+ return deepeningPlanPaths(ledger, store, plan).paths.map(({ path, reads, basis }) => ({ path, reads, ...(basis ? { basis } : {}) }));
377
+ }
378
+
379
+ /**
380
+ * §3.7: what `Focused` covers — current objects' lineage, open work, the breakpoint candidates the program computed and
381
+ * the anomalies — and what the organizing plan says to read closely, which Focused reads in full too (D62, D97). The
382
+ * candidates count whether or not one is lit: after the first usable round none is (D99), and the deepening is where a
383
+ * lane looks for their missing steps.
384
+ */
385
+ export function focusedCovers(store: ProjectStore): DeepeningPlan['focused'] {
386
+ const open = (p: string) => !/^(done|completed|accepted|closed)/i.test(p);
387
+ return [
388
+ { category: 'current product objects', count: store.reference.filter((r) => r.validity === 'Current').length },
389
+ { category: 'open work items', count: store.threads.filter((t) => t.validity === 'Current' && open(String(t.progress))).length },
390
+ { category: 'breakpoint candidates', count: store.breakpoints.filter((b) => !b.out && !b.ownerResponse).length },
391
+ { category: 'code anomalies', count: store.territories.all().reduce((n, t) => n + t.anomalies.length, 0) },
392
+ { category: 'entries the organizing plan says to read closely, read in full', count: store.plans.get(ORGANIZING_PLAN_ID)?.readClosely.length ?? 0 },
393
+ ].filter((c) => c.count > 0);
394
+ }
395
+
396
+ /** Minutes a job ran (queued time left out). */
397
+ const jobMinutes = (j: KeeperJob): number => (j.startedAt && j.endedAt ? Math.max(0, (Date.parse(j.endedAt) - Date.parse(j.startedAt)) / 60_000) : 0);
398
+ const costOf = (jobs: readonly KeeperJob[]): number | null => { const c = jobs.map((j) => j.usage.cost); return c.length && c.every((x) => x != null) ? c.reduce((n, x) => n + x!, 0) : null; };
399
+ /**
400
+ * A model job's own minutes: a lane's, or any step's, from its start to its end; the main agent's (D99) its generation
401
+ * time when the runtime measured it, since it waits parked while its lanes read and their minutes are counted as theirs.
402
+ */
403
+ const ownMinutes = (j: KeeperJob): number => (j.step?.kind === 'main' && j.timing ? j.timing.generationMs / 60_000 : jobMinutes(j));
404
+
405
+ /**
406
+ * What reading one planned material takes on this project, measured from its own jobs (§3.7: the estimate on a stated
407
+ * basis): the first usable round's model jobs — the main agent and its lanes (D99), or the steps of a round from before
408
+ * it — their own minutes and cost over the items they read. Null before it has anything to go on.
409
+ */
410
+ function measuredRate(store: ProjectStore, first: ReturnType<typeof firstUsableFigures>, firstRound: ClerkRound | undefined): { minutes: number; cost: number | null; from: string } | null {
411
+ if (!firstRound || first.itemsRead <= 0) return null;
412
+ const model = store.jobs.filter((j) => j.step?.roundId === firstRound.id && j.agent !== 'program' && j.status === 'Done');
413
+ const minutes = model.reduce((n, j) => n + ownMinutes(j), 0);
414
+ if (minutes <= 0) return null;
415
+ const cost = costOf(everyJob(store, model).filter((j) => j.agent !== 'program'));
416
+ return { minutes: minutes / first.itemsRead, cost: cost == null ? null : cost / first.itemsRead, from: `the first usable round's ${model.length} model job${model.length === 1 ? '' : 's'}: ${Math.round(minutes)} job minutes${cost == null ? '' : ` and $${cost.toFixed(2)}`} for ${first.itemsRead} items read` };
417
+ }
418
+
419
+ /** The time the main agent spent in some of its stages (its stage log), and that share of its cost. */
420
+ function stagesOf(store: ProjectStore, round: ClerkRound, stages: readonly string[]): { minutes: number; cost: number | null } | null {
421
+ const log: readonly StageEntry[] = round.stageLog ?? [];
422
+ const entries = log.filter((e) => stages.includes(e.stage) && e.timing);
423
+ if (!entries.length) return null;
424
+ const wall = entries.reduce((n, e) => n + e.timing!.wallMs, 0);
425
+ const main = store.jobs.filter((j) => j.step?.roundId === round.id && j.step.kind === 'main').sort((a, b) => b.queuedAt.localeCompare(a.queuedAt))[0];
426
+ const mainWall = log.reduce((n, e) => n + (e.timing?.wallMs ?? 0), 0);
427
+ const cost = main && main.usage.cost != null && mainWall > 0 ? main.usage.cost * (wall / mainWall) : null;
428
+ return { minutes: wall / 60_000, cost };
429
+ }
430
+
431
+ /**
432
+ * What follows the lanes, as measured (D99, D103): a deepening's own — its main agent's cross-check stage, then its
433
+ * synthesis, spot-check and process — once one ran; else the first usable round's reconcile stage and its synthesis and
434
+ * process, which read the workbench and write it the same way. A stage's cost is the main job's in the share of its time.
435
+ * The synthesis is a job of its own since D103; on a round recorded before, it is a stage of the main agent's log.
436
+ */
437
+ function afterLanes(store: ProjectStore, firstRound: ClerkRound | undefined): { minutes: number; cost: number | null; from: string } | null {
438
+ const pick = (round: ClerkRound, stages: readonly string[], jobKinds: readonly string[], from: string) => {
439
+ const main = stagesOf(store, round, stages);
440
+ const jobs = store.jobs.filter((j) => j.step?.roundId === round.id && jobKinds.includes(j.step.kind) && j.status === 'Done');
441
+ if (!main && !jobs.length) return null;
442
+ const jobCost = costOf(everyJob(store, jobs).filter((j) => j.agent !== 'program'));
443
+ const minutes = (main?.minutes ?? 0) + jobs.reduce((n, j) => n + jobMinutes(j), 0);
444
+ const cost = main && main.cost == null ? null : (main?.cost ?? 0) + (jobCost ?? 0);
445
+ return { minutes, cost, from };
446
+ };
447
+ const deepen = store.clerkRounds.filter((r) => r.kind === 'Deepen' && r.status === 'Done' && Boolean(r.stageLog?.length)).sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0];
448
+ if (deepen) { const r = pick(deepen, ['cross-check', 'synthesis'], ['synthesis', 'spot-check', 'process'], `round ${deepen.number}'s cross-check and synthesis, spot-check and process`); if (r) return r; }
449
+ return firstRound ? pick(firstRound, ['reconcile', 'synthesis'], ['synthesis', 'process'], "the first usable round's reconcile and synthesis and its process") : null;
450
+ }
451
+
452
+ /**
453
+ * The three options with this project's figures (§3.7). What Full reads closely is counted by the round's question list
454
+ * (`deepeningPlanPaths`): what each sweep's brief names, each material once however many briefs name it, and the ledger's
455
+ * totals for a kind of question no brief names anything countable for, or no brief covers (QC AY: the amounts used to
456
+ * be the ledger's totals whatever the briefs said). With no brief yet: the documents as they are now (less what the rules
457
+ * settle), commits on every ref, sessions and code files. Material is counted by file, commit and session: a document is
458
+ * one material, its older versions are looked up by question (Spec §3.3, §3.7; D99).
459
+ *
460
+ * The estimate is per material, on a stated basis (`measuredRate`): every lane reads its own planned materials (a
461
+ * material two lanes plan is read by each, for its own questions), so the job time is the lanes' readings times the
462
+ * measured minutes per material, spread over the lanes that run at once; what follows the lanes is added as measured
463
+ * (`afterLanes`). Focused is taken as halfway, since how much history the current objects involve is only known once it
464
+ * runs. Once a deepening has run, the Keeper view shows its real figures beside the estimate.
465
+ */
466
+ export function clerkDepthOptions(first: ReturnType<typeof firstUsableFigures>, ledger: Ledger | null, store: ProjectStore, lanes: number, planned?: DeepeningPlanned, project?: Project): { options: DepthOption[]; basis: string; perMaterial: { minutes: number; cost: number | null; from: string } | null } {
467
+ const plan = deepeningPlanPaths(ledger, store, planned);
468
+ const full = plan.materials;
469
+ const readings = plan.paths.reduce((n, p) => n + p.materials, 0);
470
+ const byBriefs = plan.paths.some((p) => p.basis === 'counted from what its brief names');
471
+ const firstRound = store.clerkRounds.filter((r) => r.kind === 'First usable').sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0];
472
+ const rate = first.minutes == null ? null : measuredRate(store, first, firstRound);
473
+ const after = rate ? afterLanes(store, firstRound) : null;
474
+ const parallel = Math.max(1, lanes);
475
+ const jobMinutesFull = rate ? readings * rate.minutes : 0;
476
+ const fullMinutes = rate ? Math.round(jobMinutesFull / parallel + (after?.minutes ?? 0)) : 0;
477
+ const fullCost = rate && rate.cost != null ? Math.round((readings * rate.cost + (after?.cost ?? 0)) * 100) / 100 : null;
478
+ const focusedMinutes = rate ? Math.round((fullMinutes + (after?.minutes ?? 0)) / 2) : 0;
479
+ const focusedCost = fullCost == null ? null : Math.round(((fullCost + (after?.cost ?? 0)) / 2) * 100) / 100;
480
+ const option = (depth: DepthOption['depth'], toOrganize: number, minutes: number, cost: number | null): DepthOption => ({ depth, levels: [], toOrganize, chars: 0, minutes, cost, lanes: parallel });
481
+ const counted = byBriefs
482
+ ? `by the round's question list: what each brief names, counted in the ledger, and the ledger's totals where a brief names nothing it can count or no brief covers a kind of question`
483
+ : 'documents as they are now, one material each, less what the rules settle; commits on every ref; sessions; code files';
484
+ const per = (r: { minutes: number; cost: number | null }) => `${r.minutes.toFixed(2)} job min${r.cost == null ? '' : ` and $${r.cost.toFixed(3)}`} a material`;
485
+ const basis = first.minutes == null
486
+ ? 'no estimate yet: the first usable round has not finished'
487
+ : !rate
488
+ ? 'no estimate yet: no step of this project has read anything to measure from'
489
+ : `per material, measured on this project: ${per(rate)} (${rate.from}); Full reads ${full} materials (${counted}) — ${readings} readings across its ${plan.paths.length} path${plan.paths.length === 1 ? '' : 's'}, since a material two paths plan is read by each — by lanes running ${parallel} at a time: about ${Math.round(jobMinutesFull)} job minutes spread over them${after ? `, then ${Math.round(after.minutes)} min${after.cost == null ? '' : ` and $${after.cost.toFixed(2)}`} for what follows the lanes (as long as ${after.from})` : ''}; follow-up lanes for what the coverage check finds no lane touched are not in it; Focused is taken as halfway, since how much history the current objects involve is only known once it runs`;
490
+ return {
491
+ options: [option('Full', full, fullMinutes, fullCost), option('Focused', 0, focusedMinutes, focusedCost), option('First picture only', 0, 0, 0)],
492
+ basis, perMaterial: rate,
493
+ };
494
+ }
495
+
496
+ // ───────────────────────── the deepening as it ran, against its plan (CKC-13 AC-8) ─────────────────────────
497
+
498
+ /**
499
+ * The categories what a deepening read is counted in: the plan's units, and what the plan has no unit for. The category
500
+ * a kind of question counted by the ledger's totals plans whole is `KIND_CATEGORY` (reading.ts), what `kindTotals` counts
501
+ * as its materials.
502
+ */
503
+ type Category = 'document versions' | 'code files' | 'commits' | 'sessions' | 'other files';
504
+ const CATEGORIES: readonly Category[] = ['document versions', 'code files', 'commits', 'sessions', 'other files'];
505
+ const DOCUMENT_FILE = /\.(md|markdown|txt)$/i;
506
+ type Extent = 'whole' | 'part' | null;
507
+ const best = (xs: readonly Extent[]): Extent => (xs.includes('whole') ? 'whole' : xs.includes('part') ? 'part' : null);
508
+
509
+ /**
510
+ * Where the plan's items are on disk, and which version of each document stands for the file as it is now: the ledger's
511
+ * current one, or — when the file was edited after its last version — the latest (reading the file reads that one and
512
+ * the edit after it). Every other version is the history: read only by reading it (`pk_ledger_doc_read`, `git show`).
513
+ */
514
+ interface Placed {
515
+ readonly versions: ReadonlyMap<string, { readonly file: string; readonly current: boolean; readonly commit: string }>;
516
+ readonly codeFiles: ReadonlyMap<string, string>;
517
+ readonly category: (fileKey: string) => Category | null;
518
+ /** Every session the ledger holds: its id → its log's path key (a session read through the ledger is counted as its log). */
519
+ readonly sessions: ReadonlyMap<string, string>;
520
+ }
521
+ function placePlan(ledger: Ledger, plan: DeepeningPlanned, store: ProjectStore, project: Project): Placed {
522
+ const repos = ledger.repos();
523
+ // A repository's root — or a scope item's outside version control: the ledger keeps its files by the item's id.
524
+ const rootOf = new Map<string, string>([...project.scope.map((i) => [i.id, i.path] as const), ...repos.map((r) => [r.id, r.path] as const)]);
525
+ const at = (repo: string, rel: string): string | null => { const root = rootOf.get(repo); return root ? join(root, ...rel.split('/')) : null; };
526
+ // The ledger's own rows for the planned versions: the codemap and process engines read its tables the same way.
527
+ let docRow: ((key: string) => { repo: string; path: string; commit_hash: string; at_ms: number; cur: number } | undefined) | null = null;
528
+ try {
529
+ const stmt = ledger.db.prepare('SELECT repo, path, commit_hash, at_ms, (SELECT count(*) FROM code_files f WHERE f.repo = d.repo AND f.path = d.path AND f.blob = d.blob) cur FROM docs d WHERE key = ? LIMIT 1');
530
+ docRow = (key) => stmt.get(key) as never;
531
+ } catch { docRow = null; }
532
+ const found: { key: string; abs: string; commit: string; at: number; cur: boolean }[] = [];
533
+ // What each path plans: what its brief names, and what the organizing plan adds to read closely.
534
+ const plannedOf = (p: PlannedPath) => mergeNamed(p.named, p.closely?.added);
535
+ for (const key of new Set(plan.paths.flatMap((p) => (plannedOf(p)?.versions ?? []).map((v) => v.key)))) {
536
+ const r = docRow?.(key);
537
+ const abs = r ? at(r.repo, r.path) : (() => { const p = ledger.entryPlace(key); return p && 'file' in p ? p.file : null; })();
538
+ if (abs) found.push({ key, abs, commit: r?.commit_hash ?? key.replace(/^.*@/, ''), at: Number(r?.at_ms ?? 0), cur: r ? Number(r.cur) > 0 : true });
539
+ }
540
+ const byFile = new Map<string, typeof found>();
541
+ for (const f of found) { const k = pathKey(f.abs); byFile.set(k, [...(byFile.get(k) ?? []), f]); }
542
+ const versions = new Map<string, { file: string; current: boolean; commit: string }>();
543
+ for (const [file, list] of byFile) {
544
+ const standing = list.find((v) => v.cur) ?? (existsFile(list[0]!.abs) ? [...list].sort((a, b) => b.at - a.at)[0] : undefined);
545
+ for (const v of list) versions.set(v.key, { file, current: v === standing, commit: v.commit });
546
+ }
547
+ const codeFiles = new Map<string, string>();
548
+ const rootIds = [...rootOf.keys()].sort((a, b) => b.length - a.length);
549
+ for (const key of new Set(plan.paths.flatMap((p) => (plannedOf(p)?.codeFiles ?? []).map((f) => f.key)))) {
550
+ const repo = rootIds.find((id) => key.startsWith(`${id}:`));
551
+ const abs = repo ? at(repo, key.slice(repo.length + 1)) : null;
552
+ if (abs) codeFiles.set(key, pathKey(abs));
553
+ }
554
+ // What a file read beyond the plan is: a session log, a document, a code file as the ledger counts one, or another file
555
+ // of the project (a fixture, a generated file, a worktree's copy); what lies outside the project is not counted.
556
+ const sessions = new Map<string, string>();
557
+ try { for (const s of ledger.sessionLogs()) sessions.set(s.key, pathKey(s.file)); } catch { /* a ledger without sessions */ }
558
+ const sessionLogs = new Set([...store.sources.all().flatMap((s) => (s.anchor.kind === 'session' ? [pathKey(s.anchor.file)] : [])), ...sessions.values()]);
559
+ const inProject = [...project.locations, ...project.scope.filter((i) => i.relation !== 'Excluded' && i.category !== 'Session source').map((i) => i.path)];
560
+ const excluded = project.scope.filter((i) => i.relation === 'Excluded').map((i) => i.path);
561
+ let code: ((repo: string, rel: string) => boolean) | null = null;
562
+ try {
563
+ const stmt = ledger.db.prepare("SELECT 1 x FROM code_files WHERE repo = ? AND path = ? AND generated = 0 AND classification IS NULL AND (lang IS NULL OR lang NOT IN ('markdown', 'text')) LIMIT 1");
564
+ code = (repo, rel) => stmt.get(repo, rel) !== undefined;
565
+ } catch { code = null; }
566
+ const roots = repos.map((r) => ({ id: r.id, key: pathKey(r.path) })).sort((a, b) => b.key.length - a.key.length);
567
+ const categories = new Map<string, Category | null>();
568
+ const category = (key: string): Category | null => {
569
+ if (categories.has(key)) return categories.get(key)!;
570
+ let c: Category | null;
571
+ if (sessionLogs.has(key)) c = 'sessions';
572
+ else if (!inProject.some((l) => isWithin(l, key)) || excluded.some((l) => isWithin(l, key))) c = null;
573
+ else if (DOCUMENT_FILE.test(key)) c = 'document versions';
574
+ else {
575
+ const repo = roots.find((r) => key.startsWith(`${r.key}\\`) || key.startsWith(`${r.key}/`));
576
+ const rel = repo ? key.slice(repo.key.length + 1).split(/[\\/]/).join('/') : null;
577
+ c = repo && rel && code?.(repo.id, rel) ? 'code files' : 'other files';
578
+ }
579
+ categories.set(key, c);
580
+ return c;
581
+ };
582
+ return { versions, codeFiles, category, sessions };
583
+ }
584
+ const existsFile = (path: string): boolean => { try { return statSync(path).isFile(); } catch { return false; } };
585
+
586
+ /**
587
+ * What a tally read, category by category, as items: a document version (the file as it stands, or a version from the
588
+ * history at its commit), a code file, a commit, a session log (read whole, or only some of it — a part of the log, or a
589
+ * segment of it by its source), another file of the project.
590
+ */
591
+ function readItems(tally: ReadTally, placed: Placed, store: ProjectStore): Map<Category, Map<string, 'whole' | 'part'>> {
592
+ const out = new Map<Category, Map<string, 'whole' | 'part'>>(CATEGORIES.map((c) => [c, new Map()]));
593
+ const put = (c: Category, id: string, e: Extent) => { if (!e) return; const m = out.get(c)!; if (m.get(id) !== 'whole') m.set(id, e); };
594
+ for (const [key, f] of tally.files) { const c = placed.category(key); if (c) put(c, key, extentOf(f)); }
595
+ for (const [key, list] of tally.versions) for (const v of list) put('document versions', `${key}@${v.rev}`, extentOf(v.reads));
596
+ for (const c of tally.commits) put('commits', c, 'whole');
597
+ for (const id of tally.sources) {
598
+ const s = store.sources.get(id);
599
+ if (s?.anchor.kind === 'session') put('sessions', pathKey(s.anchor.file), 'part');
600
+ }
601
+ // A session read through the ledger (`pk_ledger_sessions`): its log, whole when every message was shown.
602
+ for (const [key, f] of tally.sessions) { const log = placed.sessions.get(key); if (log) put('sessions', log, extentOf(f)); }
603
+ return out;
604
+ }
605
+
606
+ /** One category's figures as they are counted up. */
607
+ interface Tally { planned: number | null; whole: number; part: number; beyond: { whole: number; part: number } }
608
+
609
+ /**
610
+ * One step's (or the round's) reads against a plan (`DeepeningReads`): the items it names, by name; the categories it
611
+ * plans whole, by count; what was read of neither is beyond it. `named` null and no `wholeCategories`: a step no path plans.
612
+ */
613
+ function figures(tally: ReadTally, placed: Placed, store: ProjectStore, named: Named | null, wholeCategories: ReadonlyMap<Category, number>, planned: number | null): DeepeningReads {
614
+ const items = readItems(tally, placed, store);
615
+ const rows = new Map<Category, Tally>();
616
+ const rowOf = (c: Category): Tally => { let r = rows.get(c); if (!r) { r = { planned: null, whole: 0, part: 0, beyond: { whole: 0, part: 0 } }; rows.set(c, r); } return r; };
617
+ const plan = (c: Category, n: number): Tally => { const r = rowOf(c); r.planned = (r.planned ?? 0) + n; return r; };
618
+ const count = (r: Tally, e: Extent) => { if (e === 'whole') r.whole += 1; else if (e === 'part') r.part += 1; };
619
+ const counted = new Map<Category, Set<string>>(CATEGORIES.map((c) => [c, new Set()]));
620
+ // A file as it stands may fall in any of these, by its name and the ledger (a document that is not Markdown).
621
+ const FILE_CATEGORIES = ['document versions', 'code files', 'other files'] as const;
622
+ const fileRead = (key: string): Extent => best(FILE_CATEGORIES.map((c) => items.get(c)!.get(key) ?? null));
623
+ const fileCounted = (key: string) => { for (const c of FILE_CATEGORIES) counted.get(c)!.add(key); };
624
+ if (named) {
625
+ // Each planned version: the file as it stands when that version stands for it, and any read of that very version
626
+ // from the history.
627
+ const versions = plan('document versions', named.versions.length);
628
+ for (const v of named.versions) {
629
+ const p = placed.versions.get(v.key);
630
+ if (!p) continue;
631
+ const fromHistory = [...items.get('document versions')!].filter(([id]) => id.startsWith(`${p.file}@`) && sameCommit(id.slice(p.file.length + 1), p.commit));
632
+ count(versions, best([p.current ? fileRead(p.file) : null, ...fromHistory.map(([, e]) => e)]));
633
+ if (p.current) fileCounted(p.file);
634
+ for (const [id] of fromHistory) counted.get('document versions')!.add(id);
635
+ }
636
+ const code = plan('code files', named.codeFiles.length);
637
+ for (const f of named.codeFiles) {
638
+ const key = placed.codeFiles.get(f.key);
639
+ if (!key) continue;
640
+ count(code, fileRead(key));
641
+ fileCounted(key);
642
+ }
643
+ const commits = plan('commits', named.commits.length);
644
+ for (const h of named.commits) {
645
+ const hit = [...items.get('commits')!.keys()].filter((c) => sameCommit(c, h));
646
+ count(commits, hit.length ? 'whole' : null);
647
+ for (const c of hit) counted.get('commits')!.add(c);
648
+ }
649
+ // Each session the brief names: its log read (whole, or some of it) by any means, the ledger's included.
650
+ const sessions = plan('sessions', named.sessions?.length ?? 0);
651
+ for (const s of named.sessions ?? []) {
652
+ const log = placed.sessions.get(s);
653
+ if (!log) continue;
654
+ count(sessions, items.get('sessions')!.get(log) ?? null);
655
+ counted.get('sessions')!.add(log);
656
+ }
657
+ }
658
+ // A category planned whole (a path counted by the ledger's totals): whatever of it was read, up to what it plans.
659
+ for (const [c, n] of wholeCategories) {
660
+ const r = plan(c, n);
661
+ const rest = [...items.get(c)!].filter(([id]) => !counted.get(c)!.has(id)).sort((a, b) => Number(b[1] === 'whole') - Number(a[1] === 'whole'));
662
+ for (const [id, e] of rest) {
663
+ if ((r.planned ?? 0) - r.whole - r.part <= 0) break;
664
+ count(r, e);
665
+ counted.get(c)!.add(id);
666
+ }
667
+ }
668
+ // Beyond the plan: what was read and is none of the above.
669
+ for (const c of CATEGORIES) {
670
+ for (const [id, e] of items.get(c)!) {
671
+ if (counted.get(c)!.has(id)) continue;
672
+ const r = rowOf(c);
673
+ if (e === 'whole') r.beyond.whole += 1; else r.beyond.part += 1;
674
+ }
675
+ }
676
+ const byCategory = CATEGORIES.flatMap((c) => {
677
+ const r = rows.get(c);
678
+ if (!r || (!r.planned && !r.whole && !r.part && !r.beyond.whole && !r.beyond.part)) return [];
679
+ return [{ category: c, planned: r.planned, whole: r.whole, part: r.part, left: r.planned === null ? null : Math.max(0, r.planned - r.whole - r.part), beyond: { ...r.beyond } }];
680
+ });
681
+ const sum = (f: (r: (typeof byCategory)[number]) => number) => byCategory.reduce((n, r) => n + f(r), 0);
682
+ const whole = sum((r) => r.whole), part = sum((r) => r.part);
683
+ return {
684
+ planned, whole, part, left: planned === null ? null : Math.max(0, planned - whole - part),
685
+ beyond: { whole: sum((r) => r.beyond.whole), part: sum((r) => r.beyond.part) }, byCategory,
686
+ };
687
+ }
688
+
689
+ /** A job and the investigations it sent, and theirs: what a step read includes what it sent others to read. */
690
+ function withDescendants(store: ProjectStore, job: KeeperJob): KeeperJob[] {
691
+ const out = [job];
692
+ for (let i = 0; i < out.length; i++) out.push(...store.jobs.filter((j) => j.parentJobId === out[i]!.id && !out.includes(j)));
693
+ return out;
694
+ }
695
+ const everyJob = (store: ProjectStore, jobs: readonly KeeperJob[]): KeeperJob[] => [...new Map(jobs.flatMap((j) => withDescendants(store, j)).map((j) => [j.id, j])).values()];
696
+
697
+ /**
698
+ * A path's (or the round's) reads against the materials its reading planned (`DeepeningReads`), counted as the reading
699
+ * counts them (reading.ts `readsOf`: an older version of a document read through what it changed): planned, read whole,
700
+ * read in part, not read; and what was read that is none of them, beyond the plan.
701
+ */
702
+ function readingFigures(tally: ReadTally, placed: Placed | null, store: ProjectStore, materials: readonly ReadingMaterial[], states: ReadonlyMap<string, MaterialRead>): DeepeningReads {
703
+ const rows = new Map<Category, Tally>();
704
+ const rowOf = (c: Category): Tally => { let r = rows.get(c); if (!r) { r = { planned: null, whole: 0, part: 0, beyond: { whole: 0, part: 0 } }; rows.set(c, r); } return r; };
705
+ const items = placed ? readItems(tally, placed, store) : new Map<Category, Map<string, 'whole' | 'part'>>(CATEGORIES.map((c) => [c, new Map()]));
706
+ const matched = new Map<Category, Set<string>>(CATEGORIES.map((c) => [c, new Set()]));
707
+ const FILE_CATEGORIES = ['document versions', 'code files', 'other files'] as const;
708
+ for (const m of materials) {
709
+ const r = rowOf(m.category);
710
+ r.planned = (r.planned ?? 0) + 1;
711
+ const s = states.get(m.key)?.outcome;
712
+ if (s === 'whole') r.whole += 1; else if (s === 'part') r.part += 1;
713
+ // Which of the reads this material accounts for, so what is left over is beyond the plan.
714
+ if ((m.category === 'code files' || m.current) && m.file) for (const c of FILE_CATEGORIES) matched.get(c)!.add(pathKey(m.file));
715
+ if (m.category === 'document versions' && m.file && m.rev) {
716
+ const fk = pathKey(m.file);
717
+ for (const id of items.get('document versions')!.keys()) if (id.startsWith(`${fk}@`) && sameCommit(id.slice(fk.length + 1), m.rev)) matched.get('document versions')!.add(id);
718
+ }
719
+ if (m.category === 'commits' && m.rev) for (const id of items.get('commits')!.keys()) if (sameCommit(id, m.rev)) matched.get('commits')!.add(id);
720
+ if (m.category === 'sessions' && m.session) { const log = placed?.sessions.get(m.session); if (log) matched.get('sessions')!.add(log); }
721
+ }
722
+ for (const c of CATEGORIES) for (const [id, e] of items.get(c)!) {
723
+ if (matched.get(c)!.has(id)) continue;
724
+ const r = rowOf(c);
725
+ if (e === 'whole') r.beyond.whole += 1; else r.beyond.part += 1;
726
+ }
727
+ const byCategory = CATEGORIES.flatMap((c) => {
728
+ const r = rows.get(c);
729
+ if (!r || (!r.planned && !r.whole && !r.part && !r.beyond.whole && !r.beyond.part)) return [];
730
+ return [{ category: c, planned: r.planned, whole: r.whole, part: r.part, left: r.planned === null ? null : Math.max(0, r.planned - r.whole - r.part), beyond: { ...r.beyond } }];
731
+ });
732
+ const sum = (f: (r: (typeof byCategory)[number]) => number) => byCategory.reduce((n, r) => n + f(r), 0);
733
+ const whole = sum((r) => r.whole), part = sum((r) => r.part);
734
+ return { planned: materials.length, whole, part, left: Math.max(0, materials.length - whole - part), beyond: { whole: sum((r) => r.beyond.whole), part: sum((r) => r.beyond.part) }, byCategory };
735
+ }
736
+
737
+ /**
738
+ * A deepening read by reading assignments (reading.ts), as it runs: one row per path — its assignments together, their
739
+ * status and time, what the path read against its planned materials, and what it recorded as read in part or not read,
740
+ * with why — then the steps after the sweeps; and the round against every material its paths planned, each once.
741
+ */
742
+ function readingAsRun(store: ProjectStore, project: Project, round: ClerkRound, jobs: readonly KeeperJob[], placed: Placed | null): { progress: DeepeningPlan['progress']; actual: DeepeningActual } {
743
+ const reading = round.reading!;
744
+ const paths = new Set(reading.paths.map((p) => p.path));
745
+ const ACTIVE = ['Queued', 'Running', 'Waiting for quota', 'Paused'];
746
+ const rows = reading.paths.map((p) => {
747
+ const list = pathJobs(store, round.id, p.path);
748
+ const tally = tallyReads(everyJob(store, list), project);
749
+ const states = materialReads(p.materials, tally);
750
+ const bad = list.find((j) => j.status === 'Failed' || j.status === 'Stopped');
751
+ const status = p.complete ? 'Done' : list.some((j) => j.status === 'Running') ? 'Running' : bad ? bad.status : list.some((j) => ACTIVE.includes(j.status)) ? 'Queued' : 'Running';
752
+ const started = list.map((j) => j.startedAt).filter((x): x is string => Boolean(x)).sort()[0] ?? null;
753
+ const ended = p.complete ? list.map((j) => j.endedAt ?? '').sort().pop() || null : null;
754
+ return { path: p.path, status, minutes: minutesOf(started, ended), reads: readingFigures(tally, placed, store, p.materials, states), reading: pathReadingOf(store, project, round, p, states) };
755
+ });
756
+ const others = jobs.filter((j) => !(j.step?.kind === 'dig' && paths.has(j.step.path ?? j.scope.label))).map((j) => ({
757
+ path: j.step!.kind === 'dig' ? (j.step!.path ?? j.scope.label) : j.scope.label, status: j.status, minutes: minutesOf(j.startedAt, j.endedAt),
758
+ reads: readingFigures(tallyReads(withDescendants(store, j), project), placed, store, [], new Map()),
759
+ })).map((r) => ({ ...r, reads: { ...r.reads, planned: null, left: null, byCategory: r.reads.byCategory.map((c) => ({ ...c, planned: null, left: null })) } }));
760
+ // The round together: every material a path planned, once, against everything the round's jobs read.
761
+ const union = [...new Map(reading.paths.flatMap((p) => p.materials).map((m) => [m.key, m])).values()];
762
+ const all = everyJob(store, jobs);
763
+ const tally = tallyReads(all, project);
764
+ const total = readingFigures(tally, placed, store, union, materialReads(union, tally));
765
+ const costs = all.filter((j) => j.agent !== 'program').map((j) => j.usage.cost);
766
+ return {
767
+ progress: [...rows, ...others],
768
+ actual: {
769
+ ...total, depth: roundDepth(round, store.jobs.get(round.rootJobId)) ?? project.takeoverDepth ?? 'Full', roundId: round.id, status: round.status,
770
+ minutes: minutesOf(round.startedAt, round.endedAt), cost: costs.length && costs.every((c) => c != null) ? Math.round(costs.reduce((n, c) => n + c!, 0) * 100) / 100 : null,
771
+ },
772
+ };
773
+ }
774
+
775
+ /**
776
+ * The deepening under way or done (§3.7; CKC-13 AC-8): each step's status and time and, once it has read, what it read
777
+ * against its path's plan — counted from the steps' recorded reads, in the plan's units (a document version, a code file,
778
+ * a commit; a session for a path counted by the ledger's totals) — and the whole round against the whole plan, with its
779
+ * time and cost. The plan is the one the depth question counted (`deepeningPlanPaths`); a sweep the program added is
780
+ * matched by its kind. A deepening read by reading assignments is counted path by path against what its reading planned
781
+ * when its sweeps started (`readingAsRun`). Without the ledger or the project, only the status and time.
782
+ */
783
+ export function deepeningAsRun(ledger: Ledger | null, store: ProjectStore, project: Project | null, planned?: DeepeningPlanned): { progress: DeepeningPlan['progress']; actual: DeepeningActual | null } {
784
+ const round = store.clerkRounds.filter((r) => r.kind === 'Deepen').sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0];
785
+ if (!round) return { progress: [], actual: null };
786
+ const jobs = store.jobs.filter((j) => j.step?.roundId === round.id && j.agent !== 'program').sort((a, b) => a.queuedAt.localeCompare(b.queuedAt));
787
+ const pathOf = (j: KeeperJob) => (j.step!.kind === 'dig' || j.step!.kind === 'lane' ? (j.step!.path ?? j.scope.label) : j.scope.label);
788
+ const plan = ledger && project ? planned ?? deepeningPlanned(ledger, store) : null;
789
+ const placed = ledger && project && plan ? placePlan(ledger, plan, store, project) : null;
790
+ if (round.reading && project) return readingAsRun(store, project, round, jobs, placed);
791
+ const wholeOf = (kinds: readonly SweepKind[]) => new Map(kinds.map((k) => [KIND_CATEGORY[k], plan!.totals![k].materials] as const));
792
+ const progress = jobs.map((j) => {
793
+ const base = { path: pathOf(j), status: j.status, minutes: minutesOf(j.startedAt, j.endedAt) };
794
+ if (!plan || !placed || !project) return base;
795
+ const tally = tallyReads(withDescendants(store, j), project);
796
+ const pathPlan = (j.step!.kind === 'dig' || j.step!.kind === 'lane') ? plan.paths.find((p) => p.path === base.path || p.path === `${base.path}${ADDED_SWEEP}`) ?? null : null;
797
+ // What its brief names, or its kinds' whole categories, and what the organizing plan adds to read closely.
798
+ const reads = pathPlan
799
+ ? figures(tally, placed, store, mergeNamed(pathPlan.named, pathPlan.closely?.added), pathPlan.named ? new Map() : wholeOf(pathPlan.kinds), pathPlan.materials)
800
+ : figures(tally, placed, store, null, new Map(), null);
801
+ return { ...base, reads };
802
+ });
803
+ if (!plan || !placed || !project || !plan.totals) return { progress, actual: null };
804
+ // The round together: every step's reads against the plan's union — what the briefs name, each once, and the
805
+ // categories a kind counted whole plans in full (`deepeningPlanPaths`' `materials`).
806
+ const union: Named = {
807
+ versions: [...plan.named.versions].map((key) => ({ key, path: '' })),
808
+ codeFiles: [...plan.named.codeFiles].map((key) => ({ key, lines: 0 })),
809
+ commits: [...plan.named.commits],
810
+ sessions: [...(plan.named.sessions ?? [])],
811
+ };
812
+ const wholeKinds = [...plan.wholeKinds];
813
+ // A category planned whole holds what the briefs named of it; what the organizing plan adds to it lies outside it.
814
+ const added = plan.closely.added;
815
+ const namedPart: Named = {
816
+ versions: plan.wholeKinds.has(DEEPENING_PATHS[1]) ? [...added.versions].map((key) => ({ key, path: '' })) : union.versions,
817
+ codeFiles: plan.wholeKinds.has(DEEPENING_PATHS[3]) ? [...added.codeFiles].map((key) => ({ key, lines: 0 })) : union.codeFiles,
818
+ commits: plan.wholeKinds.has(DEEPENING_PATHS[2]) ? [...added.commits] : union.commits,
819
+ sessions: plan.wholeKinds.has(DEEPENING_PATHS[0]) ? [...(added.sessions ?? [])] : union.sessions,
820
+ };
821
+ const all = [...new Map(jobs.flatMap((j) => withDescendants(store, j)).map((j) => [j.id, j])).values()];
822
+ const total = figures(tallyReads(all, project), placed, store, namedPart, wholeOf(wholeKinds), plan.materials);
823
+ // Its cost: every step's, with the investigations each sent (they are that step's work).
824
+ const costs = all.filter((j) => j.agent !== 'program').map((j) => j.usage.cost);
825
+ return {
826
+ progress,
827
+ actual: {
828
+ ...total, depth: roundDepth(round, store.jobs.get(round.rootJobId)) ?? project.takeoverDepth ?? 'Full', roundId: round.id, status: round.status,
829
+ minutes: minutesOf(round.startedAt, round.endedAt), cost: costs.length && costs.every((c) => c != null) ? Math.round(costs.reduce((n, c) => n + c!, 0) * 100) / 100 : null,
830
+ },
831
+ };
832
+ }