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,1424 @@
1
+ /**
2
+ * The Keeper's position-writing tools for the clerk method (Spec v3.0 §3.3): each step of a round writes its judgements
3
+ * on fixed positions of the workbench — orientation the layer map and the generations, the skeleton the semantic-patch
4
+ * drafts, the Keeper numbers and the process links, the deep sweeps their reports, the cross-check what it confirms and
5
+ * the code territories, synthesis the send-backs and the six things, the spot check what it found; the session drafts
6
+ * are the ground the steps stand on (§3.11).
7
+ *
8
+ * The one rule that shapes every tool: the model never writes a date, a count, a label of evidence or a quoted line the
9
+ * program cannot check. It names references — evidence as `{ kind, id, line? }` (evidence.ts), object ids, message
10
+ * positions — and the program resolves them: it checks they are there, reads their labels and when they happened,
11
+ * assigns numbers, and takes the owner's words out of the session itself.
12
+ *
13
+ * Which step may write which position is the table of §3.3 ("跨对象的记录由哪一步写"); a tool called from another step,
14
+ * or from a job that is no step of a round, refuses and says whose position it is. The spot check may also correct, with
15
+ * these same writers, what it found wrong ("核错的就地改正").
16
+ */
17
+ import { join } from 'node:path';
18
+ import { laneNamesOf, noteFormRefusal, ownerTextRefusal } from './owner-text.ts';
19
+ import { Type } from 'typebox';
20
+ import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
21
+ import { Ledger } from '../ledger/index.ts';
22
+ import type {
23
+ BreakpointKind, ClerkRound, CodeTerritory, EvidenceRef, Generation, KeeperNumber, LayerEntry, LayerKind, LinkCheck, Occurred, OwnerLineKind,
24
+ LaneKind, ProcessLink, RoundDoc, RoundDocKind, RoundStepKind, SemanticPatch, SlotKind, SendBack, SessionDraft, SixThing, SpotCheck, SpotWrongKind, StepKind,
25
+ TerritoryAnomalyKind,
26
+ } from '../model/k-types.ts';
27
+ import type { WorkThread } from '../model/types.ts';
28
+ import { fingerprint, newId, stableId } from '../model/ids.ts';
29
+ import { BASIS, SESSION_HOST, isOneOf } from '../model/vocab.ts';
30
+ import { Collection } from '../store/project-store.ts';
31
+ import { redactCredentials } from '../sources/anchor.ts';
32
+ import { isOwnerMessage } from '../sources/sessions/read.ts';
33
+ import { ownerUtterances } from '../sources/sessions/utterances.ts';
34
+ import { gitTreeEntry } from '../util/git.ts';
35
+ import { pathKey, placeUnder } from '../util/paths.ts';
36
+ import { isDecision } from './adjustment.ts';
37
+ import {
38
+ earliestOccurred, evidenceKey, findObject, parsedSession, pathPresence, resolveEvidence, resolveEvidenceList, resolvePath,
39
+ compareOccurred, type EvidenceContext, type LedgerHook, type ProjectRoot,
40
+ } from './evidence.ts';
41
+ import { resolveMergedId } from './merge.ts';
42
+ import { cliCommandFor } from './sendback-text.ts';
43
+ import { absent, createNeeds, has, primaryIdentifier, type ToolContext } from './tools.ts';
44
+ import { isNumberShaped } from './numbers-check.ts';
45
+ import { definitionsInPath } from '../ledger/numbering.ts';
46
+ import { incompleteCall } from './step-timing.ts';
47
+ import { sameWords } from './verbatim.ts';
48
+ import { CLERK_WRITER_STEPS, DOCS_BY_STEP } from './clerk-steps.ts';
49
+ import { deliveryLinksFrom } from '../process/delivery-links.ts';
50
+ import { fillTools } from './organize/fill-tools.ts';
51
+ import { lightCandidate, lightingRefusal } from '../process/breakpoint-candidates.ts';
52
+ import { lookedTools } from './organize/looked-tools.ts';
53
+ import { ABSENCE_THINGS, absenceRefusal, lookedLine, lookedRecord, parseLooked, presentOf, stampAsFarAs, type AbsenceLooked } from './organize/absence.ts';
54
+ import { checkLink } from './link-check.ts';
55
+ import { roundDocRefusal, stepKindAs, writeRefusal } from './organize/stage-gate.ts';
56
+ import { isProgramNote, looksAtStandingNotes, standingCounts, standingNotes } from './organize/standing-notes.ts';
57
+ import { fullCheckTargets, placementCheckNow, placementCounts } from '../process/breakpoint-candidates.ts';
58
+ import { confirmBySpotCheck } from './organize/placement-inference.ts';
59
+
60
+ /** The tools read the step they write for, and the ledger when the build has one, at call time. */
61
+ export interface ClerkToolContext extends ToolContext {
62
+ /**
63
+ * Which step of which round this job is (Spec §3.3); null for a job that is no step of a round. A deep sweep read by
64
+ * reading assignments names its assignment: its report is that assignment's (§3.7).
65
+ */
66
+ step: { readonly roundId: string; readonly kind: RoundStepKind; readonly path: string | null; readonly assignment?: string | null; readonly lane?: { readonly kind: LaneKind; readonly slots: readonly SlotKind[] } | null } | null;
67
+ /** The ledger (§1.16), when this build has one: ledger entries are resolved through it. */
68
+ ledger?: LedgerHook | null;
69
+ }
70
+
71
+ // ───────────────────────── the fixed words, checked against the types ─────────────────────────
72
+
73
+ const LAYER_KINDS = ['Product', 'PRD', 'Spec', 'Plan', 'Task index', 'Task contract', 'Decision record', 'Execution arrangement', 'QC and receipts', 'Readme', 'Instructions', 'Other'] as const satisfies readonly LayerKind[];
74
+ const STEP_KINDS = ['Planned', 'Dispatched', 'Delivered', 'QC', 'Review', 'Walkthrough', 'Fix', 'Merged', 'Accepted', 'Handed in', 'Handed to'] as const satisfies readonly StepKind[];
75
+ const ROUND_DOC_KINDS = ['History map', 'Questions', 'Brief', 'Report', 'Adoption', 'Handover', 'Spot check', 'Layers', 'Result'] as const satisfies readonly RoundDocKind[];
76
+ const ANOMALY_KINDS = ['Unreferenced', 'Looks residual, is live', 'Built without a claiming work item', 'Docs disagree'] as const satisfies readonly TerritoryAnomalyKind[];
77
+ const LINE_KINDS = ['Chat', 'Decision', 'Confirmation'] as const satisfies readonly OwnerLineKind[];
78
+ const TERRITORY_KINDS = ['area', 'shared', 'non-product'] as const satisfies readonly CodeTerritory['kind'][];
79
+ const SENDBACK_FROM = ['breakpoint', 'verdict', 'owner-judgement', 'code-anomaly'] as const satisfies readonly SendBack['from']['kind'][];
80
+ const PATCH_STATUS = ['Draft', 'Confirmed', 'Rejected'] as const satisfies readonly SemanticPatch['status'][];
81
+ const NUMBER_KINDS = ['work', 'decision', 'patch', 'other'] as const satisfies readonly KeeperNumber['objectKind'][];
82
+ type Complete<All, Listed> = [Exclude<All, Listed>] extends [never] ? true : false;
83
+ type Must<T extends true> = T;
84
+ /** A word the types add and a list here lacks stops the build here, not in a refusal the model cannot get past. */
85
+ export type ClerkVocabularyComplete = [
86
+ Must<Complete<LayerKind, (typeof LAYER_KINDS)[number]>>, Must<Complete<StepKind, (typeof STEP_KINDS)[number]>>,
87
+ Must<Complete<RoundDocKind, (typeof ROUND_DOC_KINDS)[number]>>, Must<Complete<TerritoryAnomalyKind, (typeof ANOMALY_KINDS)[number]>>,
88
+ Must<Complete<OwnerLineKind, (typeof LINE_KINDS)[number]>>, Must<Complete<CodeTerritory['kind'], (typeof TERRITORY_KINDS)[number]>>,
89
+ Must<Complete<SendBack['from']['kind'], (typeof SENDBACK_FROM)[number]>>, Must<Complete<SemanticPatch['status'], (typeof PATCH_STATUS)[number]>>,
90
+ Must<Complete<KeeperNumber['objectKind'], (typeof NUMBER_KINDS)[number]>>,
91
+ ];
92
+
93
+ const STEP_NAME: Record<RoundStepKind, string> = {
94
+ main: 'the main agent (one session through the round’s stages)',
95
+ lane: 'a lane the main agent sent',
96
+ ledger: 'the ledger step (step 0, the program’s own)',
97
+ process: 'the process step (the program’s own: each work item’s steps and breakpoints)',
98
+ 'session-drafts': 'the session drafts',
99
+ orientation: 'orientation (step 1)',
100
+ skeleton: 'the skeleton (step 2)',
101
+ dig: 'a deep sweep (step 3)',
102
+ 'cross-check': 'the cross-check (step 4)',
103
+ synthesis: 'the synthesis (the round’s product look-back, in a session of its own after the main agent)',
104
+ 'spot-check': 'the spot check (step 6)',
105
+ };
106
+
107
+ /** The round documents each step leaves behind (§3.3 "留痕点得开"); the layer map is the program's rendering of the layers. */
108
+
109
+ /** §2.13 the six things, by number. */
110
+ const SIX: Record<SixThing, string> = { 1: 'stale', 2: 'drift', 3: 'dropped along the way', 4: 'grown by itself', 5: 'let pass', 6: 'looks residual' };
111
+ /** §2.12's table: the breakpoints that are material for one of the six things, and which. */
112
+ const BREAKPOINT_THING: Partial<Record<BreakpointKind, SixThing>> = {
113
+ 'Not planned': 3, 'No plan': 4, 'Findings open': 5, 'Passed with open items': 5, 'Fix not re-checked': 5, 'Downstream behind': 1, 'Not carried out': 2,
114
+ };
115
+ /** §2.13's table: the marks that are the position of one of the six things. */
116
+ const MARK_THING: Readonly<Record<string, SixThing>> = { 'Suspected stale': 1, 'Layer drift': 2 };
117
+ /**
118
+ * §2.13's table, row 6: the code anomalies that are the material of "looks residual" — code nothing references, and code
119
+ * whose name looks residual but that is live (the judgement of the same question, answered the other way).
120
+ */
121
+ const ANOMALY_THING: Partial<Record<TerritoryAnomalyKind, SixThing>> = { Unreferenced: 6, 'Looks residual, is live': 6 };
122
+
123
+ /** One judgement the spot check checked, as the round keeps it. */
124
+ type SpotCheckTarget = NonNullable<SpotCheck['targets']>[number];
125
+
126
+ /**
127
+ * The anomalies that judge code by its references (§1.19, §2.13 row 6): nothing references it; its name looks residual
128
+ * but it is referenced. Where the ledger reads a language's references only file by file, or not at all, such a judgement
129
+ * cannot rest on computed references alone and is `Inferred` whatever basis is given (CKC-25 AC-5; QC AY B13).
130
+ */
131
+ const BY_REFERENCES: ReadonlySet<TerritoryAnomalyKind> = new Set<TerritoryAnomalyKind>(['Unreferenced', 'Looks residual, is live']);
132
+ /** Languages that are not code: markup, data and prose reference nothing a residual judgement rests on. */
133
+
134
+ /**
135
+ * Whether the ledger reads the references of a territory's code completely enough for a residual judgement to be
136
+ * Explicit (§1.16, §1.19; CKC-25 AC-5): every code file under its paths has a reader — the TypeScript compiler, or the
137
+ * general code engine for every other language (D98 补) — and none is a file no counted reference reaches that another
138
+ * file names. Measured from the ledger (`Ledger.referenceReach`), not a fixed table per language; `gaps` says why not.
139
+ */
140
+ function referenceLevel(storeDir: string, root: ProjectRoot, paths: readonly string[]): { readonly complete: boolean; readonly gaps: readonly string[] } {
141
+ const ledger = Ledger.openDir(storeDir);
142
+ if (!ledger) return { complete: false, gaps: ['the ledger is not built yet'] };
143
+ try { return ledger.referenceReach(root.id, paths); } finally { ledger.close(); }
144
+ }
145
+
146
+ // ───────────────────────── small helpers ─────────────────────────
147
+
148
+ const text = (v: unknown): string => (typeof v === 'string' ? v : '');
149
+ const arr = <T>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []);
150
+ const now = () => new Date().toISOString();
151
+ const redact = (s: string): string => redactCredentials(s).text;
152
+ const clean = (v: unknown): string => redact(text(v)).trim();
153
+ const either = (list: readonly string[]): string => (list.length <= 1 ? list.join('') : `${list.slice(0, -1).join(', ')} or ${list[list.length - 1]}`);
154
+ const squash = (s: string): string => s.replace(/\s+/g, ' ').trim().toLowerCase();
155
+
156
+ function ok(value: unknown) {
157
+ return { content: [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value, null, 1) }], details: {} };
158
+ }
159
+ function fail(message: string) {
160
+ return { content: [{ type: 'text' as const, text: `ERROR: ${message}` }], details: {}, isError: true };
161
+ }
162
+
163
+ /** Why fields the program writes may not come in a call, or null. */
164
+ function programFields(p: Record<string, unknown>, fields: readonly string[], why: string): string | null {
165
+ const set = fields.filter((f) => p[f] !== undefined);
166
+ if (set.length === 0) return null;
167
+ return `${set.join(', ')} ${set.length === 1 ? 'is' : 'are'} not yours to write: ${why} Leave ${set.length === 1 ? 'it' : 'them'} out; nothing was written.`;
168
+ }
169
+
170
+ /** The next number of a sequence the program keeps (`SP-3` after `SP-2`). */
171
+ function nextNumber(prefix: string, taken: readonly (string | null | undefined)[]): string {
172
+ const re = new RegExp(`^${prefix}-(\\d+)$`);
173
+ const top = taken.reduce((max, n) => { const m = re.exec(n ?? ''); return m ? Math.max(max, Number(m[1])) : max; }, 0);
174
+ return `${prefix}-${top + 1}`;
175
+ }
176
+
177
+ const sourceIdsOf = (refs: readonly (EvidenceRef | null | undefined)[]): string[] =>
178
+ [...new Set(refs.flatMap((r) => (r && r.kind === 'source' ? [r.id] : [])))];
179
+
180
+ const EVIDENCE_HELP = 'Evidence is named, never written out: { kind, id, line? } — kind ledger (a ledger entry id), source (a source id), commit (a commit hash, full or short), file (a path relative to the repository root that exists now or in its history), or object (an id of the assets); repo for a file or a commit in another repository of the scope; line when the evidence is one line (a verdict, a rule, a heading), copied as the original has it — the program checks it is there. The program reads the label and when it happened: never give a date or a label.';
181
+ function EvidenceSchema(description: string) {
182
+ return Type.Object({
183
+ kind: Type.String({ description: 'ledger | source | commit | file | object' }),
184
+ id: Type.String({ description: 'ledger entry id, source id, commit hash, repository-relative path, or object id' }),
185
+ line: Type.Optional(Type.String({ description: 'one line of it, copied as the original has it; the program checks it is there' })),
186
+ repo: Type.Optional(Type.String({ description: 'a file or commit in another repository of the scope: its scope item id or path' })),
187
+ }, { description });
188
+ }
189
+
190
+ // ───────────────────────── the session as the program reads it (§3.11) ─────────────────────────
191
+
192
+ interface HeardLine {
193
+ /** The message's position in the session (its [n] in the transcript), as text. */
194
+ readonly ref: string;
195
+ readonly at: string;
196
+ readonly text: string;
197
+ /** The agent message just before it, verbatim: what a short "yes" answers. */
198
+ readonly before: string | null;
199
+ }
200
+ interface HeardSession {
201
+ readonly file: string;
202
+ readonly startedAt: string;
203
+ readonly endedAt: string;
204
+ readonly lines: readonly HeardLine[];
205
+ /** Every message's time by its position, for agent summaries that point at one. */
206
+ readonly times: ReadonlyMap<number, string | null>;
207
+ readonly readFrom: 'session log' | 'transcripts in the assets' | 'Keeper conversation';
208
+ readonly sourceIds: readonly string[];
209
+ }
210
+
211
+ /** A header of the transcript a session source holds (sources/sessions/read.ts `renderMessage`). */
212
+ const HEADER_LINE = /^\[(\d+)\] (OWNER|SUBAGENT TASK|SUBAGENT|HOST|AGENT)(?: \([^)\n]*\))?(?: (\d{4}-\d{2}-\d{2} \d{2}:\d{2}))?[ \t]*$/gm;
213
+
214
+ // ───────────────────────── the tools ─────────────────────────
215
+
216
+ export function clerkTools(ctx: ClerkToolContext): ToolDefinition[] {
217
+ const { store, project } = ctx;
218
+ const ev: EvidenceContext = ctx;
219
+ const trace = (summary: string, basisSourceIds: readonly string[] = []) => ({ jobId: ctx.jobId, basisSourceIds, summary });
220
+ const saved = (collection: string, id: string, label: string) => ctx.onSaved?.(collection, id, label);
221
+
222
+ /** Why this job may not write this position, or null (§3.3). */
223
+ const gate = (tool: string, allowed: readonly RoundStepKind[], what: string): string | null => {
224
+ const step = ctx.step;
225
+ if (!step) return `${tool} writes ${what}, a position of a round of the clerk method, and this job is no step of a round, so nothing was written.`;
226
+ // D99: the main job writes by the stage its round is in, a lane by its slots (stage-gate.ts; E148 D-a).
227
+ if (step.kind === 'main' || step.kind === 'lane') return writeRefusal(store, step, tool);
228
+ if (!allowed.includes(step.kind)) return `${tool} writes ${what}, which ${either(allowed.map((k) => STEP_NAME[k]))} writes; this job is ${STEP_NAME[step.kind]}, so nothing was written.`;
229
+ return null;
230
+ };
231
+ const roundId = (): string | null => ctx.step?.roundId ?? null;
232
+ const occurredOrSeen = (ref: EvidenceRef): Occurred => ref.occurred ?? { at: now(), basis: 'First observed', anchor: ref.id, undated: true };
233
+
234
+ const tools: ToolDefinition[] = [];
235
+
236
+ // ───────────────────────── 1 · orientation: which file is which layer ─────────────────────────
237
+
238
+ tools.push(defineTool({
239
+ name: 'pk_write_layers', label: 'Map the documents to their layers',
240
+ description: `Orientation: which document is which layer of the project’s chain — ${LAYER_KINDS.join(', ')} — and whether it is the one the project works to now. The map shows in Project scope and the skeleton builds the chain from it. One entry per document (a directory when the project keeps a layer as one, e.g. a folder of task contracts); mapping a path again updates it and gives only what changes (layer, current and note left out stay as mapped). A new entry needs layer and current. The path must exist in the repository now or in its history: a deleted or superseded plan is mapped with current: false. Map the documents the chain is made of, not every file; code is mapped as code territories in the cross-check. note: one line on what makes it that layer, when the name does not say.`,
241
+ parameters: Type.Object({
242
+ entries: Type.Array(Type.Object({
243
+ path: Type.String({ description: 'relative to the repository root' }),
244
+ repo: Type.Optional(Type.String({ description: 'another repository or directory of the scope: its scope item id or path' })),
245
+ layer: Type.Optional(Type.String({ description: `${LAYER_KINDS.join(' | ')} — a new entry needs it` })),
246
+ note: Type.Optional(Type.String()),
247
+ current: Type.Optional(Type.Boolean({ description: 'true: the version the project works to now; false: an earlier or superseded document of that layer — a new entry needs it' })),
248
+ })),
249
+ }),
250
+ execute: async (_id, p) => {
251
+ const refusal = gate('pk_write_layers', CLERK_WRITER_STEPS.pk_write_layers, 'the layer map');
252
+ if (refusal) return fail(refusal);
253
+ const entries = arr<Record<string, unknown>>(p.entries);
254
+ if (entries.length === 0) return fail('entries is empty: give each document of the chain with its layer.');
255
+ const written: Record<string, unknown>[] = [];
256
+ const refused: string[] = [];
257
+ // Entries that would map a new path but leave out what a new entry needs: when nothing else is written, the call
258
+ // is refused as incomplete, as pi refused it while the schema required layer and current.
259
+ let incomplete = 0;
260
+ for (const [i, e] of entries.entries()) {
261
+ if (has(e.layer) && !isOneOf(LAYER_KINDS, text(e.layer))) { refused.push(`entries[${i}] (${text(e.path)}): layer must be one of ${LAYER_KINDS.join(', ')}`); continue; }
262
+ if (!absent(e.current) && typeof e.current !== 'boolean') { refused.push(`entries[${i}] (${text(e.path)}): say whether it is the current one (current: true or false)`); continue; }
263
+ const place = resolvePath(project, { path: text(e.path), repo: text(e.repo) });
264
+ if (typeof place === 'string') { refused.push(`entries[${i}]: ${place}`); continue; }
265
+ if (!place.rel) { refused.push(`entries[${i}]: give a path inside ${place.root.path}`); continue; }
266
+ const presence = pathPresence(place.root, place.rel);
267
+ if (!presence) { refused.push(`entries[${i}]: ${place.rel} is not in ${place.root.path} now, and no commit in its history touched that path`); continue; }
268
+ const id = stableId('layer', project.id, pathKey(`${place.root.path}/${place.rel}`));
269
+ const previous = store.layers.get(id);
270
+ const missing = previous ? [] : [...(has(e.layer) ? [] : ['layer']), ...(typeof e.current === 'boolean' ? [] : ['current'])];
271
+ if (missing.length) {
272
+ incomplete += 1;
273
+ refused.push(`entries[${i}] (${place.rel}): not mapped yet, so this entry would map it, and a new entry needs ${missing.join(' and ')}, which it leaves out; to update an entry, give the path it has`);
274
+ continue;
275
+ }
276
+ const layer = (has(e.layer) ? text(e.layer) : previous!.layer) as LayerEntry['layer'];
277
+ const current = typeof e.current === 'boolean' ? e.current : previous!.current;
278
+ const entry: LayerEntry = { id, projectId: project.id, repo: place.root.path, path: place.rel, layer, note: clean(e.note) || previous?.note || null, current, roundId: roundId(), updatedAt: now() };
279
+ store.layers.put(entry, trace(`Layer: ${place.rel} is ${layer}${current ? '' : ' (not current)'}${presence === 'history' ? ', only in history' : ''}`));
280
+ saved('layers', id, `Layer: ${place.rel} · ${layer}`);
281
+ written.push({ id, path: place.rel, layer, current, ...(presence === 'history' ? { onlyInHistory: true } : {}), ...(previous ? { updated: true } : {}) });
282
+ }
283
+ if (written.length === 0 && incomplete > 0) throw incompleteCall(`${refused.join('\n')}\nNothing was written. An update of an entry gives only what changes: what it leaves out keeps its value.`);
284
+ if (written.length === 0) return fail(`Nothing was written:\n${refused.join('\n')}`);
285
+ return ok({ written: written.length, entries: written, ...(refused.length ? { refused } : {}) });
286
+ },
287
+ }));
288
+
289
+ // ───────────────────────── 2 · orientation / skeleton: generations of the plan ─────────────────────────
290
+
291
+ tools.push(defineTool({
292
+ name: 'pk_write_generation', label: 'Record an earlier generation of plans',
293
+ description: 'Orientation or skeleton: an earlier generation of the project’s plans, shown rolled up before the current plan and read like the current one when unrolled — so the owner can see which code and work came from plans that were later replaced. Cut a generation only where the material itself names one: a version that replaced the plans, a restart, a cleanup, a decision that ended it. Never infer a generation from reading the history, and never draw date ranges of your own. ended is the evidence of what ended it — the decision’s line, the cleanup commit, the ledger entry; its date is read from there. started, when the material says what began it. planRefs are its plan documents (a deleted one is cited as a file that exists in history, or by the History only source pk_history_read gives); workIds the work items planned in it. A generation with the same name (or its id) is updated: an update gives only what it changes, and what it leaves out keeps its value. A new generation needs name, ended and planRefs. ' + EVIDENCE_HELP,
294
+ parameters: Type.Object({
295
+ id: Type.Optional(Type.String({ description: 'an existing generation to update' })),
296
+ name: Type.Optional(Type.String({ description: 'the material’s own name for it, e.g. "Roadmap v1"; a new generation needs it, and it finds the one already recorded under that name' })),
297
+ ended: Type.Optional(EvidenceSchema('what ended it, where the material says so; a new generation needs it')),
298
+ started: Type.Optional(EvidenceSchema('what began it, where the material says so')),
299
+ planRefs: Type.Optional(Type.Array(EvidenceSchema('one of its plan documents'), { description: 'its plan documents; a new generation needs them' })),
300
+ workIds: Type.Optional(Type.Array(Type.String(), { description: 'the work items planned in it' })),
301
+ }),
302
+ execute: async (_id, raw) => {
303
+ const p = raw as Record<string, unknown>;
304
+ const refusal = gate('pk_write_generation', CLERK_WRITER_STEPS.pk_write_generation, 'a generation of the plans');
305
+ if (refusal) return fail(refusal);
306
+ const fields = programFields(p, ['endedBy', 'occurred', 'updatedAt'], 'what ended a generation is the evidence given as ended, and its date is read from that evidence.');
307
+ if (fields) return fail(fields);
308
+ const given = text(p.id).trim();
309
+ const named = given ? store.generations.get(given) : undefined;
310
+ if (given && !named) return fail(`${given} is not a generation of this project; leave id out to record a new one.`);
311
+ // The one to update: the id given, or the generation recorded under the same name.
312
+ const previous = named ?? (clean(p.name) ? store.generations.get(stableId('gen', project.id, squash(clean(p.name)))) : undefined);
313
+ if (ctx.step?.kind === 'spot-check' && !previous) return fail('The spot check corrects the generations it checked: give the id of the one to correct.');
314
+ if (!previous) {
315
+ const missing = [...(clean(p.name) ? [] : ['name']), ...(absent(p.ended) ? ['ended'] : []), ...(absent(p.planRefs) ? ['planRefs'] : [])];
316
+ if (missing.length) throw createNeeds('a generation', clean(p.name) ? `No generation is recorded as “${clean(p.name)}”` : 'This call names no recorded generation (no id, and no name to find one by)', missing, 'To update a generation, give its id, or its name as recorded.');
317
+ }
318
+ const name = clean(p.name) || previous?.name || '';
319
+ const endedBy = absent(p.ended) ? previous!.endedBy : resolveEvidence(ev, p.ended, 'ended');
320
+ if (typeof endedBy === 'string') return fail(endedBy);
321
+ // started left out keeps what began it; given as null, the generation has no recorded start.
322
+ const startedBy = p.started === undefined || p.started === null ? null : resolveEvidence(ev, p.started, 'started');
323
+ if (typeof startedBy === 'string') return fail(startedBy);
324
+ const ended = absent(p.ended) ? previous!.ended : occurredOrSeen(endedBy);
325
+ const started = p.started === undefined ? previous?.started ?? null : startedBy ? occurredOrSeen(startedBy) : null;
326
+ if (started && !started.undated && !ended.undated && compareOccurred(started, ended) > 0) {
327
+ return fail(`started (${startedBy?.label ?? 'as recorded'}, ${started.at}) comes after ended (${endedBy.label}, ${ended.at}): a generation ends after it begins. Check which evidence began it and which ended it.`);
328
+ }
329
+ const planRefs = absent(p.planRefs) ? [...previous!.planRefs] : resolveEvidenceList(ev, p.planRefs, 'planRefs');
330
+ if (typeof planRefs === 'string') return fail(planRefs);
331
+ const workIds = absent(p.workIds) ? [...(previous?.workIds ?? [])] : [...new Set(arr<string>(p.workIds).map((w) => resolveMergedId(store, text(w).trim())).filter(Boolean))];
332
+ const unknown = workIds.filter((w) => !store.threads.has(w));
333
+ if (unknown.length) return fail(`workIds names no work item of the assets: ${unknown.join(', ')}.`);
334
+ const id = previous?.id ?? stableId('gen', project.id, squash(name));
335
+ const before = store.generations.get(id);
336
+ const generation: Generation = { id, projectId: project.id, name, started, ended, endedBy, planRefs, workIds, roundId: roundId(), updatedAt: now() };
337
+ store.generations.put(generation, trace(`Generation “${name}”: ended ${ended.at} (${endedBy.label})`, sourceIdsOf([endedBy, startedBy, ...planRefs])));
338
+ saved('generations', id, `Generation: ${name}`);
339
+ return ok({ id, name, ended, started, endedBy: endedBy.label, planRefs: planRefs.length, workIds: workIds.length, updated: Boolean(before) });
340
+ },
341
+ }));
342
+
343
+ // ───────────────────────── 3 · every step: what the round leaves behind ─────────────────────────
344
+
345
+ tools.push(defineTool({
346
+ name: 'pk_write_round_doc', label: 'Write a document of this round',
347
+ description: 'What a round leaves behind, in full, openable under the round in the Keeper view: orientation writes the Questions of this round, the History map, and one Brief per deep sweep (path: the sweep’s name; its six parts: shared background and what not to report, hard rules, what to read, the three verdicts, clues to watch, how to report); a deep sweep writes its own Report (complete and densely cited, never a short result) — with a reading assignment, the report of that assignment, and in unread what on its list it could not read in full (the material’s key, part or none, and why); the cross-check writes the Adoption record (which conclusions it adopted, which not, and why); the synthesis writes the round’s Result; the spot check writes its Spot check, and corrects a Result whose claims it found wrong by writing it again. The main agent’s Handover is written by pk_stage as it hands over, not here. Give the full text — it is kept as written, never shortened. Writing the same kind (and sweep, and assignment) again in this round replaces it and gives only what changes: a title, text or unread list left out stays as written. A new document needs title and markdown. Counts and dates the program keeps are not written here as facts of your own.',
348
+ parameters: Type.Object({
349
+ kind: Type.String({ description: 'Questions | History map | Brief | Report | Adoption | Result | Spot check' }),
350
+ path: Type.Optional(Type.String({ description: 'the deep sweep’s name: for a Brief and a Report only' })),
351
+ title: Type.Optional(Type.String({ description: 'what this document is, in a few words; a new document needs it' })),
352
+ markdown: Type.Optional(Type.String({ description: 'the full text; a new document needs it' })),
353
+ unread: Type.Optional(Type.Array(Type.Object({
354
+ material: Type.String({ description: 'the material’s key, as your assignment lists it in brackets' }),
355
+ read: Type.String({ description: 'part (you read some of it) | none' }),
356
+ why: Type.String({ description: 'why it could not be read in full' }),
357
+ }), { description: 'a Report of a reading assignment: what on its list you could not read in full' })),
358
+ }),
359
+ execute: async (_id, p) => {
360
+ const step = ctx.step;
361
+ if (!step) return fail('pk_write_round_doc writes a document of a round of the clerk method, and this job is no step of a round, so nothing was written.');
362
+ const kind = text(p.kind);
363
+ if (!isOneOf(ROUND_DOC_KINDS, kind)) return fail(`kind must be one of ${ROUND_DOC_KINDS.filter((k) => k !== 'Layers' && k !== 'Handover').join(', ')}.`);
364
+ if (kind === 'Layers') return fail('The layer map is written entry by entry with pk_write_layers; its document is the program’s rendering of those entries.');
365
+ // D103: the handover is the main agent's, written by pk_stage as it hands the round over.
366
+ if (kind === 'Handover' && step.kind !== 'main') return fail('The Handover is the main agent’s, written as it hands the round over to the synthesis (pk_stage to synthesis with handover); read it with pk_read_assets kind roundDoc. Nothing was written.');
367
+ const allowed = DOCS_BY_STEP[step.kind] ?? [];
368
+ const byStage = step.kind === 'main' || step.kind === 'lane' ? roundDocRefusal(store, step, kind) : null;
369
+ if (byStage) return fail(byStage);
370
+ if (step.kind !== 'main' && step.kind !== 'lane' && !allowed.includes(kind)) {
371
+ const owner = (Object.entries(DOCS_BY_STEP) as [RoundStepKind, readonly RoundDocKind[]][]).filter(([, kinds]) => kinds.includes(kind)).map(([k]) => STEP_NAME[k]);
372
+ return fail(`${STEP_NAME[step.kind]} writes ${allowed.length ? either([...allowed]) : 'no round document'}; a ${kind} is written by ${either(owner)}. Nothing was written.`);
373
+ }
374
+ let path = clean(p.path) || null;
375
+ if (kind === 'Report') {
376
+ if (!step.path) return fail('This deep sweep has no name, so there is no sweep to file a report under.');
377
+ if (path && path !== step.path) return fail(`A deep sweep writes its own report: this one is “${step.path}”, and “${path}” is another sweep’s. Nothing was written.`);
378
+ path = step.path;
379
+ } else if (kind === 'Brief') {
380
+ if (!path) return fail('A Brief is the task of one deep sweep: give the sweep’s name in path.');
381
+ } else if (path) {
382
+ return fail(`path names a deep sweep and belongs on a Brief or a Report; a ${kind} is one for the whole round — leave path out.`);
383
+ }
384
+ // A reading assignment's report is that assignment's, beside the sweep's other assignments' (§3.7), and says what
385
+ // on its list it could not read in full; the program holds that against what its recorded reads show.
386
+ const assignment = kind === 'Report' ? step.assignment ?? null : null;
387
+ const id = assignment ? stableId('rdoc', step.roundId, kind, path ?? '', assignment) : stableId('rdoc', step.roundId, kind, path ?? '');
388
+ const previous = store.roundDocs.get(id);
389
+ // The spot-check corrects the Result the synthesis wrote; it writes none of its own (D103).
390
+ if (step.kind === 'spot-check' && kind === 'Result' && !previous) return fail('The spot check corrects the round’s Result where a claim in it is wrong; this round has no Result to correct, and the spot check writes none of its own. Nothing was written.');
391
+ if (!previous) {
392
+ const missing = [...(absent(p.title) ? ['title'] : []), ...(absent(p.markdown) ? ['markdown'] : [])];
393
+ if (missing.length) throw createNeeds(`a ${kind} document`, `This round has no ${kind} document${path ? ` for “${path}”` : ''}${assignment ? ` of assignment ${assignment}` : ''} yet`, missing, `To replace the one written, write the same kind${kind === 'Brief' ? ' and path' : ''} again in this round.`);
394
+ }
395
+ const title = clean(p.title) || previous?.title || '';
396
+ if (!title) return fail('title: what this document is, in a few words.');
397
+ const markdown = absent(p.markdown) ? previous!.markdown : redact(text(p.markdown));
398
+ if (!markdown.trim()) return fail('markdown is empty: give the document’s full text.');
399
+ // The round's Result is read by the owner (§6.13, D105; CKC-08 AC-27): no store ids, no lane named as a lane, no
400
+ // section of a lane's report or brief. The round's other documents are the agents' own and keep theirs.
401
+ if (kind === 'Result') {
402
+ const unreadable = ownerTextRefusal('The round’s Result', { title, markdown }, laneNamesOf(store));
403
+ if (unreadable) return fail(unreadable);
404
+ }
405
+ const unread: { material: string; read: 'part' | 'none'; why: string }[] = Array.isArray(p.unread) ? [] : [...(previous?.unread ?? [])];
406
+ if (Array.isArray(p.unread) && p.unread.length) {
407
+ if (!assignment) return fail('unread belongs to the report of a reading assignment: this job has no assignment, so nothing was written.');
408
+ const listed = new Set((ctx.materials ?? []).map((m) => m.key));
409
+ for (const u of p.unread as { material?: unknown; read?: unknown; why?: unknown }[]) {
410
+ const material = clean(text(u.material)).replace(/^\[|\]$/g, '');
411
+ const read = clean(text(u.read)).toLowerCase();
412
+ const why = clean(text(u.why));
413
+ if (!listed.has(material)) return fail(`unread: “${material}” is not on this assignment's list; name a material by the key your list gives it in brackets. Nothing was written.`);
414
+ if (read !== 'part' && read !== 'none') return fail(`unread: read is part or none (for ${material}). Nothing was written.`);
415
+ if (!why) return fail(`unread: say why ${material} could not be read in full. Nothing was written.`);
416
+ unread.push({ material, read, why });
417
+ }
418
+ }
419
+ // A Result the spot-check corrects stays the synthesis' document: the correction is in the trace, under the spot-check's job.
420
+ const writer = step.kind === 'spot-check' && kind === 'Result' ? previous?.jobId ?? ctx.jobId : ctx.jobId;
421
+ const doc: RoundDoc = { id, projectId: project.id, roundId: step.roundId, jobId: writer, kind, path, title, markdown, at: now(), ...(assignment ? { assignment } : {}), ...(unread.length ? { unread } : {}) };
422
+ const chars = [...markdown].length;
423
+ store.roundDocs.put(doc, trace(`${previous ? 'Replaced' : 'Wrote'} ${kind}${path ? ` · ${path}` : ''}: ${title} (${chars} characters, ${fingerprint(markdown).slice(0, 23)})`));
424
+ const round = store.clerkRounds.get(step.roundId);
425
+ if (round && kind === 'Questions' && round.questionsDocId !== id) {
426
+ store.clerkRounds.put({ ...round, questionsDocId: id, updatedAt: now() }, trace(`Round ${round.number}: its questions are ${id}`));
427
+ }
428
+ if (round && kind === 'Brief' && path && !round.paths.includes(path)) {
429
+ store.clerkRounds.put({ ...round, paths: [...round.paths, path], updatedAt: now() }, trace(`Round ${round.number}: deep sweep “${path}”`));
430
+ }
431
+ saved('roundDocs', id, `${kind}${path ? ` · ${path}` : ''}: ${title}`);
432
+ // E153: the Result of a deepening or a Follow up reports what became of the notes standing from earlier rounds; the
433
+ // program's count comes back with the write, and says when some still have no result.
434
+ const standing = round && kind === 'Result' && step.kind === 'synthesis' ? standingCounts(store, round) : null;
435
+ return ok({
436
+ id, kind, path, chars, replaced: Boolean(previous), ...(assignment ? { assignment, unread: unread.length } : {}),
437
+ ...(standing?.standing ? { standingNotes: standing, ...(standing.open ? { note: `${standing.open} of the ${standing.standing} notes standing from earlier rounds have no result yet (pk_round_state { list: "standingNotes" }): confirm, update or withdraw each, then give the three counts in the Result as the program counts them.` } : {}) } : {}),
438
+ });
439
+ },
440
+ }));
441
+
442
+ // ───────────────────────── 4 · skeleton / cross-check: semantic patches ─────────────────────────
443
+
444
+ /** Ids the patch's `affects` may name: the documents, plans, work items and code territories of the assets. */
445
+ const affectedRefusal = (ids: readonly string[]): string | null => {
446
+ const known = (id: string) => store.nodes.has(id) || store.reference.has(id) || store.threads.has(id) || store.areas.has(id)
447
+ || store.territories.has(id) || store.layers.has(id) || store.generations.has(id) || store.sources.has(id);
448
+ const unknown = ids.filter((id) => !known(id));
449
+ return unknown.length ? `affects names what is not in the assets: ${unknown.join(', ')}. Give the ids of the documents (reference items, layer entries or sources), plans, work items and code territories it affects; say the rest in affectsText.` : null;
450
+ };
451
+
452
+ tools.push(defineTool({
453
+ name: 'pk_write_patch', label: 'Draft or confirm a semantic patch',
454
+ description: 'A semantic patch: what a change of meaning in the docs withdrew and what took its place. It comes only from an explicit supersession written in the material — superseded by, replaced by, deprecated, 取代, 作废, V1 → V2, a row of the project’s own list of corrected rules — never from a paragraph whose meaning you think drifted (for that, a Suspected stale mark). The skeleton drafts it; the cross-check confirms or rejects it (status) or corrects it; the spot check corrects one it found wrong. Answer four things: which part of the old state no longer holds (invalidated), what replaces it (replacedBy — say so when nothing does), who is affected (affects: ids of documents, plans, work items, code territories; affectsText in words), what must never again pass as current (mustNotPassAsCurrent). Cite the decision, do not repeat its reasons. candidate is the explicit supersession it comes from: a ledger entry, or while this build has no ledger the file (or source, or commit) and the line that says it. oldAnchor is where the old state is written, newAnchor where the new one is. partial: true when only part of the old state is withdrawn. The program gives the number (SP-n) and reads when it happened from the candidate; the same candidate and old anchor are the same patch. ' + EVIDENCE_HELP,
455
+ parameters: Type.Object({
456
+ id: Type.Optional(Type.String({ description: 'the patch to edit or confirm: its id or its number (SP-n)' })),
457
+ title: Type.Optional(Type.String()),
458
+ invalidated: Type.Optional(Type.String({ description: 'which part of the old state no longer holds' })),
459
+ replacedBy: Type.Optional(Type.String({ description: 'what replaces it; say so when nothing does' })),
460
+ affects: Type.Optional(Type.Array(Type.String(), { description: 'ids of the documents, plans, work items and code territories affected' })),
461
+ affectsText: Type.Optional(Type.String({ description: 'who is affected, in words' })),
462
+ mustNotPassAsCurrent: Type.Optional(Type.String({ description: 'what must never again pass as current' })),
463
+ candidate: Type.Optional(EvidenceSchema('the explicit supersession it comes from')),
464
+ oldAnchor: Type.Optional(EvidenceSchema('where the old state is written')),
465
+ newAnchor: Type.Optional(EvidenceSchema('where the new state is written, when it is')),
466
+ decision: Type.Optional(EvidenceSchema('the decision or commit that replaced it')),
467
+ partial: Type.Optional(Type.Boolean({ description: 'true when only part of the old state is withdrawn and the rest stays current' })),
468
+ status: Type.Optional(Type.String({ description: 'cross-check and spot check: Confirmed | Rejected (a patch the skeleton writes is a Draft)' })),
469
+ }),
470
+ execute: async (_id, raw) => {
471
+ const p = raw as Record<string, unknown>;
472
+ const refusal = gate('pk_write_patch', CLERK_WRITER_STEPS.pk_write_patch, 'a semantic patch');
473
+ if (refusal) return fail(refusal);
474
+ const asStep = stepKindAs(store, ctx.step);
475
+ // A lane drafts, as the skeleton did (D99).
476
+ const step = asStep === 'lane' ? 'skeleton' : asStep;
477
+ const fields = programFields(p, ['number', 'occurred', 'writtenToFolder', 'updatedAt'], 'the program numbers a patch, reads when it happened from its candidate, and records its writing to the project folder.');
478
+ if (fields) return fail(fields);
479
+ const status = p.status === undefined ? null : text(p.status);
480
+ if (status !== null && !isOneOf(PATCH_STATUS, status)) return fail(`status must be one of ${PATCH_STATUS.join(', ')}.`);
481
+ if (step === 'skeleton' && status !== null && status !== 'Draft') return fail('The skeleton drafts a patch; whether it holds is the cross-check’s to say. Leave status out.');
482
+ const given = text(p.id).trim();
483
+ const named = given ? store.patches.get(given) ?? store.patches.find((x) => x.number === given) : undefined;
484
+ if (given && !named) return fail(`${given} is not a semantic patch of this project; leave id out to draft a new one.`);
485
+ if (step === 'spot-check' && !named) return fail('The spot check corrects the patches it checked: give the id of the one to correct.');
486
+ const resolveOptional = (value: unknown, label: string, kept: EvidenceRef | null): EvidenceRef | null | string =>
487
+ value === undefined ? kept : value === null ? null : resolveEvidence(ev, value, label);
488
+ const candidate = resolveOptional(p.candidate, 'candidate', named?.candidate ?? null);
489
+ if (typeof candidate === 'string') return fail(candidate);
490
+ if (!candidate) return fail('candidate: the explicit supersession this patch comes from — a ledger entry, or while this build has no ledger the file (or source, or commit) and the line that says it. A patch no written supersession backs is not one: a paragraph whose meaning may have changed gets Suspected stale instead.');
491
+ if (p.candidate !== undefined) {
492
+ if (candidate.kind === 'object') return fail('candidate is an explicit supersession in the material (a ledger entry, or a file, source or commit and its line); an object of the assets is the Keeper’s record, not the material.');
493
+ if (ctx.ledger && candidate.kind !== 'ledger') return fail('This build has the ledger: the candidate is its explicit-supersession entry (kind ledger), which the ledger found in the material.');
494
+ if (candidate.kind !== 'ledger' && !candidate.line) return fail(`candidate names ${candidate.label} but not the line that says the supersession: give the line, copied as it stands, so the program can check it is there.`);
495
+ }
496
+ const oldAnchor = resolveOptional(p.oldAnchor, 'oldAnchor', named?.oldAnchor ?? null);
497
+ if (typeof oldAnchor === 'string') return fail(oldAnchor);
498
+ if (!oldAnchor) return fail('oldAnchor: where the old state is written (the file and line, the source, the version in history).');
499
+ const newAnchor = resolveOptional(p.newAnchor, 'newAnchor', named?.newAnchor ?? null);
500
+ if (typeof newAnchor === 'string') return fail(newAnchor);
501
+ const decision = resolveOptional(p.decision, 'decision', named?.decision ?? null);
502
+ if (typeof decision === 'string') return fail(decision);
503
+ const id = named?.id ?? stableId('patch', project.id, evidenceKey(candidate), evidenceKey(oldAnchor));
504
+ const existing = named ?? store.patches.get(id);
505
+ const words = {
506
+ title: clean(p.title) || existing?.title || '',
507
+ invalidated: clean(p.invalidated) || existing?.invalidated || '',
508
+ replacedBy: clean(p.replacedBy) || existing?.replacedBy || '',
509
+ affectsText: clean(p.affectsText) || existing?.affectsText || '',
510
+ mustNotPassAsCurrent: clean(p.mustNotPassAsCurrent) || existing?.mustNotPassAsCurrent || '',
511
+ };
512
+ const missing = Object.entries(words).filter(([, v]) => !v).map(([k]) => k);
513
+ if (missing.length) return fail(`A semantic patch answers four things and has a title; missing: ${missing.join(', ')}. invalidated: which part of the old state no longer holds; replacedBy: what replaces it; affectsText (and affects): who is affected; mustNotPassAsCurrent: what must never again pass as current.`);
514
+ const affects = p.affects === undefined ? [...(existing?.affects ?? [])] : [...new Set(arr<string>(p.affects).map((a) => resolveMergedId(store, text(a).trim())).filter(Boolean))];
515
+ const affectsProblem = affectedRefusal(affects);
516
+ if (affectsProblem) return fail(affectsProblem);
517
+ const partial = typeof p.partial === 'boolean' ? p.partial : existing?.partial ?? false;
518
+ const content = { ...words, affects, partial, candidate: evidenceKey(candidate), oldAnchor: evidenceKey(oldAnchor), newAnchor: newAnchor ? evidenceKey(newAnchor) : null, decision: decision ? evidenceKey(decision) : null };
519
+ const before = existing ? { title: existing.title, invalidated: existing.invalidated, replacedBy: existing.replacedBy, affectsText: existing.affectsText, mustNotPassAsCurrent: existing.mustNotPassAsCurrent, affects: existing.affects, partial: existing.partial, candidate: evidenceKey(existing.candidate), oldAnchor: evidenceKey(existing.oldAnchor), newAnchor: existing.newAnchor ? evidenceKey(existing.newAnchor) : null, decision: existing.decision ? evidenceKey(existing.decision) : null } : null;
520
+ const changed = !before || JSON.stringify(before) !== JSON.stringify(content);
521
+ // The skeleton's writing is a draft; a draft it only repeats keeps what the cross-check said of it. The cross-check's
522
+ // own new patch is one it checked; an edit keeps its status unless the call gives one.
523
+ const nextStatus: SemanticPatch['status'] = step === 'skeleton'
524
+ ? (existing && !changed ? existing.status : 'Draft')
525
+ : (status as SemanticPatch['status'] | null) ?? existing?.status ?? (step === 'cross-check' ? 'Confirmed' : 'Draft');
526
+ const number = existing?.number ?? nextNumber('SP', store.patches.all().map((x) => x.number));
527
+ const patch: SemanticPatch = {
528
+ id, projectId: project.id, number, ...words, affects, oldAnchor, newAnchor, decision, candidate, partial,
529
+ occurred: occurredOrSeen(candidate), status: nextStatus, writtenToFolder: existing?.writtenToFolder ?? null,
530
+ roundId: existing?.roundId ?? roundId(), jobId: existing?.jobId ?? ctx.jobId, updatedAt: now(),
531
+ };
532
+ store.patches.put(patch, trace(`${existing ? 'Semantic patch updated' : 'Semantic patch drafted'} ${number} (${nextStatus}): ${words.title}`, sourceIdsOf([candidate, oldAnchor, newAnchor, decision])));
533
+ saved('patches', id, `${number} ${words.title}`);
534
+ return ok({ id, number, status: nextStatus, occurred: patch.occurred, partial, created: !existing, ...(existing && !changed && step === 'skeleton' ? { note: 'Nothing in it changed, so it keeps its status.' } : {}) });
535
+ },
536
+ }));
537
+
538
+ // ───────────────────────── 5 · skeleton: Keeper numbers ─────────────────────────
539
+
540
+ /** Whether a number is written somewhere in the project's material the assets hold (as the program reads numbers). */
541
+ const numberInMaterial = (number: string): boolean => {
542
+ const escaped = number.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
543
+ const token = new RegExp(`(?<![\\p{L}\\p{N}_-])${escaped}(?![\\p{L}\\p{N}_])`, 'u');
544
+ return store.sources.find((s) => s.ids.some((x) => x.toUpperCase() === number.toUpperCase()) || token.test(s.excerpt)) !== undefined;
545
+ };
546
+
547
+ tools.push(defineTool({
548
+ name: 'pk_number', label: 'Give a Keeper number',
549
+ description: 'Skeleton: a number for a unit of work, a decision or another object the project did not number, so it can be named the same way everywhere — the workbench shows it as the Keeper’s. Give objectId and objectKind (work, decision, patch or other); the program gives the next K-n. An object the project already numbers keeps the project’s number: it is returned and nothing is recorded. When the project later numbers something the Keeper had numbered, record it with objectId and projectNumber: the project’s number wins and the Keeper’s stays as an alias. Never make up a number, and never write one in place of the program’s.',
550
+ parameters: Type.Object({
551
+ objectId: Type.String(),
552
+ objectKind: Type.Optional(Type.String({ description: 'work | decision | patch | other — to number an object the project did not number' })),
553
+ projectNumber: Type.Optional(Type.String({ description: 'the number the project itself gave it later, as its material writes it' })),
554
+ }),
555
+ execute: async (_id, raw) => {
556
+ const p = raw as Record<string, unknown>;
557
+ const refusal = gate('pk_number', CLERK_WRITER_STEPS.pk_number, 'a Keeper number');
558
+ if (refusal) return fail(refusal);
559
+ const fields = programFields(p, ['number'], 'the program gives the next Keeper number.');
560
+ if (fields) return fail(fields);
561
+ const rawId = text(p.objectId).trim();
562
+ const kindGiven = text(p.objectKind).trim();
563
+ const projectNumber = text(p.projectNumber).trim();
564
+ if (!kindGiven && !projectNumber) return fail('Give objectKind to number an object the project did not number, or projectNumber to record the number the project gave it later.');
565
+ if (kindGiven && !isOneOf(NUMBER_KINDS, kindGiven)) return fail(`objectKind must be one of ${NUMBER_KINDS.join(', ')}.`);
566
+ const id = resolveMergedId(store, rawId);
567
+ const thread = store.threads.get(id);
568
+ const patch = store.patches.get(id) ?? store.patches.find((x) => x.number === rawId);
569
+ const ref = store.reference.get(store.nodes.get(id)?.refId ?? id);
570
+ const found = findObject(store, id);
571
+ if (!found || store.sources.has(id)) return fail(`${rawId} is not an object of this project’s assets that can carry a number.`);
572
+ const actual: KeeperNumber['objectKind'] = thread ? 'work' : patch ? 'patch' : ref && isDecision(store, ref.id) ? 'decision' : 'other';
573
+ if (kindGiven && kindGiven !== actual) return fail(`${rawId} (${found.label}) is ${actual === 'work' ? 'a work item' : actual === 'decision' ? 'a decision' : actual === 'patch' ? 'a semantic patch' : 'neither a work item, a decision nor a patch'}: its objectKind is ${actual}.`);
574
+ const objectId = patch?.id ?? found.id;
575
+ // The project's own number: its first id, or the identifier its name starts with (as the writers read it).
576
+ const numbered = thread ? { ids: thread.ids, name: thread.title } : ref ? { ids: ref.ids, name: ref.name } : null;
577
+ const own = numbered ? primaryIdentifier(numbered.ids, numbered.name) ?? numbered.ids[0] ?? null : null;
578
+ const existing = store.numbers.find((n) => n.objectId === objectId);
579
+ if (projectNumber) {
580
+ if (!existing) return fail(`${rawId} has no Keeper number to keep as an alias${own ? `; the project numbers it ${own}` : ''}. A number the project gave from the start belongs in the object’s own ids (pk_write_thread or pk_write_reference ids).`);
581
+ if (existing.projectNumber === projectNumber) return ok({ objectId, number: projectNumber, alias: existing.number, recorded: false });
582
+ if (!numberInMaterial(projectNumber)) return fail(`${projectNumber} is written nowhere in the project’s material the assets hold, so it cannot be recorded as the project’s number. A project number is the project’s own: it is recorded once the material that gives it has been read.`);
583
+ const clash = store.threads.find((t) => t.id !== objectId && t.ids.some((x) => x.toUpperCase() === projectNumber.toUpperCase()))
584
+ ?? store.reference.find((r) => r.id !== objectId && r.ids.some((x) => x.toUpperCase() === projectNumber.toUpperCase()));
585
+ const clashNumber = store.numbers.find((n) => n.objectId !== objectId && n.projectNumber === projectNumber);
586
+ if (clash || clashNumber) return fail(`${projectNumber} is already the number of ${clash ? `${clash.id} (${'title' in clash ? clash.title : clash.name})` : clashNumber!.objectId}: one number names one thing.`);
587
+ store.numbers.put({ ...existing, projectNumber }, trace(`The project numbers ${found.label} ${projectNumber}; ${existing.number} stays as an alias`));
588
+ saved('numbers', existing.id, `${projectNumber} (was ${existing.number})`);
589
+ return ok({ objectId, number: projectNumber, alias: existing.number, recorded: true });
590
+ }
591
+ if (patch) return ok({ objectId, number: patch.number, byKeeper: true, recorded: false, note: 'A semantic patch is numbered by the program when it is written.' });
592
+ if (own) return ok({ objectId, number: own, byKeeper: false, recorded: false, note: 'The project numbers it: its own number stands.' });
593
+ if (existing) return ok({ objectId, number: existing.projectNumber ?? existing.number, byKeeper: !existing.projectNumber, ...(existing.projectNumber ? { alias: existing.number } : {}), recorded: false });
594
+ const number = nextNumber('K', store.numbers.all().map((n) => n.number));
595
+ const record: KeeperNumber = { id: stableId('knum', project.id, objectId), projectId: project.id, number, objectId, objectKind: actual, projectNumber: null, at: now() };
596
+ store.numbers.put(record, trace(`Keeper number ${number}: ${found.label}`));
597
+ saved('numbers', record.id, `${number}: ${found.label}`);
598
+ return ok({ objectId, number, byKeeper: true, recorded: true });
599
+ },
600
+ }));
601
+
602
+ // ───────────────────────── 6 · skeleton / cross-check: process links ─────────────────────────
603
+
604
+ /** The repository-relative file a fact is about (a file, a source's file, a ledger entry's document), or null (a commit). */
605
+ const factPlace = (fact: EvidenceRef): { repo: string | null; path: string } | null => {
606
+ if (fact.kind === 'file') return { repo: fact.repo ? pathKey(fact.repo) : null, path: fact.id.replace(/\\/g, '/') };
607
+ if (fact.kind === 'source') {
608
+ const src = store.sources.get(fact.id);
609
+ if (!src || src.anchor.kind !== 'file') return null;
610
+ return placeUnder(project.scope.map((i) => i.path), src.anchor.path);
611
+ }
612
+ if (fact.kind === 'ledger') {
613
+ const ledger = Ledger.openDir(store.dir);
614
+ try {
615
+ const at = ledger?.pathOfEntry(fact.id) ?? null;
616
+ if (!at) return null;
617
+ const repo = ledger?.repos().find((r) => r.id === at.repo)?.path ?? null;
618
+ return { repo: repo ? pathKey(repo) : null, path: at.path };
619
+ } finally { ledger?.close(); }
620
+ }
621
+ return null;
622
+ };
623
+ const samePlace = (a: { repo: string | null; path: string } | null, b: { repo: string | null; path: string }): boolean =>
624
+ a !== null && a.path.toLowerCase() === b.path.toLowerCase() && (a.repo === null || b.repo === null || a.repo === b.repo);
625
+ /** A work item's own numbers: its ids, and an internal id that is a project number (written before CJ). */
626
+ const ownNumbers = (t: WorkThread): Set<string> => new Set([...t.ids, ...(isNumberShaped(t.id.toUpperCase()) ? [t.id] : [])].map((x) => x.trim().toUpperCase()));
627
+ /** Why a check's link to this file is refused, or null (CJ). */
628
+ const checkLinkRefusal = (work: WorkThread, path: string, stepKind: string): string | null => {
629
+ const ledger = Ledger.openDir(store.dir);
630
+ try {
631
+ const kinds = ledger?.arrangementKinds(path) ?? [];
632
+ const owners = new Set([...kinds.map((k) => k.ident ?? ''), ...definitionsInPath(path).map((d) => d.num)].filter(Boolean).map((x) => x.toUpperCase()));
633
+ const mine = ownNumbers(work);
634
+ const self = [...owners].filter((o) => mine.has(o));
635
+ if (self.length) {
636
+ return `${path} is ${self.join(', ')}’s own ${kinds.some((k) => k.kind === 'prompt') ? 'prompt' : 'material'}, and a work item is never the ${stepKind} of itself: link the ${stepKind} on the work item ${self[0]} checked — the contract or ticket its report gives a verdict for. Nothing was written.`;
637
+ }
638
+ if (kinds.some((k) => k.kind === 'prompt') && !kinds.some((k) => k.kind === 'receipt')) {
639
+ const idents = [...new Set(kinds.map((k) => k.ident).filter((x): x is string => Boolean(x)))];
640
+ const reports = idents.length && ledger ? (ledger.db.prepare(`SELECT DISTINCT path FROM plans WHERE kind = 'receipt' AND ident IN (${idents.map(() => '?').join(',')}) ORDER BY path`).all(...idents) as { path: string }[]).map((r) => r.path) : [];
641
+ return `${path} is the dispatch prompt${idents.length ? ` of ${idents.join(', ')}` : ''}: it asks for the check and gives no verdict. A ${stepKind} link cites the verdict file — the report${reports.length ? ` (${reports.slice(0, 4).join(', ')})` : ', the run’s result or last message'} — with the line that states the verdict. Nothing was written.`;
642
+ }
643
+ return null;
644
+ } finally { ledger?.close(); }
645
+ };
646
+
647
+ tools.push(defineTool({
648
+ name: 'pk_link_process', label: 'Link a fact to a step of a work item',
649
+ description: `Skeleton or cross-check: tie a fact the program could not tie by ids — a commit, a merge, a verdict, a dispatch, a receipt — to the work item whose step it is, so the process view shows it as that step (${STEP_KINDS.join(', ')}). The program already ties what carries the work item’s number; link only what it could not, and say in one sentence why it belongs (why). ledgerRef is the fact: a ledger entry, or while this build has no ledger the commit, source or file (and line). A link is Inferred. The program checks it as it is written: a commit in the ledger whose message names the work item's number (ranges count) or that changed its files, a cited line that names its number, a file that is its own — then the link is lane-checked and counts like a confirmed one; otherwise it is suspect, and the cross-check rejects it or confirms it after reading the original (confirm: true). The same work item, fact and step is the same link: linking it again updates it, and a why left out stays as written. A new link needs why. ` + EVIDENCE_HELP,
650
+ parameters: Type.Object({
651
+ workId: Type.String(),
652
+ ledgerRef: EvidenceSchema('the fact that is the step'),
653
+ stepKind: Type.String({ description: STEP_KINDS.join(' | ') }),
654
+ why: Type.Optional(Type.String({ description: 'why this fact is this step of this work item, in one sentence; a new link needs it' })),
655
+ confirm: Type.Optional(Type.Boolean({ description: 'cross-check only: true once you checked the link against the original' })),
656
+ }),
657
+ execute: async (_id, raw) => {
658
+ const p = raw as Record<string, unknown>;
659
+ const refusal = gate('pk_link_process', CLERK_WRITER_STEPS.pk_link_process, 'a link between a fact and a step of a work item');
660
+ if (refusal) return fail(refusal);
661
+ const fields = programFields(p, ['confirmed', 'basis', 'at'], 'a link the program could not make from ids is Inferred, and it is confirmed with confirm in the cross-check.');
662
+ if (fields) return fail(fields);
663
+ const workId = resolveMergedId(store, text(p.workId).trim());
664
+ const work = store.threads.get(workId);
665
+ if (!work) return fail(`${text(p.workId) || '(empty)'} is not a work item of the assets.`);
666
+ const stepKind = text(p.stepKind);
667
+ if (!isOneOf(STEP_KINDS, stepKind)) return fail(`stepKind must be one of ${STEP_KINDS.join(', ')}.`);
668
+ const fact = resolveEvidence(ev, p.ledgerRef, 'ledgerRef');
669
+ if (typeof fact === 'string') return fail(fact);
670
+ if (fact.kind === 'object') return fail('ledgerRef is a fact of the ledger (or, while this build has none, a commit, source or file); an object of the assets is a judgement, not a step of the work.');
671
+ // A ledger entry is named by its id; before the ledger, what stands in for one says its kind (`commit:<hash>`). The
672
+ // resolved evidence is kept beside it — label, the original line, when it happened, the repository — so the link
673
+ // shows what it ties without resolving it again.
674
+ const ledgerRef = fact.kind === 'ledger' ? fact.id : `${fact.kind}:${fact.id}`;
675
+ // CJ: a check's link cites the verdict — the report — never the dispatch prompt; a work item is never the check of
676
+ // itself; and one work item has one link per step and file (D99 had 18 duplicate QC links; D100 cited prompts and
677
+ // linked three tickets to themselves).
678
+ const place = factPlace(fact);
679
+ if (place && (stepKind === 'QC' || stepKind === 'Review' || stepKind === 'Walkthrough')) {
680
+ const refusal = checkLinkRefusal(work, place.path, stepKind);
681
+ if (refusal) return fail(refusal);
682
+ }
683
+ const sameFile = place ? store.links.find((l) => l.workId === workId && l.stepKind === stepKind && l.evidence != null && samePlace(factPlace(l.evidence), place)) : undefined;
684
+ const id = sameFile?.id ?? stableId('link', project.id, workId, ledgerRef, stepKind, fact.line ?? '', ...(fact.repo ? [pathKey(fact.repo)] : []));
685
+ const previous = store.links.get(id);
686
+ if (!previous && absent(p.why)) throw createNeeds('a link', `${work.title} has no ${stepKind} link to ${fact.label} yet`, ['why'], 'To update a link — to confirm it in the cross-check — give the work item, fact and step it has.');
687
+ const why = clean(p.why) || previous?.why || '';
688
+ if (!why) return fail('why: in one sentence, why this fact is this step of this work item.');
689
+ const inCrossCheck = stepKindAs(store, ctx.step) === 'cross-check';
690
+ const confirmed = (p.confirm === true && inCrossCheck) || (previous?.confirmed ?? false);
691
+ // CM (E151): the program checks the evidence as the link is written; a link that passes is lane-checked.
692
+ const ledger = Ledger.openDir(store.dir);
693
+ let check: LinkCheck;
694
+ try { check = checkLink(store, work, fact, why, ledger); } finally { ledger?.close(); }
695
+ const link: ProcessLink = { id, projectId: project.id, workId, ledgerRef, evidence: fact, stepKind, why, basis: 'Inferred', confirmed, check, roundId: previous?.roundId ?? roundId(), jobId: ctx.jobId, at: now() };
696
+ store.links.put(link, trace(`Link ${stepKind}: ${fact.label} → ${work.title}${confirmed ? ' (confirmed)' : check.passed ? ' (lane-checked)' : ' (suspect)'}`, sourceIdsOf([fact])));
697
+ saved('links', id, `${stepKind}: ${work.title}`);
698
+ return ok({
699
+ id, workId, ledgerRef, stepKind, confirmed, evidence: fact.label, occurred: fact.occurred,
700
+ check: check.passed ? `lane-checked: ${check.why}` : `suspect: ${check.why}`,
701
+ ...(!check.passed && !confirmed ? { note: 'The program could not check this link against its evidence: cite the commit or the line that names the work item, or the cross-check rejects it (or confirms it after reading the original).' } : {}),
702
+ ...(p.confirm === true && !inCrossCheck ? { note: 'Recorded unconfirmed: a link is confirmed in the cross-check.' } : {}),
703
+ });
704
+ },
705
+ }));
706
+
707
+ // ───────────────────────── 7 · cross-check: confirm or refute ─────────────────────────
708
+
709
+ tools.push(defineTool({
710
+ name: 'pk_confirm', label: 'Confirm or refute a link, breakpoint or patch',
711
+ description: 'Cross-check (check first, then adopt), and the spot check correcting what it found wrong: say whether a judged link, a breakpoint or a semantic patch holds, after checking it against the original, the ledger and the code. why says what you checked it against. A link that does not hold is removed; a breakpoint that does not hold goes out (it stays on record, no longer lit) — one the ledger’s ids show (basis Explicit) goes out only on evidence that the missing step did happen. A breakpoint the program computed is a candidate: only the round’s spot-check lights it (confirmed: true), after a lane looked for the missing step and did not find it (pk_record_looked), and never in a First usable round. A patch becomes Confirmed or Rejected. Never confirm what you did not check. ' + EVIDENCE_HELP,
712
+ parameters: Type.Object({
713
+ kind: Type.String({ description: 'link | breakpoint | patch' }),
714
+ id: Type.String({ description: 'its id (a patch also by its number, SP-n)' }),
715
+ ids: Type.Optional(Type.Array(Type.String(), { description: 'kind link only: more links checked the same way, confirmed or rejected with this one (the program checks every link as it is written; the cross-check rejects the suspect ones, or confirms them after reading the original)' })),
716
+ confirmed: Type.Boolean({ description: 'true: it holds; false: it does not' }),
717
+ why: Type.String({ description: 'what you checked it against, in one or two sentences' }),
718
+ evidence: Type.Optional(Type.Array(EvidenceSchema('what shows it'), { description: 'what shows it; required to put out a breakpoint of basis Explicit' })),
719
+ }),
720
+ execute: async (_id, raw) => {
721
+ const p = raw as Record<string, unknown>;
722
+ const refusal = gate('pk_confirm', CLERK_WRITER_STEPS.pk_confirm, 'a confirmation');
723
+ if (refusal) return fail(refusal);
724
+ const kind = text(p.kind);
725
+ if (!['link', 'breakpoint', 'patch'].includes(kind)) return fail('kind must be link, breakpoint or patch.');
726
+ if (typeof p.confirmed !== 'boolean') return fail('confirmed: true when it holds, false when it does not.');
727
+ const why = clean(p.why);
728
+ if (!why) return fail('why: what you checked it against.');
729
+ const evidence = resolveEvidenceList(ev, p.evidence, 'evidence');
730
+ if (typeof evidence === 'string') return fail(evidence);
731
+ const id = text(p.id).trim();
732
+ const verb = p.confirmed ? 'Confirmed' : 'Refuted';
733
+ if (kind === 'link') {
734
+ const all = [...new Set([id, ...(Array.isArray(p.ids) ? (p.ids as unknown[]).map((x) => text(x).trim()) : [])].filter(Boolean))];
735
+ const unknown = all.filter((x) => !store.links.get(x));
736
+ if (unknown.length) return fail(`${unknown.join(', ') || '(empty)'} ${unknown.length === 1 ? 'is' : 'are'} not ${unknown.length === 1 ? 'a process link' : 'process links'} of the assets. Nothing was written.`);
737
+ for (const lid of all) {
738
+ const link = store.links.get(lid)!;
739
+ if (!p.confirmed) {
740
+ // A link that does not hold is no association at all: left in place unconfirmed, it would read as one waiting.
741
+ store.links.remove(lid, trace(`Link refuted (${link.stepKind}: ${link.ledgerRef} → ${link.workId}): ${why}`, sourceIdsOf(evidence)));
742
+ saved('links', lid, `Refuted: ${link.stepKind}`);
743
+ continue;
744
+ }
745
+ store.links.put({ ...link, confirmed: true }, trace(`Link confirmed (${link.stepKind}: ${link.ledgerRef} → ${link.workId}): ${why}`, sourceIdsOf(evidence)));
746
+ saved('links', lid, `Confirmed: ${link.stepKind}`);
747
+ }
748
+ if (all.length === 1) return ok(p.confirmed ? { kind, id, confirmed: true } : { kind, id, confirmed: false, removed: true });
749
+ return ok({ kind, ids: all, confirmed: p.confirmed, ...(p.confirmed ? {} : { removed: all.length }) });
750
+ }
751
+ if (kind === 'breakpoint') {
752
+ const bp = store.breakpoints.get(id);
753
+ if (!bp) return fail(`${id || '(empty)'} is not a breakpoint of the assets.`);
754
+ if (p.confirmed) {
755
+ if (bp.out) return fail(`${id} is already out (${bp.out.by}, ${bp.out.at}): there is nothing lit to confirm.`);
756
+ // D99: only the round's spot-check lights, a candidate a lane looked for, outside First usable (breakpoint-candidates.ts).
757
+ const unlit = lightingRefusal(store, bp, ctx.step);
758
+ if (unlit) return fail(unlit);
759
+ const lit = lightCandidate(store, bp, { roundId: roundId()!, jobId: ctx.jobId, at: now() }, trace(`Breakpoint ${bp.kind} on ${bp.targetId} lit by the spot-check: ${why}`, sourceIdsOf(evidence)));
760
+ saved('breakpoints', id, `Lit: ${bp.kind}`);
761
+ return ok({ kind, id, confirmed: true, lit: true, checked: lit.checked });
762
+ }
763
+ if (bp.basis === 'Explicit' && evidence.length === 0) {
764
+ return fail(`${id} (${bp.kind}) is shown by the ledger’s ids (basis Explicit), so it goes out only on evidence: cite what shows the missing step did happen (evidence), or leave it lit. Nothing was written.`);
765
+ }
766
+ // Refuted by this step, with its evidence: the program does not light it again on the same evidence (§2.12).
767
+ const decidedAt = now();
768
+ const decision = { breakpointId: bp.id, at: decidedAt, roundId: roundId() };
769
+ const refutations = bp.familyBasis ? [...(bp.familyRefutations ?? []).filter((r) => r.basis.lineId !== bp.familyBasis!.lineId || r.basis.syntax !== bp.familyBasis!.syntax || r.basis.replaced !== bp.familyBasis!.replaced),
770
+ { basis: bp.familyBasis, decision, evidence }] : bp.familyRefutations;
771
+ store.breakpoints.put({ ...bp, lit: false, out: { at: decidedAt, by: 'model', evidence, decision }, familyRefutations: refutations, confirmedInRoundId: null, updatedAt: decidedAt }, trace(`Breakpoint ${bp.kind} on ${bp.targetId} put out: ${why}`, sourceIdsOf(evidence)));
772
+ saved('breakpoints', id, `Out: ${bp.kind}`);
773
+ // The commits it cites as the delivery tie to the work, so its execution reads them (§1.4).
774
+ const linked = deliveryLinksFrom(store, bp, evidence, why, { roundId: roundId(), jobId: ctx.jobId, at: decidedAt });
775
+ return ok({ kind, id, confirmed: false, lit: false, ...(linked ? { deliveryLinks: linked } : {}) });
776
+ }
777
+ const patch = store.patches.get(id) ?? store.patches.find((x) => x.number === id);
778
+ if (!patch) return fail(`${id || '(empty)'} is not a semantic patch of the assets.`);
779
+ const status: SemanticPatch['status'] = p.confirmed ? 'Confirmed' : 'Rejected';
780
+ store.patches.put({ ...patch, status, updatedAt: now() }, trace(`${verb} semantic patch ${patch.number}: ${why}`, sourceIdsOf(evidence)));
781
+ saved('patches', patch.id, `${patch.number} ${status}`);
782
+ return ok({ kind, id: patch.id, number: patch.number, status });
783
+ },
784
+ }));
785
+
786
+ // ───────────────────────── 8 · synthesis: send-backs ─────────────────────────
787
+
788
+ tools.push(defineTool({
789
+ name: 'pk_suggest_sendback', label: 'Suggest a send-back',
790
+ description: 'Synthesis, and the cross-check for a code anomaly with a clear action: suggest sending a problem back — to Work when a piece of work’s result has a gap or failed, QC failed, or a finding nobody handled; to Plan when a document or plan drifted or went stale. It is a relation, not a ticket: ProjectKeeper assigns no work, it makes the problem visible and easy to hand over (Copy for agent). Give where the problem is (what), where it should go back to (suggestion: reopen or new work; a plan or document change), the evidence — numbers, commits, files and lines, the report’s own line — and where it came from (from: the breakpoint, the verdict, the owner’s-judgement position, or the code anomaly with its index). A choice the owner has to make (whether to ratify a requirement that grew by itself) is a For your decision note, not a send-back. The stage starts at Suggested; Returned and Closed are recognised from the ledger at the next Follow up and are never set by you, and when it happened is read from the evidence. An open send-back on the same target with the same what (or from the same breakpoint or verdict) is updated, not duplicated: an update gives only what changes — to and suggestion left out stay, evidence given is added, and it keeps where it came from. A new send-back needs to, suggestion, evidence and from. ' + EVIDENCE_HELP,
791
+ parameters: Type.Object({
792
+ to: Type.Optional(Type.String({ description: 'Work | Plan — a new send-back needs it' })),
793
+ targetId: Type.String({ description: 'the object it hangs on: the work item, the plan or document, the code territory' }),
794
+ what: Type.String({ description: 'where the problem is, in one or two sentences' }),
795
+ suggestion: Type.Optional(Type.String({ description: 'where it should go back to: reopen the work or open new work; change the plan or the document — a new send-back needs it' })),
796
+ evidence: Type.Optional(Type.Array(EvidenceSchema('what shows the problem'), { description: 'what shows it; a new send-back needs it, an update adds to it' })),
797
+ from: Type.Optional(Type.Object({
798
+ kind: Type.String({ description: 'breakpoint | verdict | owner-judgement | code-anomaly' }),
799
+ id: Type.String({ description: 'the breakpoint; the verdict’s ledger entry or report source; the note or mark; the code territory' }),
800
+ index: Type.Optional(Type.Number({ description: 'code-anomaly: which anomaly of the territory, counted from 0' })),
801
+ }, { description: 'where it came from; a new send-back needs it' })),
802
+ sixThing: Type.Optional(Type.Number({ description: '1–6: which of the six things the owner judges it is material for' })),
803
+ }),
804
+ execute: async (_id, raw) => {
805
+ const p = raw as Record<string, unknown>;
806
+ const fromGiven = !absent(p.from);
807
+ const from = (p.from ?? {}) as Record<string, unknown>;
808
+ const fromKind = text(from.kind);
809
+ const fromId = text(from.id).trim();
810
+ const refusal = gate('pk_suggest_sendback', CLERK_WRITER_STEPS.pk_suggest_sendback, 'a send-back');
811
+ if (refusal) return fail(refusal);
812
+ const fields = programFields(p, ['stage', 'occurred', 'returned', 'closed', 'ownerResponse', 'updatedAt', 'at'], 'a send-back is suggested, and Returned and Closed are recognised from the ledger at the next Follow up, never set; when it happened is read from its evidence.');
813
+ if (fields) return fail(fields);
814
+ const target = findObject(store, text(p.targetId));
815
+ if (!target || store.sources.has(text(p.targetId).trim())) return fail(`${text(p.targetId) || '(empty)'} is not an object of the assets for a send-back to hang on: give the work item, the plan or document, or the code territory.`);
816
+ const what = clean(p.what);
817
+ if (!what) return fail('what: where the problem is.');
818
+ // One open send-back per problem: the same source (a failed verdict the program already sent back, a breakpoint), or
819
+ // the same target and wording. A territory's anomalies share the territory as their source, so they match by wording.
820
+ const sameSource = (s: SendBack) => fromGiven && fromKind !== 'code-anomaly' && s.from.kind === fromKind && s.from.id === fromId;
821
+ const existing = store.sendbacks.find((s) => s.stage !== 'Closed' && (sameSource(s) || (s.targetId === target.id && sameWords(s.what, what))));
822
+ if (!existing) {
823
+ const missing = [...(absent(p.to) ? ['to'] : []), ...(absent(p.suggestion) ? ['suggestion'] : []), ...(absent(p.evidence) ? ['evidence'] : []), ...(fromGiven ? [] : ['from'])];
824
+ if (missing.length) throw createNeeds('a send-back', `No open send-back on ${target.id} says “${what}”${fromGiven ? `, nor comes from ${fromKind} ${fromId}` : ''}`, missing, 'To update an open send-back, give the targetId and the what it has.');
825
+ }
826
+ // An update keeps where the send-back came from.
827
+ const originKind = fromGiven ? fromKind : existing!.from.kind;
828
+ if (stepKindAs(store, ctx.step) === 'cross-check' && originKind !== 'code-anomaly') return fail('In the cross-check a send-back is suggested only for a code anomaly with a clear action; the other send-backs are suggested in synthesis. Nothing was written.');
829
+ const to = has(p.to) ? text(p.to) : existing?.to ?? '';
830
+ if (to !== 'Work' && to !== 'Plan') return fail('to must be Work (a gap, a failure, a finding nobody handled) or Plan (a document or plan that drifted or went stale).');
831
+ const suggestion = clean(p.suggestion) || existing?.suggestion || '';
832
+ if (!suggestion) return fail('suggestion: where it should go back to — reopen the work or open new work; change the plan or the document.');
833
+ const evidence = resolveEvidenceList(ev, p.evidence, 'evidence');
834
+ if (typeof evidence === 'string') return fail(evidence);
835
+ if (!existing && evidence.length === 0) return fail('evidence is empty: a send-back carries what shows the problem — the numbers, commits, files and lines, the report’s own line.');
836
+ let anomalyIndex: number | null = null;
837
+ if (fromGiven) {
838
+ if (!isOneOf(SENDBACK_FROM, fromKind)) return fail(`from.kind must be one of ${SENDBACK_FROM.join(', ')}.`);
839
+ if (fromKind === 'breakpoint' && !store.breakpoints.has(fromId)) return fail(`from.id ${fromId || '(empty)'} is not a breakpoint of the assets.`);
840
+ if (fromKind === 'verdict' && !store.sources.has(fromId) && !ctx.ledger?.resolve(fromId)) return fail(`from.id ${fromId || '(empty)'} is neither a ledger entry nor a source: a verdict is named by the ledger entry that holds it, or the source of the report.`);
841
+ if (fromKind === 'owner-judgement' && !findObject(store, fromId)) return fail(`from.id ${fromId || '(empty)'} is not in the assets: name the note, mark or object that holds the judgement.`);
842
+ if (fromKind === 'code-anomaly') {
843
+ const territory = store.territories.get(fromId);
844
+ if (!territory) return fail(`from.id ${fromId || '(empty)'} is not a code territory of the assets.`);
845
+ if (territory.anomalies.length === 0) return fail(`${territory.name} has no anomaly for a send-back to come from.`);
846
+ const index = typeof from.index === 'number' ? from.index : territory.anomalies.length === 1 ? 0 : NaN;
847
+ if (!Number.isInteger(index) || index < 0 || index >= territory.anomalies.length) return fail(`from.index: which of ${territory.name}’s ${territory.anomalies.length} anomalies it comes from, counted from 0.`);
848
+ anomalyIndex = index;
849
+ }
850
+ }
851
+ const sixThing = p.sixThing === undefined ? null : Number(p.sixThing);
852
+ if (sixThing !== null && !(Number.isInteger(sixThing) && sixThing >= 1 && sixThing <= 6)) return fail('sixThing must be a whole number from 1 to 6.');
853
+ const merged = existing ? [...existing.evidence, ...evidence.filter((e) => !existing.evidence.some((x) => evidenceKey(x) === evidenceKey(e)))] : evidence;
854
+ const occurred = earliestOccurred(merged.map((e) => e.occurred)) ?? { at: now(), basis: 'First observed' as const, anchor: null, undated: true };
855
+ const record: SendBack = existing
856
+ ? { ...existing, to, what, suggestion, evidence: merged, sixThing: (sixThing as SixThing | null) ?? existing.sixThing, occurred, roundId: existing.roundId ?? roundId(), updatedAt: now() }
857
+ : {
858
+ id: newId('sb'), projectId: project.id, to, stage: 'Suggested', targetId: target.id, what, suggestion, evidence,
859
+ from: { kind: fromKind as SendBack['from']['kind'], id: fromId }, returned: null, closed: null, ownerResponse: null, sixThing: sixThing as SixThing | null,
860
+ occurred, roundId: roundId(), updatedAt: now(),
861
+ };
862
+ store.sendbacks.put(record, trace(`${existing ? 'Send-back updated' : 'Send-back suggested'} to ${to}: ${what}`, sourceIdsOf(merged)));
863
+ if (fromGiven && fromKind === 'breakpoint') {
864
+ const bp = store.breakpoints.get(fromId)!;
865
+ if (bp.sendBackId !== record.id) store.breakpoints.put({ ...bp, sendBackId: record.id, updatedAt: now() }, trace(`Breakpoint ${bp.kind}: send-back ${record.id}`));
866
+ }
867
+ if (anomalyIndex !== null) {
868
+ const territory = store.territories.get(fromId)!;
869
+ const anomalies = territory.anomalies.map((a, i) => (i === anomalyIndex ? { ...a, sendBackId: record.id } : a));
870
+ store.territories.put({ ...territory, anomalies, updatedAt: now() }, trace(`Code anomaly of ${territory.name}: send-back ${record.id}`));
871
+ }
872
+ saved('sendbacks', record.id, `Send-back to ${to}: ${what}`);
873
+ return ok({ id: record.id, stage: record.stage, occurred, evidence: merged.length, updated: Boolean(existing), cli: cliCommandFor(project.id, record.id) });
874
+ },
875
+ }));
876
+
877
+ // ───────────────────────── 9 · cross-check / synthesis: the six things ─────────────────────────
878
+
879
+ tools.push(defineTool({
880
+ name: 'pk_tag_six', label: 'Say which of the six things it is',
881
+ description: 'Cross-check or synthesis: say which of the six things the owner judges a mark, note, breakpoint, send-back or code territory anomaly is material for, so the owner can filter by it — 1 stale, 2 drift, 3 dropped along the way, 4 grown by itself, 5 let pass, 6 looks residual. Some kinds have a fixed one, and that one is taken: Not planned → 3, No plan → 4, Findings open → 5, Downstream behind → 1, Not carried out → 2, Suspected stale → 1, Layer drift → 2, and the code anomalies Unreferenced and Looks residual, is live → 6 (those two carry it from the moment they are written). Tag what really is material for one of them, not everything. Thing 3 or 5 on a mark, a note or a send-back says nothing took it up afterwards: give looked — where you read for what came after, and up to when — unless the target already carries it.',
882
+ parameters: Type.Object({
883
+ target: Type.Object({
884
+ kind: Type.String({ description: 'mark | note | breakpoint | sendback | territory-anomaly' }),
885
+ id: Type.String({ description: 'its id; for a territory anomaly, the code territory’s id' }),
886
+ index: Type.Optional(Type.Number({ description: 'territory-anomaly: which of the territory’s anomalies, counted from 0 (may be left out when it has one)' })),
887
+ }),
888
+ thing: Type.Number({ description: '1 stale · 2 drift · 3 dropped along the way · 4 grown by itself · 5 let pass · 6 looks residual' }),
889
+ looked: Type.Optional(Type.Object({
890
+ where: Type.Array(Type.String(), { description: 'each place you read for what came after, as the spot-check can open it again: a file with its section or lines, a ledger query, a commit range, a session' }),
891
+ upTo: Type.String({ description: 'up to when your reading reaches: the date or time (2026-09-30, 2026-09-30T19:53Z) of the newest commit, document version or report you read' }),
892
+ sessionsUpTo: Type.Optional(Type.String({ description: 'when sessions were read: the time of the last session message you read' })),
893
+ }, { description: 'thing 3 (dropped along the way) or 5 (let pass) on a mark, a note or a send-back says nothing took it up afterwards: where you looked for what came after, and up to when. Refused without it unless the target already carries it (a note written with looked)' })),
894
+ }),
895
+ execute: async (_id, raw) => {
896
+ const p = raw as Record<string, unknown>;
897
+ const refusal = gate('pk_tag_six', CLERK_WRITER_STEPS.pk_tag_six, 'which of the six things something is');
898
+ if (refusal) return fail(refusal);
899
+ const target = (p.target ?? {}) as Record<string, unknown>;
900
+ const kind = text(target.kind);
901
+ const id = text(target.id).trim();
902
+ const thing = Number(p.thing);
903
+ if (!(Number.isInteger(thing) && thing >= 1 && thing <= 6)) return fail('thing must be a whole number from 1 to 6: 1 stale, 2 drift, 3 dropped along the way, 4 grown by itself, 5 let pass, 6 looks residual.');
904
+ const six = thing as SixThing;
905
+ // CN (E152; Spec §2.12): dropped along the way and let pass say nothing took it up afterwards — an absence claim,
906
+ // which carries what was read. A breakpoint has its own way (a lane's pk_record_looked, the spot-check's light).
907
+ let given: AbsenceLooked | null = null;
908
+ if (!absent(p.looked)) {
909
+ const parsed = parseLooked(p.looked);
910
+ if (typeof parsed === 'string') return fail(`${parsed} Nothing was written.`);
911
+ given = lookedRecord(parsed, presentOf(store), { roundId: roundId(), jobId: ctx.jobId, at: now() });
912
+ }
913
+ const absence = ABSENCE_THINGS.includes(six);
914
+ const lookedFor = (had: AbsenceLooked | null | undefined, what: string): AbsenceLooked | string | null => {
915
+ const l = given ?? had ?? null;
916
+ return absence && !l ? absenceRefusal(`Tagging ${what} as ${SIX[six]}`, []) : l;
917
+ };
918
+ const mismatch = (fixed: SixThing | undefined, what: string) => (fixed !== undefined && fixed !== six ? `${what} is thing ${fixed} (${SIX[fixed]}) by the fixed table of kinds, not ${six} (${SIX[six]}).` : null);
919
+ const done = (collection: string, label: string, extra: Record<string, unknown> = {}) => {
920
+ saved(collection, id, `Six things: ${SIX[six]}`);
921
+ return ok({ kind, id, ...extra, thing: six, is: SIX[six], on: label });
922
+ };
923
+ switch (kind) {
924
+ case 'mark': {
925
+ const mark = store.marks.get(id);
926
+ if (!mark) return fail(`${id || '(empty)'} is not a mark of the assets.`);
927
+ const wrong = mismatch(MARK_THING[mark.kind], `A ${mark.kind} mark`);
928
+ if (wrong) return fail(wrong);
929
+ const looked = lookedFor(mark.looked, `the ${mark.kind} mark`);
930
+ if (typeof looked === 'string') return fail(looked);
931
+ store.marks.put({ ...mark, sixThing: six, ...(looked ? { looked, clue: absence ? stampAsFarAs(mark.clue, looked) : mark.clue } : {}) }, trace(`Six things: mark ${mark.kind} on ${mark.targetId} is ${SIX[six]}`, mark.clueSourceIds));
932
+ return done('marks', mark.kind, looked && absence ? { looked: lookedLine(looked) } : {});
933
+ }
934
+ case 'note': {
935
+ const note = store.notes.get(id);
936
+ if (!note) return fail(`${id || '(empty)'} is not a note of the assets.`);
937
+ const looked = lookedFor(note.looked, 'this note');
938
+ if (typeof looked === 'string') return fail(looked);
939
+ // The look is the note's; when it stops before the present, the note's current line says so.
940
+ const versions = looked && absence ? note.versions.map((v, i) => (i === note.versions.length - 1 ? { ...v, preview: stampAsFarAs(v.preview, looked) } : v)) : note.versions;
941
+ store.notes.put({ ...note, versions, sixThing: six, ...(looked ? { looked } : {}), updatedAt: now() }, trace(`Six things: note “${note.versions[note.versions.length - 1]?.title ?? id}” is ${SIX[six]}`));
942
+ return done('notes', note.versions[note.versions.length - 1]?.title ?? id, looked && absence ? { looked: lookedLine(looked) } : {});
943
+ }
944
+ case 'breakpoint': {
945
+ const bp = store.breakpoints.get(id);
946
+ if (!bp) return fail(`${id || '(empty)'} is not a breakpoint of the assets.`);
947
+ const wrong = mismatch(BREAKPOINT_THING[bp.kind], `A ${bp.kind} breakpoint`);
948
+ if (wrong) return fail(wrong);
949
+ store.breakpoints.put({ ...bp, sixThing: six, updatedAt: now() }, trace(`Six things: breakpoint ${bp.kind} on ${bp.targetId} is ${SIX[six]}`));
950
+ return done('breakpoints', bp.kind);
951
+ }
952
+ case 'sendback': {
953
+ const sb = store.sendbacks.get(id);
954
+ if (!sb) return fail(`${id || '(empty)'} is not a send-back of the assets.`);
955
+ const looked = lookedFor(sb.looked, 'this send-back');
956
+ if (typeof looked === 'string') return fail(looked);
957
+ store.sendbacks.put({ ...sb, sixThing: six, ...(looked ? { looked, what: absence ? stampAsFarAs(sb.what, looked) : sb.what } : {}), updatedAt: now() }, trace(`Six things: send-back “${sb.what}” is ${SIX[six]}`));
958
+ return done('sendbacks', sb.what, looked && absence ? { looked: lookedLine(looked) } : {});
959
+ }
960
+ case 'territory-anomaly': {
961
+ const territory = store.territories.get(id);
962
+ if (!territory) return fail(`${id || '(empty)'} is not a code territory of the assets: a territory anomaly is named by its territory’s id and its index.`);
963
+ if (territory.anomalies.length === 0) return fail(`${territory.name} has no anomaly to tag.`);
964
+ const index = typeof target.index === 'number' ? target.index : territory.anomalies.length === 1 ? 0 : NaN;
965
+ if (!Number.isInteger(index) || index < 0 || index >= territory.anomalies.length) return fail(`target.index: which of ${territory.name}’s ${territory.anomalies.length} anomalies, counted from 0.`);
966
+ const anomaly = territory.anomalies[index]!;
967
+ const wrong = mismatch(ANOMALY_THING[anomaly.kind], `A code anomaly “${anomaly.kind}”`);
968
+ if (wrong) return fail(wrong);
969
+ const anomalies = territory.anomalies.map((a, i) => (i === index ? { ...a, sixThing: six } : a));
970
+ store.territories.put({ ...territory, anomalies, updatedAt: now() }, trace(`Six things: ${territory.name}’s anomaly “${anomaly.kind}” is ${SIX[six]}`, sourceIdsOf(anomaly.evidence)));
971
+ return done('territories', `${territory.name}: ${anomaly.kind}`, { index });
972
+ }
973
+ default:
974
+ return fail('target.kind must be mark, note, breakpoint, sendback or territory-anomaly.');
975
+ }
976
+ },
977
+ }));
978
+
979
+ // ───────────────────────── 9b · synthesis: a note from an earlier round still holds (E153) ─────────────────────────
980
+
981
+ tools.push(defineTool({
982
+ name: 'pk_confirm_note', label: 'A standing note still holds',
983
+ description: 'Synthesis of a deepening or a Follow up: a note still current from an earlier round gets one result this round — it still holds (this tool), it is updated (pk_write_note with its id), or it is withdrawn (pk_close_note, with why). Confirm a note only after checking what it claims against what this round found: give in read each thing you read that shows it still holds, as the spot-check can open it again — a lane report with its section, a file with its lines, a ledger query, a commit. The note gets no new version: your judgement record says it was looked at again and still holds, with what you read, and the spot-check checks it like the notes this round wrote.',
984
+ parameters: Type.Object({
985
+ id: Type.String({ description: 'the note' }),
986
+ read: Type.Array(Type.String(), { description: 'what you read that shows it still holds, each as it can be opened again' }),
987
+ }),
988
+ execute: async (_id, p) => {
989
+ const refusal = gate('pk_confirm_note', CLERK_WRITER_STEPS.pk_confirm_note, 'that a note standing from an earlier round still holds');
990
+ if (refusal) return fail(refusal);
991
+ const round = store.clerkRounds.get(ctx.step!.roundId);
992
+ if (!round) return fail(`Round ${ctx.step!.roundId} is not registered, so there is no round to confirm the note in.`);
993
+ const note = store.notes.get(text(p.id).trim());
994
+ if (!note) return fail(`${text(p.id) || '(empty)'} is not a note of the assets.`);
995
+ if (!looksAtStandingNotes(round)) return fail('A first usable round has no earlier round whose notes still stand: there is nothing to confirm. Nothing was recorded.');
996
+ const standing = standingNotes(store, round).find((s) => s.note.id === note.id);
997
+ if (!standing) return fail(note.status !== 'Current' ? `That note is ${note.status}, not current: there is nothing to confirm. Nothing was recorded.` : isProgramNote(note) ? 'That note is the program’s own question to the owner, not a judgement of a round: it gets no result. Nothing was recorded.' : 'That note was written in this round: it is not standing from an earlier one, and the spot-check checks it as written. Nothing was recorded.');
998
+ if (standing.result === 'withdrawn') return fail(`That note is ${note.status}, not current: its result this round is withdrawn. Nothing was recorded.`);
999
+ if (standing.result === 'updated') return fail('That note got a new version in this round: its result is updated, and the new version says what holds. Nothing was recorded.');
1000
+ // A note the owner cannot read is not confirmed as it stands (D105; CKC-08 AC-26, AC-27): one that carries internal
1001
+ // identifiers, or asks for a decision without the question first and the options, is written again as a new version.
1002
+ const latest = note.versions[note.versions.length - 1]!;
1003
+ const unreadable = noteFormRefusal({
1004
+ ask: latest.ask, title: latest.title, preview: latest.preview, options: latest.body.options ?? [],
1005
+ body: { currentView: latest.body.currentView, whyItMatters: latest.body.whyItMatters, ...Object.fromEntries(latest.body.facts.map((f, i) => [`facts[${i}]`, f.text])), otherExplanations: latest.body.otherExplanations, keepAdjust: latest.body.keepAdjust, whatWouldSettleIt: latest.body.whatWouldSettleIt },
1006
+ }, laneNamesOf(store), `What it says may still hold, but it is not confirmed in this form: give it its result by updating it (pk_write_note with id "${note.id}"), with every part named above written anew`);
1007
+ if (unreadable) return fail(unreadable.replace('Nothing was written.', 'Nothing was recorded.'));
1008
+ const read = arr<unknown>(p.read).map((r) => clean(r)).filter(Boolean);
1009
+ if (!read.length) return fail('read is empty: a note is confirmed as still holding with what you read that shows it (a lane report and its section, a file and its lines, a ledger query, a commit). Nothing was recorded.');
1010
+ const title = note.versions[note.versions.length - 1]?.title ?? note.id;
1011
+ // Recorded in the synthesis' judgement record, with what was read; the note gets no new version (CKC-08 AC-25).
1012
+ const judgement = (ctx.judgementId ? store.judgements.get(ctx.judgementId) : undefined) ?? store.judgements.find((j) => j.jobId === ctx.jobId);
1013
+ if (!judgement) return fail('This job has no judgement record to record the confirmation in (pk_record_judgement first). Nothing was recorded.');
1014
+ const stillHolds = [...(judgement.outcome.stillHolds ?? []).filter((h) => h.noteId !== note.id), { noteId: note.id, read, at: now() }];
1015
+ store.judgements.put({ ...judgement, outcome: { ...judgement.outcome, stillHolds } }, trace(`Note “${title}” (${note.id}) still holds (round ${round.number}): read ${read.slice(0, 3).join('; ').slice(0, 200)}`));
1016
+ saved('judgements', judgement.id, `Note still holds: ${title}`);
1017
+ return ok({ id: note.id, result: 'confirmed', read: read.length, standingNotes: standingCounts(store, round) });
1018
+ },
1019
+ }));
1020
+
1021
+ // ───────────────────────── 10 · cross-check: code territories ─────────────────────────
1022
+
1023
+ tools.push(defineTool({
1024
+ name: 'pk_write_territory', label: 'Write a code territory',
1025
+ description: 'Cross-check: the judged half of a code territory — the current code cut by product area, so the owner can see what the docs became. Give its name and one sentence on what it is for, the repository, the directories or files that make it up (as they are in the current tree), its kind (area: it serves one product area, areaId; shared: a base several areas use, alsoServes; non-product: third-party, generated, tooling), and the anomalies — only what is not right: Unreferenced, Looks residual but is live, Built without a claiming work item, Docs disagree — each with the ledger entry or source that shows it and its basis. An Unreferenced or Looks-residual anomaly in code whose references the ledger reads only file by file (Dart, Python) or not at all is Inferred whatever basis you give: the program sets it. The numbers — size, dependencies, who built it, tests, last change, generation, current use — are the program’s from the ledger: do not write them. Judge residual code by its references, never by its name; code used through registration, routing or reflection is not residual. A territory with the same repository and name (or its id) is updated: an update gives only what it changes, and what it leaves out keeps its value (paths, alsoServes and anomalies, when given, replace their lists). A new territory needs name, summary, repo, paths and kind. ' + EVIDENCE_HELP,
1026
+ parameters: Type.Object({
1027
+ id: Type.Optional(Type.String({ description: 'an existing territory to update' })),
1028
+ name: Type.Optional(Type.String({ description: 'a new territory needs it; with the repository it finds the one already written' })),
1029
+ summary: Type.Optional(Type.String({ description: 'one sentence: what it is for' })),
1030
+ repo: Type.Optional(Type.String({ description: 'the repository: its scope item id or path' })),
1031
+ paths: Type.Optional(Type.Array(Type.String(), { description: 'the directories or files that make it up, relative to the repository root' })),
1032
+ kind: Type.Optional(Type.String({ description: 'area | shared | non-product' })),
1033
+ areaId: Type.Optional(Type.String({ description: 'area: the Area reference item it mainly serves' })),
1034
+ alsoServes: Type.Optional(Type.Array(Type.String(), { description: 'other Area reference items it also serves' })),
1035
+ anomalies: Type.Optional(Type.Array(Type.Object({
1036
+ kind: Type.String({ description: ANOMALY_KINDS.join(' | ') }),
1037
+ text: Type.String({ description: 'what is not right, in one or two sentences' }),
1038
+ evidence: Type.Array(EvidenceSchema('what shows it')),
1039
+ basis: Type.String({ description: 'Explicit | Inferred' }),
1040
+ }))),
1041
+ }),
1042
+ execute: async (_id, raw) => {
1043
+ const p = raw as Record<string, unknown>;
1044
+ const refusal = gate('pk_write_territory', CLERK_WRITER_STEPS.pk_write_territory, 'a code territory');
1045
+ if (refusal) return fail(refusal);
1046
+ const given = text(p.id).trim();
1047
+ const named = given ? store.territories.get(given) : undefined;
1048
+ if (given && !named) return fail(`${given} is not a code territory of this project; leave id out to write a new one.`);
1049
+ const place = resolvePath(project, { path: '', repo: text(p.repo) || named?.repo || '' });
1050
+ if (typeof place === 'string') return fail(`repo: ${place}`);
1051
+ const root: ProjectRoot = place.root;
1052
+ // The one to update: the id given, or the territory with the same repository and name.
1053
+ const previous = named ?? (clean(p.name) ? store.territories.get(stableId('terr', project.id, pathKey(root.path), squash(clean(p.name)))) : undefined);
1054
+ if (ctx.step!.kind === 'spot-check' && !previous) return fail('The spot check corrects the territories it checked: give the id of the one to correct.');
1055
+ if (!previous) {
1056
+ const lacking = (['name', 'summary', 'repo', 'paths', 'kind'] as const).filter((f) => absent(p[f]));
1057
+ if (lacking.length) throw createNeeds('a code territory', clean(p.name) ? `${root.path} has no territory named “${clean(p.name)}”` : 'This call names no code territory (no id, and no name to find one by)', lacking, 'To update a territory, give its id, or its repo and name as written.');
1058
+ }
1059
+ const name = clean(p.name) || previous?.name || '';
1060
+ const summary = clean(p.summary) || previous?.summary || '';
1061
+ if (!name || !summary) return fail('A territory has a name and one sentence on what it is for (summary).');
1062
+ const kind = has(p.kind) ? text(p.kind) : previous!.kind;
1063
+ if (!isOneOf(TERRITORY_KINDS, kind)) return fail(`kind must be one of ${TERRITORY_KINDS.join(', ')}.`);
1064
+ const kindChanged = previous !== undefined && kind !== previous.kind;
1065
+ const paths: string[] = absent(p.paths) ? [...previous!.paths] : [];
1066
+ const missing: string[] = [];
1067
+ for (const raw of absent(p.paths) ? [] : arr<string>(p.paths)) {
1068
+ const at = resolvePath(project, { path: text(raw), repo: root.id });
1069
+ if (typeof at === 'string') { missing.push(`${text(raw)} (${at})`); continue; }
1070
+ if (!at.rel) { missing.push(`${text(raw)} (the whole repository is not a territory)`); continue; }
1071
+ const inTree = (root.git && gitTreeEntry(root.path, 'HEAD', at.rel) !== null) || pathPresence(root, at.rel) === 'now';
1072
+ if (!inTree) { missing.push(at.rel); continue; }
1073
+ if (!paths.includes(at.rel)) paths.push(at.rel);
1074
+ }
1075
+ if (missing.length) return fail(`A territory is made of the current code, and these are not in ${root.path}’s current tree: ${missing.join(', ')}. Nothing was written.`);
1076
+ if (paths.length === 0) return fail('paths is empty: give the directories or files that make it up.');
1077
+ const isArea = (id: string) => store.reference.get(id)?.category === 'Area';
1078
+ // The area it serves stays with it while its kind stays; a territory that changes kind names its area anew.
1079
+ const areaId = text(p.areaId).trim() || (previous && !kindChanged ? previous.areaId : null);
1080
+ if (kind === 'area' && (!areaId || !isArea(areaId))) return fail(`An area territory names the Area it mainly serves: areaId must be an Area reference item${areaId ? `; ${areaId} is not one` : ''}.`);
1081
+ if (kind !== 'area' && areaId) return fail(`A ${kind} territory serves no single area: leave areaId out${kind === 'shared' ? ' and name the areas it serves in alsoServes' : ''}.`);
1082
+ const alsoServes = [...new Set((absent(p.alsoServes) ? [...(previous?.alsoServes ?? [])] : arr<string>(p.alsoServes)).map((a) => text(a).trim()).filter((a) => a && a !== areaId))];
1083
+ const notAreas = alsoServes.filter((a) => !isArea(a));
1084
+ if (notAreas.length) return fail(`alsoServes names what is not an Area reference item: ${notAreas.join(', ')}.`);
1085
+ const id = previous?.id ?? stableId('terr', project.id, pathKey(root.path), squash(name));
1086
+ const anomalies: CodeTerritory['anomalies'][number][] = absent(p.anomalies) ? [...(previous?.anomalies ?? [])] : [];
1087
+ // Read once, only when an anomaly judges by references: how far the ledger reads this code's references.
1088
+ let reach: ReturnType<typeof referenceLevel> | null = null;
1089
+ const inferredBy: string[] = [];
1090
+ for (const [i, a] of arr<Record<string, unknown>>(p.anomalies).entries()) {
1091
+ const anomalyKind = text(a.kind);
1092
+ if (!isOneOf(ANOMALY_KINDS, anomalyKind)) return fail(`anomalies[${i}].kind must be one of ${ANOMALY_KINDS.join(', ')}.`);
1093
+ const said = clean(a.text);
1094
+ if (!said) return fail(`anomalies[${i}].text: what is not right.`);
1095
+ const given = text(a.basis);
1096
+ if (!isOneOf(BASIS, given)) return fail(`anomalies[${i}].basis must be Explicit or Inferred.`);
1097
+ // The program, not the model, says when a residual judgement cannot be Explicit (CKC-25 AC-5).
1098
+ let basis: typeof given = given;
1099
+ if (BY_REFERENCES.has(anomalyKind) && given === 'Explicit') {
1100
+ reach ??= referenceLevel(ctx.store.dir, root, paths);
1101
+ if (!reach.complete) {
1102
+ basis = 'Inferred';
1103
+ inferredBy.push(`anomalies[${i}] (${anomalyKind}) is Inferred: ${reach.gaps.join('; ')}`);
1104
+ }
1105
+ }
1106
+ const evidence = resolveEvidenceList(ev, a.evidence, `anomalies[${i}].evidence`);
1107
+ if (typeof evidence === 'string') return fail(evidence);
1108
+ if (evidence.length === 0) return fail(`anomalies[${i}] cites no evidence: every anomaly names the ledger entry or source that shows it.`);
1109
+ // What the owner or a later step hung on the same anomaly stays with it; which of the six things it is, too — and
1110
+ // a kind §2.13's table fixes carries its thing from the start.
1111
+ const before = previous?.anomalies.find((x) => x.kind === anomalyKind && sameWords(x.text, said));
1112
+ anomalies.push({
1113
+ kind: anomalyKind, text: said, evidence, basis, sendBackId: before?.sendBackId ?? null, noteIds: before?.noteIds ?? [],
1114
+ sixThing: ANOMALY_THING[anomalyKind] ?? before?.sixThing ?? null,
1115
+ });
1116
+ }
1117
+ const territory: CodeTerritory = {
1118
+ id, projectId: project.id, name, summary, repo: root.path, paths, kind, areaId, alsoServes, anomalies,
1119
+ roundId: previous?.roundId ?? roundId(), jobId: ctx.jobId, updatedAt: now(),
1120
+ };
1121
+ const shared = store.territories.filter((t) => t.id !== id && pathKey(t.repo) === pathKey(root.path) && t.paths.some((x) => paths.includes(x)));
1122
+ store.territories.put(territory, trace(`${previous ? 'Code territory updated' : 'Code territory'}: ${name} (${paths.length} path${paths.length === 1 ? '' : 's'}, ${anomalies.length} anomal${anomalies.length === 1 ? 'y' : 'ies'})`, sourceIdsOf(anomalies.flatMap((a) => a.evidence))));
1123
+ saved('territories', id, `Code territory: ${name}`);
1124
+ return ok({
1125
+ id, paths: paths.length, anomalies: anomalies.length, updated: Boolean(previous),
1126
+ ...(inferredBy.length ? { basisSetByProgram: inferredBy } : {}),
1127
+ ...(shared.length ? { warning: `Some of these paths are also in ${shared.map((t) => `${t.id} (${t.name})`).join(', ')}: a directory or file belongs to one territory; the others are counted twice until one lets go of it.` } : {}),
1128
+ });
1129
+ },
1130
+ }));
1131
+
1132
+ // ───────────────────────── 11 · spot check ─────────────────────────
1133
+
1134
+ tools.push(defineTool({
1135
+ name: 'pk_record_spot_check', label: 'Record what the spot check found',
1136
+ description: 'Spot check: record the judgements of this round you checked again against the original, the ledger and the code — above all what asserts the state of the code, a supersession, decided or not, nobody handled it, residual — each as Right or Wrong, with the kind of judgement it is, and for a wrong one whether it is wrong in substance or only by timing (wrongKind: the state it describes was right when written and a later write or recompute overtook it). Correct a wrong one first, with the writer of that position (the patch, the territory, the confirmation, the work item …), then record it with what you corrected: the program counts it corrected only when this job wrote to it. The round’s result shows how many were checked, how many were wrong and in which kinds; the program counts them from these records — do not write the counts yourself. A judgement counts once in the round, whichever session records it: once found wrong it stays counted wrong, once corrected it stays corrected, and a later record gives its kind.',
1137
+ parameters: Type.Object({
1138
+ checked: Type.Array(Type.Object({
1139
+ target: Type.Object({
1140
+ collection: Type.String({ description: 'the assets collection it is in: patches, breakpoints, sendbacks, territories, links, generations, layers, threads, reference, notes, marks, facts …' }),
1141
+ id: Type.String(),
1142
+ }),
1143
+ kind: Type.String({ description: 'the kind of judgement, e.g. code state, supersession, decided or not, nobody handled it, residual' }),
1144
+ verdict: Type.String({ description: 'Right | Wrong' }),
1145
+ wrongKind: Type.Optional(Type.String({ description: 'Wrong only: timing — it was right when written and a later write or recompute overtook it (a candidate still listed after its link was confirmed) — or substance — the judgement itself is wrong (the default)' })),
1146
+ correction: Type.Optional(Type.String({ description: 'Wrong only: what you corrected, once you have corrected it' })),
1147
+ })),
1148
+ }),
1149
+ execute: async (_id, p) => {
1150
+ const refusal = gate('pk_record_spot_check', CLERK_WRITER_STEPS.pk_record_spot_check, 'what the spot check found');
1151
+ if (refusal) return fail(refusal);
1152
+ const round = store.clerkRounds.get(ctx.step!.roundId);
1153
+ if (!round) return fail(`Round ${ctx.step!.roundId} is not registered, so there is no round result to count the spot check in.`);
1154
+ const entries = arr<Record<string, unknown>>(p.checked);
1155
+ if (entries.length === 0) return fail('checked is empty: give each judgement you checked.');
1156
+ const written = new Set(store.traceByJob(ctx.jobId, 1_000_000).map((t) => `${t.collection}\x1f${t.id}`));
1157
+ const problems: string[] = [];
1158
+ const accepted: SpotCheckTarget[] = [];
1159
+ for (const [i, e] of entries.entries()) {
1160
+ const target = (e.target ?? {}) as Record<string, unknown>;
1161
+ const collection = text(target.collection).trim();
1162
+ const holder = (store as unknown as Record<string, unknown>)[collection];
1163
+ if (!(holder instanceof Collection) || ['jobs', 'contexts', 'judgements', 'clerkRounds'].includes(collection)) { problems.push(`checked[${i}].target.collection: ${collection || '(empty)'} is not a collection of judgements in the assets`); continue; }
1164
+ const rawId = text(target.id).trim();
1165
+ const id = collection === 'threads' ? resolveMergedId(store, rawId) : rawId;
1166
+ const touchedHere = written.has(`${collection}\x1f${id}`);
1167
+ if (!holder.has(id) && !touchedHere) { problems.push(`checked[${i}]: ${rawId || '(empty)'} is not in ${collection}`); continue; }
1168
+ const kind = clean(e.kind);
1169
+ if (!kind) { problems.push(`checked[${i}].kind: what kind of judgement it is`); continue; }
1170
+ const verdict = text(e.verdict);
1171
+ if (verdict !== 'Right' && verdict !== 'Wrong') { problems.push(`checked[${i}].verdict must be Right or Wrong`); continue; }
1172
+ const correction = clean(e.correction);
1173
+ if (correction && verdict === 'Right') { problems.push(`checked[${i}]: a judgement that is right needs no correction`); continue; }
1174
+ // CM: a state-timing wrong is told apart from a wrong of substance (two of the gated run's three errors were timing).
1175
+ const wrongKindGiven = text(e.wrongKind).trim().toLowerCase();
1176
+ if (wrongKindGiven && wrongKindGiven !== 'timing' && wrongKindGiven !== 'substance') { problems.push(`checked[${i}].wrongKind must be timing or substance`); continue; }
1177
+ if (wrongKindGiven && verdict === 'Right') { problems.push(`checked[${i}]: wrongKind says how a wrong judgement is wrong; this one is Right`); continue; }
1178
+ const wrongKind: SpotWrongKind | null = verdict === 'Wrong' ? (wrongKindGiven === 'timing' ? 'timing' : 'substance') : null;
1179
+ if (correction && !touchedHere) { problems.push(`checked[${i}]: nothing in this job wrote to ${collection} ${id}, so it is not corrected yet — correct it with the writer of that position, then record it`); continue; }
1180
+ accepted.push({ collection, id, kind, wrong: verdict === 'Wrong', corrected: verdict === 'Wrong' && Boolean(correction), ...(wrongKind ? { wrongKind } : {}) });
1181
+ }
1182
+ if (problems.length) return fail(`Nothing was recorded:\n${problems.join('\n')}`);
1183
+ // The round's record of what was checked, one entry per collection and id, whichever session records it. What was
1184
+ // found wrong stays found wrong and a correction stays made: a judgement checked again after it was corrected is
1185
+ // right, and the round still made the error. Every count is taken from these entries.
1186
+ const merged = new Map<string, SpotCheckTarget>((round.spotCheck?.targets ?? []).map((t) => [`${t.collection}\x1f${t.id}`, t]));
1187
+ for (const a of accepted) {
1188
+ const key = `${a.collection}\x1f${a.id}`;
1189
+ const before = merged.get(key);
1190
+ merged.set(key, {
1191
+ collection: a.collection, id: a.id,
1192
+ kind: a.wrong || !before?.wrong ? a.kind : before.kind,
1193
+ wrong: a.wrong || (before?.wrong ?? false),
1194
+ corrected: a.corrected || (before?.corrected ?? false),
1195
+ // A wrong of substance stays one; a timing wrong stays timing until a later record finds substance.
1196
+ ...(a.wrong ? { wrongKind: before?.wrongKind === 'substance' ? 'substance' as const : a.wrongKind ?? 'substance' } : before?.wrongKind ? { wrongKind: before.wrongKind } : {}),
1197
+ });
1198
+ }
1199
+ const targets = [...merged.values()];
1200
+ const byKind: Record<string, number> = {};
1201
+ for (const t of targets) if (t.wrong) byKind[t.kind] = (byKind[t.kind] ?? 0) + 1;
1202
+ const wrongByKind = { timing: targets.filter((t) => t.wrong && t.wrongKind === 'timing').length, substance: targets.filter((t) => t.wrong && t.wrongKind !== 'timing').length };
1203
+ // D103 (E153): the synthesis' outputs and the notes are checked in full, and counted apart from the sample.
1204
+ const full = new Set(fullCheckTargets(store, round).map((t) => `${t.collection}\x1f${t.id}`));
1205
+ const inFull = targets.filter((t) => full.has(`${t.collection}\x1f${t.id}`));
1206
+ const synthesis = { outputs: full.size, checked: inFull.length, wrong: inFull.filter((t) => t.wrong).length };
1207
+ // CS (D104): the placement readings, counted apart, against what this spot-check was given as it started (fixed on
1208
+ // the round then; for a round whose step did not fix it, as the store stands at the first record). Each reason
1209
+ // recorded is kept with its words and its outcome, so a later round's spot-check is not given it again while it is
1210
+ // unchanged; a program placement found right has its result, on the record of the round that placed it.
1211
+ const plan = round.placementCheck ?? placementCheckNow(store, now());
1212
+ const readings = placementCounts(plan, targets, now(), round.spotCheck?.reasonChecks ?? []);
1213
+ const hasReadings = plan.reasons.length > 0 || plan.placements.length > 0;
1214
+ confirmBySpotCheck(store, readings.right, { jobId: ctx.jobId, roundId: round.id });
1215
+ const counts = { sampled: targets.length, wrong: targets.filter((t) => t.wrong).length, byKind, corrected: targets.filter((t) => t.corrected).length, wrongByKind, ...(full.size ? { synthesis } : {}), ...(hasReadings ? { placement: readings.placement } : {}) };
1216
+ const spotCheck: SpotCheck = { ...counts, targets, ...(readings.checks.length ? { reasonChecks: readings.checks } : {}) };
1217
+ // The round as it stands now: a program placement of this same round may just have got its result above.
1218
+ const updated: ClerkRound = { ...(store.clerkRounds.get(round.id) ?? round), spotCheck, ...(hasReadings ? { placementCheck: plan } : {}), updatedAt: now() };
1219
+ store.clerkRounds.put(updated, trace(`Spot check of round ${round.number}: ${accepted.length} recorded (${accepted.filter((a) => a.wrong).length} wrong) — ${counts.sampled} checked, ${counts.wrong} wrong, ${counts.corrected} corrected`));
1220
+ saved('clerkRounds', round.id, `Spot check: ${counts.sampled} checked, ${counts.wrong} wrong`);
1221
+ const left = full.size - inFull.length;
1222
+ const reasonsLeft = readings.placement.reasons - readings.placement.reasonsChecked;
1223
+ const notes = [
1224
+ ...(left > 0 ? [`${left} of the ${full.size} targets checked in full (the notes, the synthesis' outputs, the Result) are not recorded yet: check and record each.`] : []),
1225
+ ...(reasonsLeft > 0 ? [`${reasonsLeft} of the ${readings.placement.reasons} reasons that leave an item unplaced (noPlanWhy, noAreaWhy, wholeProductWhy) are not recorded yet: check and record each.`] : []),
1226
+ ];
1227
+ return ok({ roundId: round.id, recorded: accepted.length, spotCheck: counts, ...(notes.length ? { note: notes.join(' ') } : {}) });
1228
+ },
1229
+ }));
1230
+
1231
+ // ───────────────────────── 12 · session drafts (§3.11) ─────────────────────────
1232
+
1233
+ /** The owner's lines of one session and the times of its messages, as the program reads them. */
1234
+ const hearSession = (host: string, sessionId: string): HeardSession | string => {
1235
+ const sources = store.sources.filter((s) => s.anchor.kind === 'session' && s.anchor.host === host && s.anchor.sessionId === sessionId)
1236
+ .sort((a, b) => (a.anchor.kind === 'session' && b.anchor.kind === 'session' ? a.anchor.messageStart - b.anchor.messageStart : 0));
1237
+ if (sources.length === 0) return `No ${host} session ${sessionId} is among this project’s sources. pk_list_sources with kind "session" lists them.`;
1238
+ const first = sources[0]!.anchor;
1239
+ if (first.kind !== 'session') return `${sessionId} is not a session.`;
1240
+ const sourceIds = sources.map((s) => s.id);
1241
+ const at = (s: string | null | undefined) => s ?? '';
1242
+ if (host === 'pi') {
1243
+ const found = ownerUtterances(store, { sessionSourceId: sources[0]!.id });
1244
+ if (typeof found === 'string') return found;
1245
+ const times = new Map<number, string | null>();
1246
+ for (const s of sources) if (s.anchor.kind === 'session') times.set(s.anchor.messageStart, s.anchor.at);
1247
+ const lines = found.utterances.map((u) => {
1248
+ const a = store.sources.get(u.sourceId)?.anchor;
1249
+ return { ref: String(a?.kind === 'session' ? a.messageStart : u.order), at: at(u.at), text: u.text, before: null };
1250
+ });
1251
+ const stamps = [...times.values()].filter((t): t is string => Boolean(t)).sort();
1252
+ return { file: first.file, startedAt: stamps[0] ?? '', endedAt: stamps[stamps.length - 1] ?? '', lines, times, readFrom: 'Keeper conversation', sourceIds };
1253
+ }
1254
+ const parsed = parsedSession(host, first.file);
1255
+ if (parsed) {
1256
+ const times = new Map<number, string | null>(parsed.messages.map((m) => [m.index, m.at]));
1257
+ const lines: HeardLine[] = [];
1258
+ let agent: string | null = null;
1259
+ for (const m of parsed.messages) {
1260
+ if (isOwnerMessage(m)) {
1261
+ lines.push({ ref: String(m.index), at: at(m.at), text: redact(m.text), before: agent === null ? null : redact(agent) });
1262
+ agent = null;
1263
+ } else if (m.role === 'assistant' && !m.sidechain && m.text.trim()) {
1264
+ agent = m.text;
1265
+ }
1266
+ }
1267
+ return { file: first.file, startedAt: at(parsed.startedAt), endedAt: at(parsed.endedAt), lines, times, readFrom: 'session log', sourceIds };
1268
+ }
1269
+ // The log is gone or unreadable (D71): the transcripts the assets keep hold the owner's messages whole, and the
1270
+ // agent's trimmed; their times are the minutes the headers show.
1271
+ const times = new Map<number, string | null>();
1272
+ const lines: HeardLine[] = [];
1273
+ let agent: string | null = null;
1274
+ for (const s of sources) {
1275
+ const transcript = s.excerpt.normalize('NFC');
1276
+ const heads = [...transcript.matchAll(HEADER_LINE)];
1277
+ for (const [k, h] of heads.entries()) {
1278
+ const index = Number(h[1]);
1279
+ const minute = h[3] ? `${h[3].replace(' ', 'T')}Z` : null;
1280
+ times.set(index, minute ? new Date(minute).toISOString() : null);
1281
+ const body = transcript.slice(h.index! + h[0].length, k + 1 < heads.length ? heads[k + 1]!.index! : transcript.length).replace(/^\r?\n/, '').replace(/\s+$/, '');
1282
+ if (h[2] === 'OWNER') { lines.push({ ref: String(index), at: times.get(index) ?? '', text: body, before: agent }); agent = null; }
1283
+ else if (h[2] === 'AGENT' && body.trim()) agent = body.replace(/\n {2}tools: [^\n]*$/, '').trim() || agent;
1284
+ }
1285
+ }
1286
+ const stamps = [...times.values()].filter((t): t is string => Boolean(t)).sort();
1287
+ return { file: first.file, startedAt: stamps[0] ?? at(first.at), endedAt: stamps[stamps.length - 1] ?? at(first.at), lines, times, readFrom: 'transcripts in the assets', sourceIds };
1288
+ };
1289
+
1290
+ tools.push(defineTool({
1291
+ name: 'pk_write_session_draft', label: 'Write the draft of a session',
1292
+ description: 'Session drafts: one draft per session — the ground the owner’s words, the lineage, the deep sweeps and the Keeper’s recall stand on. The program takes the owner’s words out of the session itself, verbatim, with their times, and for each line the agent message just before it; you only classify the owner’s lines by their ref (the [n] the transcript shows, or a ledger message id) as Chat or Decision — a Confirmation is a decision, the owner saying yes to what the agent proposed, and confirms says in your words what was confirmed (“the owner confirmed X’s proposal to …”), never the rest of the proposal as the owner’s. Lines you do not classify stay unjudged; on a later call they keep the kind they had. agentSummary: the agents’ intents and reports as claims — who, and what they said they did or meant to do — each optionally pointing at the agent message it is about (at: its [n]; the program reads its time). Given, it replaces the summary the draft had; left out of a later call, the draft keeps it. A new draft needs lines and agentSummary (either may be an empty list). Never retype the owner’s words, never give a time: the program reads both.',
1293
+ parameters: Type.Object({
1294
+ session: Type.Object({ host: Type.String({ description: 'claude | codex | pi' }), sessionId: Type.String() }),
1295
+ lines: Type.Optional(Type.Array(Type.Object({
1296
+ ref: Type.String({ description: 'the owner’s message: its [n] in the session transcript, or its ledger message id' }),
1297
+ kind: Type.String({ description: 'Chat | Decision | Confirmation' }),
1298
+ confirms: Type.Optional(Type.String({ description: 'Confirmation only (required): what the owner confirmed, in your words' })),
1299
+ }), { description: 'your classification of the owner’s lines; a new draft needs it (an empty list when you classify none yet)' })),
1300
+ agentSummary: Type.Optional(Type.Array(Type.Object({
1301
+ at: Type.Optional(Type.String({ description: 'the agent message it is about: its [n] in the transcript; leave out for the session as a whole' })),
1302
+ who: Type.String({ description: 'which agent, e.g. "Claude Code (opus)", "Codex worker"' }),
1303
+ summary: Type.String({ description: 'what it said it did or meant to do — a claim' }),
1304
+ }), { description: 'the agents’ intents and reports; a new draft needs it, a later call that leaves it out keeps the draft’s' })),
1305
+ }),
1306
+ execute: async (_id, raw) => {
1307
+ const p = raw as Record<string, unknown>;
1308
+ const refusal = gate('pk_write_session_draft', CLERK_WRITER_STEPS.pk_write_session_draft, 'a session draft');
1309
+ if (refusal) return fail(refusal);
1310
+ const session = (p.session ?? {}) as Record<string, unknown>;
1311
+ const host = text(session.host).trim();
1312
+ const sessionId = text(session.sessionId).trim();
1313
+ if (!isOneOf(SESSION_HOST, host)) return fail(`session.host must be one of ${SESSION_HOST.join(', ')}.`);
1314
+ if (!sessionId) return fail('session.sessionId: which session.');
1315
+ const drafted = store.drafts.get(stableId('draft', project.id, host, sessionId));
1316
+ if (!drafted) {
1317
+ const missing = (['lines', 'agentSummary'] as const).filter((f) => absent(p[f]));
1318
+ if (missing.length) throw createNeeds('a session draft', `${host} session ${sessionId} has no draft yet`, missing, 'A later call about the same session updates its draft.');
1319
+ }
1320
+ const heard = hearSession(host, sessionId);
1321
+ if (typeof heard === 'string') return fail(heard);
1322
+ const byRef = new Map(heard.lines.map((l) => [l.ref, l]));
1323
+ /** What the model classified, by the line's position in the session (a line only the ledger has: its ledger id). */
1324
+ const classified = new Map<string, { kind: OwnerLineKind; confirms: string | null }>();
1325
+ /** Lines cited by their ledger message id: that id is their ref (a draft's refs are the ledger's message ids). */
1326
+ const ledgerRefs = new Map<string, string>();
1327
+ const fromLedger: HeardLine[] = [];
1328
+ const problems: string[] = [];
1329
+ for (const [i, l] of arr<Record<string, unknown>>(p.lines).entries()) {
1330
+ const own = programFields(l, ['text', 'at', 'answers', 'time', 'quote'], 'the owner’s words, their time and the message they answer are taken from the session by the program.');
1331
+ if (own) { problems.push(`lines[${i}]: ${own}`); continue; }
1332
+ const kind = text(l.kind);
1333
+ if (!isOneOf(LINE_KINDS, kind)) { problems.push(`lines[${i}].kind must be one of ${LINE_KINDS.join(', ')}`); continue; }
1334
+ const confirms = clean(l.confirms);
1335
+ if (kind === 'Confirmation' && !confirms) { problems.push(`lines[${i}] is a Confirmation: say in confirms what the owner confirmed, in your words`); continue; }
1336
+ if (kind !== 'Confirmation' && confirms) { problems.push(`lines[${i}] is ${kind}: confirms belongs to a Confirmation only`); continue; }
1337
+ const rawRef = text(l.ref).trim();
1338
+ const position = /^\[?(\d+)\]?$/.exec(rawRef)?.[1];
1339
+ let key: string | null = position !== undefined && byRef.has(String(Number(position))) ? String(Number(position)) : null;
1340
+ if (key === null && rawRef && ctx.ledger) {
1341
+ // A ledger message id: its words are the ledger's; the session read here supplies the message it answers.
1342
+ const entry = ctx.ledger.resolve(rawRef);
1343
+ if (entry?.text) {
1344
+ const same = heard.lines.find((h) => sameWords(h.text, entry.text) && (ledgerRefs.get(h.ref) ?? rawRef) === rawRef);
1345
+ if (same) {
1346
+ ledgerRefs.set(same.ref, rawRef);
1347
+ key = same.ref;
1348
+ } else {
1349
+ if (!fromLedger.some((f) => f.ref === rawRef)) fromLedger.push({ ref: rawRef, at: entry.occurred.at, text: redact(entry.text), before: null });
1350
+ key = rawRef;
1351
+ }
1352
+ }
1353
+ }
1354
+ if (key === null) {
1355
+ const refs = heard.lines.map((h) => h.ref);
1356
+ problems.push(`lines[${i}].ref ${rawRef || '(empty)'} is not one of the owner’s messages in this session${refs.length ? ` (their positions: ${refs.slice(0, 40).join(', ')}${refs.length > 40 ? ' …' : ''})` : ' — the program found no owner message in it'}`);
1357
+ continue;
1358
+ }
1359
+ const before = classified.get(key);
1360
+ if (before && (before.kind !== kind || before.confirms !== (confirms || null))) { problems.push(`lines[${i}] classifies message ${rawRef} again, differently`); continue; }
1361
+ classified.set(key, { kind, confirms: confirms || null });
1362
+ }
1363
+ const summaries: SessionDraft['agentSummary'][number][] = [];
1364
+ for (const [i, s] of arr<Record<string, unknown>>(p.agentSummary).entries()) {
1365
+ const who = clean(s.who);
1366
+ const summary = clean(s.summary);
1367
+ if (!who || !summary) { problems.push(`agentSummary[${i}]: who and summary`); continue; }
1368
+ const atRef = text(s.at).trim();
1369
+ let at = heard.startedAt;
1370
+ if (atRef) {
1371
+ const position = /^\[?(\d+)\]?$/.exec(atRef)?.[1];
1372
+ if (position === undefined || !heard.times.has(Number(position))) {
1373
+ problems.push(`agentSummary[${i}].at: “${atRef}” is not a message of this session — at names the message by its [n] in the transcript, and the program reads its time; a time is never written`);
1374
+ continue;
1375
+ }
1376
+ at = heard.times.get(Number(position)) ?? heard.startedAt;
1377
+ }
1378
+ summaries.push({ at, who, summary });
1379
+ }
1380
+ if (problems.length) return fail(`Nothing was written:\n${problems.join('\n')}`);
1381
+ const id = stableId('draft', project.id, host, sessionId);
1382
+ const previous = store.drafts.get(id);
1383
+ // What an earlier draft said of a line stays with it: found by its ref, else by its words at the same time, else by
1384
+ // its words when no other line of the earlier draft has them.
1385
+ const kept = previous?.ownerLines ?? [];
1386
+ const keptFor = (ref: string, h: HeardLine) => kept.find((k) => k.ref === ref || k.ref === h.ref)
1387
+ ?? kept.find((k) => k.at === h.at && sameWords(k.text, h.text))
1388
+ ?? (() => { const same = kept.filter((k) => sameWords(k.text, h.text)); return same.length === 1 ? same[0] : undefined; })();
1389
+ const ownerLines: SessionDraft['ownerLines'][number][] = [...heard.lines, ...fromLedger].map((h) => {
1390
+ const mine = classified.get(h.ref);
1391
+ const had = keptFor(ledgerRefs.get(h.ref) ?? h.ref, h);
1392
+ // A line once known by its ledger message id keeps that id.
1393
+ const ref = ledgerRefs.get(h.ref) ?? (had && !byRef.has(had.ref) ? had.ref : h.ref);
1394
+ const kind = mine?.kind ?? had?.kind ?? null;
1395
+ return {
1396
+ ref, at: h.at, text: h.text, kind,
1397
+ answers: kind === 'Confirmation' ? h.before : null,
1398
+ confirms: kind === 'Confirmation' ? mine?.confirms ?? had?.confirms ?? null : null,
1399
+ };
1400
+ });
1401
+ // Every job that wrote the draft is kept with its round, so each round's session-drafts step lists the drafts it
1402
+ // wrote even after a later round drafted the session again (CKC-23 AC-18). A job writing it again moves to the end.
1403
+ const at = now();
1404
+ const earlier = previous ? (previous.writtenBy?.length ? previous.writtenBy : [{ jobId: previous.jobId, roundId: null, at: previous.at }]) : [];
1405
+ // A summary left out of a later call is the one the draft had.
1406
+ const agentSummary = absent(p.agentSummary) && previous ? [...previous.agentSummary] : summaries;
1407
+ const draft: SessionDraft = {
1408
+ id, projectId: project.id, session: { host, sessionId, file: heard.file, startedAt: heard.startedAt, endedAt: heard.endedAt },
1409
+ ownerLines, agentSummary, jobId: ctx.jobId, at,
1410
+ writtenBy: [...earlier.filter((w) => w.jobId !== ctx.jobId), { jobId: ctx.jobId, roundId: ctx.step?.roundId ?? null, at }],
1411
+ };
1412
+ const judged = ownerLines.filter((l) => l.kind !== null).length;
1413
+ store.drafts.put(draft, trace(`Session draft ${host} ${sessionId.slice(0, 8)}: ${ownerLines.length} owner lines (${judged} classified), ${agentSummary.length} agent summaries — from the ${heard.readFrom}`, heard.sourceIds));
1414
+ saved('drafts', id, `Session draft: ${host} ${sessionId.slice(0, 8)}`);
1415
+ return ok({ id, ownerLines: ownerLines.length, classified: judged, unclassified: ownerLines.length - judged, agentSummary: agentSummary.length, readFrom: heard.readFrom, updated: Boolean(previous) });
1416
+ },
1417
+ }));
1418
+
1419
+ tools.push(...fillTools(ctx)); // D99: the table and heading fill-in tools (organize/fill-tools.ts)
1420
+ // D99: where a lane looked for a breakpoint's missing step (organize/looked-tools.ts).
1421
+ tools.push(...lookedTools(ctx));
1422
+
1423
+ return tools;
1424
+ }