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,639 @@
1
+ /**
2
+ * What the workbench shows, assembled from the assets (Spec §6.2–§6.5): graph nodes with their
3
+ * visibility flags and markers, the strip (Notes (attention), Recent changes, Since last visit),
4
+ * and object details. Pure reads; nothing here changes an asset.
5
+ */
6
+ import type { ChangeRecord, FollowUpRound, GraphNode, GraphRelation, LackedItem, Note, NoteMount, PendingWait, Project, RoundResult } from '../model/types.ts';
7
+ import type { RoundNews, RoundNewsItem } from '../model/k-types.ts';
8
+ import { OWNER_WORDS, type ChangeEffect, type Propagation } from '../model/vocab.ts';
9
+ import type { ProjectStore } from '../store/project-store.ts';
10
+ import { anchorLabel } from '../sources/anchor.ts';
11
+ import { itemsOf, notJudgedReason, objectUpdatedAt, roundNumberOf } from '../keeper/adjustment.ts';
12
+ import { generationPlanIds } from '../process/generations.ts';
13
+ import { authorityOf, carryOutOf, checksOn, claimMaterials, isRemoved, OWNER_WORDS_GROUP, statementView } from './workbench-content.ts';
14
+ import { scheduleOf } from '../keeper/organize/schedule.ts';
15
+ import { carriedOn } from '../keeper/organize/carried-on.ts';
16
+
17
+ export interface ViewNode extends GraphNode {
18
+ /** The current notes on it; a note on a path counts on the path's top object only (Spec §6.2, D100). */
19
+ readonly noteCount: number;
20
+ /** Of those, the notes still in `Notes (attention)` (§2.7): a highlighted ❓ on the object (D100). */
21
+ readonly noteAttention: number;
22
+ readonly noteAsk: string | null; // strongest ask among current notes on it
23
+ readonly marks: readonly { readonly kind: string; readonly clue: string }[];
24
+ readonly updatePending: boolean;
25
+ readonly noEstablishedLink: boolean;
26
+ /** `Owner's words` is the group on top of the graph and above the List's areas, folded until opened (§6.3, D63). */
27
+ readonly group: 'Existing foundation' | 'Unplaced' | typeof OWNER_WORDS_GROUP | null;
28
+ readonly recentChange: boolean; // affected by, or replaced through, a recent change
29
+ readonly parentId: string | null; // area for work items; work item for sessions/runs/results
30
+ readonly summary: string | null; // a line of the object's own text for lists
31
+ readonly changedAt: string | null; // latest change record that affects it (or replaced it)
32
+ /** A work item's `serves`, in the order the Keeper wrote them: the first Area written is the module its dispatch
33
+ * ticket or prompt names, where it is drawn solid when it implements no contract (owner, 2026-09-30; ui/placement.js). */
34
+ readonly servesOrder?: readonly string[];
35
+ /** An Area that is a cross-cutting foundation (D101; Spec §1.4, §6.3): its column goes after every module's and its
36
+ * head says `Cross-cutting foundation` (ui/placement.js). Present only when set. */
37
+ readonly foundation?: true;
38
+ /** A requirement, design or decision placed on the whole product, with the Keeper's written reason (CM; Spec §1.4
39
+ * 落位): the cross-cutting ring's `Whole product` group shows it with that reason. Present only when written. */
40
+ readonly wholeProductWhy?: string;
41
+ /** A work item that serves its whole plan — all of the plan's modules — and no single one, with the Keeper's written
42
+ * reason (CN; Spec §1.4 落位): it stands in its plan's cross-cutting cell with that reason. Present only when written. */
43
+ readonly wholePlanWhy?: string;
44
+ /** CQ (D104): a work item written as in no plan, with the recorded reason the program accepted; the placement detail shows it
45
+ * as "written: no plan — why" and it is not counted as unplaced. Present only when written. */
46
+ readonly noPlanWhy?: string;
47
+ /** CQ (D104): a work item written as in no module, with its recorded reason. Present only when written. */
48
+ readonly noAreaWhy?: string;
49
+ }
50
+
51
+ const ASK_ORDER: Record<string, number> = { 'For your decision': 3, 'Worth discussing': 2, 'For information': 1 };
52
+ const RECENT_CHANGES = 10;
53
+
54
+ function latest(note: Note) { return note.versions[note.versions.length - 1]!; }
55
+
56
+ /** The layers of the product from the top, for a path's top object (Spec §6.2: a note on a path is marked on its top). */
57
+ const LAYER_RANK: Record<string, number> = { [OWNER_WORDS]: 0, Product: 1, Goal: 2, Area: 3, Requirement: 4, Design: 5, Decision: 6, Plan: 7, 'Work item': 8, Review: 9, Test: 9, Result: 9, Session: 10, Run: 10, Change: 11 };
58
+
59
+ /**
60
+ * The objects a note is marked on (D100): the objects of a node or relation mount; a path's top object — the one
61
+ * highest in the product's layers, the first of them on a tie; nothing for the whole project (the drawer has those).
62
+ */
63
+ export function noteOnIds(store: ProjectStore, note: Note): readonly string[] {
64
+ if (note.mount.kind === 'project') return [];
65
+ if (note.mount.kind !== 'path') return note.mount.ids;
66
+ const rank = (id: string) => { const n = store.nodes.get(id); return n ? LAYER_RANK[n.category] ?? 99 : store.relations.has(id) ? 98 : 100; };
67
+ const top = [...note.mount.ids].sort((a, b) => rank(a) - rank(b))[0];
68
+ return top ? [top] : [];
69
+ }
70
+
71
+ /**
72
+ * Whether a current note is still in `Notes (attention)` (Spec §2.7): it asks for a decision or is worth discussing,
73
+ * and the owner has not answered it in a way that lets it leave. The drawer lists the ones on the whole project; the
74
+ * others are the highlighted ❓ on their objects (D100).
75
+ */
76
+ export function inAttention(n: Note): boolean {
77
+ if (n.status !== 'Current') return false;
78
+ const v = latest(n);
79
+ const resp = n.ownerResponse ?? null;
80
+ const left = resp === 'Decided' || resp === 'Delegated' || resp === 'No action needed' || (v.ask === 'Worth discussing' && resp === 'Discussed');
81
+ return (v.ask === 'For your decision' || v.ask === 'Worth discussing') && !left;
82
+ }
83
+
84
+ export function recentChanges(store: ProjectStore, limit = RECENT_CHANGES): ChangeRecord[] {
85
+ return store.changes.all().sort((a, b) => b.at.localeCompare(a.at)).slice(0, limit).reverse();
86
+ }
87
+
88
+ /** The objects a recent change affected, or that replaced or were replaced through one: drawn even when no longer in force. */
89
+ export function recentlyChanged(recent: readonly ChangeRecord[], relations: readonly Pick<GraphRelation, 'type' | 'from' | 'to'>[]): Set<string> {
90
+ const ids = new Set(recent.flatMap((c) => c.affects));
91
+ for (const r of relations) if (r.type === 'replaces' && (ids.has(r.from) || ids.has(r.to))) { ids.add(r.from); ids.add(r.to); }
92
+ return ids;
93
+ }
94
+
95
+ /** The fields of a node that only its placement on the story map reads (ui/placement.js), each present only when set. */
96
+ export type PlacementFields = Pick<ViewNode, 'servesOrder' | 'foundation' | 'wholeProductWhy' | 'wholePlanWhy' | 'noPlanWhy' | 'noAreaWhy'>;
97
+
98
+ /**
99
+ * What a node's placement reads besides the node itself: one definition for the view the workbench draws and for the
100
+ * program's own count of what is not placed (DB; keeper/organize/workbench-placement.ts).
101
+ */
102
+ export function placementFields(store: ProjectStore, n: Pick<GraphNode, 'refKind' | 'refId'>): PlacementFields {
103
+ const thread = n.refKind === 'thread' ? store.threads.get(n.refId) : undefined;
104
+ const servesOrder = thread?.serves.map((s) => s.referenceId);
105
+ // D101 and CM (Spec §1.4): the foundation column and the whole-product reason ride on the node only when set.
106
+ const ref = n.refKind === 'reference' ? store.reference.get(n.refId) : undefined;
107
+ const foundation = ref?.category === 'Area' && ref.foundation === true;
108
+ const wholeProductWhy = ref?.wholeProductWhy?.trim() || null;
109
+ const wholePlanWhy = thread?.wholePlanWhy?.trim() || null;
110
+ // CQ (D104): the recorded reasons the records lead nowhere, shown where the placement detail shows the whole-plan reason.
111
+ const noPlanWhy = thread?.noPlanWhy?.trim() || null;
112
+ const noAreaWhy = thread?.noAreaWhy?.trim() || null;
113
+ return { ...(servesOrder ? { servesOrder } : {}), ...(foundation ? { foundation: true as const } : {}), ...(wholeProductWhy ? { wholeProductWhy } : {}), ...(wholePlanWhy ? { wholePlanWhy } : {}), ...(noPlanWhy ? { noPlanWhy } : {}), ...(noAreaWhy ? { noAreaWhy } : {}) };
114
+ }
115
+
116
+ /**
117
+ * The earlier generations as the story map reads them (Spec §2.12, D85, D100): their plans and work items as the Keeper
118
+ * recorded them. CZ: `carriedIds` are the items carried on under the same number — current work, placed in its current plan.
119
+ */
120
+ export function generationsView(store: ProjectStore) {
121
+ const carried = carriedOn(store);
122
+ return store.generations.all().sort((a, b) => a.ended.at.localeCompare(b.ended.at)).map((g) => ({ id: g.id, name: g.name, started: g.started, ended: g.ended, endedBy: { label: g.endedBy.label, line: g.endedBy.line ?? null }, workIds: g.workIds, carriedIds: g.workIds.filter((id) => carried.has(id)), planIds: generationPlanIds(store, g) }));
123
+ }
124
+
125
+ export function graphView(store: ProjectStore, project: Project) {
126
+ // What was removed from the project's current version stays in the assets as history, but is neither drawn nor
127
+ // listed, and nothing is drawn to it (§2.1, D61; CKC-09 AC-32): it appears only, struck through, in the changes.
128
+ const removed = new Set(store.nodes.all().filter((n) => n.validity === 'Removed' || isRemoved(store, n.refId)).map((n) => n.id));
129
+ const nodes = store.nodes.all().filter((n) => !removed.has(n.id));
130
+ const relations = store.relations.all().filter((r) => !removed.has(r.from) && !removed.has(r.to));
131
+ const byId = new Map(nodes.map((n) => [n.id, n]));
132
+ const out = new Map<string, GraphRelation[]>();
133
+ const inc = new Map<string, GraphRelation[]>();
134
+ for (const r of relations) {
135
+ if (!out.has(r.from)) out.set(r.from, []);
136
+ out.get(r.from)!.push(r);
137
+ if (!inc.has(r.to)) inc.set(r.to, []);
138
+ inc.get(r.to)!.push(r);
139
+ }
140
+ const currentNotes = store.notes.filter((n) => n.status === 'Current');
141
+ const notesOn = new Map<string, Note[]>();
142
+ for (const n of currentNotes) for (const id of noteOnIds(store, n)) { if (!notesOn.has(id)) notesOn.set(id, []); notesOn.get(id)!.push(n); }
143
+ const marksOn = new Map<string, { kind: string; clue: string }[]>();
144
+ for (const m of store.marks.filter((x) => !x.closed)) { if (!marksOn.has(m.targetId)) marksOn.set(m.targetId, []); marksOn.get(m.targetId)!.push({ kind: m.kind, clue: m.clue }); }
145
+ const recent = recentChanges(store);
146
+ const recentIds = recentlyChanged(recent, relations);
147
+ const changedAt = new Map<string, string>();
148
+ for (const c of store.changes.all()) for (const id of c.affects) if ((changedAt.get(id) ?? '') < c.at) changedAt.set(id, c.at);
149
+ for (const r of relations) if (r.type === 'replaces') { const at = changedAt.get(r.from) ?? changedAt.get(r.to); if (at) { if ((changedAt.get(r.from) ?? '') < at) changedAt.set(r.from, at); if ((changedAt.get(r.to) ?? '') < at) changedAt.set(r.to, at); } }
150
+ const hasWork = nodes.some((n) => n.category === 'Work item');
151
+ // `Update pending` (§1.11): a work item, an area — its own item or the work its understanding is made of — or any other
152
+ // product reference item that a change waiting for the next round affects (keeper/organize/update-pending.ts).
153
+ const pendingThreads = new Set(store.threads.filter((t) => (t.pendingSourceIds ?? []).length > 0).map((t) => t.id));
154
+ const pendingAreas = new Set(store.areas.filter((a) => (a.pendingSourceIds ?? []).length > 0).map((a) => a.referenceId));
155
+ const pendingRefs = new Set(store.reference.filter((r) => (r.pendingSourceIds ?? []).length > 0).map((r) => r.id));
156
+ const workOf = (n: GraphNode): string | null => {
157
+ // sessions, runs, results and reviews fold under the work item they relate to
158
+ for (const r of [...(out.get(n.id) ?? []), ...(inc.get(n.id) ?? [])]) {
159
+ const other = r.from === n.id ? r.to : r.from;
160
+ if (byId.get(other)?.category === 'Work item') return other;
161
+ }
162
+ return null;
163
+ };
164
+ const FOLDABLE = new Set(['Session', 'Run', 'Result', 'Review', 'Test']);
165
+ const viewNodes: ViewNode[] = nodes.map((n) => {
166
+ const notes = notesOn.get(n.id) ?? [];
167
+ const ask = notes.map((x) => latest(x).ask).sort((a, b) => (ASK_ORDER[b] ?? 0) - (ASK_ORDER[a] ?? 0))[0] ?? null;
168
+ const outs = out.get(n.id) ?? [];
169
+ const ins = inc.get(n.id) ?? [];
170
+ let noLink = false;
171
+ if (hasWork && n.validity === 'Current') {
172
+ if (n.category === 'Work item' && n.progress === 'In progress' && !outs.some((r) => r.type === 'serves' || r.type === 'implements' || r.type === 'refines')) noLink = true;
173
+ if ((n.category === 'Goal' || n.category === 'Area') && !ins.some((r) => r.type === 'serves' || r.type === 'refines' || r.type === 'implements') && !outs.some((r) => r.type === 'refines')) noLink = true;
174
+ }
175
+ let group: ViewNode['group'] = null;
176
+ const parentWork = FOLDABLE.has(n.category) ? workOf(n) : null;
177
+ if (n.category === OWNER_WORDS) group = OWNER_WORDS_GROUP;
178
+ if (n.category === 'Work item' && n.validity === 'Current' && n.progress === 'Done' && !recentIds.has(n.id) && notes.length === 0) group = 'Existing foundation';
179
+ if (FOLDABLE.has(n.category) && !parentWork && !ins.length && !outs.length) group = 'Unplaced';
180
+ if (FOLDABLE.has(n.category) && !parentWork && (ins.length || outs.length) && ![...ins, ...outs].some((r) => { const o = byId.get(r.from === n.id ? r.to : r.from); return o && (o.category === 'Area' || o.category === 'Goal' || o.category === 'Work item'); })) group = 'Unplaced';
181
+ const updatePending = (n.refKind === 'thread' && pendingThreads.has(n.refId)) || (n.refKind === 'reference' && (pendingRefs.has(n.refId) || (n.category === 'Area' && pendingAreas.has(n.refId))));
182
+ const summary = n.refKind === 'reference' ? (store.reference.get(n.refId)?.text ?? null) : n.refKind === 'thread' ? (store.threads.get(n.refId)?.doing ?? null) : n.refKind === 'change' ? (store.changes.get(n.refId)?.summary ?? null) : null;
183
+ return { ...n, ...placementFields(store, n), noteCount: notes.length, noteAttention: notes.filter(inAttention).length, noteAsk: ask, marks: marksOn.get(n.id) ?? [], updatePending, noEstablishedLink: noLink, group, recentChange: recentIds.has(n.id), changedAt: changedAt.get(n.id) ?? null, parentId: n.category === 'Work item' ? n.areaId : parentWork ?? n.parentWorkId, summary: summary ? summary.slice(0, 240) : null };
184
+ });
185
+ const relationNotes = new Map<string, { all: number; attention: number }>();
186
+ for (const [id, list] of notesOn) if (store.relations.has(id)) relationNotes.set(id, { all: list.length, attention: list.filter(inAttention).length });
187
+ // The project's rules are not on the graph (R-50; CKC-21 AC-12), and neither is what hangs on them: a mark on a rule
188
+ // is shown with the rule in Project scope. A mark on a removed object goes with it.
189
+ const graphMarks = store.marks.filter((m) => !m.closed && !store.rules.has(m.targetId) && !removed.has(m.targetId));
190
+ return {
191
+ nodes: viewNodes,
192
+ relations: relations.map((r) => ({ ...r, noteCount: relationNotes.get(r.id)?.all ?? 0, noteAttention: relationNotes.get(r.id)?.attention ?? 0 })),
193
+ // The earlier generations (Spec §2.12, D85, D100), for the story map's rolled bands above the current plan: their
194
+ // plans and work items as the Keeper recorded them; where each item went is read off the relations (ui/placement.js).
195
+ // CZ: `carriedIds` are the items carried on under the same number — current work, placed in its current plan.
196
+ generations: generationsView(store),
197
+ marks: graphMarks,
198
+ recentChangeIds: recent.map((c) => c.id),
199
+ changes: store.changes.all().map((c) => ({ id: c.id, at: c.at, title: c.title, effect: c.effect, affects: c.affects, propagation: c.propagation })),
200
+ categories: [...new Set(nodes.map((n) => n.category))],
201
+ relationTypes: [...new Set(relations.map((r) => r.type))],
202
+ markKinds: [...new Set(graphMarks.map((m) => m.kind))],
203
+ counts: { existingFoundation: viewNodes.filter((n) => n.group === 'Existing foundation').length, unplaced: viewNodes.filter((n) => n.group === 'Unplaced').length, replacedOrDeferred: viewNodes.filter((n) => n.validity === 'Replaced' || n.validity === 'Deferred' || n.validity === 'Abandoned').length },
204
+ };
205
+ }
206
+
207
+ /** Where a note came from (Spec §4.1), with the changes a follow-up note is about named by their titles. */
208
+ export function cameFromView(store: ProjectStore, n: Note) {
209
+ const c = n.cameFrom;
210
+ if (!c) return null;
211
+ return { kind: c.kind, jobKind: c.jobKind, changes: c.changeIds.map((id) => ({ id, title: store.changes.get(id)?.title ?? id })) };
212
+ }
213
+
214
+ /**
215
+ * What a row of the bottom strip is about (Spec §6.2: mark · one sentence · object · time), named, so the page neither
216
+ * shows a bare id nor asks for each object in turn. A note's mount: the objects it hangs on, a relation written as its
217
+ * type and its two ends; `project` hangs on nothing. What is not a note says where it belongs in the same slot.
218
+ */
219
+ export interface RowObject { readonly kind: NoteMount['kind'] | 'scope' | 'request' | 'round'; readonly objects: readonly { readonly id: string; readonly kind: 'node' | 'relation'; readonly label: string }[] }
220
+ export function mountView(store: ProjectStore, mount: NoteMount): RowObject {
221
+ // A note on the whole project hangs on nothing, even when ids were filled in with the project id.
222
+ if (mount.kind === 'project') return { kind: 'project', objects: [] };
223
+ const nodeLabel = (id: string): string | null => store.nodes.get(id)?.label ?? store.reference.get(id)?.name ?? store.threads.get(id)?.title ?? null;
224
+ const objects: { id: string; kind: 'node' | 'relation'; label: string }[] = [];
225
+ for (const id of mount.ids) {
226
+ if (mount.kind === 'relation') {
227
+ const rel = store.relations.get(id);
228
+ if (!rel) continue; // an id the project does not have is not listed, and is not shown as itself
229
+ const from = nodeLabel(rel.from);
230
+ const to = nodeLabel(rel.to);
231
+ objects.push({ id, kind: 'relation', label: `${rel.type}: ${from ?? '…'} → ${to ?? '…'}` });
232
+ continue;
233
+ }
234
+ const label = nodeLabel(id);
235
+ if (!label) continue;
236
+ objects.push({ id, kind: 'node', label });
237
+ }
238
+ return { kind: mount.kind, objects };
239
+ }
240
+
241
+ function noteRow(store: ProjectStore, n: Note) {
242
+ const v = latest(n);
243
+ return { id: n.id, title: v.title, preview: v.preview, ask: v.ask, mount: n.mount, status: n.status, ownerResponse: n.ownerResponse, updatedAt: n.updatedAt, version: v.version, cameFrom: cameFromView(store, n) };
244
+ }
245
+
246
+ /**
247
+ * Where a piece of a Follow up round's news is, as one of the jumps the workbench already has (§3.8: "各带位置"; CKC-07
248
+ * AC-27, CKC-24 AC-15): the object in the process view (a work item, a plan or a document on the Project graph), a
249
+ * territory in `Code`, the note, the semantic patch. `label` names the destination for the page's selection. Null when
250
+ * what it hangs on is not on the workbench (any more): the entry is still listed, with its position, and has no jump.
251
+ */
252
+ export interface NewsGo { readonly to: 'process' | 'code' | 'note' | 'patch'; readonly id: string; readonly label: string }
253
+ export type NewsItemView = RoundNewsItem & { readonly go: NewsGo | null };
254
+ /** A round's news as `Notes (attention)` carries it: the program's count (round-news.ts), each entry with its jump. */
255
+ export type RoundNewsView = Omit<RoundNews, 'breakpoints' | 'sendbacks' | 'sixThings' | 'patches' | 'notes'> & {
256
+ readonly breakpoints: readonly NewsItemView[];
257
+ readonly sendbacks: readonly NewsItemView[];
258
+ readonly sixThings: readonly NewsItemView[];
259
+ readonly patches: readonly NewsItemView[];
260
+ readonly notes: readonly NewsItemView[];
261
+ };
262
+
263
+ /**
264
+ * The jump of one news entry, by what it is: a patch opens itself; a note (written or updated, or a note that became one
265
+ * of the six things) opens itself; anything else goes to the object it hangs on — a code territory in `Code`, any other
266
+ * object drawn on the Project graph in the process view.
267
+ */
268
+ function newsGo(store: ProjectStore, kind: 'breakpoints' | 'sendbacks' | 'sixThings' | 'patches' | 'notes', item: RoundNewsItem): NewsGo | null {
269
+ if (kind === 'patches') { const p = store.patches.get(item.id); return p ? { to: 'patch', id: p.id, label: `${p.number} ${p.title}` } : null; }
270
+ const note = kind === 'notes' || kind === 'sixThings' ? store.notes.get(item.id) : undefined;
271
+ if (note) return { to: 'note', id: note.id, label: latest(note).title };
272
+ const id = item.objectId;
273
+ if (!id) return null;
274
+ const territory = store.territories.get(id);
275
+ if (territory) return { to: 'code', id, label: territory.name };
276
+ const node = store.nodes.get(id);
277
+ return node && node.validity !== 'Removed' && !isRemoved(store, node.refId) ? { to: 'process', id, label: node.label } : null;
278
+ }
279
+
280
+ function newsView(store: ProjectStore, news: RoundNews): RoundNewsView {
281
+ const withGo = (kind: 'breakpoints' | 'sendbacks' | 'sixThings' | 'patches' | 'notes') => news[kind].map((i) => ({ ...i, go: newsGo(store, kind, i) }));
282
+ return { ...news, breakpoints: withGo('breakpoints'), sendbacks: withGo('sendbacks'), sixThings: withGo('sixThings'), patches: withGo('patches'), notes: withGo('notes') };
283
+ }
284
+
285
+ /** The news of a Follow up round as lines a person reads, each thing with its position (§3.8: "各带位置"). */
286
+ function newsLines(news: RoundNews): string[] {
287
+ const section = (title: string, items: readonly RoundNewsItem[]) => (items.length ? [`${title} (${items.length}):`, ...items.map((i) => `- ${i.label} — ${i.position}${i.detail ? ` (${i.detail})` : ''}`)] : []);
288
+ return [
289
+ ...section('Breakpoints newly lit', news.breakpoints),
290
+ ...section('Send-backs new or moved', news.sendbacks),
291
+ ...section('Newly among the six things', news.sixThings),
292
+ ...section('Semantic patches confirmed', news.patches),
293
+ ...section('Notes written or updated', news.notes),
294
+ ];
295
+ }
296
+
297
+ /**
298
+ * A Follow up round by the name the workbench gives it (§6.2, §6.9): its number among all rounds — the clerk round it
299
+ * closed with — or, for a round from before the clerk's rounds, the record's own number.
300
+ */
301
+ export function roundName(store: ProjectStore, recordId: string): string {
302
+ const clerk = store.clerkRounds.find((c) => c.followUpRoundId === recordId);
303
+ return `Follow up round ${clerk?.number ?? store.rounds.get(recordId)?.number ?? roundNumberOf(recordId)}`;
304
+ }
305
+
306
+ /** One row of the bottom strip (Spec §6.2): what `Notes (attention)` lists, and a round of `Since last visit` too. */
307
+ export interface StripItem {
308
+ readonly kind: string;
309
+ readonly id: string;
310
+ readonly label: string;
311
+ readonly detail: string;
312
+ readonly ask: string | null;
313
+ readonly at: string | null;
314
+ readonly object: RowObject;
315
+ readonly cameFrom?: ReturnType<typeof cameFromView>;
316
+ /** A Follow up result: its news with each entry's jump, its name, and how many objects its judgements left behind. */
317
+ readonly news?: RoundNewsView;
318
+ readonly roundName?: string;
319
+ readonly behind?: number;
320
+ readonly unassigned?: number;
321
+ /** A Follow up result: the summary after its name — what it made new, that it found nothing new, or what it left behind. */
322
+ readonly line?: string;
323
+ /** A Follow up result the owner has already opened. */
324
+ readonly seen?: boolean;
325
+ }
326
+
327
+ /**
328
+ * A Follow up round's result as one row (§3.8, §5.5, §6.2; D79; CKC-07 AC-27, CKC-24 AC-15): its name and, in one line,
329
+ * what it made new — breakpoints newly lit, send-backs new or moved, what newly became one of the six things, semantic
330
+ * patches confirmed, notes written or updated, each with its position and its jump — or that it found nothing new, and
331
+ * the objects its judgements left on the old understanding (§5.5). A result closed before rounds counted their news says
332
+ * what it left behind, or how many objects it judged. The same row stands in `Notes (attention)` until the owner has
333
+ * opened it, and in `Since last visit` for every round since their last visit.
334
+ */
335
+ function roundItem(store: ProjectStore, r: FollowUpRound): StripItem {
336
+ const result = r.result!;
337
+ const behind = result.counts.behind;
338
+ const unassigned = result.unassigned.length;
339
+ const name = roundName(store, r.id);
340
+ const line = roundLine(result);
341
+ const base = { kind: 'round', id: r.id, label: `${name}: ${line}`, line, ask: 'For information', at: r.endedAt, roundName: name, behind, unassigned, seen: Boolean(r.seenAt) };
342
+ const news = result.news ?? null;
343
+ if (!news) return { ...base, detail: result.summary, object: { kind: 'round', objects: [] } };
344
+ const lines = [...newsLines(news), ...(behind ? [`${behind} object${behind === 1 ? '' : 's'} still on the old understanding${unassigned ? `, ${unassigned} with no holder` : ''}: the round's result names them.`] : [])];
345
+ const located = new Map<string, string>();
346
+ for (const i of [...news.breakpoints, ...news.sendbacks, ...news.sixThings, ...news.patches, ...news.notes]) {
347
+ if (!i.objectId || located.has(i.objectId)) continue;
348
+ const label = store.nodes.get(i.objectId)?.label ?? store.reference.get(i.objectId)?.name ?? store.threads.get(i.objectId)?.title ?? store.territories.get(i.objectId)?.name ?? null;
349
+ if (label) located.set(i.objectId, label);
350
+ }
351
+ return { ...base, detail: lines.join('\n'), object: { kind: 'round', objects: [...located].map(([id, label]) => ({ id, kind: 'node' as const, label })) }, news: newsView(store, news) };
352
+ }
353
+
354
+ /** A round's result in one line, after its name: what it made new, or that it found nothing new; counted before rounds
355
+ * counted news, what it left on the old understanding — or how many objects it judged. */
356
+ function roundLine(result: RoundResult): string {
357
+ const news = result.news ?? null;
358
+ if (news) return news.nothingNew ? 'found nothing new' : news.statement;
359
+ const { behind, objectsJudged } = result.counts;
360
+ const unassigned = result.unassigned.length;
361
+ return [behind ? `${behind} object${behind === 1 ? '' : 's'} still on the old understanding` : `${objectsJudged} object${objectsJudged === 1 ? '' : 's'} judged`, unassigned ? `${unassigned} with no holder` : ''].filter(Boolean).join(', ');
362
+ }
363
+
364
+ /** Whether a round's result is something new to the owner: news, or — counted before rounds counted news — objects left behind. */
365
+ const roundHasNews = (x: StripItem) => (x.news ? !x.news.nothingNew : (x.behind ?? 0) > 0 || (x.unassigned ?? 0) > 0);
366
+
367
+ /** The Follow up rounds that have their result, in the order they ended. */
368
+ const finishedRounds = (store: ProjectStore) => store.rounds.filter((r) => r.endedAt !== null && r.result !== null).sort((a, b) => (a.endedAt ?? '').localeCompare(b.endedAt ?? '') || a.number - b.number);
369
+
370
+ export function needsYou(store: ProjectStore, project: Project) {
371
+ // A Follow up result carries its news with each entry's jump, and how many objects its judgements left behind (§5.5),
372
+ // so the page lists them by category without reading the lines of `detail` back.
373
+ const items: StripItem[] = [];
374
+ for (const n of store.notes.filter((x) => x.status === 'Current')) {
375
+ const v = latest(n);
376
+ if (inAttention(n)) items.push({ kind: 'note', id: n.id, label: v.title, detail: v.preview, ask: v.ask, at: n.updatedAt, object: mountView(store, n.mount), cameFrom: cameFromView(store, n) });
377
+ }
378
+ for (const q of project.scopeQuestions.filter((x) => !x.answer)) items.push({ kind: 'scope-question', id: q.id, label: q.question, detail: q.whyItMatters, ask: 'For your decision', at: null, object: { kind: 'scope', objects: [] } });
379
+ for (const j of store.jobs.filter((x) => x.kind === 'Your request' && x.status === 'Done' && !(x.task as { seen?: boolean } | null)?.seen)) items.push({ kind: 'job', id: j.id, label: `Done: ${j.scope.label}`, detail: (j.resultText ?? '').slice(0, 200), ask: 'For information', at: j.endedAt, object: { kind: 'request', objects: [] } });
380
+ // One item per Follow up round, not one per object (D56 item 3), and it leaves once the owner has opened it: Follow up
381
+ // is how the owner sees what is new (§3.8, §6.2; D79). A round that found nothing new has no item here — the top bar
382
+ // says so (`coverage.lastFollowUp`) — and neither has a result from before rounds counted their news that left no
383
+ // object behind. Once opened, a result stays reachable: in `Since last visit`, and in the Keeper view's rounds (§6.9).
384
+ for (const r of finishedRounds(store)) {
385
+ if (r.seenAt) continue;
386
+ const item = roundItem(store, r);
387
+ if (roundHasNews(item)) items.push(item);
388
+ }
389
+ return items;
390
+ }
391
+
392
+ function propagationSummary(c: ChangeRecord) {
393
+ const counts: Record<string, number> = {};
394
+ for (const p of c.propagation) counts[p.state] = (counts[p.state] ?? 0) + 1;
395
+ return counts;
396
+ }
397
+
398
+ export function changeRow(store: ProjectStore, c: ChangeRecord) {
399
+ const labelOf = (id: string) => store.nodes.get(id)?.label ?? store.reference.get(id)?.name ?? store.threads.get(id)?.title ?? id;
400
+ // Every object a record reached is named, not only the ones it changed: a reader given a bare id has to look each
401
+ // one up, and Spec §7.10 asks for the other end's name with its id (batch C, 2026-09-20). One that has since been
402
+ // removed from the project's current version is flagged, so it is struck through: the changes are the only place it
403
+ // still appears (§2.1, §6.3; CKC-09 AC-32).
404
+ const named = (id: string) => ({ id, label: labelOf(id), removed: isRemoved(store, id) });
405
+ const touched = [...new Set([...c.affects, ...(c.items ?? []).flatMap((i) => i.affects), ...(c.notJudged ?? []).map((n) => n.nodeId), ...c.propagation.map((p) => p.nodeId)])];
406
+ return {
407
+ ...c,
408
+ affectsLabels: c.affects.map(named), propagationLabels: c.propagation.map((p) => named(p.nodeId)), notJudgedLabels: (c.notJudged ?? []).map((n) => named(n.nodeId)),
409
+ removed: touched.filter((id) => isRemoved(store, id)).map((id) => ({ id, label: labelOf(id) })),
410
+ propagationSummary: propagationSummary(c), sources: c.sourceIds.map((id) => { const s = store.sources.get(id); return { id, label: s ? anchorLabel(s.anchor) : id, title: s?.title ?? id }; }),
411
+ };
412
+ }
413
+
414
+ export function overview(store: ProjectStore, project: Project, since: string | null, selectionId: string | null) {
415
+ const current = store.notes.filter((n) => n.status === 'Current');
416
+ // With nothing selected the default view lists every current note, not only the project-level ones: the owner has
417
+ // to see everything still open without clicking object by object to find it (D49). A selection narrows it to that
418
+ // object. Notes that ask something are listed in `Notes (attention)` in the default view.
419
+ const related = selectionId ? current.filter((n) => n.mount.ids.includes(selectionId)) : current;
420
+ const relook = store.judgements.size > 0;
421
+ const recent = recentChanges(store).map((c) => changeRow(store, c));
422
+ let sinceLastVisit: null | { since: string; rounds: StripItem[]; threads: { id: string; title: string; progress: string; updatedAt: string }[]; changes: { id: string; title: string; effect: string; at: string }[]; notes: { id: string; title: string; ask: string; at: string; object: RowObject }[]; jobs: number; failed: number } = null;
423
+ if (since) {
424
+ // Every Follow up round that ended since the last visit, each summed up in one line as `Notes (attention)` gives it —
425
+ // what it made new, or that it found nothing new — whether or not the owner has opened it since (§3.8, §5.5).
426
+ const rounds = finishedRounds(store).filter((r) => (r.endedAt ?? '') > since).map((r) => roundItem(store, r));
427
+ // A removed work item appears only through the change that removed it (§2.1), not as work that moved on.
428
+ const threads = store.threads.filter((t) => t.updatedAt > since && t.validity !== 'Removed').map((t) => ({ id: t.id, title: t.title, progress: t.progress, updatedAt: t.updatedAt })).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 12);
429
+ const changes = store.changes.filter((c) => c.updatedAt > since).sort((a, b) => a.at.localeCompare(b.at)).map((c) => ({ id: c.id, title: c.title, effect: c.effect, at: c.at })).slice(0, 12);
430
+ const notes = current.filter((n) => n.updatedAt > since).map((n) => ({ id: n.id, title: latest(n).title, ask: latest(n).ask, at: n.updatedAt, object: mountView(store, n.mount) })).slice(0, 12);
431
+ const jobs = store.jobs.filter((j) => j.status === 'Done' && (j.endedAt ?? '') > since).length;
432
+ const failed = store.jobs.filter((j) => j.status === 'Failed' && (j.endedAt ?? '') > since).length;
433
+ // Rounds that found nothing new are summed up with the rest, but alone they are no meaningful change: then there is
434
+ // no summary at all, and the top bar says the last round found nothing new (§3.8, §6.2).
435
+ if (threads.length || changes.length || notes.length || rounds.some(roundHasNews)) sinceLastVisit = { since, rounds, threads, changes, notes, jobs, failed };
436
+ }
437
+ // How the current notes divide up, so the default view can say why fewer are waiting than exist (owner D50).
438
+ const asking = current.filter((n) => latest(n).ask !== 'For information');
439
+ const noteCounts = {
440
+ current: current.length,
441
+ asking: asking.length,
442
+ information: current.length - asking.length,
443
+ answered: asking.length - needsYou(store, project).filter((x) => x.kind === 'note').length,
444
+ };
445
+ const row = (n: Note) => noteRow(store, n);
446
+ return { notes: related.map(row), noteCounts, relookDone: relook, needsYou: needsYou(store, project), recentChanges: recent, sinceLastVisit, projectNote: current.filter((n) => n.mount.kind === 'project').map(row)[0] ?? null, roundResults: roundResults(store) };
447
+ }
448
+
449
+ /**
450
+ * Every Follow up round that has its result, in the order they ended: its name, the clerk round it closed with and its
451
+ * one-line summary. The Keeper view's rounds open their results by these (§6.9: a finished Follow up round opens its
452
+ * result), so a result stays reachable after the owner has opened it once and it left `Notes (attention)`.
453
+ */
454
+ export function roundResults(store: ProjectStore): { readonly id: string; readonly name: string; readonly clerkRoundId: string | null; readonly at: string; readonly line: string; readonly seen: boolean }[] {
455
+ return finishedRounds(store).map((r) => ({
456
+ id: r.id, name: roundName(store, r.id), clerkRoundId: store.clerkRounds.find((c) => c.followUpRoundId === r.id)?.id ?? null,
457
+ at: r.endedAt!, line: roundLine(r.result!), seen: Boolean(r.seenAt),
458
+ }));
459
+ }
460
+
461
+ /** The excerpt goes whole. It used to be cut at 400 characters, which left `pk get` a half sentence it was not allowed
462
+ * to print (CKC-12 AC-31), so the reader got only an id where the original was right there (batch C, 2026-09-20). */
463
+ function sourceRef(store: ProjectStore, id: string) {
464
+ const s = store.sources.get(id);
465
+ return s ? { id, title: s.title, label: anchorLabel(s.anchor), excerpt: s.excerpt, usedAs: s.usedAs, availability: s.availability } : { id, title: id, label: id, excerpt: '', usedAs: null, availability: null };
466
+ }
467
+
468
+ /**
469
+ * A fact record's statements say who claimed what and when; `checks` says what was found when the material they rest
470
+ * on was checked against the code (§2.4, §6.4; CKC-09 AC-33).
471
+ */
472
+ export function factDetail(store: ProjectStore, id: string) {
473
+ const f = store.facts.get(id);
474
+ if (!f) return null;
475
+ const materials = [...new Set([...f.aboutSourceIds, ...claimMaterials([...f.statements, ...f.executionFacts])])];
476
+ return { ...f, statements: f.statements.map((s) => statementView(store, s)), executionFacts: f.executionFacts.map((s) => statementView(store, s)), checks: checksOn(store, materials), sources: f.aboutSourceIds.map((s) => sourceRef(store, s)), trace: store.traceFor('facts', f.id, 20) };
477
+ }
478
+
479
+ export function threadDetail(store: ProjectStore, id: string) {
480
+ const t = store.threads.get(id);
481
+ if (!t) return null;
482
+ return { ...t, executionFacts: t.executionFacts.map((s) => statementView(store, s)), qcFacts: t.qcFacts.map((s) => statementView(store, s)), checks: checksOn(store, claimMaterials([...t.executionFacts, ...t.qcFacts])), facts: t.factRecordIds.map((f) => { const x = store.facts.get(f); return x ? { id: x.id, title: x.title, statements: x.statements.length, open: x.openQuestions.length, asOf: x.asOf } : { id: f, title: f, statements: 0, open: 0, asOf: null }; }), servesLabels: t.serves.map((s) => ({ ...s, name: store.reference.get(s.referenceId)?.name ?? s.referenceId })), trace: store.traceFor('threads', t.id, 20) };
483
+ }
484
+
485
+ /**
486
+ * When what an entry waits for will be taken in (§3.8; §7.1: `Update pending` says it): at the next Follow up at the
487
+ * project's rhythm, after the current stretch of work when it organizes continuously, as it goes during the takeover.
488
+ */
489
+ export function pendingWhen(store: ProjectStore, project: Project): string {
490
+ const takingOver = store.coverage.takeover ? store.coverage.takeover.stage !== 'Daily' : false;
491
+ return takingOver ? 'as the takeover goes on' : scheduleOf(project).frequency === 'Continuous' ? 'after the current stretch of work' : 'at the next Follow up (on the owner’s schedule, or earlier when the owner starts one)';
492
+ }
493
+
494
+ /**
495
+ * `Update pending` on one object (§1.11, §6.4, §7.1): the changes it waits for — the coverage's own entries, each with the
496
+ * parts of it that reach the object (source ids to read now) and how — and when they will be taken in. An area's are its
497
+ * own item's and those of the work its understanding is made of.
498
+ */
499
+ function pendingOf(store: ProjectStore, project: Project, waits: readonly (readonly PendingWait[] | undefined)[], ids: readonly (readonly string[] | undefined)[]) {
500
+ const byRef = new Map<string, PendingWait>();
501
+ for (const w of waits.flatMap((x) => x ?? [])) {
502
+ const had = byRef.get(w.ref);
503
+ byRef.set(w.ref, had ? { ...had, sourceIds: [...new Set([...had.sourceIds, ...w.sourceIds])], reasons: [...new Map([...had.reasons, ...w.reasons].map((r) => [`${r.link}|${r.detail}`, r])).values()] } : w);
504
+ }
505
+ const sourceIds = [...new Set(ids.flatMap((x) => x ?? []))];
506
+ const waitsFor = [...byRef.values()].sort((a, b) => a.since.localeCompare(b.since) || a.label.localeCompare(b.label));
507
+ return { updatePending: sourceIds.length > 0 || waitsFor.length > 0, pendingSourceIds: sourceIds, waitsFor, pendingWhen: sourceIds.length || waitsFor.length ? pendingWhen(store, project) : null };
508
+ }
509
+
510
+ // ───────────────────────── an object's propagation state (§2.10, §5.5, §6.4) ─────────────────────────
511
+
512
+ /** A change record as an object's propagation names it: its title, its piece of work, its effect, and when. */
513
+ export interface ChangeRef { readonly id: string; readonly title: string; readonly work: string | null; readonly effect: ChangeEffect; readonly at: string }
514
+ const changeRef = (c: ChangeRecord): ChangeRef => ({ id: c.id, title: c.title, work: c.work?.label ?? null, effect: c.effect, at: c.at });
515
+
516
+ /** One item an object still lacks (§2.10: "还差哪几项、各差什么"), with the change it comes from. */
517
+ export interface LackView {
518
+ readonly changeId: string;
519
+ readonly itemId: string;
520
+ /** What the object still has to follow, in the judgement's words; empty when the judgement did not say. */
521
+ readonly what: string;
522
+ /** The record it comes from; null when that record is no longer in the assets. */
523
+ readonly change: ChangeRef | null;
524
+ /** The item of that record it lacks; null when the record reads as one item (written before records had items). */
525
+ readonly item: { readonly title: string; readonly effect: ChangeEffect; readonly at: string } | null;
526
+ }
527
+
528
+ /**
529
+ * An object's propagation as its details show it (Spec §5.5: its current state, which records it covers, which items it
530
+ * still lacks; §2.10; QC AH #6): the state its latest round gave it, with the round, when and why; what it still lacks,
531
+ * item by item, each with the change it comes from; the records that state covers; and the records that reached it
532
+ * since and wait for the next Follow up.
533
+ */
534
+ export interface PropagationView {
535
+ readonly state: Propagation;
536
+ /** The Follow up round whose one judgement gave the state, and whether it has ended (its result opens then); null when
537
+ * no round has judged the object — the state is then what the per-change entries say. */
538
+ readonly round: { readonly id: string; readonly name: string; readonly ended: boolean } | null;
539
+ readonly at: string | null;
540
+ readonly reason: string;
541
+ /** The object changed after it was judged: the next Follow up judges again what it still lacks (§5.5, §2.10). */
542
+ readonly movedSince: boolean;
543
+ readonly lacks: readonly LackView[];
544
+ /** The records the state covers, each with how many of its items, and where the object stands on that record. */
545
+ readonly covers: readonly { readonly change: ChangeRef; readonly items: number; readonly state: Propagation; readonly reason: string }[];
546
+ /** Records whose items reached it and have not been judged yet: they wait for the next Follow up. */
547
+ readonly waiting: readonly ChangeRef[];
548
+ }
549
+
550
+ /**
551
+ * The reading is the one the context pack makes (context/assemble.ts `behindOn`), so the owner and an agent are told the
552
+ * same: the object's latest judgement — rounds ordered by number, never as strings — carries every lack still open, since
553
+ * each round judges what reached the object together with what it still lacked (§2.10, §5.5). An object no round has
554
+ * judged has only the per-change entries, which say how it stood against each record. Null for what is never judged — a
555
+ * point-in-time record or a decision (§2.10) — and for an object no change has reached.
556
+ */
557
+ export function propagationOf(store: ProjectStore, nodeId: string): PropagationView | null {
558
+ if (notJudgedReason(store, nodeId) !== null) return null;
559
+ const latest = store.propagation.filter((j) => j.nodeId === nodeId).sort((a, b) => roundNumberOf(b.roundId) - roundNumberOf(a.roundId))[0] ?? null;
560
+ const entries = store.changes.all().flatMap((change) => change.propagation.filter((p) => p.nodeId === nodeId).map((entry) => ({ change, entry })));
561
+ if (!latest && entries.length === 0) return null;
562
+ const covered = (changeId: string, itemId: string) => latest?.covers.some((c) => c.changeId === changeId && (c.itemId === itemId || c.itemId === '')) ?? false;
563
+ // An entry is `Not yet checked` when it is new, or when an item reached the object after its judgement (graph.ts
564
+ // `seedPropagation`): what it carries that the latest judgement did not cover waits for the next round.
565
+ const waiting = entries
566
+ .filter(({ change, entry }) => entry.state === 'Not yet checked' && (entry.itemIds ?? itemsOf(change).map((i) => i.id)).some((itemId) => !covered(change.id, itemId)))
567
+ .map(({ change }) => changeRef(change)).sort((a, b) => a.at.localeCompare(b.at));
568
+ const lackOf = (l: LackedItem): LackView => {
569
+ const change = store.changes.get(l.changeId);
570
+ const item = change && l.itemId ? itemsOf(change).find((i) => i.id === l.itemId) ?? null : null;
571
+ return { changeId: l.changeId, itemId: l.itemId, what: l.what, change: change ? changeRef(change) : null, item: item ? { title: item.title, effect: item.effect, at: item.at } : null };
572
+ };
573
+ const inOrder = (a: LackView, b: LackView) => (a.item?.at ?? a.change?.at ?? '').localeCompare(b.item?.at ?? b.change?.at ?? '');
574
+ if (latest) {
575
+ // Only `Still on old understanding` lacks anything: judging it names the lacked items, and the program keeps that state
576
+ // while any is left (adjustment.ts `judgeObject`).
577
+ const lacks = latest.state === 'Still on old understanding' ? latest.lacks.map(lackOf).sort(inOrder) : [];
578
+ const lacking = new Set(lacks.map((l) => l.changeId));
579
+ const items = new Map<string, number>();
580
+ for (const c of latest.covers) items.set(c.changeId, (items.get(c.changeId) ?? 0) + 1);
581
+ // Where it stands on each record follows from the one judgement, as the record's own entry does.
582
+ const covers = [...items].flatMap(([changeId, n]) => {
583
+ const change = store.changes.get(changeId);
584
+ if (!change) return [];
585
+ const state: Propagation = latest.state === 'Still on old understanding' && !lacking.has(changeId) ? 'Updated' : latest.state;
586
+ return [{ change: changeRef(change), items: n, state, reason: '' }];
587
+ }).sort((a, b) => a.change.at.localeCompare(b.change.at));
588
+ const record = store.rounds.get(latest.roundId);
589
+ return {
590
+ state: latest.state, round: { id: latest.roundId, name: roundName(store, latest.roundId), ended: Boolean(record?.endedAt && record.result) },
591
+ at: latest.at, reason: latest.sourceOrReason, movedSince: objectUpdatedAt(store, nodeId) > latest.objectUpdatedAt,
592
+ lacks, covers, waiting,
593
+ };
594
+ }
595
+ const judged = entries.filter(({ entry }) => entry.state !== 'Not yet checked').sort((a, b) => a.change.at.localeCompare(b.change.at));
596
+ const lacks = judged.filter(({ entry }) => entry.state === 'Still on old understanding').map(({ change, entry }) => lackOf({ changeId: change.id, itemId: '', what: entry.sourceOrReason }));
597
+ const last = [...judged].sort((a, b) => b.entry.updatedAt.localeCompare(a.entry.updatedAt))[0] ?? null;
598
+ const state: Propagation = lacks.length ? 'Still on old understanding' : waiting.length || !last ? 'Not yet checked' : last.entry.state;
599
+ return {
600
+ state, round: null, at: last?.entry.updatedAt ?? null, reason: '', movedSince: false, lacks,
601
+ covers: judged.map(({ change, entry }) => ({ change: changeRef(change), items: entry.itemIds?.length ?? itemsOf(change).length, state: entry.state, reason: entry.sourceOrReason })),
602
+ waiting,
603
+ };
604
+ }
605
+
606
+ export function nodeDetail(store: ProjectStore, project: Project, id: string) {
607
+ const node = store.nodes.get(id);
608
+ if (!node) return null;
609
+ const relations = store.relations.filter((r) => r.from === id || r.to === id).map((r) => ({ ...r, fromLabel: store.nodes.get(r.from)?.label ?? r.from, toLabel: store.nodes.get(r.to)?.label ?? r.to }));
610
+ const notes = store.notes.filter((n) => n.status === 'Current' && n.mount.ids.includes(id)).map((n) => noteRow(store, n));
611
+ const changes = store.changes.filter((c) => c.affects.includes(id)).sort((a, b) => a.at.localeCompare(b.at)).map((c) => changeRow(store, c));
612
+ const marks = store.marks.filter((m) => m.targetId === id && !m.closed);
613
+ // Its propagation (§5.5, §6.4): its state, the records that state covers, and what it still lacks, item by item.
614
+ const propagation = propagationOf(store, id);
615
+ const base = { node, relations, notes, changes, marks, propagation, sources: node.sourceIds.map((s) => sourceRef(store, s)), trace: store.traceFor(node.refKind === 'reference' ? 'reference' : node.refKind === 'thread' ? 'threads' : node.refKind === 'change' ? 'changes' : 'sources', node.refId, 20) };
616
+ const none = { updatePending: false, pendingSourceIds: [] as string[], waitsFor: [] as PendingWait[], pendingWhen: null };
617
+ if (node.refKind === 'reference') {
618
+ const item = store.reference.get(node.refId);
619
+ const area = item?.category === 'Area' ? store.areas.find((a) => a.referenceId === item.id) : undefined;
620
+ const threads = area ? area.contributions.map((c) => ({ ...c, thread: threadDetail(store, c.threadId) })) : [];
621
+ // The authority layer and, for a decision that asks for something, its carry-out (§1.9, §2.2, §6.4; CKC-09 AC-33).
622
+ return { ...base, reference: item ?? null, area: area ?? null, contributions: threads, authority: authorityOf(store, node.refId), carryOut: carryOutOf(store, node.refId), ...pendingOf(store, project, [item?.waitsFor, area?.waitsFor], [item?.pendingSourceIds, area?.pendingSourceIds]) };
623
+ }
624
+ if (node.refKind === 'thread') { const t = store.threads.get(node.refId); return { ...base, thread: threadDetail(store, node.refId), ...pendingOf(store, project, [t?.waitsFor], [t?.pendingSourceIds]) }; }
625
+ if (node.refKind === 'change') return { ...base, change: store.changes.get(node.refId) ? changeRow(store, store.changes.get(node.refId)!) : null, ...none };
626
+ return { ...base, ...none };
627
+ }
628
+
629
+ export function relationDetail(store: ProjectStore, id: string) {
630
+ const r = store.relations.get(id);
631
+ if (!r) return null;
632
+ return {
633
+ ...r, fromLabel: store.nodes.get(r.from)?.label ?? r.from, toLabel: store.nodes.get(r.to)?.label ?? r.to,
634
+ evidenceSources: r.evidence.sourceIds.map((s) => sourceRef(store, s)),
635
+ evidenceFacts: r.evidence.factRecordIds.map((f) => ({ id: f, title: store.facts.get(f)?.title ?? f })),
636
+ notes: store.notes.filter((n) => n.status === 'Current' && n.mount.ids.includes(id)).map((n) => noteRow(store, n)),
637
+ trace: store.traceFor('relations', r.id, 20),
638
+ };
639
+ }