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,471 @@
1
+ /**
2
+ * The main agent's stages (D99; build plan §2 "主会话", §6): `pk_stage` moves the round to its next stage and returns
3
+ * that stage's skill; `pk_round_state` says where the round stands, so the main agent finds its place again after its
4
+ * session was continued or compacted.
5
+ *
6
+ * The program keeps the order and the few gates the method has; the judging stays the main agent's:
7
+ * - only forward along `ROUND_STAGES`; a stage may be skipped only where the method allows it, with why (a Follow up
8
+ * whose document chain did not change skips the skeleton and the reconciling; a round with nothing to dig skips the
9
+ * dig and the coverage check);
10
+ * - leaving a deepening's dig, each of the four kinds of question has a lane whose brief answers it, or the main agent
11
+ * says why not (E148 D-g; replaces the briefs the program used to compose);
12
+ * - a `Full` deepening enters the cross-check only once its coverage is settled (`coverageSettled`, W5), and the
13
+ * synthesis only once every breakpoint candidate has a result — linked or put out, or looked for with where
14
+ * (`pk_record_looked`) — or the main agent says why not; handing over to the synthesis, every round is told what is
15
+ * still unplaced (work items in no plan or module, decisions placed on nothing — DB: counted by the workbench's own
16
+ * placement, requirements and designs with them), which refuses nothing (round-open.ts);
17
+ * - (CJ, E150) what the program can count does not rest on the model's promise: a First usable round leaves `reconcile`,
18
+ * and a deepening enters `cross-check`, only once every number the current decision records, plans, task indexes and
19
+ * execution arrangements define at an entry position is carried by an item or accounted for by its number
20
+ * (entry-gate.ts); a deepening enters `synthesis` only once the cross-check confirmed or rejected every link of the
21
+ * round, or the main agent says why not;
22
+ * - (CM, E151) the program carries the lists and the checks: `pk_round_state` returns counts and what changed since the
23
+ * last call, one list in full by its key; a deepening's synthesis waits on the suspect links only (a link whose evidence
24
+ * passed the program's check when written is lane-checked), and — at `Full` depth — on every item of an earlier
25
+ * generation having a destination;
26
+ * - entering the stages where the process matters, the breakpoint candidates are recomputed (the planner's hook).
27
+ * Each stage is recorded on the round with its start, its end and the main job's time in it (§3.10).
28
+ *
29
+ * D103 (E153; the owner: 「可以,合成另开会话,做吧」): the main agent's session ends after its last check. Its stages are
30
+ * orientation → skeleton → reconcile in a first usable round and orientation → dig → coverage → cross-check in a deepening
31
+ * (`ROUND_STAGES`); `pk_stage({ to: "synthesis" })` is the handover. It runs every gate it ran when the synthesis was a stage
32
+ * — candidates, suspect links, generation destinations, the unplaced note, the candidate recompute — and then, instead of
33
+ * returning the synthesis' skill, it takes the main agent's handover (what it settled, what it left open and why, what the
34
+ * synthesis should look at first), writes it as the round's `Handover` document, ends the stage log at the last stage and
35
+ * closes the main agent's work: the planner starts the synthesis as a job of its own, in a fresh pi session, which reads
36
+ * the lane reports and the handover (clerk.ts `startSynthesis`). On the CM run the main agent entered the synthesis at
37
+ * 341–399K tokens and wrote the two notes the spot-check had to correct within three minutes, from memory of the stages
38
+ * before, not from the reports.
39
+ */
40
+ import { laneUnsure } from './absence.ts';
41
+ import { Type } from 'typebox';
42
+ import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
43
+ import type { ClerkRound, ClerkStage, RoundDoc, RoundKind, StageEntry, StepTiming } from '../../model/k-types.ts';
44
+ import type { Project } from '../../model/types.ts';
45
+ import type { ProjectStore } from '../../store/project-store.ts';
46
+ import type { ToolContext } from '../tools.ts';
47
+ import { HANDOVER_TO, ROUND_STAGES } from '../clerk-steps.ts';
48
+ import { stableId } from '../../model/ids.ts';
49
+ import { redactCredentials } from '../../sources/anchor.ts';
50
+ import { DEEPENING_PATHS, sweepKindsOf, type SweepKind } from './clerk-prompts.ts';
51
+ import { coverageSettled as settledCoverage } from './coverage-gate.ts';
52
+ import { lanesOf } from './lane-tools.ts';
53
+ import { emptySlotsOf } from './slots.ts';
54
+ import { stageSkill as skillOf } from './skills.ts';
55
+ import { handedOver, stageOfRound } from './stage-gate.ts';
56
+ import { standingCounts } from './standing-notes.ts';
57
+ import { candidatesAnswered, candidatesWithoutResult, OPEN_KEYS, openCounts, roundOpen, suspectLinks, unplacedNote, type RoundOpen } from './round-open.ts';
58
+ import { placeByProgram, placedNote, settleProgramPlacements } from './placement-inference.ts';
59
+ import { checkUncheckedLinks } from '../link-check.ts';
60
+ import { attachAcceptedItems, itemsWithoutDestination } from './generation-check.ts';
61
+ import { Ledger } from '../../ledger/index.ts';
62
+ import { codeAnomalyCandidates } from '../../codemap/facts.ts';
63
+ import { uncitedOwnerLines } from './owner-lines.ts';
64
+ import { entryGapsNow, entryGateRefusal } from './entry-gate.ts';
65
+
66
+ const STAGES: readonly ClerkStage[] = ['orientation', 'skeleton', 'reconcile', 'dig', 'coverage', 'cross-check', 'synthesis'];
67
+
68
+ /** A round's stages as `pk_stage` moves through them: the main agent's, then the handover to the synthesis (D103). */
69
+ export function stageOrder(kind: RoundKind): readonly ClerkStage[] {
70
+ return [...ROUND_STAGES[kind], HANDOVER_TO];
71
+ }
72
+
73
+ /** The three parts of the main agent's handover to the synthesis (D103), as `pk_stage` takes them and the document heads them. */
74
+ export const HANDOVER_PARTS = [
75
+ { key: 'settled', head: 'What I settled', what: 'what you settled in your last stage (the reconciling, or the cross-check): the judgements across lanes you made and where you wrote them' },
76
+ { key: 'open', head: 'What I left open, and why', what: 'what you left open and why: what stays unplaced, unconfirmed, without a result or without a destination, and a lane’s Unsure you did not settle' },
77
+ { key: 'first', head: 'Look at first', what: 'what the synthesis should look at first: the reports, positions or questions that matter most for this round’s answers' },
78
+ ] as const;
79
+
80
+ /** The main agent's handover as given to `pk_stage`: each part's text, or which parts are missing. */
81
+ export function handoverOf(raw: unknown): { readonly markdown: string } | { readonly missing: readonly string[] } {
82
+ const given = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record<string, unknown> : {};
83
+ const parts = HANDOVER_PARTS.map((p) => ({ ...p, text: typeof given[p.key] === 'string' ? redactCredentials(given[p.key] as string).text.trim() : '' }));
84
+ const missing = parts.filter((p) => !p.text).map((p) => p.key);
85
+ if (missing.length) return { missing };
86
+ return { markdown: parts.map((p) => `## ${p.head}\n\n${p.text}`).join('\n\n') };
87
+ }
88
+
89
+ /** The id of a round's Handover document: one per round, replaced when the handover is given again. */
90
+ export const handoverDocId = (roundId: string): string => stableId('rdoc', roundId, 'Handover', '');
91
+
92
+ /** A round's Handover document, if the main agent handed over (D103). */
93
+ export function handoverDoc(store: ProjectStore, round: Pick<ClerkRound, 'id' | 'handover'>): RoundDoc | null {
94
+ return store.roundDocs.get(round.handover?.docId ?? handoverDocId(round.id)) ?? null;
95
+ }
96
+
97
+ /** How many items one page of a list gives by default (CM). */
98
+ export const LIST_PAGE = 100;
99
+ /** How many names of what was added or went each changed list shows. */
100
+ const CHANGED_NAMES = 12;
101
+
102
+ const withLedger = <T>(store: ProjectStore, fallback: T, f: (l: Ledger) => T): T => {
103
+ let l: Ledger | null = null;
104
+ try { l = Ledger.openDir(store.dir); } catch { l = null; }
105
+ if (!l) return fallback;
106
+ try { return f(l); } finally { l.close(); }
107
+ };
108
+
109
+ /**
110
+ * The round's own lists `pk_round_state` gives by key besides the open ones (CM): what the program lists that the main
111
+ * agent does not act on item by item comes as a count where it would have been pasted, and in full here (the gated run's
112
+ * 53K code-anomaly list in the cross-check's notes was waved off in one sentence).
113
+ */
114
+ export const EXTRA_KEYS: readonly { readonly key: string; readonly what: string; readonly items: (store: ProjectStore, round: ClerkRound, project: Project) => readonly unknown[] }[] = [
115
+ { key: 'lanes', what: 'the round’s lanes with their slots, brief and report', items: (store, round) => lanesOf(store, round).map(({ jobId: _j, ...l }) => l) },
116
+ { key: 'emptySlots', what: 'the workbench slots a first usable round’s skeleton gave to no lane, each with the recorded reason, its round, and whether the workbench holds anything there now', items: (store) => emptySlotsOf(store).map(({ at: _at, ...e }) => e) },
117
+ { key: 'documents', what: 'the round’s documents', items: (store, round) => store.roundDocs.filter((d) => d.roundId === round.id).map((d) => ({ id: d.id, kind: d.kind, path: d.path, title: d.title, chars: d.markdown.length })) },
118
+ { key: 'unsure', what: 'what this round’s lane reports marked Unsure: each is read to settle it, or written as unsure — never restated as fact', items: (store, round) => laneUnsure(store, round).map((u) => ({ lane: u.lane, report: `${u.reportId}:${u.line}`, text: u.text })) },
119
+ { key: 'coverage', what: 'the coverage check: what no lane touched, and what was accounted for', items: (_s, round) => [...(round.coverage?.untouched ?? []).map((u) => ({ untouched: u })), ...(round.coverage?.accounted ?? [])] },
120
+ { key: 'ownerLines', what: 'the owner’s lines not looked at yet: cited by nothing and not judged to need nothing (the Owner’s words lane has them verbatim)', items: (store) => withLedger(store, [] as unknown[], (l) => uncitedOwnerLines(store, l).lines.map((x) => ({ id: `${x.draftId}:${x.ref}`, at: x.at, kind: x.kind, draft: x.draftId, ref: x.ref, cite: x.sourceIds, text: x.text.replace(/\s+/g, ' ').slice(0, 300) }))) },
121
+ { key: 'ownerLinesJudged', what: 'the owner’s lines judged to need nothing, each with why (pk_judge_owner_lines)', items: (store) => withLedger(store, [] as unknown[], (l) => uncitedOwnerLines(store, l).judged.map((x) => ({ id: `${x.draftId}:${x.ref}`, at: x.at, kind: x.kind, why: x.why, by: x.by === 'lane' ? x.lane ?? 'a lane' : 'main', text: x.text.replace(/\s+/g, ' ').slice(0, 300) }))) },
122
+ { key: 'codeAnomalies', what: 'the program’s code anomaly candidates, to judge before writing an anomaly with pk_write_territory', items: (store, _r, project) => withLedger(store, [] as unknown[], (l) => codeAnomalyCandidates(l, store, project).map((c) => ({ kind: c.kind, territory: c.territoryId, path: c.path, commit: c.commit?.slice(0, 12) ?? null, detail: c.detail, evidence: c.evidence.map((e) => e.id) }))) },
123
+ ];
124
+
125
+ /** The stages a round may skip, with why (§6): a Follow up the skeleton and the reconciling when its document chain did not change, and a round with nothing to dig the dig and the coverage check. */
126
+ export function skippableStages(kind: RoundKind): readonly ClerkStage[] {
127
+ return kind === 'Follow up' ? ['skeleton', 'reconcile', 'dig', 'coverage'] : kind === 'Deepen' ? ['dig', 'coverage'] : [];
128
+ }
129
+
130
+ /**
131
+ * Entering these stages the breakpoint candidates are recomputed (§6): a first usable round's synthesis; a deepening's and
132
+ * a Follow up's dig, cross-check and synthesis. The synthesis is the end of the cross-check (CM, E151): on the gated run
133
+ * K-2's and K-3's links were confirmed at 23:15 and the candidates only recomputed at 23:45, after the spot-check had
134
+ * read them as still open — two of its three "errors" were this timing. D103: `synthesis` here is the handover to the
135
+ * synthesis (`pk_stage({ to: "synthesis" })`), the main agent's last call; the synthesis job reads them recomputed.
136
+ */
137
+ export function recomputesCandidates(kind: RoundKind, stage: ClerkStage): boolean {
138
+ return kind === 'First usable' ? stage === 'synthesis' : stage === 'dig' || stage === 'cross-check' || stage === 'synthesis';
139
+ }
140
+
141
+ /** How many of a brief's first lines may name the kind of question it answers (the skills ask for it there). */
142
+ const BRIEF_HEAD_LINES = 5;
143
+ const plain = (s: string): string => s.toLowerCase().replace(/[’‘`]/g, "'").replace(/\s+/g, ' ');
144
+
145
+ /**
146
+ * The four kinds of question (Spec §3.3) no lane of the round's dig answers. A lane answers the kinds its brief names in
147
+ * its title or first lines, as the skills ask the main agent to write them (`DEEPENING_PATHS`, word for word); failing
148
+ * that, the kinds its name or its brief's name begins with (`sweepKindsOf`).
149
+ */
150
+ export function kindsUnanswered(store: ProjectStore, round: ClerkRound): SweepKind[] {
151
+ const answered = new Set<SweepKind>();
152
+ for (const lane of (round.lanes ?? []).filter((l) => l.stage === 'dig' || l.stage === 'coverage')) {
153
+ const brief = store.roundDocs.get(lane.briefDocId);
154
+ const head = plain([brief?.title ?? '', ...(brief?.markdown ?? '').split(/\r?\n/).filter((l) => l.trim()).slice(0, BRIEF_HEAD_LINES)].join('\n'));
155
+ const named = DEEPENING_PATHS.filter((k) => head.includes(plain(k)));
156
+ for (const k of named.length ? named : [lane.name, brief?.path ?? '', brief?.title ?? ''].flatMap((n) => sweepKindsOf(n))) answered.add(k);
157
+ }
158
+ return DEEPENING_PATHS.filter((k) => !answered.has(k));
159
+ }
160
+
161
+ const zero: StepTiming = { wallMs: 0, generationMs: 0, toolMs: 0, queueMs: 0, parseRetryMs: 0, otherMs: 0 };
162
+ const sub = (a: StepTiming, b: StepTiming): StepTiming => ({
163
+ wallMs: Math.max(0, a.wallMs - b.wallMs), generationMs: Math.max(0, a.generationMs - b.generationMs), toolMs: Math.max(0, a.toolMs - b.toolMs),
164
+ queueMs: Math.max(0, a.queueMs - b.queueMs), parseRetryMs: Math.max(0, a.parseRetryMs - b.parseRetryMs), otherMs: Math.max(0, a.otherMs - b.otherMs),
165
+ });
166
+ const add = (a: StepTiming, b: StepTiming): StepTiming => ({
167
+ wallMs: a.wallMs + b.wallMs, generationMs: a.generationMs + b.generationMs, toolMs: a.toolMs + b.toolMs, queueMs: a.queueMs + b.queueMs, parseRetryMs: a.parseRetryMs + b.parseRetryMs, otherMs: a.otherMs + b.otherMs,
168
+ });
169
+
170
+ /**
171
+ * The stage log with the running stage ended now: its time is the main job's time so far less what the stages before it
172
+ * took (they follow one another from the job's start).
173
+ */
174
+ export function endStage(log: readonly StageEntry[], at: string, snapshot: StepTiming | null): StageEntry[] {
175
+ const open = log.findIndex((e) => e.endedAt === null);
176
+ if (open < 0) return [...log];
177
+ const before = log.slice(0, open).reduce((t, e) => (e.timing ? add(t, e.timing) : t), zero);
178
+ return log.map((e, i) => (i === open ? { ...e, endedAt: at, timing: snapshot ? sub(snapshot, before) : null } : e));
179
+ }
180
+
181
+ export interface StageDeps {
182
+ readonly coverageSettled: (store: ProjectStore, round: ClerkRound, opts?: { readonly project?: Project | null }) => boolean;
183
+ readonly stageSkill: (stage: ClerkStage) => string;
184
+ }
185
+
186
+ const text = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
187
+ const now = () => new Date().toISOString();
188
+ /** The unplaced note's words for the Result, which the main agent's copy turns to its handover (round-open.ts `unplacedNote`). */
189
+ const UNPLACED_IN_RESULT = 'say in the Result which stay unplaced and why';
190
+ function ok(value: unknown) {
191
+ return { content: [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value, null, 1) }], details: {} };
192
+ }
193
+ function fail(message: string) {
194
+ return { content: [{ type: 'text' as const, text: `ERROR: ${message}` }], details: {}, isError: true };
195
+ }
196
+
197
+ export function stageTools(ctx: ToolContext, deps: StageDeps = { coverageSettled: settledCoverage, stageSkill: skillOf }): ToolDefinition[] {
198
+ const { store, project } = ctx;
199
+ const trace = (summary: string) => ({ jobId: ctx.jobId, basisSourceIds: [] as string[], summary });
200
+
201
+ /**
202
+ * CQ (D104): the program places what the records lead to — basis Inferred, the chain in each claim, recorded on the round
203
+ * (placement-inference.ts) — as the main agent enters reconcile and the cross-check, and at its handover. Its note tells
204
+ * the main agent what was placed and how to check it; a failure of the program's own run is said, not hidden.
205
+ */
206
+ const placeNow = (roundId: string, stage: string): string | null => {
207
+ let l: Ledger | null = null;
208
+ try { l = Ledger.openDir(store.dir); } catch { l = null; }
209
+ try { return placedNote(placeByProgram(store, l, { id: roundId }, ctx.jobId, stage), stage); } catch (e) { return `=== Placed by the program\nThe program could not run its placement (${(e as Error).message}); place from the records yourself.`; } finally { l?.close(); }
210
+ };
211
+
212
+ const mainRound = (tool: string): ClerkRound | string => {
213
+ const step = ctx.step;
214
+ if (!step || step.kind !== 'main') return `${tool} is the main agent's: only the main job of a round moves through its stages.`;
215
+ const round = store.clerkRounds.get(step.roundId);
216
+ if (!round) return `The round ${step.roundId} is not in the assets.`;
217
+ return round;
218
+ };
219
+
220
+ /** The round `pk_round_state` reads: the main job's, or the synthesis job's (D103: it finds where the round stands from the counts and lists). */
221
+ const stateRound = (): ClerkRound | string => {
222
+ const step = ctx.step;
223
+ if (!step || (step.kind !== 'main' && step.kind !== 'synthesis')) return "pk_round_state is the main agent's and the synthesis': only those jobs of a round read where it stands.";
224
+ const round = store.clerkRounds.get(step.roundId);
225
+ if (!round) return `The round ${step.roundId} is not in the assets.`;
226
+ return round;
227
+ };
228
+
229
+ const tools: ToolDefinition[] = [];
230
+
231
+ tools.push(defineTool({
232
+ name: 'pk_stage', label: 'Move to the next stage',
233
+ description: `Move this round to its next stage when the one you are in is done, and get that stage's skill: what to do in it. The stages go forward only, in the order of the round's kind — First usable: ${ROUND_STAGES['First usable'].join(' → ')}; Deepen: ${ROUND_STAGES.Deepen.join(' → ')}; Follow up: ${ROUND_STAGES['Follow up'].join(' → ')}. Your last stage ends with the handover: pk_stage({ to: "synthesis", handover: { settled, open, first } }) hands the round over to the synthesis, which runs in a session of its own and reads the lane reports and your handover; your work is done then, and your session writes nothing more. A Follow up whose document chain did not change may skip skeleton and reconcile, and a round with nothing to dig may skip dig and coverage: say why. Leaving a deepening's dig, each of the four kinds of question (${DEEPENING_PATHS.join('; ')}) has a lane whose brief answers it, or you say why not. A First usable round leaves reconcile, and a deepening enters cross-check, only once every number the current decision records, plans, task indexes and execution arrangements define at an entry position is carried by an item (its ids, or the start of its name) or accounted for by its number (pk_account_entries); the refusal lists what is missing, by file. A Full deepening enters cross-check only once its coverage is settled, and hands over to the synthesis only once every breakpoint candidate has a result — linked or put out, or looked for with where (pk_record_looked) — or you say why not; a deepening hands over only once every suspect link — one whose evidence failed the program's check when it was written — is confirmed or rejected (pk_confirm), or you say why not (a lane-checked link needs nothing more), and (Full) once each item of an earlier generation has its destination, or you say why not; the handover also notes what is still unplaced as the workbench places it (work items in no plan or module, decisions, requirements and designs placed on nothing or only on the Product with no reason), without refusing: say in your handover which stay unplaced and why. Asking for the stage you are in gives its skill again. What each stage lets you write follows from it: a writer outside its stage refuses and says which stage writes it.`,
234
+ parameters: Type.Object({
235
+ to: Type.String({ description: STAGES.join(' | ') }),
236
+ why: Type.Optional(Type.String({ description: 'why a stage is skipped, why a kind of question has no lane, or (a deepening handing over to the synthesis) why breakpoint candidates are left with no result, suspect links unsettled, or generation items without a destination' })),
237
+ handover: Type.Optional(Type.Object({
238
+ settled: Type.Optional(Type.String({ description: HANDOVER_PARTS[0].what })),
239
+ open: Type.Optional(Type.String({ description: HANDOVER_PARTS[1].what })),
240
+ first: Type.Optional(Type.String({ description: HANDOVER_PARTS[2].what })),
241
+ }, { description: 'to synthesis only: your handover to the synthesis, short — it reads the lane reports itself. All three parts are needed' })),
242
+ }),
243
+ execute: async (_id, p) => {
244
+ const round = mainRound('pk_stage');
245
+ if (typeof round === 'string') return fail(round);
246
+ if (round.status !== 'Running') return fail(`Round ${round.number} is ${round.status}: its stages are over.`);
247
+ const to = text(p.to) as ClerkStage;
248
+ const why = text(p.why);
249
+ const order = stageOrder(round.kind);
250
+ const where = { kind: round.kind, number: round.number };
251
+ // D103: once handed over, the main agent's work is done; asking again says so and changes nothing.
252
+ if (handedOver(round)) {
253
+ if (to === HANDOVER_TO) return ok({ handedOver: true, round: where, note: 'You handed this round over to the synthesis already; your work is done. End with a short summary of what you handed over.' });
254
+ return fail('This round was handed over to the synthesis, which runs in a session of its own: its stages are over for the main agent. End with a short summary of what you handed over.');
255
+ }
256
+ if (!order.includes(to)) return fail(`${to || '(none)'} is not a stage of a ${round.kind} round; its stages are ${ROUND_STAGES[round.kind].join(' → ')}, then the handover to the synthesis (to: "synthesis").`);
257
+ const from = stageOfRound(round);
258
+ const i = order.indexOf(from);
259
+ const j = order.indexOf(to);
260
+ if (j === i) return ok({ stage: to, skill: deps.stageSkill(to), round: where, note: `You are in the ${to} stage already; this is its skill again.` });
261
+ if (j < i) return fail(`The stages go forward only: this round is in ${from}, and ${to} came before it. Do what is left in ${from}, then move on (next: ${order[i + 1] ?? 'none'}).`);
262
+ const skipped = order.slice(i + 1, j);
263
+ if (skipped.length) {
264
+ const allowed = skippableStages(round.kind);
265
+ const not = skipped.filter((s) => !allowed.includes(s));
266
+ if (not.length) return fail(`A ${round.kind} round does not skip ${not.join(' and ')}: ${order[i + 1] === HANDOVER_TO ? 'what comes next is the handover to the synthesis' : `the next stage is ${order[i + 1]}`}. Nothing changed.`);
267
+ if (!why) return fail(`Moving to ${to} skips ${skipped.join(' and ')}: say why in why (the document chain did not change since the last round; there is nothing to dig). Nothing changed.`);
268
+ }
269
+ if (round.kind === 'Deepen' && from === 'dig') {
270
+ const missing = kindsUnanswered(store, round);
271
+ if (missing.length && !why) return fail(`A deepening answers the four kinds of question, and no lane of this dig answers ${missing.map((k) => `“${k}”`).join(', ')} (a lane answers a kind when its name or its brief's names it). Send a lane for each with pk_send_lanes, or say in why why this round needs none. Nothing changed.`);
272
+ }
273
+ // CJ (E150): leaving the first usable round's reconcile, every counted entry is on the workbench or accounted for.
274
+ if (round.kind === 'First usable' && from === 'reconcile') {
275
+ const refusal = entryGateRefusal(entryGapsNow(store), 'leave reconcile');
276
+ if (refusal) return fail(refusal);
277
+ }
278
+ if (to === 'cross-check' && round.kind === 'Deepen' && (project.takeoverDepth ?? 'Full') === 'Full' && !deps.coverageSettled(store, round, { project })) {
279
+ return fail('A Full deepening enters the cross-check once every planned material is read, read in part with why, or accounted for: the coverage check still lists material no lane touched. Account for it (pk_account_material) or send a follow-up lane, then move on. Nothing changed.');
280
+ }
281
+ if (to === 'cross-check' && round.kind === 'Deepen') {
282
+ const refusal = entryGateRefusal(entryGapsNow(store), 'enter the cross-check');
283
+ if (refusal) return fail(refusal);
284
+ }
285
+ if (to === 'synthesis' && round.kind === 'Deepen' && (project.takeoverDepth ?? 'Full') === 'Full') {
286
+ const open = candidatesWithoutResult(store, round.id);
287
+ if (open.length && !why) {
288
+ const shown = open.slice(0, 12).map((b) => `${b.id} (${b.kind})`).join(', ');
289
+ return fail(`A Full deepening gives every breakpoint candidate a result before the synthesis: the lane that owns it links the missing step it found (pk_link_process, which puts the candidate out), or records where it looked and did not find it (pk_record_looked); in the cross-check you put out one the evidence answers (pk_confirm kind breakpoint, confirmed false). ${open.length} candidate${open.length === 1 ? ' has' : 's have'} no result yet: ${shown}${open.length > 12 ? `, and ${open.length - 12} more` : ''} (pk_round_state lists them, with the briefs that name each). Put out those the evidence answers, or say in why why they are left with no result this round and what takes them up (lanes are sent from the dig and the coverage stage, which this round has left). Nothing changed.`);
290
+ }
291
+ }
292
+ // CJ, CM: the program checked every link's evidence as it was written (link-check.ts); a lane-checked link counts like
293
+ // a confirmed one. The synthesis waits only on the suspect links — their evidence failed the check — until the
294
+ // cross-check rejects each or confirms it after reading the original, or says why. (On the gated run the gate
295
+ // counted every unconfirmed link, and 82 were confirmed in 26 seconds without a file opened.)
296
+ if (to === 'synthesis' && round.kind === 'Deepen') {
297
+ checkUncheckedLinks(store, ctx.jobId);
298
+ const links = suspectLinks(store);
299
+ if (links.length && !why) {
300
+ const shown = links.slice(0, 12).map((l) => `${l.id} (${l.stepKind} → ${l.workId}: ${l.check?.why ?? 'not checked'})`).join(', ');
301
+ return fail(`A deepening's cross-check settles every suspect link before the synthesis: the program checked each link's evidence as it was written, and these failed — reject each (pk_confirm kind link, confirmed false, which removes it; several at once with ids), or confirm it after reading the original (confirmed true). ${links.length} suspect link${links.length === 1 ? '' : 's'}: ${shown}${links.length > 12 ? `, and ${links.length - 12} more` : ''} (pk_round_state { list: "links" }). Settle them, or say in why why they stay this round. Nothing changed.`);
302
+ }
303
+ }
304
+ // CM (E151): each item of a recognised generation has a destination before the synthesis — the cross-check's duty,
305
+ // counted by the program (the gated run wrote 「去向已记全」 with twenty CKT items that had none).
306
+ if (to === 'synthesis' && round.kind === 'Deepen' && (project.takeoverDepth ?? 'Full') === 'Full') {
307
+ withLedger(store, 0, (l) => attachAcceptedItems(store, l, ctx.jobId));
308
+ const left = itemsWithoutDestination(store);
309
+ if (left.length && !why) {
310
+ const shown = left.slice(0, 12).map((x) => x.number || x.id).join(', ');
311
+ return fail(`A deepening records where each item of an earlier generation went before the synthesis: what replaced it (pk_write_thread replacedBy — an id or the project's number, T-04), a current item that depends on or carries it (pk_relate, dependsOn), or that it was abandoned (validity Abandoned). ${left.length} item${left.length === 1 ? ' has' : 's have'} no destination: ${shown}${left.length > 12 ? `, and ${left.length - 12} more` : ''} (pk_round_state { list: "withoutDestination" }). Record them, or say in why why they stay without one this round. Nothing changed.`);
312
+ }
313
+ }
314
+ // D103: the handover. Every gate above has passed; the main agent says what it settled, what it left open and why,
315
+ // and what the synthesis should look at first — told what is still unplaced, so its handover can say which stay so
316
+ // and why. Then its stage log ends, and the round is the synthesis job's.
317
+ if (to === HANDOVER_TO) {
318
+ // CQ (D104): the program places what the records lead to before the unplaced note is counted.
319
+ const placedNow = placeNow(round.id, 'the handover');
320
+ const unplaced = unplacedNote(roundOpen(store, store.clerkRounds.get(round.id) ?? round));
321
+ const given = handoverOf((p as Record<string, unknown>).handover);
322
+ if ('missing' in given) {
323
+ return fail([
324
+ `The gates are passed. Handing over to the synthesis needs your handover: the synthesis runs in a session of its own and was not there for this round — it reads the lane reports itself, and your handover tells it where you stand. Call pk_stage again with handover: { ${HANDOVER_PARTS.map((x) => x.key).join(', ')} }${why ? ', and the same why' : ''} — ${HANDOVER_PARTS.map((x) => `${x.key}: ${x.what}`).join('; ')}. Keep it short. Missing: ${given.missing.join(', ')}. Nothing changed.`,
325
+ ...(unplaced ? [unplaced.replace(UNPLACED_IN_RESULT, 'say in your handover (open) which stay unplaced and why')] : []),
326
+ ].join('\n\n'));
327
+ }
328
+ const at = now();
329
+ const current = store.clerkRounds.get(round.id) ?? round;
330
+ const docId = handoverDocId(round.id);
331
+ const title = `Handover to the synthesis: ${round.kind} round ${round.number}`;
332
+ const markdown = [`# ${title}`, `From the main agent, as it left the ${from} stage.`, ...(why ? [`Why it handed over with a gate left open: ${why}`] : []), given.markdown].join('\n\n');
333
+ const replaced = store.roundDocs.has(docId);
334
+ store.roundDocs.put({ id: docId, projectId: project.id, roundId: round.id, jobId: ctx.jobId, kind: 'Handover', path: null, title, markdown, at } satisfies RoundDoc,
335
+ trace(`${replaced ? 'Replaced' : 'Wrote'} Handover: ${title} (${[...markdown].length} characters)`));
336
+ ctx.onSaved?.('roundDocs', docId, `Handover: ${title}`);
337
+ const started = current.stageLog?.length ? [...current.stageLog] : [{ stage: from, startedAt: current.startedAt, endedAt: null, timing: null } satisfies StageEntry];
338
+ const stageLog = endStage(started, at, ctx.timingSnapshot?.() ?? null);
339
+ store.clerkRounds.put({ ...current, stage: from, stageLog, handover: { at, docId, ...(why ? { why } : {}) }, updatedAt: at }, trace(`Round ${round.number}: the main agent hands over to the synthesis from ${from}${skipped.length ? `, skipping ${skipped.join(' and ')}` : ''}${why ? ` (${why})` : ''}`));
340
+ // The candidate recompute the synthesis stage had as it was entered (CM): the synthesis and the spot-check read them current.
341
+ const entered = await ctx.stageEntered?.(round.id, HANDOVER_TO);
342
+ const notes = [
343
+ ...(skipped.length ? [`Skipped ${skipped.join(' and ')}: ${why}`] : []),
344
+ ...(entered?.note ? [entered.note] : []),
345
+ ...(placedNow ? [placedNow] : []),
346
+ ...(unplaced ? [unplaced.replace(UNPLACED_IN_RESULT, 'the synthesis says in the Result which stay unplaced and why')] : []),
347
+ ];
348
+ return ok({ handedOver: true, round: where, handover: docId, note: ['The round is handed over: the synthesis runs next, in a session of its own, and reads the lane reports and your handover. Your work in this round is done and your session writes nothing more. End now with a short summary of what you handed over.', ...notes].join('\n\n') });
349
+ }
350
+ const at = now();
351
+ const current = store.clerkRounds.get(round.id) ?? round;
352
+ const started = current.stageLog?.length ? [...current.stageLog] : [{ stage: from, startedAt: current.startedAt, endedAt: null, timing: null } satisfies StageEntry];
353
+ const stageLog = [...endStage(started, at, ctx.timingSnapshot?.() ?? null), { stage: to, startedAt: at, endedAt: null, timing: null, ...(why ? { why } : {}) } satisfies StageEntry];
354
+ store.clerkRounds.put({ ...current, stage: to, stageLog, updatedAt: at }, trace(`Round ${round.number}: the main agent moves from ${from} to ${to}${skipped.length ? `, skipping ${skipped.join(' and ')}` : ''}${why ? ` (${why})` : ''}`));
355
+ // CQ (D104): entering reconcile and the cross-check, the program places what the records lead to, basis Inferred.
356
+ const placedNow = to === 'reconcile' || to === 'cross-check' ? placeNow(round.id, to) : null;
357
+ const entered = await ctx.stageEntered?.(round.id, to);
358
+ const notes = [
359
+ ...(skipped.length ? [`Skipped ${skipped.join(' and ')}: ${why}`] : []),
360
+ ...(placedNow ? [placedNow] : []),
361
+ ...(entered?.note ? [entered.note] : []),
362
+ ];
363
+ return ok({ stage: to, skill: deps.stageSkill(to), round: where, ...(notes.length ? { note: notes.join('\n\n') } : {}) });
364
+ },
365
+ }));
366
+
367
+ /** What the last pk_round_state call of this session saw, by key: the ids of each list (CM: what changed since). */
368
+ let seen: Map<string, Map<string, string>> | null = null;
369
+
370
+ tools.push(defineTool({
371
+ name: 'pk_round_state', label: 'Where this round stands',
372
+ description: `Where this round stands: the main agent's stage and the stages it went through (and whether it handed over to the synthesis), its lanes with their status and reports, its coverage counted, its breakpoints counted, its documents counted, and open — how many items each list of what is still open holds — with what changed in each since your last call. One list comes in full only when you ask for it by its key: { list: "entries" } (offset and limit page a long one). The keys: ${[...Object.entries(OPEN_KEYS).map(([k, v]) => `${k} (${v.what})`), ...EXTRA_KEYS.map((k) => `${k.key} (${k.what})`)].join('; ')}. Call it to find your place again after your session was continued or compacted, and before you leave a stage, to check nothing it owns is left open; ask for a list when you act on it.`,
373
+ parameters: Type.Object({
374
+ list: Type.Optional(Type.String({ description: 'one list in full, by its key (see the description)' })),
375
+ offset: Type.Optional(Type.Number({ description: 'with list: where to start (default 0)' })),
376
+ limit: Type.Optional(Type.Number({ description: `with list: how many items (default ${LIST_PAGE})` })),
377
+ }),
378
+ execute: async (_id, p) => {
379
+ const round = stateRound();
380
+ if (typeof round === 'string') return fail(round);
381
+ const key = text((p as Record<string, unknown>).list);
382
+ if (key) {
383
+ const all = fullList(round, key);
384
+ if (all === null) return fail(`${key} is not a list of pk_round_state; the keys are ${[...Object.keys(OPEN_KEYS), ...EXTRA_KEYS.map((k) => k.key)].join(', ')}.`);
385
+ const offset = Math.max(0, Math.trunc(Number((p as Record<string, unknown>).offset) || 0));
386
+ const limit = Math.max(1, Math.min(LIST_PAGE * 5, Math.trunc(Number((p as Record<string, unknown>).limit) || LIST_PAGE)));
387
+ const items = all.items.slice(offset, offset + limit);
388
+ return ok({ list: key, count: all.count, offset, items, ...(offset + items.length < all.items.length ? { next: offset + items.length } : {}) });
389
+ }
390
+ // The program's own upkeep before it counts — a link not checked yet, an accepted generation's items — is done
391
+ // under the main job, whose stages it serves. The synthesis job only reads: what its job wrote is what the
392
+ // spot-check checks in full, and the program's upkeep is not among it (D103).
393
+ if (ctx.step?.kind === 'main') {
394
+ checkUncheckedLinks(store, ctx.jobId);
395
+ withLedger(store, 0, (l) => attachAcceptedItems(store, l, ctx.jobId));
396
+ // CS: a program placement of any round that a write took off its item has its result recorded before the count.
397
+ settleProgramPlacements(store, { jobId: ctx.jobId, roundId: round.id });
398
+ }
399
+ const bps = store.breakpoints.all().filter((b) => !b.out);
400
+ const open = roundOpen(store, round, { limit: Infinity, suggest: false });
401
+ const counts = openCounts(open);
402
+ const now = idsByKey(open);
403
+ const changed: Record<string, { before: number; now: number; added?: string[]; gone?: string[] }> = {};
404
+ if (seen) {
405
+ for (const [k, ids] of now) {
406
+ const was = seen.get(k) ?? new Map<string, string>();
407
+ const added = [...ids].filter(([id]) => !was.has(id)).map(([, name]) => name);
408
+ const gone = [...was].filter(([id]) => !ids.has(id)).map(([, name]) => name);
409
+ if (added.length || gone.length) changed[k] = { before: was.size, now: ids.size, ...(added.length ? { added: added.slice(0, CHANGED_NAMES) } : {}), ...(gone.length ? { gone: gone.slice(0, CHANGED_NAMES) } : {}) };
410
+ }
411
+ }
412
+ const first = seen === null;
413
+ seen = now;
414
+ const docs: Record<string, number> = {};
415
+ for (const d of store.roundDocs.filter((x) => x.roundId === round.id)) docs[d.kind] = (docs[d.kind] ?? 0) + 1;
416
+ const coverage = round.coverage ?? null;
417
+ const standing = standingCounts(store, round);
418
+ const zeros = Object.fromEntries([['noPlan', open.workItems.noPlan.notApplicable], ['noModule', open.workItems.noModule.notApplicable], ['noContract', open.tickets.noContract.notApplicable]].filter(([, v]) => v)) as Record<string, string>;
419
+ const notApplicable = Object.keys(zeros).length ? zeros : null;
420
+ return ok({
421
+ round: { id: round.id, kind: round.kind, number: round.number, depth: round.kind === 'Deepen' ? project.takeoverDepth ?? 'Full' : null },
422
+ stage: stageOfRound(round),
423
+ // D103: after the main agent's last stage comes the handover to the synthesis; once handed over, nothing.
424
+ next: handedOver(round) ? null : stageOrder(round.kind)[stageOrder(round.kind).indexOf(stageOfRound(round)) + 1] ?? null,
425
+ ...(handedOver(round) ? { handedOver: { at: round.handover?.at ?? null, handover: round.handover?.docId ?? null, to: 'the synthesis, in a session of its own' } } : {}),
426
+ ...(standing.standing ? { standingNotes: standing } : {}),
427
+ stageLog: (round.stageLog ?? []).map((e) => ({ stage: e.stage, startedAt: e.startedAt, endedAt: e.endedAt, ...(e.why ? { why: e.why } : {}) })),
428
+ lanes: lanesOf(store, round).map((l) => ({ name: l.name, kind: l.kind, status: l.status, reportDocId: l.reportDocId })),
429
+ coverage: coverage ? { at: coverage.at, settled: coverage.settled, untouched: coverage.untouched?.length ?? 0, accounted: coverage.accounted?.length ?? 0 } : null,
430
+ breakpoints: { candidates: bps.filter((b) => !b.lit).length, looked: bps.filter((b) => b.looked).length, lit: bps.filter((b) => b.lit).length, answered: candidatesAnswered(store, round.id).length, withoutResult: open.candidates.count },
431
+ documents: docs,
432
+ open: counts,
433
+ // CS: the program's placements of every round, counted — placed in all, confirmed, moved, still unreviewed (the list).
434
+ ...(open.programPlacements.placed ? { programPlacements: open.programPlacements } : {}),
435
+ // CQ (D104): the defined zeros — a layer the project lacks is said in a sentence, not counted as a list of items.
436
+ ...(notApplicable ? { notApplicable } : {}),
437
+ ...(first ? {} : { changed: Object.keys(changed).length ? changed : 'nothing since your last call' }),
438
+ lists: 'pk_round_state { list: "<key>" } gives one list in full; open gives every key with its count.',
439
+ });
440
+ },
441
+ }));
442
+
443
+ /** Every open list's ids with a name, by key: what the next call compares with. */
444
+ const idsByKey = (open: RoundOpen): Map<string, Map<string, string>> => {
445
+ const out = new Map<string, Map<string, string>>();
446
+ for (const [k, v] of Object.entries(OPEN_KEYS)) {
447
+ const m = new Map<string, string>();
448
+ for (const item of v.of(open).items as Record<string, unknown>[]) {
449
+ const id = String(item.id ?? item.path ?? JSON.stringify(item));
450
+ m.set(id, String(item.name ?? item.target ?? item.path ?? id).slice(0, 80));
451
+ }
452
+ out.set(k, m);
453
+ }
454
+ return out;
455
+ };
456
+
457
+ /** One list in full by its key: an open list, or one of the round's own (lanes, documents, coverage, owner lines, code anomalies). */
458
+ const fullList = (round: ClerkRound, key: string): { count: number; items: readonly unknown[] } | null => {
459
+ const openKey = OPEN_KEYS[key];
460
+ if (openKey) {
461
+ const v = openKey.of(roundOpen(store, round, { limit: Infinity }));
462
+ return { count: v.count, items: v.items };
463
+ }
464
+ const extra = EXTRA_KEYS.find((k) => k.key === key);
465
+ if (!extra) return null;
466
+ const items = extra.items(store, round, project);
467
+ return { count: items.length, items };
468
+ };
469
+
470
+ return tools;
471
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The notes still current from earlier rounds (E153, the owner, 2026-10-01: 「first usable 不去full synthesis check 理论上
3
+ * 第二轮应该其实是比较容易看出矛盾的,但是我不确定第二轮是确定会纠正么。」).
4
+ *
5
+ * On the CM run the first usable round's two notes — one of them For your decision — were untouched by the deepening
6
+ * (still version 1) and were not among its spot-check's targets: nothing made a later round look at them again. So in a
7
+ * deepening and a Follow up:
8
+ * - the synthesis is given every note that was current when the round began (`standingNotesBlock`) and gives each one
9
+ * result — **confirmed** as still holding, with what it read (`pk_confirm_note`, recorded in its judgement record);
10
+ * **updated** (`pk_write_note` with its id: a new version); or **withdrawn**, with why (`pk_close_note`: Resolved or
11
+ * Withdrawn);
12
+ * - a standing note with no result is an open item of the round (`pk_round_state` open `standingNotes`), and the round
13
+ * keeps the three counts (`standingCounts`);
14
+ * - the spot-check checks every note current at the end of the round, this round's and the standing ones, in full
15
+ * (process/breakpoint-candidates.ts `spotCheckTargets`).
16
+ *
17
+ * The first usable round has no earlier round, and gets no such list.
18
+ *
19
+ * The program reads the result off the records: the note was closed since the round began (withdrawn; the note states
20
+ * Resolved or Withdrawn, Spec §2.7), it has a version written since the round began (updated), or the judgement record of
21
+ * this round's synthesis says it still holds, with what was read (confirmed; CKC-08 AC-25 — no new version is written).
22
+ */
23
+ import type { ClerkRound } from '../../model/k-types.ts';
24
+ import type { Note } from '../../model/types.ts';
25
+ import type { ProjectStore } from '../../store/project-store.ts';
26
+
27
+ export type StandingResult = 'confirmed' | 'updated' | 'withdrawn';
28
+
29
+ export interface StandingNote {
30
+ readonly note: Note;
31
+ /** What this round did with it; null while it has no result. */
32
+ readonly result: StandingResult | null;
33
+ }
34
+
35
+ export interface StandingCounts {
36
+ /** The notes that were current when the round began. */
37
+ readonly standing: number;
38
+ readonly confirmed: number;
39
+ readonly updated: number;
40
+ readonly withdrawn: number;
41
+ /** Those with no result yet. */
42
+ readonly open: number;
43
+ }
44
+
45
+ type RoundRef = Pick<ClerkRound, 'id' | 'kind' | 'startedAt'>;
46
+
47
+ const firstAt = (n: Note): string => n.versions[0]?.at ?? n.updatedAt;
48
+ const lastOf = (n: Note) => n.versions[n.versions.length - 1];
49
+
50
+ /**
51
+ * A note the program wrote itself (the takeover's depth question, service.ts `writeDepthNote`): the planner's question to
52
+ * the owner, not a judgement of a round — no synthesis gives it a result and no spot-check checks it.
53
+ */
54
+ export const isProgramNote = (note: Pick<Note, 'author'>): boolean => note.author.agent === 'ProjectKeeper';
55
+
56
+ /** Whether a round looks at the standing notes: a deepening and a Follow up; the first usable round has none before it. */
57
+ export const looksAtStandingNotes = (round: Pick<ClerkRound, 'kind'>): boolean => round.kind !== 'First usable';
58
+
59
+ /**
60
+ * The notes that were current when the round began, each with what the round did with it. A note written before the
61
+ * round began is standing when it is current now, or was closed since the round began (it stood, and the round — or the
62
+ * owner meanwhile — withdrew it).
63
+ */
64
+ export function standingNotes(store: ProjectStore, round: RoundRef): StandingNote[] {
65
+ if (!looksAtStandingNotes(round)) return [];
66
+ const held = stillHolding(store, round.id);
67
+ const out: StandingNote[] = [];
68
+ for (const note of store.notes.all()) {
69
+ if (isProgramNote(note)) continue;
70
+ if (firstAt(note) >= round.startedAt) continue; // written in this round: not standing
71
+ if (note.status !== 'Current') {
72
+ if (note.updatedAt >= round.startedAt) out.push({ note, result: 'withdrawn' });
73
+ continue;
74
+ }
75
+ const result: StandingResult | null = (lastOf(note)?.at ?? '') >= round.startedAt ? 'updated' : held.has(note.id) ? 'confirmed' : null;
76
+ out.push({ note, result });
77
+ }
78
+ return out.sort((a, b) => firstAt(a.note).localeCompare(firstAt(b.note)) || a.note.id.localeCompare(b.note.id));
79
+ }
80
+
81
+ /** The notes the judgement records of this round's jobs say still hold, each with what was read (the latest say). */
82
+ export function stillHolding(store: ProjectStore, roundId: string): Map<string, { readonly read: readonly string[]; readonly at: string }> {
83
+ const jobs = new Set(store.jobs.filter((j) => j.step?.roundId === roundId).map((j) => j.id));
84
+ const out = new Map<string, { read: readonly string[]; at: string }>();
85
+ for (const j of store.judgements.filter((x) => jobs.has(x.jobId))) for (const h of j.outcome.stillHolds ?? []) {
86
+ if ((out.get(h.noteId)?.at ?? '') <= h.at) out.set(h.noteId, { read: h.read, at: h.at });
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /** The standing notes counted by result. */
92
+ export function standingCounts(store: ProjectStore, round: RoundRef): StandingCounts {
93
+ const all = standingNotes(store, round);
94
+ const n = (r: StandingResult | null) => all.filter((s) => s.result === r).length;
95
+ return { standing: all.length, confirmed: n('confirmed'), updated: n('updated'), withdrawn: n('withdrawn'), open: n(null) };
96
+ }
97
+
98
+ const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
99
+ const one = (s: string): string => s.replace(/\s+/g, ' ').trim();
100
+
101
+ /** What a standing note is mounted on, in a few words. */
102
+ function mountOf(store: ProjectStore, note: Note): string {
103
+ if (note.mount.kind === 'project' || !note.mount.ids.length) return 'the project';
104
+ const name = (id: string): string => {
105
+ const t = store.threads.get(id);
106
+ if (t) return `${t.ids[0] ? `${t.ids[0]} ` : ''}${t.title} (${id})`;
107
+ const r = store.reference.get(id);
108
+ return r ? `${r.category} ${r.name} (${id})` : id;
109
+ };
110
+ return note.mount.ids.slice(0, 3).map(name).join(', ') + (note.mount.ids.length > 3 ? `, and ${note.mount.ids.length - 3} more` : '');
111
+ }
112
+
113
+ /** A standing note as the open list and the synthesis' block give it: its id, title, ask, what it claims and its sources. */
114
+ export function standingItem(store: ProjectStore, s: StandingNote): { readonly id: string; readonly name: string; readonly ask: string; readonly since: string; readonly on: string; readonly claims: string; readonly sources: readonly string[] } {
115
+ const v = lastOf(s.note);
116
+ const sources = [...new Set((v?.body.facts ?? []).flatMap((f) => f.sourceIds))];
117
+ return { id: s.note.id, name: v?.title ?? s.note.id, ask: v?.ask ?? '', since: firstAt(s.note).slice(0, 10), on: mountOf(store, s.note), claims: clip(one(v?.preview ?? ''), 320), sources };
118
+ }
119
+
120
+ /** How many source ids of a note the synthesis' block names; the note itself holds them all. */
121
+ const SOURCES_SHOWN = 6;
122
+
123
+ /**
124
+ * The synthesis' block of the notes still current from earlier rounds: each with its id, title, ask, what it claims and
125
+ * its sources, and the one result it is to get. Empty for a round with none.
126
+ */
127
+ export function standingNotesBlock(store: ProjectStore, round: RoundRef): string | null {
128
+ const all = standingNotes(store, round);
129
+ if (!all.length) return null;
130
+ const lines = [
131
+ `=== Notes still current from earlier rounds (${all.length}): give each one result`,
132
+ 'The owner still reads each of these as the Keeper\'s view. Check each against what this round found, then give it one result: it still holds — pk_confirm_note({ id, read }) with what you read that shows it; it holds in part or needs more — pk_write_note({ id, … }), which writes a new version; it no longer holds — pk_close_note({ id, status, reason }) (Resolved when the situation is gone, Withdrawn when the judgement was wrong). One with no result stays listed as open (pk_round_state open standingNotes). Read a note in full with pk_read_assets kind note.',
133
+ ];
134
+ for (const s of all) {
135
+ const i = standingItem(store, s);
136
+ lines.push(`- ${i.id} · “${clip(i.name, 120)}” · ${i.ask} · since ${i.since} · on ${i.on}${s.result ? ` · ${s.result} this round` : ''}`);
137
+ if (i.claims) lines.push(` claims: ${i.claims}`);
138
+ if (i.sources.length) lines.push(` sources: ${i.sources.slice(0, SOURCES_SHOWN).join(', ')}${i.sources.length > SOURCES_SHOWN ? `, and ${i.sources.length - SOURCES_SHOWN} more` : ''}`);
139
+ }
140
+ return lines.join('\n');
141
+ }