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,230 @@
1
+ /**
2
+ * Graph derivation (Spec §1.5, §1.6): nodes are the project's own objects already in the assets;
3
+ * relations come from the claims the assets carry (serves, refines, depends on, replaces, affects)
4
+ * plus relations the Keeper wrote directly. Derivation is deterministic and keeps existing
5
+ * assessments; nothing here calls a model.
6
+ */
7
+ import type { ChangeRecord, GraphNode, GraphRelation, Project, PropagationEntry, ReferenceItem, Source, WorkThread } from '../../model/types.ts';
8
+ import type { NodeCategory } from '../../model/vocab.ts';
9
+ import { stableId } from '../../model/ids.ts';
10
+ import type { ProjectStore } from '../../store/project-store.ts';
11
+ import { anchorLabel } from '../../sources/anchor.ts';
12
+ import { downstreamOfChange } from '../adjustment.ts';
13
+ import { isNumberOnly } from './entries.ts';
14
+
15
+ const now = () => new Date().toISOString();
16
+
17
+ function referenceCategory(item: ReferenceItem): NodeCategory {
18
+ return item.category === 'Boundary' ? 'Decision' : item.category;
19
+ }
20
+
21
+ function sourceCategory(source: Source): NodeCategory {
22
+ switch (source.anchor.kind) {
23
+ case 'session': return 'Session';
24
+ case 'command': return 'Run';
25
+ default:
26
+ // What the material is used as decides the observed-reality category (§1.5): a QC or review report, a test, or a result.
27
+ if (source.usedAs === 'QC') return 'Review';
28
+ if (source.usedAs === 'Test') return 'Test';
29
+ return 'Result';
30
+ }
31
+ }
32
+
33
+ /** The Area an item belongs to: itself, or the first Area reached through `refines`. */
34
+ export function areaOf(store: ProjectStore, referenceId: string, depth = 0): string | null {
35
+ const item = store.reference.get(referenceId);
36
+ if (!item || depth > 6) return null;
37
+ if (item.category === 'Area') return item.id;
38
+ for (const up of item.refines) { const a = areaOf(store, up, depth + 1); if (a) return a; }
39
+ return null;
40
+ }
41
+
42
+ export function threadArea(store: ProjectStore, thread: WorkThread): string | null {
43
+ for (const s of thread.serves) { const a = areaOf(store, s.referenceId); if (a) return a; }
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * What an item is called on the workbench (CJ): its name; a name that is the number alone (`D1`, written before the writers
49
+ * refused it) shows the first sentence of its text after the number, so the List and the Graph read as the document does.
50
+ */
51
+ export function displayName(name: string, text: string): string {
52
+ if (!isNumberOnly(name)) return name;
53
+ const num = name.trim().replace(/^[\s[((]+|[\s\])).::·、—–-]+$/g, '');
54
+ const body = text.replace(/\*\*|__|`/g, '').replace(/\s+/g, ' ').trim();
55
+ const rest = body.startsWith(num) ? body.slice(num.length).replace(/^[\s·::.、—–-]+/, '') : body;
56
+ const first = (/^(.+?[。!?!?;;]|.+?\.(?=\s|$))/u.exec(rest)?.[1] ?? rest).trim();
57
+ if (!first) return name;
58
+ return `${num} · ${first.length > 80 ? `${first.slice(0, 79)}…` : first}`;
59
+ }
60
+
61
+ /** A node as the derivation writes it, before the project and its times are put on it. */
62
+ export type DerivedNode = Omit<GraphNode, 'projectId' | 'updatedAt' | 'createdAt'>;
63
+
64
+ /** The node of a reference item: one definition for the derivation and for the program's own reading of the workbench (workbench-placement.ts). */
65
+ export function referenceNode(store: ProjectStore, item: ReferenceItem): DerivedNode {
66
+ return { id: item.id, category: referenceCategory(item), label: displayName(item.name, item.text), refKind: 'reference', refId: item.id, validity: item.validity, progress: item.progress, basis: item.basis, attribution: item.attribution, sourceIds: item.sourceIds, areaId: item.category === 'Area' ? item.id : areaOf(store, item.id), parentWorkId: null, replacedBy: item.replacedBy };
67
+ }
68
+
69
+ const factSourcesOf = (store: ProjectStore, thread: WorkThread): string[] => [...new Set((thread.factRecordIds ?? []).flatMap((f) => store.facts.get(f)?.aboutSourceIds ?? []))];
70
+
71
+ /** The node of a work item. */
72
+ export function threadNode(store: ProjectStore, thread: WorkThread): DerivedNode {
73
+ return { id: thread.id, category: 'Work item', label: thread.ids.length && !thread.title.includes(thread.ids[0]) ? `${thread.ids[0]} ${thread.title}` : thread.title, refKind: 'thread', refId: thread.id, validity: thread.validity, progress: thread.progress, acceptance: thread.acceptance ?? '', basis: 'Explicit', attribution: thread.attribution, sourceIds: factSourcesOf(store, thread), areaId: threadArea(store, thread), parentWorkId: null, replacedBy: thread.replacedBy };
74
+ }
75
+
76
+ /** A relation the assets' own claims state: what an item refines, serves, depends on or replaces. */
77
+ export interface ClaimedRelation {
78
+ readonly type: GraphRelation['type'];
79
+ readonly from: string;
80
+ readonly to: string;
81
+ readonly claim: string;
82
+ readonly basis: GraphRelation['basis'];
83
+ readonly evidence: GraphRelation['evidence'];
84
+ /** An area's contribution restates a `serves` the work item may claim itself: written only when no claim before it did. */
85
+ readonly unlessClaimed?: true;
86
+ }
87
+
88
+ /**
89
+ * The relations the assets' claims state as they stand now, in the derivation's order: each reference item's `refines`
90
+ * and `replacedBy`, each work item's `serves`, `dependsOn` and `replacedBy`, each area's contributions. The derivation
91
+ * writes them; the program's reading of the workbench (workbench-placement.ts) reads them before the next derivation.
92
+ */
93
+ export function claimedRelations(store: ProjectStore): ClaimedRelation[] {
94
+ const out: ClaimedRelation[] = [];
95
+ for (const item of store.reference.all()) {
96
+ for (const up of item.refines ?? []) if (store.reference.has(up)) out.push({ type: 'refines', from: item.id, to: up, claim: `${item.name} refines ${store.reference.get(up)!.name}`, basis: item.basis, evidence: { sourceIds: item.sourceIds, factRecordIds: [], factsSoFar: '' } });
97
+ if (item.replacedBy && store.reference.has(item.replacedBy)) out.push({ type: 'replaces', from: item.replacedBy, to: item.id, claim: `${store.reference.get(item.replacedBy)!.name} replaces ${item.name}`, basis: item.basis, evidence: { sourceIds: item.sourceIds, factRecordIds: [], factsSoFar: '' } });
98
+ }
99
+ for (const thread of store.threads.all()) {
100
+ const factSources = factSourcesOf(store, thread);
101
+ for (const s of thread.serves ?? []) if (store.reference.has(s.referenceId)) out.push({ type: 'serves', from: thread.id, to: s.referenceId, claim: s.claim, basis: s.basis, evidence: { sourceIds: factSources.slice(0, 12), factRecordIds: thread.factRecordIds, factsSoFar: `${thread.progress}: ${thread.results.slice(0, 200)}` } });
102
+ for (const d of thread.dependsOn ?? []) if (store.threads.has(d.threadId)) out.push({ type: 'depends on', from: thread.id, to: d.threadId, claim: d.claim, basis: d.basis, evidence: { sourceIds: factSources.slice(0, 6), factRecordIds: thread.factRecordIds, factsSoFar: '' } });
103
+ if (thread.replacedBy && store.threads.has(thread.replacedBy)) out.push({ type: 'replaces', from: thread.replacedBy, to: thread.id, claim: `${store.threads.get(thread.replacedBy)!.title} replaces ${thread.title}`, basis: 'Explicit', evidence: { sourceIds: factSources.slice(0, 6), factRecordIds: [], factsSoFar: '' } });
104
+ }
105
+ for (const area of store.areas.all()) {
106
+ for (const c of area.contributions ?? []) {
107
+ if (!store.threads.has(c.threadId) || !store.reference.has(area.referenceId)) continue;
108
+ out.push({ type: 'serves', from: c.threadId, to: area.referenceId, claim: c.claim, basis: c.basis, evidence: { sourceIds: [], factRecordIds: store.threads.get(c.threadId)!.factRecordIds, factsSoFar: '' }, unlessClaimed: true });
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+
114
+ export interface DeriveResult { nodes: number; relations: number; removedNodes: number; removedRelations: number }
115
+
116
+ export function deriveGraph(store: ProjectStore, project: Project): DeriveResult {
117
+ const nodes = new Map<string, GraphNode>();
118
+ const relations = new Map<string, GraphRelation>();
119
+ const known = store.nodes.size > 0;
120
+ const put = (n: DerivedNode) => { const prev = store.nodes.get(n.id); nodes.set(n.id, { ...n, projectId: project.id, createdAt: prev ? (prev.createdAt ?? '') : known || store.jobs.size > 0 ? now() : '', updatedAt: now() }); };
121
+ const relate = (type: GraphRelation['type'], from: string, to: string, claim: string, basis: GraphRelation['basis'], evidence: GraphRelation['evidence']) => {
122
+ const id = stableId('rel', type, from, to);
123
+ const previous = store.relations.get(id);
124
+ // The replacement check (§5.5, §2.1) settles two things about a `replaces` relation that no field can rebuild:
125
+ // which part of the old decision the new one supersedes, and whether the new decision said so itself
126
+ // (`Explicit`) or only gave different content for the same matter (`Inferred`). The derivation restates the
127
+ // relation from `replacedBy` without touching either. It used to overwrite both on the first derivation after
128
+ // any round that ran the check: `Inferred` silently became `Explicit`, so the owner lost the mark saying the
129
+ // basis was the program's reading and could be corrected, and the superseded part vanished from the claim.
130
+ const settled = type === 'replaces' && previous !== undefined;
131
+ const keptClaim = settled ? previous.claim : claim;
132
+ const keptBasis = settled ? previous.basis : basis;
133
+ relations.set(id, { id, projectId: project.id, type, from, to, claim: keptClaim, basis: keptBasis, evidence, assessment: previous?.assessment ?? 'Not assessed', assessedAt: previous?.assessedAt ?? null, assessedInJobId: previous?.assessedInJobId ?? null, updatedAt: previous && previous.claim === keptClaim ? previous.updatedAt : now() });
134
+ };
135
+
136
+ for (const item of store.reference.all()) put(referenceNode(store, item));
137
+ for (const thread of store.threads.all()) put(threadNode(store, thread));
138
+ for (const c of claimedRelations(store)) {
139
+ if (c.unlessClaimed && relations.has(stableId('rel', c.type, c.from, c.to))) continue;
140
+ relate(c.type, c.from, c.to, c.claim, c.basis, c.evidence);
141
+ }
142
+
143
+ for (const change of store.changes.all()) {
144
+ put({ id: change.id, category: 'Change', label: change.title, refKind: 'change', refId: change.id, validity: 'Current', progress: null, basis: 'Explicit', attribution: change.by, sourceIds: change.sourceIds, areaId: null, parentWorkId: null, replacedBy: null });
145
+ for (const target of change.affects) relate('affects', change.id, target, `${change.effect}: ${change.title}`, 'Explicit', { sourceIds: change.sourceIds, factRecordIds: [], factsSoFar: '' });
146
+ }
147
+
148
+ // Relations the Keeper wrote directly stay; endpoints that are sources become nodes of their kind.
149
+ for (const r of store.relations.all()) {
150
+ if (relations.has(r.id)) continue;
151
+ relations.set(r.id, r);
152
+ }
153
+ for (const r of relations.values()) {
154
+ for (const end of [r.from, r.to]) {
155
+ if (nodes.has(end)) continue;
156
+ const source = store.sources.get(end);
157
+ // What exists only in history — an old version read from version history, what the project keeps for recovery
158
+ // only — forms no node (Spec §1.2, §2.6, D61), so a relation to it is dropped below; a source deleted from the
159
+ // project's current version is not drawn as current.
160
+ if (!source || source.usedAs === 'History only' || source.anchor.kind === 'revision') continue;
161
+ put({ id: source.id, category: sourceCategory(source), label: source.title, refKind: 'source', refId: source.id, validity: source.availability === 'No longer available' ? 'Removed' : 'Current', progress: null, basis: 'Explicit', attribution: null, sourceIds: [source.id], areaId: null, parentWorkId: null, replacedBy: null });
162
+ }
163
+ }
164
+ // Drop relations whose endpoints are not nodes (a fact record or a removed asset).
165
+ for (const [id, r] of [...relations]) if (!nodes.has(r.from) || !nodes.has(r.to)) relations.delete(id);
166
+
167
+ const existingNodes = store.nodes.all();
168
+ const removedNodes = existingNodes.filter((n) => !nodes.has(n.id)).map((n) => n.id);
169
+ const existingRelations = store.relations.all();
170
+ const removedRelations = existingRelations.filter((r) => !relations.has(r.id)).map((r) => r.id);
171
+ for (const id of removedNodes) store.nodes.remove(id);
172
+ for (const id of removedRelations) store.relations.remove(id);
173
+ const changedNodes = [...nodes.values()].filter((n) => { const e = store.nodes.get(n.id); return !e || JSON.stringify({ ...e, updatedAt: '' }) !== JSON.stringify({ ...n, updatedAt: '' }); });
174
+ if (changedNodes.length) store.nodes.putMany(changedNodes);
175
+ const changedRelations = [...relations.values()].filter((r) => { const e = store.relations.get(r.id); return !e || JSON.stringify(e) !== JSON.stringify(r); });
176
+ if (changedRelations.length) store.relations.putMany(changedRelations);
177
+
178
+ // Seeding comes last, over the graph as it now stands. It used to run in the middle of the derivation, before the
179
+ // relations the Keeper wrote directly were merged back in, so a change never seeded downstream along an
180
+ // `implements`, `verifies`, `depends on` or `serves` relation the Keeper had written itself: the real current
181
+ // objects working to the old understanding never appeared as `Not yet checked`, and no round ever asked about them.
182
+ for (const change of store.changes.all()) seedPropagation(store, change);
183
+
184
+ return { nodes: nodes.size, relations: relations.size, removedNodes: removedNodes.length, removedRelations: removedRelations.length };
185
+ }
186
+
187
+ /**
188
+ * Give a change's downstream objects an entry to judge (§5.5); new ones start as `Not yet checked`. The walk runs
189
+ * per item, from that item's own subject, and leaves out what is never judged — a point-in-time record or a
190
+ * decision — which is listed in the record's detail instead (D56 rule 2). Seeding those as `Not yet checked` was
191
+ * how 5,237 judgements landed on 233 objects in the 2026-09-18 assets.
192
+ *
193
+ * Each entry records which items reached that object, so a piece of work with several unrelated items asks each
194
+ * object only about the items that came near it. Without that, one object was handed the whole record and judged
195
+ * against items that never touched it.
196
+ *
197
+ * An item that reaches an object after its entry was written has not been judged (§2.10: what arrives in a round is
198
+ * judged in it): the entry then reads as a new one does — `Not yet checked`, no reason — until the next round judges it
199
+ * together with what the object still lacked. It used to keep the earlier state and reason, so the change's detail and
200
+ * the counts showed that conclusion as if it covered the new item too. Nothing is lost by this: the object's judgement
201
+ * keeps what it followed and what it lacks, the next round carries the lacks forward, and the pack reads them from the
202
+ * judgement. An entry written before items were recorded covered the whole record; it only has its items written down.
203
+ */
204
+ function seedPropagation(store: ProjectStore, change: ChangeRecord): void {
205
+ const { judged, notJudged } = downstreamOfChange(store, change);
206
+ const blocked = new Set(notJudged.map((n) => n.nodeId));
207
+ const reached = new Map(judged.map((j) => [j.nodeId, j.itemIds]));
208
+ const at = now();
209
+ let moved = false;
210
+ const propagation: PropagationEntry[] = [];
211
+ for (const p of change.propagation) {
212
+ if (blocked.has(p.nodeId)) { moved = true; continue; }
213
+ const arrived = reached.get(p.nodeId);
214
+ const merged = arrived ? [...new Set([...(p.itemIds ?? []), ...arrived])] : p.itemIds;
215
+ if (merged && (!p.itemIds || merged.length !== p.itemIds.length)) {
216
+ propagation.push(p.itemIds ? { nodeId: p.nodeId, state: 'Not yet checked', sourceOrReason: '', updatedAt: at, itemIds: merged } : { ...p, itemIds: merged, updatedAt: at });
217
+ moved = true;
218
+ continue;
219
+ }
220
+ propagation.push(p);
221
+ }
222
+ for (const [nodeId, itemIds] of reached) {
223
+ if (change.propagation.some((p) => p.nodeId === nodeId)) continue;
224
+ propagation.push({ nodeId, state: 'Not yet checked', sourceOrReason: '', updatedAt: at, itemIds });
225
+ moved = true;
226
+ }
227
+ const listed = [...(change.notJudged ?? [])];
228
+ for (const n of notJudged) if (!listed.some((x) => x.nodeId === n.nodeId)) { listed.push(n); moved = true; }
229
+ if (moved) store.changes.put({ ...change, propagation, notJudged: listed });
230
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * The main agent's lanes (D99; E148 D-c; build plan §2 "各路", §6): `pk_send_lanes` sends several lanes in one call — GLM
3
+ * makes about one and a half calls a turn, so lanes sent one call at a time would run one after another — and waits
4
+ * until every one has ended; `pk_lanes` lists the round's lanes.
5
+ *
6
+ * Each lane is a job of its own under the main job (`step.kind` `lane`, `path` its name, `lane` its kind and slots), in a
7
+ * session of its own, sent through the runtime's `delegate`: it runs on whichever key has a free slot while the main job
8
+ * waits parked, so several lanes run at once across all keys. Its prompt is its lane's skill, its brief (a `Brief` round
9
+ * document the main agent wrote first), the round and the project's rules.
10
+ *
11
+ * Sending is idempotent by lane name within the round: a lane already sent is not sent twice. Sent again, a lane that is
12
+ * Done returns its summary and report; one queued or running is waited for; one a restart or the provider interrupted
13
+ * goes on in its own session; one that failed runs again up to `RUN_AGAIN_LIMIT` times, then comes back `Listed`. So the
14
+ * main agent can send the same call again after a restart, or after its turn was cut short, without doubling any lane.
15
+ * The planner never runs a lane again itself: the main job owns its lanes.
16
+ *
17
+ * DB: when a first usable round's skeleton lanes are sent, a slot of the workbench that no lane holds is listed back
18
+ * before anything is sent (slots.ts): the main agent gives it to a lane, or says in one line why the project has nothing
19
+ * for it (`empty`), which is kept on the round and shown to the next spot-check. Nothing more is refused.
20
+ */
21
+ import { Type } from 'typebox';
22
+ import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
23
+ import type { ClerkRound, EmptySlot, LaneKind, RoundDoc, RoundLane, SlotKind } from '../../model/k-types.ts';
24
+ import { SKELETON_SLOTS, skeletonSlot, slotKey, slotsUnheld } from './slots.ts';
25
+ import type { KeeperJob } from '../../model/types.ts';
26
+ import type { ToolContext } from '../tools.ts';
27
+ import { RUN_AGAIN_LIMIT, runsOf, withEarlierRuns, type StepRun } from './clerk.ts';
28
+ import { laneBlocksOf, lanePrompt, roundBlockOf, rulesBlockOf } from './round-blocks.ts';
29
+ import { Ledger } from '../../ledger/index.ts';
30
+ import { stageOfRound } from './stage-gate.ts';
31
+
32
+ /** How the runtime runs a lane of the main job (runtime.ts `delegate`); each promise settles when the lane has ended —
33
+ * Done, Failed, or Stopped with nothing to go on with — never on `Waiting for quota`, which moves it to another key. */
34
+ export interface LaneRunner {
35
+ /** Queue a new lane under the main job. */
36
+ send(request: LaneJobRequest): { readonly job: KeeperJob; readonly ended: Promise<KeeperJob> };
37
+ /** Queue an ended lane again: it continues its own session when it has one to go on with (`resume`), else runs afresh. */
38
+ again(jobId: string): Promise<KeeperJob>;
39
+ /** Wait for a lane already queued or running. */
40
+ wait(jobId: string): Promise<KeeperJob>;
41
+ }
42
+
43
+ export interface LaneJobRequest {
44
+ readonly scope: { readonly kind: string; readonly ids: readonly string[]; readonly label: string };
45
+ readonly prompt: string;
46
+ readonly step: NonNullable<KeeperJob['step']>;
47
+ readonly task: unknown;
48
+ readonly timeoutMs: number;
49
+ }
50
+
51
+ /** A lane's time limit (E148 D-j): eight hours, the same as a step's before D99. */
52
+ export const LANE_TIMEOUT_MS = 8 * 60 * 60_000;
53
+ /** How much of a lane's final message comes back to the main agent: its summary; the full report is its Report document. */
54
+ export const LANE_SUMMARY_CHARS = 2_000;
55
+
56
+ export const LANE_KINDS: readonly LaneKind[] = ['slot', 'plan', 'topic', 'follow-up'];
57
+ const SLOT_WORDS = ['threads', 'links', 'territories', 'patches', 'generations', 'relations'] as const;
58
+ export const isSlotKind = (s: string): s is SlotKind => (SLOT_WORDS as readonly string[]).includes(s) || /^reference:\S/.test(s);
59
+
60
+ export type LaneStatus = 'Done' | 'Listed' | 'Stopped';
61
+ export interface LaneOutcome { readonly name: string; readonly status: LaneStatus; readonly summary: string; readonly reportDocId: string | null; readonly jobId: string }
62
+
63
+ const text = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
64
+ /** The runtime's words for a job a restart of ProjectKeeper cut short (runtime.ts `init`). */
65
+ const RESTARTED = /restarted while this work was running/i;
66
+ const now = () => new Date().toISOString();
67
+ function ok(value: unknown) {
68
+ return { content: [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value, null, 1) }], details: {} };
69
+ }
70
+ function fail(message: string) {
71
+ return { content: [{ type: 'text' as const, text: `ERROR: ${message}` }], details: {}, isError: true };
72
+ }
73
+
74
+ /** The lane's final message, trimmed, as its summary for the main agent. */
75
+ export function laneSummary(job: KeeperJob): string {
76
+ const t = (job.resultText ?? '').trim();
77
+ if (!t) return '';
78
+ return t.length > LANE_SUMMARY_CHARS ? `${t.slice(0, LANE_SUMMARY_CHARS)}… (the rest is in its Report)` : t;
79
+ }
80
+
81
+ /** The lane's Report round document: the latest `Report` of the round under the lane's name. */
82
+ export function laneReport(store: ToolContext['store'], roundId: string, name: string): RoundDoc | null {
83
+ return store.roundDocs.filter((d) => d.roundId === roundId && d.kind === 'Report' && d.path === name).sort((a, b) => b.at.localeCompare(a.at))[0] ?? null;
84
+ }
85
+
86
+ /** Runs of a lane the main agent's `pk_send_lanes` ran again since the owner last did, as the planner counts a step's. */
87
+ const countedRuns = (job: KeeperJob): number => { const runs = runsOf(job); return runs.slice(runs.map((r) => r.again).lastIndexOf('owner') + 1).filter((r) => r.counted).length; };
88
+
89
+ /** A lane's status as the round's lanes list shows it. */
90
+ function laneStatusOf(job: KeeperJob | undefined): string {
91
+ if (!job) return 'Unknown';
92
+ if (job.status === 'Failed' && countedRuns(job) >= RUN_AGAIN_LIMIT) return 'Listed';
93
+ return job.status;
94
+ }
95
+
96
+ /** The round's lanes with their jobs' status and report, for `pk_lanes` and `pk_round_state`. */
97
+ export function lanesOf(store: ToolContext['store'], round: ClerkRound): { name: string; kind: LaneKind; slots: readonly SlotKind[]; status: string; reportDocId: string | null; briefDocId: string; jobId: string; stage: string }[] {
98
+ return (round.lanes ?? []).map((l) => ({
99
+ name: l.name, kind: l.kind, slots: l.slots, status: laneStatusOf(store.jobs.get(l.jobId)), reportDocId: laneReport(store, round.id, l.name)?.id ?? l.reportDocId, briefDocId: l.briefDocId, jobId: l.jobId, stage: l.stage,
100
+ }));
101
+ }
102
+
103
+ export function laneTools(ctx: ToolContext): ToolDefinition[] {
104
+ const { store, project } = ctx;
105
+ const trace = (summary: string) => ({ jobId: ctx.jobId, basisSourceIds: [] as string[], summary });
106
+
107
+ /** The main job's round, or why this job may not send lanes. */
108
+ const mainRound = (tool: string): ClerkRound | string => {
109
+ const step = ctx.step;
110
+ if (!step || step.kind !== 'main') return `${tool} is the main agent's: only the main job of a round sends and lists lanes.`;
111
+ const round = store.clerkRounds.get(step.roundId);
112
+ if (!round) return `The round ${step.roundId} is not in the assets.`;
113
+ return round;
114
+ };
115
+
116
+ /** Record a lane on the round (or its report once written), keeping the lanes others recorded meanwhile. */
117
+ const putLane = (roundId: string, lane: RoundLane, summary: string) => {
118
+ const current = store.clerkRounds.get(roundId)!;
119
+ const lanes = [...(current.lanes ?? []).filter((l) => l.name !== lane.name), lane];
120
+ const order = new Map((current.lanes ?? []).map((l, i) => [l.name, i]));
121
+ lanes.sort((a, b) => (order.get(a.name) ?? Infinity) - (order.get(b.name) ?? Infinity));
122
+ store.clerkRounds.put({ ...current, lanes, updatedAt: now() }, trace(summary));
123
+ };
124
+
125
+ /** A failed lane, run again: its earlier runs with it, as the planner runs a step's job again (clerk.ts `runAgain`). */
126
+ const runAgain = async (job: KeeperJob): Promise<KeeperJob> => {
127
+ const at = now();
128
+ const run: StepRun = { endedAt: job.endedAt ?? at, status: job.status === 'Stopped' ? 'Stopped' : 'Failed', reason: job.error?.trim() || 'Failed with no reason recorded', usage: job.usage, again: 'program', at, counted: true };
129
+ const runs = [...runsOf(job), run];
130
+ const task = (job.task ?? {}) as { prompt?: string; extra?: Record<string, unknown> | null };
131
+ store.jobs.put({ ...job, task: { ...task, prompt: withEarlierRuns(task.prompt ?? '', job, runs), extra: { ...(task.extra ?? {}), runs } } }, { jobId: job.id, summary: `${job.scope.label}: run again by the main agent's pk_send_lanes (run ${runs.length + 1}); its last run failed: ${run.reason.slice(0, 200)}` });
132
+ return ctx.lanes!.again(job.id);
133
+ };
134
+
135
+ /** Wait until a lane has ended, running it again while it fails and may run again; then what the main agent gets back. */
136
+ const settle = async (roundId: string, name: string, first: Promise<KeeperJob>): Promise<LaneOutcome> => {
137
+ let job = await first;
138
+ while (job.status === 'Failed' && countedRuns(job) < RUN_AGAIN_LIMIT) job = await runAgain(job);
139
+ const report = laneReport(store, roundId, name);
140
+ const round = store.clerkRounds.get(roundId);
141
+ const recorded = round?.lanes?.find((l) => l.name === name);
142
+ if (recorded && report && recorded.reportDocId !== report.id) putLane(roundId, { ...recorded, reportDocId: report.id }, `Lane ${name}: its report is ${report.id}`);
143
+ const status: LaneStatus = job.status === 'Done' ? 'Done' : job.status === 'Failed' ? 'Listed' : 'Stopped';
144
+ const summary = status === 'Done' ? laneSummary(job)
145
+ : status === 'Listed' ? `Failed ${runsOf(job).length + 1} times and is listed as not finished: ${job.error ?? 'no reason recorded'}.${laneSummary(job) ? ` What it had written: ${laneSummary(job)}` : ''}`
146
+ : `Stopped${job.error ? ` (${job.error})` : ' by the owner'} before it finished.${laneSummary(job) ? ` What it had written: ${laneSummary(job)}` : ''}`;
147
+ return { name, status, summary, reportDocId: report?.id ?? null, jobId: job.id };
148
+ };
149
+
150
+ const tools: ToolDefinition[] = [];
151
+
152
+ tools.push(defineTool({
153
+ name: 'pk_send_lanes', label: 'Send lanes',
154
+ description: 'Send the lanes of this stage, all in one call: each lane is a subagent in a session of its own that answers its brief and writes the slots you give it, and they run at the same time. Write each lane\'s brief first as a Brief round document (pk_write_round_doc kind Brief, path = the lane\'s name) and give its id here; the brief is not repeated in this call. kind: slot (a group of workbench slots, first usable), plan (one plan or stage: is it done, where is the evidence, what is open), topic (a theme orientation found), follow-up (what the coverage check left). slots: what the lane may write — reference:<category> (e.g. reference:Goal, reference:Requirement, reference:Design; reference:Owner\'s words gets the owner’s lines in full with its brief), threads (work items and their numbers), links (process links to deliveries), territories (code territories), patches (draft semantic patches), generations (earlier generations — threads comes with it, for their items and each item’s destination), relations; every lane also writes its own Report and pk_record_looked. The call waits until every lane has ended and returns each one\'s status (Done, Listed after failing three times, or Stopped), its summary and its Report id: read a report in full with pk_read_assets kind roundDoc. A lane is known by its name in this round: sending a name again never sends it twice — a finished lane returns its result, a running one is waited for, an interrupted one goes on. So after an interruption, send the same call again. A first usable round’s skeleton fills every slot of the workbench: a slot no lane of the call holds is listed back before anything is sent — give it to a lane, or say in empty why the project has nothing for it (one line each, kept on the round and shown to the spot-check).',
155
+ parameters: Type.Object({
156
+ lanes: Type.Array(Type.Object({
157
+ name: Type.String({ description: 'the lane\'s name, unique in this round; its Brief\'s path' }),
158
+ kind: Type.String({ description: 'slot | plan | topic | follow-up' }),
159
+ briefDocId: Type.String({ description: 'the id of the lane\'s Brief round document of this round' }),
160
+ slots: Type.Array(Type.String(), { description: 'reference:<category> | threads | links | territories | patches | generations | relations' }),
161
+ }), { description: 'every lane of this stage, in one call' }),
162
+ empty: Type.Optional(Type.Array(Type.Object({
163
+ slot: Type.String({ description: 'the slot no lane holds, e.g. reference:Boundary' }),
164
+ why: Type.String({ description: 'one line: why this project has nothing for it (what you read that shows it), or that you fill it yourself in reconcile and from what' }),
165
+ }), { description: 'first usable skeleton only: the slots you give to no lane, each with why' })),
166
+ }),
167
+ execute: async (_id, p, signal) => {
168
+ const round = mainRound('pk_send_lanes');
169
+ if (typeof round === 'string') return fail(round);
170
+ if (round.status !== 'Running') return fail(`Round ${round.number} is ${round.status}: no lane is sent for it.`);
171
+ if (!ctx.lanes) return fail('This build cannot run lanes (no runtime to send them): do the lanes\' work yourself, one after another, and say so in the round\'s Result.');
172
+ const given = Array.isArray(p.lanes) ? p.lanes as Record<string, unknown>[] : [];
173
+ if (!given.length) return fail('lanes is empty: give every lane of this stage, each with name, kind, briefDocId and slots.');
174
+ const problems: string[] = [];
175
+ const names = new Set<string>();
176
+ const wanted: { name: string; kind: LaneKind; brief: RoundDoc; slots: SlotKind[] }[] = [];
177
+ for (const [i, l] of given.entries()) {
178
+ const name = text(l.name);
179
+ const kind = text(l.kind) as LaneKind;
180
+ const briefId = text(l.briefDocId);
181
+ const slots = Array.isArray(l.slots) ? (l.slots as unknown[]).map((s) => text(s).replace(/[’‘`]/g, "'")) : [];
182
+ const at = `lanes[${i}]${name ? ` (${name})` : ''}`;
183
+ if (!name) { problems.push(`${at}: give the lane a name`); continue; }
184
+ if (names.has(name)) { problems.push(`${at}: the name ${name} is given twice in this call; each lane has its own name`); continue; }
185
+ names.add(name);
186
+ if (!LANE_KINDS.includes(kind)) problems.push(`${at}: kind must be one of ${LANE_KINDS.join(', ')}`);
187
+ const brief = store.roundDocs.get(briefId);
188
+ if (!brief || brief.roundId !== round.id || brief.kind !== 'Brief') problems.push(`${at}: briefDocId ${briefId || '(none)'} is not a Brief of this round; write the brief first with pk_write_round_doc kind Brief, path ${name}, and give the id it returns`);
189
+ const bad = slots.filter((s) => !isSlotKind(s));
190
+ if (bad.length) problems.push(`${at}: ${bad.join(', ')} ${bad.length === 1 ? 'is' : 'are'} not a slot; slots are reference:<category>, threads, links, territories, patches, generations, relations`);
191
+ // CM (E151): a lane that records generations records their items and each item's destination too — work items.
192
+ // On the gated run the lane had `generations` without `threads` and could write neither.
193
+ const given = slots.includes('generations') && !slots.includes('threads') ? [...slots, 'threads'] : slots;
194
+ if (brief && !bad.length && LANE_KINDS.includes(kind)) wanted.push({ name, kind, brief, slots: given as SlotKind[] });
195
+ }
196
+ // DB: the reasons given for slots left to no lane, each a slot of the skeleton with a line of why.
197
+ const emptyGiven: { slot: SlotKind; why: string }[] = [];
198
+ for (const [i, e] of (Array.isArray((p as Record<string, unknown>).empty) ? (p as Record<string, unknown>).empty as Record<string, unknown>[] : []).entries()) {
199
+ const known = skeletonSlot(text(e.slot));
200
+ const why = text(e.why).replace(/\s+/g, ' ');
201
+ if (!known) problems.push(`empty[${i}]: ${text(e.slot) || '(none)'} is not a slot the skeleton fills; they are ${SKELETON_SLOTS.map((s) => s.slot).join(', ')}`);
202
+ else if (!why) problems.push(`empty[${i}] (${known.slot}): say in one line why the project has nothing for it`);
203
+ else emptyGiven.push({ slot: known.slot, why });
204
+ }
205
+ if (problems.length) return fail(`No lane was sent:\n${problems.join('\n')}`);
206
+
207
+ const notes = new Map<string, string>();
208
+ const pending: Promise<LaneOutcome>[] = [];
209
+ const stage = stageOfRound(store.clerkRounds.get(round.id) ?? round);
210
+ // DB: a first usable round's skeleton — every slot of the workbench is held by a lane, or has its one-line reason.
211
+ // Checked when the call sends a lane not sent before; sending the same call again after an interruption waits as ever.
212
+ if (round.kind === 'First usable' && stage === 'skeleton') {
213
+ const current = store.clerkRounds.get(round.id) ?? round;
214
+ const sent = current.lanes ?? [];
215
+ const held = [...sent.flatMap((l) => l.slots), ...wanted.flatMap((w) => w.slots)];
216
+ const heldKeys = new Set(held.map(slotKey));
217
+ const at = now();
218
+ const reasons: EmptySlot[] = [
219
+ ...(current.emptySlots ?? []).filter((e) => !heldKeys.has(slotKey(e.slot)) && !emptyGiven.some((g) => slotKey(g.slot) === slotKey(e.slot))),
220
+ ...emptyGiven.filter((g) => !heldKeys.has(slotKey(g.slot))).map((g) => ({ slot: g.slot, why: g.why, at })),
221
+ ];
222
+ const unheld = slotsUnheld(held, reasons);
223
+ if (unheld.length && wanted.some((w) => !sent.some((l) => l.name === w.name))) {
224
+ return fail(`No lane was sent yet. The skeleton fills every slot of the workbench, and ${unheld.length === 1 ? 'this slot is' : `these ${unheld.length} slots are`} held by no lane of this call:\n${unheld.map((u) => `- ${u.slot} — ${u.holds}`).join('\n')}\nFor each: where the project has such material, add the slot to the lane that reads it (and name the material in that lane’s brief); where it has none, say so in this call — empty: [{ slot, why }], one line each on why the project has nothing for it. The reasons are kept on the round and shown to the spot-check; nothing more is asked. Then send the call again with the same lanes.`);
225
+ }
226
+ const before = current.emptySlots ?? [];
227
+ if (reasons.length !== before.length || reasons.some((r, i) => r.slot !== before[i]?.slot || r.why !== before[i]?.why)) {
228
+ store.clerkRounds.put({ ...current, emptySlots: reasons, updatedAt: at }, trace(`Round ${round.number}: ${reasons.length} slot${reasons.length === 1 ? '' : 's'} held by no lane, with why: ${reasons.map((r) => `${r.slot} — ${r.why}`).join('; ').slice(0, 600)}`));
229
+ }
230
+ for (const g of emptyGiven.filter((x) => heldKeys.has(slotKey(x.slot)))) {
231
+ const holder = [...sent, ...wanted].find((l) => l.slots.some((s) => slotKey(s) === slotKey(g.slot)));
232
+ if (holder) notes.set(holder.name, `${g.slot} is held by this lane, so its reason in empty was not recorded.`);
233
+ }
234
+ }
235
+ for (const w of wanted) {
236
+ const current = store.clerkRounds.get(round.id) ?? round;
237
+ const sent = current.lanes?.find((l) => l.name === w.name);
238
+ const job = sent ? store.jobs.get(sent.jobId) : undefined;
239
+ if (sent && job) {
240
+ if (sent.briefDocId !== w.brief.id || sent.kind !== w.kind || JSON.stringify(sent.slots) !== JSON.stringify(w.slots)) notes.set(w.name, `${w.name} was sent already with brief ${sent.briefDocId}, kind ${sent.kind} and slots ${sent.slots.join(', ') || 'none'}: a lane is known by its name, so it was not sent again. To send another lane, give it a name of its own.`);
241
+ // A lane a restart cut short goes on — in its own session when it had one open, else afresh from its task (a
242
+ // restart before its session was open left nothing to continue); one the owner stopped stays stopped.
243
+ const restarted = job.status === 'Stopped' && RESTARTED.test(job.error ?? '');
244
+ if (job.status === 'Done' || job.status === 'Failed' || (job.status === 'Stopped' && !job.resume && !restarted)) pending.push(settle(round.id, w.name, Promise.resolve(job)));
245
+ else if (job.status === 'Stopped') pending.push(settle(round.id, w.name, ctx.lanes.again(job.id)));
246
+ else pending.push(settle(round.id, w.name, ctx.lanes.wait(job.id)));
247
+ continue;
248
+ }
249
+ const roundBlock = roundBlockOf(store, project, current);
250
+ // CM: the lane that writes the owner's words gets the owner's lines in full (the main agent, their count).
251
+ const previous = current.kind === 'Follow up' ? store.clerkRounds.filter((r) => r.id !== current.id && r.status === 'Done').sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0] : undefined;
252
+ let ledger: Ledger | null = null;
253
+ try { ledger = Ledger.openDir(store.dir); } catch { ledger = null; }
254
+ let blocks: string[];
255
+ try { blocks = laneBlocksOf(store, w.slots, ledger, previous?.startedAt ?? null); } finally { ledger?.close(); }
256
+ const prompt = lanePrompt({ name: w.name, kind: w.kind, slots: w.slots, brief: w.brief.markdown, briefDocId: w.brief.id, roundBlock, rules: rulesBlockOf(store), blocks });
257
+ const { job: queued, ended } = ctx.lanes.send({
258
+ scope: { kind: 'clerk-step', ids: [round.id], label: `Lane: ${w.name}` },
259
+ prompt,
260
+ step: { roundId: round.id, kind: 'lane', path: w.name, lane: { kind: w.kind, slots: w.slots } },
261
+ task: { kind: 'clerk-step', roundId: round.id, route: 'lane', lane: w.name },
262
+ timeoutMs: LANE_TIMEOUT_MS,
263
+ });
264
+ // Recorded before waiting, so a restart while the lanes run finds each lane by its name and never sends it twice.
265
+ putLane(round.id, { name: w.name, kind: w.kind, briefDocId: w.brief.id, slots: w.slots, jobId: queued.id, stage, sentAt: now(), reportDocId: null }, `Round ${round.number}: lane ${w.name} sent (${w.kind}; ${w.slots.join(', ') || 'no slots'})`);
266
+ pending.push(settle(round.id, w.name, ended));
267
+ }
268
+ // The main agent's own turn ending (the owner's Stop, a pause) does not wait for the lanes: they go on, or stop, by themselves.
269
+ const aborted = new Promise<null>((resolve) => { if (signal?.aborted) resolve(null); else signal?.addEventListener('abort', () => resolve(null), { once: true }); });
270
+ const outcomes = await Promise.race([Promise.all(pending), aborted]);
271
+ if (!outcomes) return fail('The main agent\'s turn was stopped while its lanes ran; they go on or stop by themselves. Send the same call again to wait for them.');
272
+ // The contract's answer (§6): one entry per lane; a lane sent before under other terms says so in its note.
273
+ return ok(outcomes.map((o) => ({ ...o, ...(notes.has(o.name) ? { note: notes.get(o.name) } : {}) })));
274
+ },
275
+ }));
276
+
277
+ tools.push(defineTool({
278
+ name: 'pk_lanes', label: 'List this round\'s lanes',
279
+ description: 'The lanes of this round: each one\'s name, kind, slots, brief, status and Report id, in the order sent.',
280
+ parameters: Type.Object({}),
281
+ execute: async () => {
282
+ const round = mainRound('pk_lanes');
283
+ if (typeof round === 'string') return fail(round);
284
+ return ok({ round: { id: round.id, kind: round.kind, number: round.number }, lanes: lanesOf(store, round) });
285
+ },
286
+ }));
287
+
288
+ return tools;
289
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Where a lane looked for a breakpoint's missing step (Spec §2.12 没有痕迹,要读过才算; D99; W0 contract
3
+ * `pk_record_looked({ breakpointId, where })` → `{ id, looked }`).
4
+ *
5
+ * The program's breakpoints are candidates: a lookup by number that found nothing. A lane reads where the step would be
6
+ * written — the execution arrangement, the milestone summaries, the receipts, the QC reports, the ledger — and either
7
+ * finds it, and links it (pk_link_process; a delivery link puts the candidate out), or does not, and records here where
8
+ * it looked. Only then can the round's spot-check light it (process/breakpoint-candidates.ts). The record is kept while
9
+ * the candidate holds on the same evidence (breakpoints.ts `reconcile`).
10
+ */
11
+ import { Type } from 'typebox';
12
+ import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
13
+ import type { Breakpoint } from '../../model/k-types.ts';
14
+ import type { ClerkToolContext } from '../clerk-tools.ts';
15
+
16
+ /** The jobs that record where they looked: the lanes the main agent sends (clerk-steps.ts `LANE_ALWAYS`). */
17
+ const LOOKERS: readonly string[] = ['lane'];
18
+
19
+ const ok = (value: unknown) => ({ content: [{ type: 'text' as const, text: JSON.stringify(value, null, 1) }], details: {} });
20
+ const fail = (message: string) => ({ content: [{ type: 'text' as const, text: `ERROR: ${message}` }], details: {}, isError: true });
21
+
22
+ export function lookedTools(ctx: ClerkToolContext): ToolDefinition[] {
23
+ const { store } = ctx;
24
+ return [defineTool({
25
+ name: 'pk_record_looked', label: 'Record where you looked for a missing step',
26
+ description: 'A lane (a missing step counts only after reading): for a breakpoint candidate the program computed — a lookup by number that found nothing — record where you read for the missing step and did not find it. Name each place as the spot-check can open it: a file with its section or lines, a ledger query, a commit range. If you found the step, do not call this: link what you found (pk_link_process), which puts the candidate out. Only a candidate a lane looked for can be lit, and only by the round’s independent spot-check. Calling it again replaces where you looked.',
27
+ parameters: Type.Object({
28
+ breakpointId: Type.String({ description: 'the breakpoint candidate’s id (bp_…)' }),
29
+ where: Type.Array(Type.String(), { description: 'each place you read for the missing step, as the spot-check can open it again' }),
30
+ }),
31
+ execute: async (_id, raw) => {
32
+ const p = raw as Record<string, unknown>;
33
+ const step = ctx.step;
34
+ if (!step || !LOOKERS.includes(step.kind)) {
35
+ return fail(`pk_record_looked records where a lane looked for a breakpoint's missing step; this job is ${step ? `the round's ${step.kind}` : 'no step of a round'}, so nothing was written.`);
36
+ }
37
+ const id = typeof p.breakpointId === 'string' ? p.breakpointId.trim() : '';
38
+ const bp = store.breakpoints.get(id);
39
+ if (!bp) return fail(`${id || '(empty)'} is not a breakpoint of the assets.`);
40
+ if (bp.out) {
41
+ return fail(`${id} (${bp.kind} on ${bp.targetId}) is already out (${bp.out.by}${bp.ownerResponse ? ': the owner answered No action needed' : ''}, ${bp.out.at}): there is no missing step to look for. Nothing was written.`);
42
+ }
43
+ const where = [...new Set((Array.isArray(p.where) ? p.where : []).filter((w): w is string => typeof w === 'string').map((w) => w.trim()).filter(Boolean))];
44
+ if (where.length === 0) return fail('where: each place you read for the missing step (a file and its section or lines, a ledger query, a commit range), so the spot-check can read it again. Nothing was written.');
45
+ const at = new Date().toISOString();
46
+ const looked: NonNullable<Breakpoint['looked']> = { roundId: step.roundId, jobId: ctx.jobId, where, at };
47
+ store.breakpoints.put({ ...bp, looked, updatedAt: at }, {
48
+ jobId: ctx.jobId, summary: `Looked for ${bp.kind} on ${bp.targetId}, not found: ${where.join('; ').slice(0, 200)}`,
49
+ });
50
+ ctx.onSaved?.('breakpoints', id, `Looked: ${bp.kind}`);
51
+ return ok({ id, looked });
52
+ },
53
+ })];
54
+ }