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,718 @@
1
+ /**
2
+ * The workbench as a fill-in-the-blank (D99; Spec §3.3 "工作台是一道填空题"; CKC-23 AC-21): what a document writes
3
+ * outright becomes a slot, named as the document names it and cited to its line. The model says which table or which
4
+ * headings, and what each column is; the program copies — one call per table instead of one writer call per row (on the
5
+ * resident run, 100 of the skeleton's 126 nodes were rows and headings the model wrote out one by one).
6
+ *
7
+ * - `pk_fill_from_table`: each row of one table becomes a work item and/or a reference item; the columns the model names
8
+ * give its title, its project id, what it hangs under (`parent`), its plan, what it depends on, and its written status.
9
+ * - `pk_fill_from_headings`: each heading of one level (under one heading, when named) becomes a reference item — an ADR's
10
+ * decisions, a PRD's goals, a design's modules.
11
+ *
12
+ * Both write through the writers the model uses (`pk_write_reference`, `pk_write_thread`, `pk_relate` in tools.ts): the
13
+ * same validation, the same ids, the same one-item-per-project-id matching. Names are verbatim, basis `Explicit`, and each
14
+ * item cites the file's section source that holds its line (the source `pk_list_sources` lists by path). A written status
15
+ * stays a written status (`writtenStatus`); progress is set only through the model's `statusMap` — "ready" is not a
16
+ * progress (§2.2). Running the same table again updates what it wrote. What the tools cannot judge — which document is
17
+ * which layer, what a column means — is the model's.
18
+ *
19
+ * DB: whether a status word is progress or readiness is no longer left to the caller for the words the program knows
20
+ * (readiness.ts): a `statusMap` entry from a word for the readiness or approval of the document itself — ready, draft,
21
+ * approved, accepted, final and their Chinese counterparts — is refused, naming the rule, while the rest of the call goes
22
+ * through; the rows that write such a word come back counted (`progressToJudge`), their progress left for the lane to
23
+ * judge from the execution records. On the DeepSeek run a brief's 「statusMap:ready→Planned」 was taken as given.
24
+ */
25
+ import { areaNames } from './placing.ts';
26
+ import { readFileSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import { Type } from 'typebox';
29
+ import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
30
+ import { markdownTables, type MarkdownTable } from '../../ledger/arrangements.ts';
31
+ import type { ClerkStage, SlotKind } from '../../model/k-types.ts';
32
+ import type { KeeperJob, ReferenceItem, Source, WorkThread, WrittenStatus } from '../../model/types.ts';
33
+ import { stableId } from '../../model/ids.ts';
34
+ import { OWNER_WORDS, PROGRESS, REFERENCE_CATEGORY, isOneOf } from '../../model/vocab.ts';
35
+ import { redactCredentials } from '../../sources/anchor.ts';
36
+ import { pathKey } from '../../util/paths.ts';
37
+ import { SLOT_WRITERS, STAGE_WRITERS } from '../clerk-steps.ts';
38
+ import type { ClerkToolContext } from '../clerk-tools.ts';
39
+ import { resolvePath } from '../evidence.ts';
40
+ import { resolveMergedId } from '../merge.ts';
41
+ import { keeperTools, primaryIdentifier } from '../tools.ts';
42
+ import { otherObjectOf, withNumbering } from '../numbers-check.ts';
43
+ import { entriesOf } from './entries.ts';
44
+ import { definitionsInText, isSeriesOnlyFamily } from '../../ledger/numbering.ts';
45
+ import { READINESS_EXAMPLES, READINESS_RULE, READINESS_SENTENCE, readinessWord } from './readiness.ts';
46
+
47
+ type FillTool = 'pk_fill_from_table' | 'pk_fill_from_headings' | 'pk_fill_from_bold';
48
+
49
+ /** What one call returns (W0 contract §6), plus what could not be tied (`unlinked`) and why nothing was (`note`). */
50
+ interface FillResult {
51
+ written: number;
52
+ updated: number;
53
+ items: { id: string; title: string; line: number; referenceId?: string }[];
54
+ skipped: { line: number; why: string }[];
55
+ unlinked?: { line: number; column: string; value: string; why: string }[];
56
+ /** CZ: dependencies this tool wrote from the same column before and took back, because the cell does not state them outright. */
57
+ removed?: { line: number; column: string; value: string; why: string }[];
58
+ warnings?: string[];
59
+ /** DB: the statusMap entries not taken — a readiness word mapped to a progress — each with the rule. */
60
+ refused?: { statusMap: string; why: string }[];
61
+ /** DB: the work-item rows whose status writes readiness, not progress: how many, which words, their lines, and what to do. */
62
+ progressToJudge?: { rows: number; statuses: Record<string, number>; lines: number[]; note: string };
63
+ note?: string;
64
+ tables?: { line: number; under: string | null; columns: readonly string[]; rows: number }[];
65
+ }
66
+
67
+ // ───────────────────────── small helpers ─────────────────────────
68
+
69
+ const text = (v: unknown): string => (typeof v === 'string' ? v : '');
70
+ const redact = (s: string): string => redactCredentials(s).text;
71
+ const squash = (s: string): string => s.replace(/\s+/g, ' ').trim().toLowerCase();
72
+ const either = (list: readonly string[]): string => (list.length <= 1 ? list.join('') : `${list.slice(0, -1).join(', ')} or ${list[list.length - 1]}`);
73
+
74
+ /** A cell or heading as the reader sees it: link text for links, no bold or code markers; the words themselves untouched. */
75
+ export function plainCell(cell: string): string {
76
+ return cell
77
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
78
+ .replace(/<br\s*\/?>/gi, ' ')
79
+ .replace(/\*\*|__|`/g, '')
80
+ .replace(/\\\|/g, '|')
81
+ .replace(/\s+/g, ' ')
82
+ .trim();
83
+ }
84
+
85
+ /** An empty cell: nothing, or only dashes and punctuation ("—", "-", "/"). */
86
+ const blank = (s: string): boolean => !/[\p{L}\p{N}]/u.test(s);
87
+ /** Whether a title already opens with the number (`CKC-22 账本…`, `[AP] …`), so the number is not written before it again. */
88
+ const opensWith = (title: string, number: string): boolean => {
89
+ const n = number.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
90
+ return new RegExp(`^[\\s[((]*${n}(?![\\p{L}\\p{N}_])`, 'iu').test(title);
91
+ };
92
+
93
+ function ok(value: FillResult) {
94
+ return { content: [{ type: 'text' as const, text: JSON.stringify(value, null, 1) }], details: {} };
95
+ }
96
+ function fail(message: string) {
97
+ return { content: [{ type: 'text' as const, text: `ERROR: ${message}` }], details: {}, isError: true };
98
+ }
99
+
100
+ interface Heading { readonly level: number; readonly text: string; readonly line: number }
101
+
102
+ /** The Markdown headings of a text, outside code fences, with their 1-based lines. */
103
+ function headingsOf(lines: readonly string[]): Heading[] {
104
+ const out: Heading[] = [];
105
+ let fence = false;
106
+ lines.forEach((l, i) => {
107
+ if (/^\s*(```|~~~)/.test(l)) { fence = !fence; return; }
108
+ if (fence) return;
109
+ const m = /^(#{1,6})\s+(.*)$/.exec(l);
110
+ if (m) out.push({ level: m[1]!.length, text: plainCell(m[2]!.replace(/\s+#+\s*$/, '')), line: i + 1 });
111
+ });
112
+ return out;
113
+ }
114
+ /** The last line of a heading's scope: before the next heading of its level or higher, or the end of the text. */
115
+ function scopeEnd(headings: readonly Heading[], h: Heading, lineCount: number): number {
116
+ const next = headings.find((x) => x.line > h.line && x.level <= h.level);
117
+ return next ? next.line - 1 : lineCount;
118
+ }
119
+ /** The heading a line stands under, or null. */
120
+ function headingAbove(headings: readonly Heading[], line: number): Heading | null {
121
+ let found: Heading | null = null;
122
+ for (const h of headings) { if (h.line >= line) break; found = h; }
123
+ return found;
124
+ }
125
+
126
+ /** Identifier ranges a cell writes ("CKC-02~CKC-05"), expanded; the other identifiers as written. */
127
+ function identifiersIn(s: string): string[] {
128
+ const out: string[] = [];
129
+ const range = /([A-Za-z]{1,6}-?)(\d{1,4})\s*[~~–—]\s*(?:\1)?(\d{1,4})/g;
130
+ let rest = s;
131
+ for (const m of s.matchAll(range)) {
132
+ const from = Number(m[2]), to = Number(m[3]);
133
+ if (to >= from && to - from <= 200) for (let n = from; n <= to; n++) out.push(`${m[1]}${String(n).padStart(m[2]!.length, '0')}`);
134
+ rest = rest.replace(m[0], ' ');
135
+ }
136
+ for (const m of rest.matchAll(/\b([A-Za-z]{1,6}(?:-[A-Za-z]{1,4})?-?\d{1,4}(?:\.\d{1,3})*)\b/g)) out.push(m[1]!);
137
+ return out;
138
+ }
139
+
140
+ const IDENTIFIER = String.raw`[A-Za-z]{1,6}(?:-[A-Za-z]{1,4})?-?\d{1,4}(?:\.\d{1,3})*`;
141
+ /** A part of a clause that is an identifier and nothing else: one (`CKC-02`, `AP`), or a range (`CKC-07~CKC-09`). */
142
+ const ONLY_IDENTIFIERS = new RegExp(`^(?:(?:${IDENTIFIER})(?:\\s*[~~–—]\\s*(?:${IDENTIFIER}|\\d{1,4}))?|[A-Z]{2})$`);
143
+ /** A clause that says there is no dependency. */
144
+ const NO_DEPENDENCY = /^(?:无|没有|none|n\/a|na|nil)$/i;
145
+
146
+ // ───────────────────────── the tools ─────────────────────────
147
+
148
+ export function fillTools(ctx: ClerkToolContext): ToolDefinition[] {
149
+ const { store, project } = ctx;
150
+ const trace = (summary: string, basisSourceIds: readonly string[] = []) => ({ jobId: ctx.jobId, basisSourceIds, summary });
151
+
152
+ // The model's own writers, called as the model calls them: one way to write an item.
153
+ let writers: ToolDefinition[] | null = null;
154
+ const write = async (name: 'pk_write_reference' | 'pk_write_thread' | 'pk_relate', params: Record<string, unknown>): Promise<{ id: string; warning?: string } | { error: string }> => {
155
+ writers ??= keeperTools(ctx);
156
+ const tool = writers.find((t) => t.name === name)!;
157
+ const run = tool.execute as unknown as (id: string, p: unknown) => Promise<{ content: { text: string }[]; isError?: boolean }>;
158
+ try {
159
+ const result = await run('fill', params);
160
+ const body = result.content.map((c) => c.text).join('\n');
161
+ if (result.isError) return { error: body.replace(/^ERROR:\s*/, '') };
162
+ const json = JSON.parse(body) as { id: string; warning?: string };
163
+ return json;
164
+ } catch (e) {
165
+ return { error: (e as Error).message };
166
+ }
167
+ };
168
+
169
+ /** Why this job may not fill these slots, or null (D99: the main agent in skeleton and reconcile; a lane in its slots). */
170
+ const gate = (tool: FillTool, writes: { threads: boolean; category: string | null }): string | null => {
171
+ const step = ctx.step;
172
+ if (!step) return `${tool} fills the workbench’s slots in a round of the clerk method, and this job is no step of a round, so nothing was written.`;
173
+ if (step.kind === 'main') {
174
+ const stage: ClerkStage | null = store.clerkRounds.get(step.roundId)?.stage ?? null;
175
+ const stages = (Object.keys(STAGE_WRITERS) as ClerkStage[]).filter((s) => STAGE_WRITERS[s].includes(tool));
176
+ if (!stage) return `${tool} is the main agent’s in the ${either(stages)} stage, and this round has entered no stage yet (pk_stage), so nothing was written.`;
177
+ if (!STAGE_WRITERS[stage].includes(tool)) return `${tool} is the main agent’s in the ${either(stages)} stage; this round is in ${stage}, so nothing was written.`;
178
+ return null;
179
+ }
180
+ if (step.kind === 'lane') {
181
+ // The job's step as the runtime gives it (KeeperJob['step']): a lane carries its slots (W0).
182
+ const slots: readonly SlotKind[] = (step as NonNullable<KeeperJob['step']>).lane?.slots ?? [];
183
+ const writersOf = (slot: string): readonly string[] => (SLOT_WRITERS as Readonly<Record<string, readonly string[]>>)[slot.startsWith('reference:') ? 'reference' : slot] ?? [];
184
+ if (!slots.some((s) => writersOf(s).includes(tool))) return `${tool} fills ${tool === 'pk_fill_from_headings' ? 'reference items' : 'work items or reference items'}, and this lane writes ${slots.length ? slots.join(', ') : 'no slot'}, so nothing was written.`;
185
+ if (writes.threads && !slots.includes('threads')) return `Work items are the threads slot, and this lane writes ${slots.join(', ')}, so nothing was written. Fill reference items only (into: "reference"), or leave the work items to the lane that has them.`;
186
+ if (writes.category && !slots.some((s) => s.toLowerCase() === `reference:${writes.category}`.toLowerCase())) {
187
+ return `${writes.category} items are the reference:${writes.category} slot, and this lane writes ${slots.join(', ')}, so nothing was written.${writes.threads ? ' Leave category out to fill only the work items.' : ''}`;
188
+ }
189
+ return null;
190
+ }
191
+ return `${tool} belongs to a round run by one main agent with its lanes — the main agent in skeleton and reconcile, a lane in its reference and threads slots — and this job is the ${step.kind} step of the earlier method, where each step was a job of its own, so nothing was written.`;
192
+ };
193
+
194
+ /** The document, read now from the project, with its path relative to its repository. */
195
+ const readDocument = (path: string): { rel: string; abs: string; rootId: string; lines: string[] } | string => {
196
+ const place = resolvePath(project, { path });
197
+ if (typeof place === 'string') return place;
198
+ if (!place.rel) return `give the path of a file inside ${place.root.path}.`;
199
+ const abs = join(place.root.path, place.rel);
200
+ let body: string;
201
+ try { body = readFileSync(abs, 'utf8'); } catch { return `${place.rel} is not a file in ${place.root.path} now: the fill-in tools copy what a current document writes. An earlier version is read with pk_history_read and written with the writers.`; }
202
+ return { rel: place.rel, abs, rootId: place.root.id, lines: body.split(/\r?\n/) };
203
+ };
204
+
205
+ /** The file's section source that holds this line as it is written now, or why none does. */
206
+ const sourcesOf = (doc: { abs: string; rootId: string }) => {
207
+ const key = pathKey(doc.abs);
208
+ const all = store.sources.filter((s) => s.anchor.kind === 'file' && pathKey(s.anchor.path) === key && s.availability !== 'No longer available');
209
+ return (line: number, written: string): Source | string => {
210
+ if (all.length === 0) return 'no source of this project is this file yet (it is read into the sources when the project is scanned), so the row has nothing to cite';
211
+ const holding = all.filter((s) => s.anchor.kind === 'file' && s.anchor.lineStart <= line && line <= s.anchor.lineEnd)
212
+ .sort((a, b) => (a.scopeItemId === doc.rootId ? 0 : 1) - (b.scopeItemId === doc.rootId ? 0 : 1)
213
+ || (a.anchor.kind === 'file' && b.anchor.kind === 'file' ? (a.anchor.lineEnd - a.anchor.lineStart) - (b.anchor.lineEnd - b.anchor.lineStart) : 0));
214
+ const words = redact(written).trim();
215
+ const hit = holding.find((s) => s.excerpt.includes(words));
216
+ if (hit) return hit;
217
+ return holding.length
218
+ ? `the file changed since its sources were read: the source that holds line ${line} does not have it as written now, so the row is not cited to a line it does not hold (the next scan reads the file again)`
219
+ : `no source of this file holds line ${line} (the file changed since its sources were read; the next scan reads it again)`;
220
+ };
221
+ };
222
+
223
+ /** A reference item this call would update: the one of the category carrying the identifier, or its stable id. */
224
+ const existingReference = (category: string, identifier: string | null, fallbackId: string): ReferenceItem | undefined =>
225
+ identifier
226
+ ? store.reference.find((r) => r.category === category && (r.ids.some((x) => x.toUpperCase() === identifier) || primaryIdentifier(r.ids, r.name) === identifier))
227
+ : store.reference.get(fallbackId);
228
+ /** A work item this call would update: the one carrying the identifier, or its stable id. */
229
+ const existingThread = (identifier: string | null, fallbackId: string): WorkThread | undefined =>
230
+ identifier
231
+ ? store.threads.find((t) => t.ids.some((x) => x.toUpperCase() === identifier) || primaryIdentifier(t.ids, t.title) === identifier)
232
+ : store.threads.get(resolveMergedId(store, fallbackId));
233
+
234
+ /** Keep the written status on the item: the document's words, not its progress (AC-21). */
235
+ const keepStatus = (kind: 'threads' | 'reference', id: string, status: WrittenStatus | null) => {
236
+ const item = kind === 'threads' ? store.threads.get(id) : store.reference.get(id);
237
+ if (!item) return;
238
+ const before = item.writtenStatus ?? null;
239
+ if (JSON.stringify(before) === JSON.stringify(status)) return;
240
+ const summary = status ? `Written status “${status.text}” (line ${status.line})` : 'No written status any more';
241
+ if (kind === 'threads') store.threads.put({ ...(item as WorkThread), writtenStatus: status }, trace(summary, status ? [status.sourceId] : []));
242
+ else store.reference.put({ ...(item as ReferenceItem), writtenStatus: status }, trace(summary, status ? [status.sourceId] : []));
243
+ };
244
+
245
+ /** The one item a cell names among the candidates: by id, project id, name, or as a whole word of one name. */
246
+ const named = <T extends { id: string }>(candidates: readonly T[], value: string, ids: (c: T) => readonly string[], name: (c: T) => string): T | string => {
247
+ const v = value.trim();
248
+ const upper = v.toUpperCase();
249
+ const byId = candidates.find((c) => c.id === v);
250
+ if (byId) return byId;
251
+ const byNumber = candidates.filter((c) => ids(c).some((x) => x.toUpperCase() === upper) || primaryIdentifier(ids(c), name(c)) === upper);
252
+ if (byNumber.length === 1) return byNumber[0]!;
253
+ const byName = candidates.filter((c) => squash(plainCell(name(c))) === squash(v));
254
+ if (byName.length === 1) return byName[0]!;
255
+ if (byNumber.length > 1 || byName.length > 1) return `several items carry “${v}”: ${[...byNumber, ...byName].slice(0, 5).map((c) => `${c.id} (${name(c)})`).join(', ')}`;
256
+ // D101 (CM): an Area's short form names it — a Module column's 「贯穿」 is the Area 「贯穿 · Keeper runtime 与项目资产」.
257
+ const byShort = candidates.filter((c) => (c as { category?: string }).category === 'Area' && areaNames({ name: name(c), ids: ids(c) }).some((n) => n.toLowerCase() === v.toLowerCase()));
258
+ if (byShort.length === 1) return byShort[0]!;
259
+ const escaped = v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
260
+ // Case as written: a one-letter increment ("A") is not the article of another name.
261
+ const word = new RegExp(`(?<![\\p{L}\\p{N}_-])${escaped}(?![\\p{L}\\p{N}_])`, 'u');
262
+ const byWord = candidates.filter((c) => word.test(plainCell(name(c))));
263
+ if (byWord.length === 1) return byWord[0]!;
264
+ return byWord.length > 1 ? `“${v}” is a word of several names: ${byWord.slice(0, 5).map((c) => `${c.id} (${name(c)})`).join(', ')}` : `nothing in the assets is named “${v}”`;
265
+ };
266
+ /** Every item a cell names: the whole cell, or else each part of a list ("CK-M2、CK-M3"), or else each identifier in it. */
267
+ const namedAll = <T extends { id: string }>(candidates: readonly T[], cell: string, ids: (c: T) => readonly string[], name: (c: T) => string): { found: T[]; missing: { value: string; why: string }[] } => {
268
+ const whole = named(candidates, cell, ids, name);
269
+ if (typeof whole !== 'string') return { found: [whole], missing: [] };
270
+ const found: T[] = [];
271
+ const missing: { value: string; why: string }[] = [];
272
+ for (const part of cell.split(/[,,、;;/]+/).map((p) => p.trim()).filter((p) => !blank(p))) {
273
+ const hit = named(candidates, part, ids, name);
274
+ if (typeof hit !== 'string') { found.push(hit); continue; }
275
+ const numbers = identifiersIn(part);
276
+ const hits = numbers.map((n) => named(candidates, n, ids, name));
277
+ const good = hits.filter((h): h is T => typeof h !== 'string');
278
+ if (good.length) found.push(...good);
279
+ const bad = numbers.filter((_, i) => typeof hits[i] === 'string');
280
+ if (!numbers.length) missing.push({ value: part, why: hit });
281
+ else for (const n of bad) missing.push({ value: n, why: hits[numbers.indexOf(n)] as string });
282
+ }
283
+ return { found: [...new Map(found.map((f) => [f.id, f])).values()], missing };
284
+ };
285
+
286
+ /**
287
+ * CZ: what a "depends on" cell states outright. A cell is read clause by clause (`;`, `;`, `。`); a clause that holds
288
+ * nothing but identifiers — a list, a range, the names of items — gives its dependencies; a clause that says more
289
+ * (`AC 级:CKC-08、CKC-09`, `经 CKC-04 验收`, `P1 评审`) is not linked at all and comes back with the cell's text, for the
290
+ * lane to judge what it states. (On the flash run every identifier of such a clause became a dependency.)
291
+ */
292
+ const dependenciesIn = <T extends { id: string }>(candidates: readonly T[], cell: string, ids: (c: T) => readonly string[], name: (c: T) => string): { found: T[]; missing: { value: string; why: string }[] } => {
293
+ const whole = named(candidates, cell, ids, name);
294
+ if (typeof whole !== 'string') return { found: [whole], missing: [] };
295
+ const found: T[] = [];
296
+ const missing: { value: string; why: string }[] = [];
297
+ for (const clause of cell.split(/[;;。]+/).map((c) => c.trim()).filter((c) => !blank(c))) {
298
+ if (NO_DEPENDENCY.test(clause)) continue;
299
+ const parts = clause.split(/[,,、/]+|\s+(?:and|和|及|与)\s+|\s*[&+]\s*/).map((x) => x.trim()).filter((x) => !blank(x));
300
+ const read = parts.map((part) => {
301
+ const hit = named(candidates, part, ids, name);
302
+ if (typeof hit !== 'string') return { items: [hit], unknown: [] as { value: string; why: string }[] };
303
+ if (!ONLY_IDENTIFIERS.test(part)) return null;
304
+ const numbers = identifiersIn(part).length ? identifiersIn(part) : [part];
305
+ const hits = numbers.map((n) => named(candidates, n, ids, name));
306
+ return { items: hits.filter((h): h is T => typeof h !== 'string'), unknown: numbers.flatMap((n, i) => (typeof hits[i] === 'string' ? [{ value: n, why: hits[i] as string }] : [])) };
307
+ });
308
+ if (read.some((r) => r === null)) {
309
+ missing.push({ value: clause, why: `this clause says more than identifiers, so nothing in it was linked: read what it states and write the dependencies that follow yourself (pk_write_thread dependsOn; pk_relate for reference items). The cell: “${cell}”` });
310
+ continue;
311
+ }
312
+ for (const r of read) { found.push(...r!.items); missing.push(...r!.unknown); }
313
+ }
314
+ return { found: [...new Map(found.map((f) => [f.id, f])).values()], missing };
315
+ };
316
+
317
+ /** Whose number this is when it is another object's (a decision's, a module's), or null (numbers-check.ts). */
318
+ const otherNumber = (n: string): string | null => withNumbering(store, (ledger) => otherObjectOf(store, ledger, n), null);
319
+
320
+ const referenceIds = (r: ReferenceItem) => r.ids;
321
+ const referenceName = (r: ReferenceItem) => r.name;
322
+
323
+ const tools: ToolDefinition[] = [];
324
+
325
+ // ───────────────────────── a table: one item per row ─────────────────────────
326
+
327
+ tools.push(defineTool({
328
+ name: 'pk_fill_from_table', label: 'Fill slots from a table',
329
+ description: 'Fill the workbench from one Markdown table: each row becomes a work item (into "threads"), a reference item of category (into "reference"), or both (into "threads" with a category — a plan’s contract table gives each contract as a Requirement and a Work item). You say which table — the line of any of its rows, or a heading it stands under — and which column is what; the program copies: the title verbatim (a reference item is named “number · title” when the id column gives its number, as a heading is; a work item keeps the title and carries the number in its ids), the project id, basis Explicit, the file’s section source of the row, and the row’s line. columns name the table’s own headers: title (required), id, parent (the Area, Goal or other reference item it hangs under — a name or a project id, several separated by commas or 、), plan (the Plan item a work item belongs to), dependsOn (the work items it depends on; ranges like T-02~T-05 are read; only a clause that holds nothing but identifiers is linked — a clause that says more, like “T-08, its interface only” or “once T-04 is accepted”, comes back in unlinked with the cell’s text for you to judge, and a dependency this tool wrote from such a clause before is taken back and listed in removed), status (kept as the written status). Progress is yours to judge: a status becomes progress only through statusMap, and only where the word states progress, e.g. { "done": "Done" }. ' + READINESS_SENTENCE + ': a statusMap entry from such a word is refused (it comes back in refused; the rest of the call goes through), whoever asked for it, and the rows that write one come back counted in progressToJudge — judge each one’s progress from the execution records (the tickets that implement it, their deliveries and checks) and write it with pk_write_thread. A new work item starts Planned until you judge it. Running the same table again updates the items it wrote. Rows that cannot be written come back in skipped, with why; a parent, plan or dependency no item answers to comes back in unlinked. A file with no such table writes nothing and lists its tables.',
330
+ parameters: Type.Object({
331
+ path: Type.String({ description: 'the document, relative to the repository root' }),
332
+ table: Type.Object({
333
+ line: Type.Optional(Type.Number({ description: 'a line of the table (its header or any row), or the heading line it stands under' })),
334
+ heading: Type.Optional(Type.String({ description: 'words of the heading the table stands under, e.g. "Task index"' })),
335
+ }, { description: 'which table; leave both out when the file has one table' }),
336
+ into: Type.String({ description: 'threads | reference' }),
337
+ category: Type.Optional(Type.String({ description: `the reference category of each row: ${REFERENCE_CATEGORY.filter((c) => c !== OWNER_WORDS).join(', ')} — needed with into "reference"; with into "threads", each row is also this reference item and its work item serves it` })),
338
+ columns: Type.Object({
339
+ title: Type.String({ description: 'the column that names each row' }),
340
+ id: Type.Optional(Type.String({ description: 'the column with the project’s own id (T-22, #18 …)' })),
341
+ parent: Type.Optional(Type.String({ description: 'the column naming what it hangs under (an Area, a Goal …)' })),
342
+ plan: Type.Optional(Type.String({ description: 'the column naming the Plan item (an increment, a phase) a work item belongs to' })),
343
+ dependsOn: Type.Optional(Type.String({ description: 'the column listing what it depends on' })),
344
+ status: Type.Optional(Type.String({ description: 'the column with the status the document writes' })),
345
+ }, { description: 'each field: a header of the table, as written' }),
346
+ statusMap: Type.Optional(Type.Record(Type.String(), Type.String(), { description: `written status → progress (${PROGRESS.join(', ')}), for the statuses that state progress; never from a readiness word (${READINESS_EXAMPLES})` })),
347
+ }),
348
+ execute: async (_id, raw) => {
349
+ const p = raw as Record<string, unknown>;
350
+ const into = text(p.into);
351
+ if (into !== 'threads' && into !== 'reference') return fail('into is "threads" (work items) or "reference" (reference items of category).');
352
+ const category = text(p.category).trim() || null;
353
+ if (category !== null && !isOneOf(REFERENCE_CATEGORY, category)) return fail(`category must be one of ${REFERENCE_CATEGORY.join(', ')}.`);
354
+ if (category === OWNER_WORDS) return fail('Owner’s words carry the owner’s own quote from where they were said: write them with pk_write_reference, one by one.');
355
+ if (into === 'reference' && !category) return fail('into "reference" needs category: which reference item each row is.');
356
+ const refusal = gate('pk_fill_from_table', { threads: into === 'threads', category });
357
+ if (refusal) return fail(refusal);
358
+ const columns = (p.columns ?? {}) as Record<string, unknown>;
359
+ if (into === 'reference' && text(columns.plan)) return fail('plan places a work item under its Plan item; a reference item hangs under what parent names. Leave plan out, or fill into "threads".');
360
+ const statusMap = new Map<string, WorkThread['progress']>();
361
+ // DB: a readiness word maps to no progress, whoever asked for the mapping (readiness.ts). The entry is not taken; the
362
+ // call goes on, and the rows that write the word keep it as their written status.
363
+ const refused: NonNullable<FillResult['refused']> = [];
364
+ for (const [k, v] of Object.entries((p.statusMap ?? {}) as Record<string, unknown>)) {
365
+ if (!isOneOf(PROGRESS, v)) return fail(`statusMap: “${k}” maps to “${text(v)}”, and progress is one of ${PROGRESS.join(', ')}.`);
366
+ if (readinessWord(plainCell(k))) { refused.push({ statusMap: `“${k}” → ${v}`, why: `Not taken: ${READINESS_RULE}. The rows that write it are listed in progressToJudge.` }); continue; }
367
+ statusMap.set(squash(plainCell(k)), v);
368
+ }
369
+ const doc = readDocument(text(p.path));
370
+ if (typeof doc === 'string') return fail(doc);
371
+ const headings = headingsOf(doc.lines);
372
+ const all = markdownTables(doc.lines.join('\n'));
373
+ const listing = () => all.map((t) => ({ line: t.line, under: headingAbove(headings, t.line)?.text ?? null, columns: t.header, rows: t.rows.length }));
374
+ const nothing = (note: string): FillResult => ({ written: 0, updated: 0, items: [], skipped: [], note, ...(all.length ? { tables: listing() } : {}) });
375
+ if (all.length === 0) return ok(nothing(`${doc.rel} has no table.`));
376
+
377
+ // Which table: the one holding the line, or the first under it; the first under the heading; the only one.
378
+ const where = (p.table ?? {}) as Record<string, unknown>;
379
+ const line = typeof where.line === 'number' ? Math.trunc(where.line) : null;
380
+ const heading = text(where.heading).trim();
381
+ const last = (t: MarkdownTable) => t.rows[t.rows.length - 1]?.line ?? t.line + 1;
382
+ let table: MarkdownTable | undefined;
383
+ if (line !== null) {
384
+ table = all.find((t) => t.line <= line && line <= last(t));
385
+ if (!table) {
386
+ const next = headings.find((h) => h.line > line);
387
+ table = all.find((t) => t.line >= line && (!next || t.line < next.line));
388
+ }
389
+ if (!table) return ok(nothing(`No table of ${doc.rel} is at line ${line} or right under it.`));
390
+ } else if (heading) {
391
+ const wanted = squash(plainCell(heading));
392
+ for (const h of headings.filter((x) => squash(x.text).includes(wanted))) {
393
+ const end = scopeEnd(headings, h, doc.lines.length);
394
+ table = all.find((t) => t.line > h.line && t.line <= end);
395
+ if (table) break;
396
+ }
397
+ if (!table) return ok(nothing(`No table of ${doc.rel} stands under a heading with “${heading}”.`));
398
+ } else if (all.length === 1) {
399
+ table = all[0];
400
+ } else {
401
+ return ok(nothing(`${doc.rel} has ${all.length} tables: say which with table.line or table.heading.`));
402
+ }
403
+ const t = table!;
404
+
405
+ // Which column is what: the model's naming of the table's own headers.
406
+ const header = t.header.map((h) => squash(plainCell(h)));
407
+ const col: Partial<Record<'title' | 'id' | 'parent' | 'plan' | 'dependsOn' | 'status', number>> = {};
408
+ for (const field of ['title', 'id', 'parent', 'plan', 'dependsOn', 'status'] as const) {
409
+ const given = text(columns[field]).trim();
410
+ if (!given) continue;
411
+ const k = header.indexOf(squash(plainCell(given)));
412
+ if (k < 0) return fail(`columns.${field}: “${given}” is not a column of the table at line ${t.line} of ${doc.rel}; its columns are ${t.header.map((h) => `“${h}”`).join(', ')}.`);
413
+ col[field] = k;
414
+ }
415
+ if (col.title === undefined) return fail('columns.title: the column that names each row.');
416
+ const cell = (row: MarkdownTable['rows'][number], field: keyof typeof col): string => (col[field] === undefined ? '' : plainCell(row.cells[col[field]!] ?? ''));
417
+
418
+ const sourceAt = sourcesOf(doc);
419
+ const result: FillResult = { written: 0, updated: 0, items: [], skipped: [] };
420
+ const unlinked: NonNullable<FillResult['unlinked']> = [];
421
+ const removed: NonNullable<FillResult['removed']> = [];
422
+ const warnings = new Set<string>();
423
+ const seen = new Map<string, number>();
424
+ const readiness: { line: number; word: string }[] = [];
425
+ const done: { line: number; title: string; source: Source; threadId: string | null; referenceId: string | null; row: MarkdownTable['rows'][number]; warning: string | null }[] = [];
426
+
427
+ // Pass 1: every row's item(s), named and cited as the document writes them.
428
+ for (const row of t.rows) {
429
+ const title = redact(cell(row, 'title'));
430
+ if (blank(title)) { result.skipped.push({ line: row.line, why: `the ${t.header[col.title]} column is empty` }); continue; }
431
+ const source = sourceAt(row.line, doc.lines[row.line - 1] ?? '');
432
+ if (typeof source === 'string') { result.skipped.push({ line: row.line, why: source }); continue; }
433
+ const idCell = cell(row, 'id');
434
+ // The project's own id (CKC-22, #18, and two capitals like AS in an index — CJ); a bare row number (1, 2 …) numbers
435
+ // the row, not the work, and is not one.
436
+ // CQ (D104): a Plan's id is whatever its table's first column writes — a batch label (`批次 1`, `M1`, `Sprint 3`) as well
437
+ // as a number — so every writer that names a Plan by that id finds it. Other categories keep to the numbering shapes.
438
+ const planLabel = category === 'Plan' && idCell && /^[\p{L}\p{N}][\p{L}\p{N}.-]{0,11}(?:\s?\p{N}{1,3})?$/u.test(idCell.trim()) && !/^\d+$/.test(idCell.trim()) ? idCell.trim() : '';
439
+ const numbered = idCell && (primaryIdentifier([idCell], '') || /^#\d+$/.test(idCell)) ? idCell : planLabel;
440
+ const titled = numbered ? null : primaryIdentifier([], title);
441
+ // CJ: a title that opens with another object's number (D99 W1+W2 …) does not give the row that number.
442
+ const identifier = numbered ? primaryIdentifier([numbered], '') ?? numbered.toUpperCase() : titled && !otherNumber(titled) ? titled : null;
443
+ const key = identifier ?? `${pathKey(doc.rel)}\u0000${blank(idCell) ? squash(title) : squash(idCell)}`;
444
+ if (seen.has(key)) { result.skipped.push({ line: row.line, why: `row ${seen.get(key)} already wrote ${identifier ?? `“${title}”`}: one item per id` }); continue; }
445
+ seen.set(key, row.line);
446
+ const statusText = cell(row, 'status');
447
+ const progress = statusText ? statusMap.get(squash(statusText)) ?? null : null;
448
+ const status: WrittenStatus | null = col.status === undefined ? null : blank(statusText) ? null : { text: statusText, sourceId: source.id, line: row.line };
449
+ const rowText = t.header.map((h, k) => [h, plainCell(row.cells[k] ?? '')] as const).filter(([, v]) => !blank(v)).map(([h, v]) => `${h}: ${v}`).join(' · ');
450
+ const ids = identifier ? [numbered || identifier] : [];
451
+ // CZ: a row's reference item is named "number · title", as a heading's is — the number its id column writes, then
452
+ // the title. (Named by the title alone, a lane "fixed" a Decision's name by writing its number as an id, and the
453
+ // number stood twice.) A work item keeps the title as written: it carries the number in its ids, and every view
454
+ // and block writes that number before its title.
455
+ const name = numbered && !opensWith(title, numbered) ? `${numbered.trim()} · ${title}` : title;
456
+ let existed = false;
457
+ let warning: string | null = null;
458
+
459
+ let referenceId: string | null = null;
460
+ if (category) {
461
+ const fallback = stableId('ref', project.id, category, pathKey(doc.rel), key);
462
+ const previous = existingReference(category, identifier, fallback);
463
+ existed ||= Boolean(previous);
464
+ const params: Record<string, unknown> = previous
465
+ ? { id: previous.id, name, text: redact(rowText), sourceIds: [...new Set([...previous.sourceIds, source.id])], ...(into === 'reference' && progress ? { progress } : {}) }
466
+ : {
467
+ ...(identifier ? {} : { id: fallback }), category, name, ids, text: redact(rowText),
468
+ basis: 'Explicit', validity: 'Current', identity: 'Artifact', sourceIds: [source.id], ...(into === 'reference' && progress ? { progress } : {}),
469
+ };
470
+ const w = await write('pk_write_reference', params);
471
+ if ('error' in w) { result.skipped.push({ line: row.line, why: w.error }); continue; }
472
+ referenceId = w.id;
473
+ warning = w.warning ?? null;
474
+ keepStatus('reference', w.id, status);
475
+ }
476
+
477
+ let threadId: string | null = null;
478
+ if (into === 'threads') {
479
+ const fallback = stableId('thread', project.id, pathKey(doc.rel), key);
480
+ const previous = existingThread(identifier, fallback);
481
+ existed ||= Boolean(previous);
482
+ // CZ: the row's source is kept on the work item as what it was written from (inputs.sourceIds), so a generation
483
+ // can tell the items written from its own documents (generation-check.ts `originOf`).
484
+ const from = { ...(previous?.inputs ?? {}), sourceIds: [...new Set([...(previous?.inputs?.sourceIds ?? []), source.id])] };
485
+ const params: Record<string, unknown> = previous
486
+ ? { id: previous.id, title, inputs: from, ...(progress ? { progress } : {}) }
487
+ : { ...(identifier ? {} : { id: fallback }), title, ids, progress: progress ?? 'Planned', inputs: from };
488
+ const w = await write('pk_write_thread', params);
489
+ if ('error' in w) { result.skipped.push({ line: row.line, why: w.error }); continue; }
490
+ threadId = w.id;
491
+ warning = w.warning ?? null;
492
+ keepStatus('threads', w.id, status);
493
+ }
494
+ if (existed) result.updated += 1; else result.written += 1;
495
+ const word = threadId && !progress && statusText ? readinessWord(statusText) : null;
496
+ if (word) readiness.push({ line: row.line, word });
497
+ result.items.push({ id: (threadId ?? referenceId)!, title: threadId ? title : name, line: row.line, ...(threadId && referenceId ? { referenceId } : {}) });
498
+ done.push({ line: row.line, title: threadId ? title : name, source, threadId, referenceId, row, warning });
499
+ }
500
+
501
+ // Pass 2: what each row hangs under, belongs to and depends on — rows of this table included.
502
+ const claimAt = (column: 'parent' | 'plan' | 'dependsOn', value: string, line: number) => `${doc.rel}, line ${line}: ${t.header[col[column]!]} “${value}”`;
503
+ for (const r of done) {
504
+ let warning = r.warning;
505
+ const references = store.reference.all();
506
+ const parentCell = cell(r.row, 'parent');
507
+ const parents = blank(parentCell) ? { found: [], missing: [] } : namedAll(references.filter((x) => x.id !== r.referenceId), parentCell, referenceIds, referenceName);
508
+ for (const m of parents.missing) unlinked.push({ line: r.line, column: 'parent', value: m.value, why: m.why });
509
+ const planCell = cell(r.row, 'plan');
510
+ const plans = blank(planCell) ? { found: [], missing: [] } : namedAll(references.filter((x) => x.category === 'Plan'), planCell, referenceIds, referenceName);
511
+ for (const m of plans.missing) unlinked.push({ line: r.line, column: 'plan', value: m.value, why: `${m.why} among the Plan items` });
512
+ const depCell = cell(r.row, 'dependsOn');
513
+
514
+ if (r.referenceId && parents.found.length) {
515
+ const item = store.reference.get(r.referenceId)!;
516
+ const refines = [...new Set([...item.refines, ...parents.found.map((x) => x.id)])];
517
+ if (refines.length !== item.refines.length) {
518
+ const w = await write('pk_write_reference', { id: r.referenceId, refines });
519
+ if ('error' in w) unlinked.push({ line: r.line, column: 'parent', value: parentCell, why: w.error });
520
+ else if (!r.threadId) warning = w.warning ?? null;
521
+ }
522
+ }
523
+ // CZ: a dependency this tool wrote from this column before (its claim says so) that the cell does not state
524
+ // outright is taken back; what a lane or the main agent wrote with a claim of its own stays.
525
+ const fromThisColumn = (claim: string): boolean => col.dependsOn !== undefined && claim.startsWith(`${doc.rel}, line `) && claim.includes(`: ${t.header[col.dependsOn]} “`);
526
+ const NOT_STATED = 'the fill wrote it from this cell before; the cell does not state it outright (it stands in a clause that says more than identifiers)';
527
+ if (r.referenceId && !r.threadId && col.dependsOn !== undefined) {
528
+ const deps = blank(depCell) ? { found: [], missing: [] } : dependenciesIn(store.reference.all().filter((x) => x.id !== r.referenceId), depCell, referenceIds, referenceName);
529
+ for (const m of deps.missing) unlinked.push({ line: r.line, column: 'dependsOn', value: m.value, why: m.why });
530
+ for (const d of deps.found) {
531
+ const w = await write('pk_relate', { type: 'depends on', fromId: r.referenceId, toId: d.id, claim: claimAt('dependsOn', depCell, r.line), basis: 'Explicit', evidenceSourceIds: [r.source.id] });
532
+ if ('error' in w) unlinked.push({ line: r.line, column: 'dependsOn', value: d.id, why: w.error });
533
+ }
534
+ for (const old of store.relations.filter((x) => x.type === 'depends on' && x.from === r.referenceId && fromThisColumn(x.claim) && !deps.found.some((d) => d.id === x.to))) {
535
+ const w = await write('pk_relate', { type: 'depends on', fromId: old.from, toId: old.to, withdraw: true, why: NOT_STATED });
536
+ if (!('error' in w)) removed.push({ line: r.line, column: 'dependsOn', value: store.reference.get(old.to)?.ids[0] ?? old.to, why: NOT_STATED });
537
+ }
538
+ }
539
+ if (r.threadId) {
540
+ const serves = [
541
+ ...parents.found.map((x) => ({ referenceId: x.id, claim: claimAt('parent', parentCell, r.line), basis: 'Explicit' })),
542
+ ...(r.referenceId ? [{ referenceId: r.referenceId, claim: `${doc.rel}: the same row (line ${r.line})`, basis: 'Explicit' }] : []),
543
+ ...plans.found.map((x) => ({ referenceId: x.id, claim: claimAt('plan', planCell, r.line), basis: 'Explicit' })),
544
+ ];
545
+ const threads = store.threads.all().filter((x) => x.id !== r.threadId);
546
+ const deps = blank(depCell) ? { found: [], missing: [] } : dependenciesIn(threads, depCell, (x) => x.ids, (x) => x.title);
547
+ for (const m of deps.missing) unlinked.push({ line: r.line, column: 'dependsOn', value: m.value, why: m.why });
548
+ const dependsOn = deps.found.map((x) => ({ threadId: x.id, claim: claimAt('dependsOn', depCell, r.line), basis: 'Explicit' }));
549
+ const standing = store.threads.get(r.threadId)?.dependsOn ?? [];
550
+ const stale = standing.filter((d) => fromThisColumn(d.claim) && !deps.found.some((x) => x.id === d.threadId));
551
+ if (serves.length || dependsOn.length || stale.length) {
552
+ const whole = stale.length ? [...standing.filter((d) => !stale.includes(d) && !deps.found.some((x) => x.id === d.threadId)), ...dependsOn] : dependsOn;
553
+ const w = await write('pk_write_thread', { id: r.threadId, ...(serves.length ? { serves } : {}), ...(stale.length ? { dependsOn: whole, replaceDependsOn: true } : dependsOn.length ? { dependsOn } : {}) });
554
+ if ('error' in w) unlinked.push({ line: r.line, column: 'serves', value: [parentCell, planCell, depCell].filter((v) => !blank(v)).join(' / '), why: w.error });
555
+ else {
556
+ warning = w.warning ?? null;
557
+ for (const d of stale) removed.push({ line: r.line, column: 'dependsOn', value: store.threads.get(d.threadId)?.ids[0] ?? d.threadId, why: NOT_STATED });
558
+ }
559
+ }
560
+ }
561
+ if (warning) warnings.add(warning);
562
+ }
563
+ if (unlinked.length) result.unlinked = unlinked;
564
+ if (removed.length) result.removed = removed;
565
+ if (warnings.size) result.warnings = [...warnings];
566
+ // DB: the rows whose status is readiness, counted — the program set no progress for them (a new one stands at the
567
+ // starting Planned, an existing one as it was), and says so where a mapping would have hidden it.
568
+ let progressToJudge: FillResult['progressToJudge'];
569
+ if (readiness.length) {
570
+ const statuses: Record<string, number> = {};
571
+ for (const r of readiness) statuses[r.word] = (statuses[r.word] ?? 0) + 1;
572
+ progressToJudge = {
573
+ rows: readiness.length, statuses, lines: readiness.map((r) => r.line),
574
+ note: `These rows write the readiness of their document, not how far the work is: the program set no progress for them (a new work item stands at the starting Planned, which is not a judgement). Judge each one’s progress from the execution records — the tickets or deliveries that implement it, their merges and checks — and write it with pk_write_thread (progress, with progressWhy where the records seem to say otherwise). Where the project has no execution records for a row, it stays Planned.`,
575
+ };
576
+ }
577
+ // What was refused comes first, so it is read before the counts.
578
+ return ok({ ...(refused.length ? { refused } : {}), ...result, ...(progressToJudge ? { progressToJudge } : {}) });
579
+ },
580
+ }));
581
+
582
+ // ───────────────────────── headings: one item per heading ─────────────────────────
583
+
584
+ tools.push(defineTool({
585
+ name: 'pk_fill_from_headings', label: 'Fill slots from headings',
586
+ description: 'Fill the workbench from the headings of one document: each heading of level (under the heading whose words are under, when given) becomes a reference item of category — each entry of a decision record a Decision, each goal of a PRD a Goal, each module an Area. The program copies the heading verbatim as its name (its project id, D12 or T-22, read from its start), its section as its text, basis Explicit, and the file’s section source of the heading with its line. Validity starts Current and progress stays unset: what replaced what, and what is done, are yours to judge. Running it again updates the items it wrote. Headings that cannot be written come back in skipped, with why; a file with no such heading writes nothing.',
587
+ parameters: Type.Object({
588
+ path: Type.String({ description: 'the document, relative to the repository root' }),
589
+ level: Type.Number({ description: 'the heading level, 1–6 (### is 3)' }),
590
+ category: Type.String({ description: REFERENCE_CATEGORY.filter((c) => c !== OWNER_WORDS).join(' | ') }),
591
+ under: Type.Optional(Type.String({ description: 'words of the heading they stand under, e.g. "Goals"' })),
592
+ }),
593
+ execute: async (_id, raw) => {
594
+ const p = raw as Record<string, unknown>;
595
+ const category = text(p.category).trim();
596
+ if (!isOneOf(REFERENCE_CATEGORY, category)) return fail(`category must be one of ${REFERENCE_CATEGORY.join(', ')}.`);
597
+ if (category === OWNER_WORDS) return fail('Owner’s words carry the owner’s own quote from where they were said: write them with pk_write_reference, one by one.');
598
+ const level = typeof p.level === 'number' ? Math.trunc(p.level) : NaN;
599
+ if (!(level >= 1 && level <= 6)) return fail('level is a heading level from 1 to 6 (### is 3).');
600
+ const refusal = gate('pk_fill_from_headings', { threads: false, category });
601
+ if (refusal) return fail(refusal);
602
+ const doc = readDocument(text(p.path));
603
+ if (typeof doc === 'string') return fail(doc);
604
+ const headings = headingsOf(doc.lines);
605
+ const under = text(p.under).trim();
606
+ let scopes: { from: number; to: number }[] = [{ from: 0, to: doc.lines.length }];
607
+ if (under) {
608
+ const wanted = squash(plainCell(under));
609
+ scopes = headings.filter((h) => squash(h.text).includes(wanted)).map((h) => ({ from: h.line, to: scopeEnd(headings, h, doc.lines.length) }));
610
+ if (!scopes.length) return ok({ written: 0, updated: 0, items: [], skipped: [], note: `No heading of ${doc.rel} has “${under}”.` });
611
+ }
612
+ const picked = headings.filter((h) => h.level === level && scopes.some((s) => h.line > s.from && h.line <= s.to));
613
+ if (!picked.length) return ok({ written: 0, updated: 0, items: [], skipped: [], note: `${doc.rel} has no level-${level} heading${under ? ` under “${under}”` : ''}.` });
614
+
615
+ const sourceAt = sourcesOf(doc);
616
+ const result: FillResult = { written: 0, updated: 0, items: [], skipped: [] };
617
+ const warnings = new Set<string>();
618
+ const seen = new Map<string, number>();
619
+ // CZ: a number of a family that counts only in the document holding its series (a side decision log's V1, V2 …;
620
+ // numbering.ts) is the heading's number here, though it is never read off a name elsewhere.
621
+ const series = new Map(definitionsInText(doc.lines.join('\n')).filter((d) => d.position === 'heading' && isSeriesOnlyFamily(d.family)).map((d) => [d.line ?? 0, d.num]));
622
+ for (const h of picked) {
623
+ const name = redact(h.text);
624
+ if (blank(name)) { result.skipped.push({ line: h.line, why: 'the heading has no words' }); continue; }
625
+ const source = sourceAt(h.line, doc.lines[h.line - 1] ?? '');
626
+ if (typeof source === 'string') { result.skipped.push({ line: h.line, why: source }); continue; }
627
+ const identifier = primaryIdentifier([], name) ?? series.get(h.line) ?? null;
628
+ const key = identifier ?? `${pathKey(doc.rel)}\u0000${squash(name)}`;
629
+ if (seen.has(key)) { result.skipped.push({ line: h.line, why: `the heading at line ${seen.get(key)} already wrote ${identifier ?? `“${name}”`}: one item per id` }); continue; }
630
+ seen.set(key, h.line);
631
+ // Its text: the section under the heading, up to the next heading of any level.
632
+ const next = headings.find((x) => x.line > h.line);
633
+ const body = doc.lines.slice(h.line, next ? next.line - 1 : doc.lines.length).join('\n').trim();
634
+ const sectionText = redact(body.length > 1200 ? `${body.slice(0, 1200)}…` : body) || name;
635
+ const fallback = stableId('ref', project.id, category, pathKey(doc.rel), key);
636
+ const previous = existingReference(category, identifier, fallback);
637
+ const params: Record<string, unknown> = previous
638
+ ? { id: previous.id, name, text: sectionText, sourceIds: [...new Set([...previous.sourceIds, source.id])] }
639
+ : { ...(identifier ? {} : { id: fallback }), category, name, ids: identifier ? [identifier] : [], text: sectionText, basis: 'Explicit', validity: 'Current', identity: 'Artifact', sourceIds: [source.id] };
640
+ const w = await write('pk_write_reference', params);
641
+ if ('error' in w) { result.skipped.push({ line: h.line, why: w.error }); continue; }
642
+ if (w.warning) warnings.add(w.warning);
643
+ if (previous) result.updated += 1; else result.written += 1;
644
+ result.items.push({ id: w.id, title: name, line: h.line });
645
+ }
646
+ if (warnings.size) result.warnings = [...warnings];
647
+ return ok(result);
648
+ },
649
+ }));
650
+
651
+ // ───────────────────────── bold entry lines: one item per number ─────────────────────────
652
+
653
+ tools.push(defineTool({
654
+ name: 'pk_fill_from_bold', label: 'Fill slots from bold entries',
655
+ description: 'Fill the workbench from the bold entry lines of one document, the way pk_fill_from_headings fills from headings: each line that opens with a number in bold — `**D1 · …**`, `- **R-52** …`, the entries of a decision record kept as paragraphs — becomes a reference item of category, one per number, the whole log in one call. The program reads the lines by the ledger’s own rule for a bold entry, and copies: the name is the number and its title as written (the bold words, or the words right after a bold that holds only the number), the text is the entry up to the next entry, with its supplements (`**D12 addendum (…)**`, a later bold line of the same number) folded in, basis Explicit, and the file’s section source of its line. Bold lines that are points of a numbered heading of another family (`**P1 …**` under `### M3`) are not entries of the document and are left out. family keeps one family (`D`, `R-`); under keeps the lines under a heading. Validity starts Current and progress stays unset: what replaced what is yours to judge. Running it again updates what it wrote. Lines that cannot be written come back in skipped, with why.',
656
+ parameters: Type.Object({
657
+ path: Type.String({ description: 'the document, relative to the repository root' }),
658
+ category: Type.String({ description: REFERENCE_CATEGORY.filter((c) => c !== OWNER_WORDS).join(' | ') }),
659
+ family: Type.Optional(Type.String({ description: 'only the numbers of one family: its letters, e.g. "D" or "R-"' })),
660
+ under: Type.Optional(Type.String({ description: 'words of the heading they stand under' })),
661
+ }),
662
+ execute: async (_id, raw) => {
663
+ const p = raw as Record<string, unknown>;
664
+ const category = text(p.category).trim();
665
+ if (!isOneOf(REFERENCE_CATEGORY, category)) return fail(`category must be one of ${REFERENCE_CATEGORY.join(', ')}.`);
666
+ if (category === OWNER_WORDS) return fail('Owner’s words carry the owner’s own quote from where they were said: write them with pk_write_reference, one by one.');
667
+ const refusal = gate('pk_fill_from_bold', { threads: false, category });
668
+ if (refusal) return fail(refusal);
669
+ const doc = readDocument(text(p.path));
670
+ if (typeof doc === 'string') return fail(doc);
671
+ const family = text(p.family).trim().toUpperCase();
672
+ const letters = (num: string) => num.replace(/\d+$/, '');
673
+ const all = entriesOf(doc.lines.join('\n'), { positions: ['bold entry'] })
674
+ .filter((e) => !family || letters(e.num) === family || letters(e.num) === `${family}-` || letters(e.num) === family.replace(/-$/, ''));
675
+ const headings = headingsOf(doc.lines);
676
+ const under = text(p.under).trim();
677
+ let picked = all;
678
+ if (under) {
679
+ const wanted = squash(plainCell(under));
680
+ const scopes = headings.filter((h) => squash(h.text).includes(wanted)).map((h) => ({ from: h.line, to: scopeEnd(headings, h, doc.lines.length) }));
681
+ if (!scopes.length) return ok({ written: 0, updated: 0, items: [], skipped: [], note: `No heading of ${doc.rel} has “${under}”.` });
682
+ picked = all.filter((e) => scopes.some((s) => e.line > s.from && e.line <= s.to));
683
+ }
684
+ if (!picked.length) return ok({ written: 0, updated: 0, items: [], skipped: [], note: `${doc.rel} has no bold entry line${family ? ` of ${family}` : ''}${under ? ` under “${under}”` : ''}.` });
685
+ const sourceAt = sourcesOf(doc);
686
+ const result: FillResult = { written: 0, updated: 0, items: [], skipped: [] };
687
+ const warnings = new Set<string>();
688
+ const folded: { line: number; into: string }[] = [];
689
+ for (const e of picked) {
690
+ const name = redact(e.name);
691
+ const source = sourceAt(e.line, doc.lines[e.line - 1] ?? '');
692
+ if (typeof source === 'string') { result.skipped.push({ line: e.line, why: source }); continue; }
693
+ const own = doc.lines.slice(e.line - 1, e.endLine).join('\n').trim();
694
+ const more = e.foldedRanges.map((f) => doc.lines.slice(f.line - 1, f.endLine).join('\n').trim());
695
+ const body = [own, ...more].filter(Boolean).join('\n\n');
696
+ const entryText = redact(body.length > 2400 ? `${body.slice(0, 2400)}…` : body) || name;
697
+ for (const f of e.foldedRanges) folded.push({ line: f.line, into: e.num });
698
+ // The supplements' sources are cited too, when the file's sources hold them.
699
+ const extra = e.foldedRanges.map((f) => sourceAt(f.line, doc.lines[f.line - 1] ?? '')).filter((x): x is Source => typeof x !== 'string').map((x) => x.id);
700
+ const fallback = stableId('ref', project.id, category, pathKey(doc.rel), e.num);
701
+ const previous = existingReference(category, e.num, fallback);
702
+ const sourceIds = [...new Set([...(previous?.sourceIds ?? []), source.id, ...extra])];
703
+ const params: Record<string, unknown> = previous
704
+ ? { id: previous.id, name, text: entryText, sourceIds }
705
+ : { category, name, ids: [e.num], text: entryText, basis: 'Explicit', validity: 'Current', identity: 'Artifact', sourceIds };
706
+ const w = await write('pk_write_reference', params);
707
+ if ('error' in w) { result.skipped.push({ line: e.line, why: w.error }); continue; }
708
+ if (w.warning) warnings.add(w.warning);
709
+ if (previous) result.updated += 1; else result.written += 1;
710
+ result.items.push({ id: w.id, title: name, line: e.line });
711
+ }
712
+ if (warnings.size) result.warnings = [...warnings];
713
+ return ok({ ...result, ...(folded.length ? { folded } : {}) });
714
+ },
715
+ }));
716
+
717
+ return tools;
718
+ }