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,1045 @@
1
+ /**
2
+ * Placement by the program (CQ, D104; Spec §1.4 落位: 执行层的派工、批次与没编号的工作单位,落在它们所执行的那个产品层计划里;
3
+ * 归档的执行安排仍是落位的依据;确实没有的记明为什么,程序推得出时拒绝;程序先落、标 Inferred).
4
+ *
5
+ * The six-run read-through (2026-10-01) found every symptom the program only lists comes back: on the D103 run 12 tickets
6
+ * and 61 of the execution log's entries were left unplaced with "their arrangement is archived", while the program's own
7
+ * trace had already led to the plan. So the program places first, basis Inferred, and a reason for "no plan" is a record
8
+ * it can refuse.
9
+ *
10
+ * - **The records lead.** A work item's plan and module are read off the records that place it, each a pointer the
11
+ * program follows: the row of a plan or dispatch table or of the index that lists it; the batch or row of an
12
+ * arrangement its own words name; its prompt's front matter (plan, batch, milestone, upstream, module); the contract it
13
+ * implements and that contract's plan; the decisions it carries out, cites or is cited by and the plan they lead to
14
+ * (placing.ts `planThroughDecisions`); a link or path in the ticket to a plan document; the directory it sits in when
15
+ * that is a plan's. **An execution arrangement, current or archived, places the work its rows list in the product-layer
16
+ * plan that arrangement executed** (`planOf`: what its header says it executes, else the one plan the contracts and
17
+ * tickets its rows name stand in — an item standing in several plans votes for none, and a table whose rows span
18
+ * several plans is an index of several and places nothing by itself). An execution decision (an entry of a log) follows
19
+ * the arrangement it belongs to — the batch, the row, the arrangement document or the log's own plan it names — to that
20
+ * plan (`inferDecision`).
21
+ * - **One target, or none.** Where the pointers lead to exactly one plan (or one clearly heaviest) the program writes the
22
+ * placement — `serves` on a work item (its Area first when one is inferred, then the Plan), `refines` on a decision —
23
+ * basis Inferred, the chain in the claim, and records it on the round (`inferredPlacements`). It never overwrites or
24
+ * removes a placement a lane or the main agent wrote.
25
+ * - **A program placement stays listed until it has a result** (CS; the owner's U86): unreviewed ones stand as placed and
26
+ * no gate refuses on them, but the list covers every round's, so a later round's cross-check is shown what an earlier
27
+ * round left; a result — confirmed, moved — lands on the record of the round that placed it (`noteOverride`,
28
+ * `settleProgramPlacements`, `confirmBySpotCheck`), and the handover note counts placed, confirmed, moved, unreviewed.
29
+ * - **A reason is a record the program can refuse.** `noPlanWhy` / `noAreaWhy` on a work item are refused when the records
30
+ * lead to a plan or an area (the refusal names the chain), accepted when the chain is empty, and always accepted when the
31
+ * project has no plan layer (`planLayerPresent`) or no Areas.
32
+ * - **Time order** (CR): a token a record names counts only when what it names was first written on or before the record's
33
+ * date, and a work item or an execution decision does not go to a plan first written after it (`placement-time.ts`).
34
+ * Each dropped token or lead is listed in the pointers tried with its dates; a refusal never names a dropped chain.
35
+ * - **Generality** (D98): nothing here names one project's objects; a missing layer gives a defined zero.
36
+ */
37
+ import { join } from 'node:path';
38
+ import type { Ledger } from '../../ledger/index.ts';
39
+ import { definitionsInPath } from '../../ledger/numbering.ts';
40
+ import type { ClerkRound, InferredPlacement } from '../../model/k-types.ts';
41
+ import type { ReferenceItem, WorkThread } from '../../model/types.ts';
42
+ import type { ProjectStore, TraceInfo } from '../../store/project-store.ts';
43
+ import { plansOf } from '../../process/placement.ts';
44
+ import { NAME_SEP, areaByWrittenName, entryTextOf, lineIndex, planThroughDecisions, reachOf, traceIndex, tracePlacement, type LineIndex, type Reach, type TraceIndex } from './placing.ts';
45
+ import { chronology, citedAfter, laterThan, type Chronology } from './placement-time.ts';
46
+ import { earlierWork } from './carried-on.ts';
47
+
48
+ const GONE = new Set(['Replaced', 'Removed']);
49
+ const slash = (p: string) => p.replace(/\\/g, '/');
50
+ const squash = (s: string) => s.replace(/\s+/g, ' ').trim();
51
+ const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
52
+ const upper = (s: string) => s.trim().toUpperCase();
53
+ const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
54
+
55
+ // ───────────────────────── the pointers' weights ─────────────────────────
56
+
57
+ /** How much a pointer counts: what a record writes outright, over what it leads to through another item. */
58
+ const WEIGHT = {
59
+ /** The row, the front matter or the entry writes the plan or module itself. */
60
+ written: 5,
61
+ /** The contract it implements; the arrangement (a plan document) that lists it or whose batch it names. */
62
+ contract: 4,
63
+ /** A link to the arrangement, or an item the record names that is placed. */
64
+ record: 3,
65
+ /** A decision the record names, one step away. */
66
+ decision: 2,
67
+ /** The directory it sits in; a citation. */
68
+ faint: 1,
69
+ } as const;
70
+
71
+ /** Front-matter fields that say which plan, batch or milestone a prompt belongs to; which unit it follows; which module. */
72
+ const PLAN_FIELDS = /^(plan|plans|increment|milestone|phase|batch|sprint|iteration|stage|epic|release)$/i;
73
+ const UPSTREAM_FIELDS = /^(upstream|parent|follows|for|target|targets|contract|contracts|implements|task|tasks)$/i;
74
+ const AREA_FIELDS = /^(module|modules|area|areas|component|components|scope)$/i;
75
+ /** The words a batch or stage follows when a text names one by its label (`批次 B`, `batch 3`, `阶段 2`): a bilingual word list. */
76
+ const BATCH_WORDS = /(?:批次|批|阶段|第\s*\d+\s*批|batch|stage|phase|sprint|step)\s*([A-Za-z0-9][A-Za-z0-9]{0,3})(?![\p{L}\p{N}_])/giu;
77
+
78
+ export interface Lead {
79
+ readonly id: string;
80
+ /** The Plan or Area by its short name. */
81
+ readonly name: string;
82
+ readonly category: 'Plan' | 'Area';
83
+ readonly weight: number;
84
+ /** How the records lead there, each step in words the main agent can check (at most three). */
85
+ readonly through: readonly string[];
86
+ }
87
+
88
+ export interface Inference {
89
+ /** The plans the records lead to, heaviest first. */
90
+ readonly plans: readonly Lead[];
91
+ /** The areas the records lead to, heaviest first. */
92
+ readonly areas: readonly Lead[];
93
+ /** The pointers tried and what each gave, for a reason the program accepts and for the spot-check. */
94
+ readonly tried: readonly string[];
95
+ }
96
+
97
+ class Tally {
98
+ private readonly at = new Map<string, { weight: number; through: string[] }>();
99
+ private readonly store: ProjectStore;
100
+ constructor(store: ProjectStore) { this.store = store; }
101
+ add(id: string, weight: number, how: string): void {
102
+ const t = this.at.get(id) ?? { weight: 0, through: [] };
103
+ t.weight += weight;
104
+ if (!t.through.includes(how) && t.through.length < 3) t.through.push(how);
105
+ this.at.set(id, t);
106
+ }
107
+ get size(): number { return this.at.size; }
108
+ leads(): Lead[] {
109
+ return [...this.at].map(([id, t]) => ({ id, name: shortName(this.store, id), category: (this.store.reference.get(id)?.category === 'Area' ? 'Area' : 'Plan') as 'Plan' | 'Area', weight: t.weight, through: t.through }))
110
+ .sort((a, b) => b.weight - a.weight || a.name.localeCompare(b.name, undefined, { numeric: true }));
111
+ }
112
+ }
113
+
114
+ export const shortName = (store: ProjectStore, id: string): string => {
115
+ const r = store.reference.get(id);
116
+ if (r) return squash(r.name.split(NAME_SEP)[0] ?? r.name) || r.name;
117
+ const t = store.threads.get(id);
118
+ return t ? t.ids[0] ?? t.title : id;
119
+ };
120
+ const threadName = (t: WorkThread): string => (t.ids[0] && !t.title.includes(t.ids[0]) ? `${t.ids[0]} ${t.title}` : t.title);
121
+ const refName = (r: ReferenceItem): string => (r.ids[0] && !r.name.startsWith(r.ids[0]) ? `${r.ids[0]} ${r.name}` : r.name);
122
+ const listOf = (names: readonly string[], n = 4): string => `${names.slice(0, n).join(', ')}${names.length > n ? ` (+${names.length - n})` : ''}`;
123
+
124
+ /** The lead the program may act on: the only one, or the one clearly heaviest; null when none or a tie. */
125
+ export function pick(leads: readonly Lead[]): Lead | null {
126
+ if (!leads.length) return null;
127
+ if (leads.length === 1 || leads[0]!.weight > leads[1]!.weight) return leads[0]!;
128
+ return null;
129
+ }
130
+
131
+ // ───────────────────────── the project's arrangements, as the ledger read them ─────────────────────────
132
+
133
+ export interface ArrangementFile {
134
+ /** Repository-relative, with forward slashes. */
135
+ readonly path: string;
136
+ readonly repo: string | null;
137
+ readonly kind: 'index' | 'plan' | 'prompt' | 'status' | 'receipt';
138
+ readonly ident: string | null;
139
+ readonly title: string;
140
+ readonly fields: Readonly<Record<string, string>>;
141
+ readonly rows: readonly { readonly id: string; readonly cells: Readonly<Record<string, string>>; readonly line: number }[];
142
+ readonly batches: readonly string[];
143
+ }
144
+ type Row = ArrangementFile['rows'][number];
145
+
146
+ /** Every arrangement file the checkout has now (`plans.current = 1`): archived ones included, each path once. */
147
+ export function arrangementFiles(ledger: Ledger | null): ArrangementFile[] {
148
+ if (!ledger) return [];
149
+ let rows: { repo: string | null; path: string; kind: string; ident: string | null; data: string }[] = [];
150
+ try { rows = ledger.db.prepare('SELECT repo, path, kind, ident, data FROM plans WHERE current = 1 ORDER BY repo IS NULL, path').all() as typeof rows; } catch { rows = []; }
151
+ let repos: { id: string; path: string }[] = [];
152
+ try { repos = ledger.repos().map((r) => ({ id: r.id, path: slash(r.path).replace(/\/+$/, '') })); } catch { repos = []; }
153
+ const out = new Map<string, ArrangementFile>();
154
+ for (const r of rows) {
155
+ let path = slash(r.path);
156
+ let repo = r.repo;
157
+ // A file read outside version control is keyed by its absolute path: make it repository-relative when it is in one.
158
+ if (/^(?:[A-Za-z]:)?\//.test(path)) {
159
+ const root = repos.filter((x) => path.toLowerCase().startsWith(`${x.path.toLowerCase()}/`)).sort((a, b) => b.path.length - a.path.length)[0];
160
+ if (root) { path = path.slice(root.path.length + 1); repo ??= root.id; }
161
+ }
162
+ if (out.has(path) && out.get(path)!.repo !== null) continue;
163
+ let data: Record<string, unknown> = {};
164
+ try { data = JSON.parse(r.data) as Record<string, unknown>; } catch { data = {}; }
165
+ const kind = (['index', 'plan', 'prompt', 'status', 'receipt'].includes(r.kind) ? r.kind : 'receipt') as ArrangementFile['kind'];
166
+ out.set(path, {
167
+ path, repo, kind, ident: r.ident, title: typeof data.title === 'string' ? data.title : '',
168
+ fields: (data.fields && typeof data.fields === 'object' ? data.fields : {}) as Record<string, string>,
169
+ rows: Array.isArray(data.rows) ? (data.rows as ArrangementFile['rows']) : [],
170
+ batches: Array.isArray(data.batches) ? (data.batches as string[]) : [],
171
+ });
172
+ }
173
+ return [...out.values()];
174
+ }
175
+
176
+ /** The leading token of a row's id (`B1b`, `A · D56 与非 agent 规则` → `A`, `**F · 先做的小件**` → `F`, `C1/C2` → `C1`), upper case. */
177
+ const rowToken = (id: string): string => upper((id.replace(/[*`[\]]/g, '').split(NAME_SEP)[0] ?? '').split(/[\s//、,]+/)[0] ?? '');
178
+
179
+ // ───────────────────────── the context one run shares ─────────────────────────
180
+
181
+ export interface InferenceContext {
182
+ readonly store: ProjectStore;
183
+ readonly ledger: Ledger | null;
184
+ readonly reach: Reach;
185
+ readonly index: TraceIndex;
186
+ readonly lines: LineIndex;
187
+ readonly files: readonly ArrangementFile[];
188
+ readonly areas: readonly ReferenceItem[];
189
+ /** The plan an arrangement executed, by what its header and rows say (cached); empty for an index of several plans' work. */
190
+ readonly planOf: (file: ArrangementFile) => readonly Lead[];
191
+ /** The plans a decision leads to: the plan it sits on, else what its entry names, else the decisions it names (cached). */
192
+ readonly decisionLeads: (d: ReferenceItem) => readonly { readonly id: string; readonly through: string }[];
193
+ /** The Areas and Plans an item of the workbench stands in (a thread by what it serves, a reference item by what it refines). */
194
+ readonly placesOf: (id: string) => { readonly plans: readonly string[]; readonly areas: readonly string[] };
195
+ /** The items a written name stands for: what `traceIndex` resolves, and a decision or boundary by its number. */
196
+ readonly resolve: (name: string) => string[];
197
+ /** The index and plan rows that list a number: the row's own id, or a list-shaped cell naming it. */
198
+ readonly rowsNaming: (nums: readonly string[]) => { readonly file: ArrangementFile; readonly row: Row }[];
199
+ /** The rows of plan arrangements a text names by their batch or row id (`B2`, `批次 B`, `S2a`). */
200
+ readonly rowsNamedIn: (text: string) => { readonly file: ArrangementFile; readonly row: Row; readonly token: string }[];
201
+ /** The prompts of a number: the arrangement files about it. */
202
+ readonly promptsOf: (nums: readonly string[]) => ArrangementFile[];
203
+ /** The arrangement file a written path names, by its end. */
204
+ readonly fileByPath: (written: string) => ArrangementFile | null;
205
+ /** The text of an arrangement file as the ledger holds it now (its prompt body), or null. */
206
+ readonly textOf: (file: ArrangementFile) => string | null;
207
+ /** The Plan items whose own document is this file (repository-relative path), by the Plans' sources. */
208
+ readonly plansInDocument: (rel: string) => ReferenceItem[];
209
+ /** The project's chronology: when items were first written, and the dates of the records that cite them (CR). */
210
+ readonly time: Chronology;
211
+ /**
212
+ * Whether a token a record names counts: what it names was first written on or before the record's date (one day of
213
+ * slack); when not, the note ("its entry names CN, first written 2026-10-01, after this entry of 2026-09-22") goes to
214
+ * `tried`. Unknown dates keep the token.
215
+ */
216
+ readonly existed: (citing: Citing, n: string, id: string, tried: string[]) => boolean;
217
+ /** What a decision's own entry named and the program dropped for time order, as notes (cached with `decisionLeads`). */
218
+ readonly decisionDrops: (d: ReferenceItem) => readonly string[];
219
+ }
220
+
221
+ /**
222
+ * A record that cites: its date, how a citation from it is said (`its entry`), and how the record itself is said (`this
223
+ * entry`); for an entry, its text (a later note in it that names the token cites as of the date that note writes).
224
+ */
225
+ export interface Citing { readonly day: string | null; readonly how: string; readonly self: string; readonly text?: string }
226
+
227
+ const noteOf = (citing: Citing, n: string, first: string): string => `${citing.how} names ${n}, first written ${first}, after ${citing.self} of ${citing.day}`;
228
+
229
+ export function inferenceContext(store: ProjectStore, ledger: Ledger | null, shared: { readonly reach?: Reach; readonly index?: TraceIndex; readonly lines?: LineIndex; readonly time?: Chronology } = {}): InferenceContext {
230
+ const reach = shared.reach ?? reachOf(store);
231
+ const index = shared.index ?? traceIndex(store, reach, { ledger });
232
+ const lines = shared.lines ?? lineIndex(store);
233
+ const time = shared.time ?? chronology(store, ledger);
234
+ const existed = (citing: Citing, n: string, id: string, tried: string[]): boolean => {
235
+ if (!citing.day) return true;
236
+ const first = time.first(id);
237
+ if (!citedAfter(first, citing.day, citing.text, n)) return true;
238
+ const note = noteOf(citing, n, first!);
239
+ if (!tried.includes(note)) tried.push(note);
240
+ return false;
241
+ };
242
+ const files = arrangementFiles(ledger);
243
+ const areas = store.reference.filter((r) => r.category === 'Area' && !GONE.has(r.validity) && r.validity !== 'Abandoned');
244
+ const isDecision = (r: ReferenceItem | undefined): r is ReferenceItem => !!r && (r.category === 'Decision' || r.category === 'Boundary');
245
+ const decisionByNumber = new Map<string, ReferenceItem>();
246
+ for (const d of store.reference.filter((r) => isDecision(r) && !GONE.has(r.validity))) for (const i of d.ids) if (!decisionByNumber.has(upper(i))) decisionByNumber.set(upper(i), d);
247
+ const resolve = (name: string): string[] => {
248
+ const ids = index.resolve(name);
249
+ const d = decisionByNumber.get(upper(name));
250
+ return d && !ids.includes(d.id) ? [...ids, d.id] : ids;
251
+ };
252
+ const placesOf = (id: string) => {
253
+ const t = store.threads.get(id);
254
+ if (t) return reach.ofThread(t);
255
+ const r = store.reference.get(id);
256
+ if (!r) return { plans: [], areas: [] };
257
+ if (r.category === 'Plan') return { plans: [id], areas: [] };
258
+ if (r.category === 'Area') return { plans: [], areas: [id] };
259
+ const x = reach.ofReference(id);
260
+ return { plans: x.plans, areas: x.areas };
261
+ };
262
+ const kindOf = (id: string): 'Plan' | 'Area' | 'Decision' | 'item' | null => {
263
+ if (store.threads.has(id)) return 'item';
264
+ const r = store.reference.get(id);
265
+ if (!r) return null;
266
+ return r.category === 'Plan' ? 'Plan' : r.category === 'Area' ? 'Area' : isDecision(r) ? 'Decision' : 'item';
267
+ };
268
+ /** The one plan an item stands in; null when none or several (an item in several plans votes for none). */
269
+ const onePlan = (id: string): string | null => { const p = placesOf(id).plans; return p.length === 1 ? p[0]! : null; };
270
+
271
+ // A decision leads through at most one other decision (D57 → D56 → the contracts of one plan); the cache keeps only
272
+ // what a decision leads to on its own, so cached chains never stitch into longer ones.
273
+ const decisionCache = new Map<string, { id: string; through: string }[]>();
274
+ const dropCache = new Map<string, string[]>();
275
+ const decisionLeads = (d: ReferenceItem, depth = 0): { id: string; through: string }[] => {
276
+ const hit = depth === 0 ? decisionCache.get(d.id) : undefined;
277
+ if (hit) return hit;
278
+ if (depth > 1) return [];
279
+ const label = d.ids[0] ?? clip(squash(d.name), 30);
280
+ // CR: what its entry names counts only when it existed at the entry's date; an execution decision leads to no plan
281
+ // first written after it.
282
+ const drops: string[] = [];
283
+ const own = time.own(d.id);
284
+ const citing: Citing = { day: own, how: `${label}'s entry`, self: `${label}'s entry`, text: entryTextOf(store, d) };
285
+ const execution = time.executionEntry(d);
286
+ const inTime = (leads: { id: string; through: string }[]): { id: string; through: string }[] => leads.filter((l) => {
287
+ const first = execution && own ? time.first(l.id) : null;
288
+ if (!first || !laterThan(first, own!)) return true;
289
+ const note = `${label} leads to ${shortName(store, l.id)} (${l.through}); ${shortName(store, l.id)} was first written ${first}, after ${label}'s entry of ${own}`;
290
+ if (!drops.includes(note)) drops.push(note);
291
+ return false;
292
+ });
293
+ let out: { id: string; through: string }[] = [];
294
+ const direct = reach.ofReference(d.id).plans;
295
+ if (direct.length) out = inTime(direct.map((p) => ({ id: p, through: `${label} is placed on ${shortName(store, p)}` })));
296
+ else {
297
+ const entry = entryTextOf(store, d);
298
+ const names = index.named(entry).filter((n) => n.toUpperCase() !== (d.ids[0] ?? '').toUpperCase());
299
+ const tally = new Map<string, { n: number; names: string[] }>();
300
+ const vote = (p: string, n: string, w: number) => { const t = tally.get(p) ?? { n: 0, names: [] }; t.n += w; if (!t.names.includes(n)) t.names.push(n); tally.set(p, t); };
301
+ for (const n of names) for (const id of resolve(n)) {
302
+ if (!existed(citing, n, id, drops)) continue;
303
+ const k = kindOf(id);
304
+ if (k === 'Plan') vote(id, n, 2);
305
+ else if (k === 'item') { const p = onePlan(id); if (p) vote(p, n, 1); }
306
+ }
307
+ if (tally.size) {
308
+ const best = [...tally].sort((a, b) => b[1].n - a[1].n);
309
+ const top = best.filter((x) => x[1].n === best[0]![1].n);
310
+ out = inTime(top.map(([p, t]) => ({ id: p, through: t.names.some((n) => resolve(n).includes(p)) ? `${label}'s entry names ${listOf(t.names)}` : `${label}'s entry names ${listOf(t.names)}, which serve ${shortName(store, p)}` })));
311
+ }
312
+ if (!out.length) {
313
+ for (const n of names) {
314
+ const next = decisionByNumber.get(upper(n));
315
+ if (!next || next.id === d.id || !existed(citing, n, next.id, drops)) continue;
316
+ const via = inTime(decisionLeads(next, depth + 1).map((v) => ({ id: v.id, through: `${label}'s entry names ${n}; ${v.through}` })));
317
+ if (depth === 0) for (const x of dropCache.get(next.id) ?? []) if (!drops.includes(x)) drops.push(x);
318
+ if (via.length) { out = via; break; }
319
+ }
320
+ }
321
+ }
322
+ if (depth === 0) { decisionCache.set(d.id, out); dropCache.set(d.id, drops); }
323
+ return out;
324
+ };
325
+ const decisionDrops = (d: ReferenceItem): readonly string[] => { if (!dropCache.has(d.id)) decisionLeads(d); return dropCache.get(d.id) ?? []; };
326
+
327
+ const planCache = new Map<string, Lead[]>();
328
+ const planOf = (file: ArrangementFile): Lead[] => {
329
+ const hit = planCache.get(file.path);
330
+ if (hit) return hit;
331
+ // An index of the project's work lists every plan's tickets: it executes no plan of its own, and only a row's own
332
+ // cells (an increment or module column) place the work it lists.
333
+ if (file.kind === 'index') { planCache.set(file.path, []); return []; }
334
+ const tally = new Tally(store);
335
+ const header = [file.title, ...Object.entries(file.fields).filter(([k]) => PLAN_FIELDS.test(k) || UPSTREAM_FIELDS.test(k)).map(([, v]) => v), ...file.batches.slice(0, 20)].join('\n');
336
+ const headerPlans = new Set<string>();
337
+ // CR: what the arrangement names counts only when it existed when the arrangement (its header, its row) was last written.
338
+ const unused: string[] = [];
339
+ const headerCiting: Citing = { day: time.file(file.path), how: 'its header', self: 'the arrangement' };
340
+ for (const n of index.named(header)) {
341
+ for (const id of resolve(n)) {
342
+ if (!existed(headerCiting, n, id, unused)) continue;
343
+ const k = kindOf(id);
344
+ if (k === 'Plan') { tally.add(id, WEIGHT.written, `its header names ${n}`); headerPlans.add(id); }
345
+ else if (k === 'item') { const p = onePlan(id); if (p) tally.add(p, WEIGHT.decision, `its header names ${n}, which serves ${shortName(store, p)}`); }
346
+ // A decision the header names is one step removed from the arrangement's own rows: it counts for half a citation.
347
+ else if (k === 'Decision') for (const lead of decisionLeads(store.reference.get(id)!)) tally.add(lead.id, WEIGHT.faint / 2, `its header names ${n}; ${lead.through}`);
348
+ }
349
+ }
350
+ // Its rows: a cell that writes the plan; the contracts and tickets its rows name, each voting for the one plan it
351
+ // stands in (an item in several plans votes for none).
352
+ const byPlan = new Map<string, Set<string>>();
353
+ let written = 0;
354
+ for (const row of file.rows) {
355
+ const rowCiting: Citing = { day: time.line(file.path, row.line), how: 'its row', self: 'that row' };
356
+ for (const value of Object.values(row.cells)) {
357
+ for (const n of index.named(value)) {
358
+ for (const id of resolve(n)) {
359
+ if (!existed(rowCiting, n, id, unused)) continue;
360
+ const k = kindOf(id);
361
+ if (k === 'Plan') { tally.add(id, WEIGHT.record, `its row ${clip(row.id, 20)} writes ${n}`); written += 1; }
362
+ else if (k === 'item') { const p = onePlan(id); if (p) { const s = byPlan.get(p) ?? new Set(); s.add(n); byPlan.set(p, s); } }
363
+ }
364
+ }
365
+ }
366
+ }
367
+ // What its header writes outright decides; the rows then only add their evidence to that plan.
368
+ if (headerPlans.size) {
369
+ for (const [p, names] of byPlan) if (headerPlans.has(p)) tally.add(p, Math.min(12, names.size), `its rows name ${listOf([...names])}, which serve ${shortName(store, p)}`);
370
+ const out = tally.leads().filter((l) => headerPlans.has(l.id));
371
+ planCache.set(file.path, out);
372
+ return out;
373
+ }
374
+ // Else the one plan its rows' contracts and tickets stand in — clearly (twice the runner-up), or the file is a table
375
+ // of several plans' work and places nothing by itself.
376
+ for (const [p, names] of byPlan) tally.add(p, Math.min(12, names.size) * (written ? 0.5 : 1), `its rows name ${listOf([...names])}, which serve ${shortName(store, p)}`);
377
+ const all = tally.leads().filter((l) => l.category === 'Plan');
378
+ const out = all.length === 1 || (all.length > 1 && all[0]!.weight >= 2 * all[1]!.weight) ? all.slice(0, 1) : [];
379
+ planCache.set(file.path, out);
380
+ return out;
381
+ };
382
+
383
+ /** A cell that is a list of numbers (`AB、AC`, `CKC-02(词表)、CKC-04`, `CKC-02~CKC-05`): nothing but numbers, ranges and separators once the parentheticals are gone. */
384
+ const listCell = (cell: string): boolean => {
385
+ const bare = cell.replace(/[((][^(())]*[))]/g, ' ').replace(/\[[^\]]*\]\([^)]*\)/g, ' ').replace(/[*`]/g, '');
386
+ const nums = index.numbers(bare);
387
+ if (!nums.length) return false;
388
+ let rest = bare;
389
+ for (const n of nums) rest = rest.replace(new RegExp(esc(n), 'gi'), ' ');
390
+ return rest.replace(/[\p{P}\p{S}\s\d]/gu, '').length <= 2;
391
+ };
392
+ /** Whether a row lists a number: its id cell is the number, or a list-shaped cell names it (a prose mention does not list). */
393
+ const rowLists = (row: Row, want: ReadonlySet<string>): boolean => {
394
+ if (want.has(rowToken(row.id)) || index.numbers(row.id).some((n) => want.has(n))) return true;
395
+ return Object.values(row.cells).some((c) => listCell(c) && index.numbers(c).some((n) => want.has(n)));
396
+ };
397
+ const rowsNaming = (nums: readonly string[]) => {
398
+ const want = new Set(nums.map(upper));
399
+ const out: { file: ArrangementFile; row: Row }[] = [];
400
+ for (const file of files) {
401
+ if (file.kind !== 'index' && file.kind !== 'plan') continue;
402
+ for (const row of file.rows) if (rowLists(row, want)) out.push({ file, row });
403
+ }
404
+ return out;
405
+ };
406
+ // The batch and row ids of the plan arrangements (`B2`, `B1b`, `S2a`, `A`). A row whose id is a project number (a
407
+ // dispatch table keyed by ticket) is that ticket's own row, not a batch, and is left to the number pointers. A text
408
+ // names a batch by a label with a digit as a whole token (`B2`), or by any label after a batch word (`批次 B`): a bare
409
+ // letter or word (`A`, `UI`) is prose otherwise.
410
+ const rowIds = new Map<string, { file: ArrangementFile; row: Row }[]>();
411
+ for (const file of files) if (file.kind === 'plan') for (const row of file.rows) { const tok = rowToken(row.id); if (tok && /^[A-Z0-9][A-Z0-9.-]{0,7}$/.test(tok) && !/^\d+$/.test(tok) && !resolve(tok).length) rowIds.set(tok, [...(rowIds.get(tok) ?? []), { file, row }]); }
412
+ const longIds = [...rowIds.keys()].filter((k) => k.length >= 2 && /\d/.test(k));
413
+ const longRe = longIds.length ? new RegExp(`(?<![\\p{L}\\p{N}_#-])(?:${longIds.sort((a, b) => b.length - a.length).map(esc).join('|')})(?![\\p{L}\\p{N}_])`, 'giu') : null;
414
+ const rowsNamedIn = (text: string) => {
415
+ const out: { file: ArrangementFile; row: Row; token: string }[] = [];
416
+ const seen = new Set<string>();
417
+ const take = (tok: string) => { for (const hit of rowIds.get(tok) ?? []) { const key = `${hit.file.path}:${hit.row.line}`; if (!seen.has(key)) { seen.add(key); out.push({ ...hit, token: tok }); } } };
418
+ if (longRe) for (const m of text.matchAll(longRe)) take(upper(m[0]));
419
+ for (const m of text.matchAll(BATCH_WORDS)) take(upper(m[1]!));
420
+ return out;
421
+ };
422
+ const promptsOf = (nums: readonly string[]) => {
423
+ const want = new Set(nums.map(upper));
424
+ return files.filter((f) => f.kind === 'prompt' && ((f.ident && want.has(upper(f.ident))) || definitionsInPath(f.path).some((d) => want.has(upper(d.num)))));
425
+ };
426
+ const fileByPath = (written: string): ArrangementFile | null => {
427
+ const p = slash(written).replace(/^\.\//, '').replace(/[#?].*$/, '').replace(/\/+$/, '').toLowerCase();
428
+ if (!p) return null;
429
+ return files.find((f) => f.path.toLowerCase() === p) ?? files.find((f) => f.path.toLowerCase().endsWith(`/${p}`)) ?? null;
430
+ };
431
+ const textCache = new Map<string, string | null>();
432
+ let repos: { id: string; path: string }[] | null = null;
433
+ const textOf = (file: ArrangementFile): string | null => {
434
+ if (!ledger) return null;
435
+ if (textCache.has(file.path)) return textCache.get(file.path)!;
436
+ let text: string | null = null;
437
+ try {
438
+ repos ??= ledger.repos().map((r) => ({ id: r.id, path: r.path }));
439
+ const root = repos.find((r) => r.id === file.repo) ?? repos[0];
440
+ if (root) text = ledger.currentText(join(root.path, ...file.path.split('/')));
441
+ } catch { text = null; }
442
+ textCache.set(file.path, text);
443
+ return text;
444
+ };
445
+ const plansInDocument = (rel: string): ReferenceItem[] => {
446
+ const key = slash(rel).toLowerCase();
447
+ return store.reference.filter((r) => r.category === 'Plan' && !GONE.has(r.validity) && r.sourceIds.some((sid) => { const a = store.sources.get(sid)?.anchor; return a?.kind === 'file' && slash(a.path).toLowerCase().endsWith(`/${key}`); }));
448
+ };
449
+ return { store, ledger, reach, index, lines, files, areas, planOf, decisionLeads, placesOf, resolve, rowsNaming, rowsNamedIn, promptsOf, fileByPath, textOf, plansInDocument, time, existed, decisionDrops };
450
+ }
451
+
452
+ // ───────────────────────── following what a record writes ─────────────────────────
453
+
454
+ /** Markdown links and bare paths to Markdown documents in a text, as written. */
455
+ export function pathsIn(text: string): string[] {
456
+ const out = new Set<string>();
457
+ for (const m of text.matchAll(/\]\(([^)\s]+\.md)(?:#[^)]*)?\)/gi)) out.add(m[1]!);
458
+ for (const m of text.matchAll(/(?<![\w./\\-])((?:[\w.-]+[\\/])+[\w.-]+\.md)(?![\w.])/gi)) out.add(m[1]!);
459
+ return [...out];
460
+ }
461
+
462
+ const ownWords = (t: WorkThread): string => [t.title, t.doing, t.results].filter(Boolean).join('\n');
463
+
464
+ interface Weights { readonly written: number; readonly item: number; readonly decision: number }
465
+
466
+ /**
467
+ * What a written value leads to — a plan or an area written outright, a contract or ticket placed, a decision one step
468
+ * away — added to the tallies. A token naming what did not exist yet at the citing record's date is dropped (CR), noted in
469
+ * `tried`.
470
+ */
471
+ function follow(ctx: InferenceContext, value: string, how: string, plans: Tally, areas: Tally, w: Weights, citing: Citing, tried: string[]): boolean {
472
+ let found = false;
473
+ for (const n of ctx.index.named(value)) {
474
+ for (const id of ctx.resolve(n)) {
475
+ if (!ctx.existed(citing, n, id, tried)) continue;
476
+ const r = ctx.store.reference.get(id);
477
+ if (r?.category === 'Plan') { plans.add(id, w.written, `${how} ${n}`); found = true; }
478
+ else if (r?.category === 'Area') { areas.add(id, w.written, `${how} ${n}`); found = true; }
479
+ else if (r && (r.category === 'Decision' || r.category === 'Boundary')) {
480
+ for (const lead of ctx.decisionLeads(r)) { plans.add(lead.id, w.decision, `${how} ${n}; ${lead.through}`); found = true; }
481
+ for (const note of ctx.decisionDrops(r)) { const x = `${how} ${n}; ${note}`; if (!tried.includes(x)) tried.push(x); }
482
+ for (const a of ctx.reach.ofReference(r.id).areas) { areas.add(a, w.decision, `${how} ${n}, which is placed on ${shortName(ctx.store, a)}`); found = true; }
483
+ } else if (r || ctx.store.threads.has(id)) {
484
+ const at = ctx.placesOf(id);
485
+ for (const p of at.plans) { plans.add(p, w.item / at.plans.length, `${how} ${n}, which serves ${shortName(ctx.store, p)}`); found = true; }
486
+ for (const a of at.areas) { areas.add(a, w.item / at.areas.length, `${how} ${n}, which serves ${shortName(ctx.store, a)}`); found = true; }
487
+ }
488
+ }
489
+ }
490
+ // A module written by its short form (a Module column's cell, a prompt's `module:`).
491
+ const area = areaByWrittenName(ctx.areas, value);
492
+ if (area) { areas.add(area.id, w.written, `${how} ${squash(value)}`); found = true; }
493
+ return found;
494
+ }
495
+
496
+ /** The batches or rows of plan arrangements a text names, each leading to the plan that arrangement executed (a batch first written after the citing record is dropped, CR). */
497
+ function followRows(ctx: InferenceContext, text: string, how: string, plans: Tally, citing: Citing, tried: string[]): boolean {
498
+ let found = false;
499
+ for (const { file, row, token } of ctx.rowsNamedIn(text)) {
500
+ const first = citing.day ? ctx.time.firstIn(token, file.path) : null;
501
+ if (first && laterThan(first, citing.day!)) { const note = `${noteOf(citing, `${token} (a row of ${file.path})`, first)}`; if (!tried.includes(note)) tried.push(note); continue; }
502
+ const executed = ctx.planOf(file);
503
+ if (!executed.length) continue;
504
+ // The arrangement that lists the work by its own batch is the record Spec §1.4 places by: it counts as written.
505
+ plans.add(executed[0]!.id, WEIGHT.written, `${how} ${token}, a row of ${file.path} (line ${row.line}), which executed ${executed[0]!.name}`);
506
+ found = true;
507
+ }
508
+ return found;
509
+ }
510
+
511
+ /** A link or path in a text to an arrangement or a plan document, each leading to its plan. */
512
+ function followPaths(ctx: InferenceContext, text: string, how: string, plans: Tally): boolean {
513
+ let found = false;
514
+ for (const p of pathsIn(text)) {
515
+ const file = ctx.fileByPath(p);
516
+ if (file && (file.kind === 'plan' || file.kind === 'index')) {
517
+ for (const lead of ctx.planOf(file).slice(0, 1)) { plans.add(lead.id, WEIGHT.record, `${how} ${file.path}, which executed ${lead.name} (${lead.through[0] ?? 'by its rows'})`); found = true; }
518
+ continue;
519
+ }
520
+ const docs = ctx.plansInDocument(p.replace(/^\.\//, ''));
521
+ if (docs.length === 1) { plans.add(docs[0]!.id, WEIGHT.decision, `${how} ${p}, the document of ${shortName(ctx.store, docs[0]!.id)}`); found = true; }
522
+ }
523
+ return found;
524
+ }
525
+
526
+ // ───────────────────────── the pointers of a work item ─────────────────────────
527
+
528
+ /** Where the records lead for a work item: its plan and its module, with the pointers tried. */
529
+ export function inferThread(ctx: InferenceContext, t: WorkThread): Inference {
530
+ const { store } = ctx;
531
+ const plans = new Tally(store);
532
+ const areas = new Tally(store);
533
+ const tried: string[] = [];
534
+ const nums = [...new Set([...t.ids.map(upper), ...(t.ids.length ? [] : [upper(t.id)])])].filter((n) => n.length >= 2 && n.length <= 24);
535
+ // CR: each of its records cites as of the date it was last written; its own words as of its records now.
536
+ const records: Citing = { day: ctx.time.current(t.id), how: 'it', self: 'its records' };
537
+
538
+ // a · the rows of a plan or dispatch table, and of the index, that list it.
539
+ const rows = nums.length ? ctx.rowsNaming(nums) : [];
540
+ if (!rows.length) tried.push('no plan or dispatch table and no index row lists it');
541
+ for (const { file, row } of rows) {
542
+ const where = `${file.path}:${row.line}`;
543
+ const before = plans.size;
544
+ const rowCiting: Citing = { day: ctx.time.line(file.path, row.line) ?? records.day, how: `its row (${where})`, self: 'that row' };
545
+ for (const [header, value] of Object.entries(row.cells)) follow(ctx, value, `its row (${where}) writes ${header}:`, plans, areas, { written: WEIGHT.written, item: WEIGHT.record, decision: WEIGHT.decision }, rowCiting, tried);
546
+ if (plans.size > before) continue;
547
+ const executed = ctx.planOf(file);
548
+ // A plan arrangement that lists the work is the record Spec §1.4 places by: it counts as written.
549
+ if (executed.length) plans.add(executed[0]!.id, WEIGHT.written, `${file.path} lists it (line ${row.line}); that arrangement executed ${executed[0]!.name} (${executed[0]!.through[0] ?? 'by its rows'})`);
550
+ else tried.push(`${file.path} lists it (line ${row.line}) but ${file.kind === 'index' ? "its row writes no plan or module (an index of every plan's work places only by its row's own cells)" : 'that arrangement names no plan, or names several'}`);
551
+ }
552
+
553
+ // b · the batch or row of an arrangement its own words name (`D59 批次 B`, `B2`).
554
+ if (!followRows(ctx, ownWords(t), 'its own words name', plans, records, tried)) tried.push('its own words name no batch or row of an arrangement');
555
+
556
+ // c · its prompt's front matter.
557
+ const prompts = nums.length ? ctx.promptsOf(nums) : [];
558
+ if (!prompts.length) tried.push('no prompt of its own carries metadata');
559
+ for (const f of prompts) {
560
+ let any = false;
561
+ const promptCiting: Citing = { day: ctx.time.file(f.path) ?? records.day, how: `its front matter (${f.path})`, self: 'that prompt' };
562
+ for (const [k, v] of Object.entries(f.fields)) {
563
+ if (PLAN_FIELDS.test(k) || AREA_FIELDS.test(k)) any = follow(ctx, v, `its front matter (${f.path}) says ${k}:`, plans, areas, { written: WEIGHT.written, item: WEIGHT.record, decision: WEIGHT.decision }, promptCiting, tried) || any;
564
+ else if (UPSTREAM_FIELDS.test(k)) any = follow(ctx, v, `its front matter (${f.path}) says ${k}:`, plans, areas, { written: WEIGHT.record, item: WEIGHT.record, decision: WEIGHT.decision }, promptCiting, tried) || any;
565
+ }
566
+ if (!any) tried.push(`its prompt ${f.path} carries metadata (${Object.keys(f.fields).slice(0, 6).join(', ')}) that names no plan or module`);
567
+ }
568
+
569
+ // d · the contract it implements.
570
+ const contracts = store.relations.filter((r) => r.type === 'implements' && r.from === t.id).map((r) => r.to);
571
+ if (!contracts.length) tried.push('it implements no contract');
572
+ for (const c of contracts) {
573
+ const name = store.threads.get(c)?.ids[0] ?? store.reference.get(c)?.ids[0] ?? shortName(store, c);
574
+ const at = ctx.placesOf(c);
575
+ for (const p of at.plans) plans.add(p, WEIGHT.contract / at.plans.length, `it implements ${name}, which serves ${shortName(store, p)}`);
576
+ at.areas.forEach((a, i) => areas.add(a, i === 0 ? WEIGHT.contract : WEIGHT.record, `it implements ${name}, which serves ${shortName(store, a)}`));
577
+ if (!at.plans.length && !at.areas.length) tried.push(`it implements ${name}, which is placed nowhere yet`);
578
+ }
579
+
580
+ // e · the decisions it carries out, cites or is cited by. What it carries out counts in full (3 each); citations are one
581
+ // kind of evidence and diminish: the first `cites` 2 and each further 1, the first `cited by` 1 and each further ½ (a
582
+ // later decision that reviews old work as history names many old numbers).
583
+ const via = planThroughDecisions(store, ctx.ledger, t, { reach: ctx.reach, index: ctx.index, lines: ctx.lines, time: ctx.time, dropped: tried });
584
+ if (!via.length) tried.push('no decision it carries out, cites or is cited by leads to a plan');
585
+ for (const s of via) {
586
+ const kinds = s.through.map((x) => (x.endsWith('(carries out)') ? 'carries out' : x.endsWith('(cites)') ? 'cites' : 'cited by'));
587
+ const n = (k: string) => kinds.filter((x) => x === k).length;
588
+ const weight = n('carries out') * 3 + (n('cites') ? 2 + (n('cites') - 1) : 0) + (n('cited by') ? 1 + (n('cited by') - 1) * 0.5 : 0);
589
+ plans.add(s.planId, weight, `${s.through.slice(0, 3).join(', ')} → ${s.plan}`);
590
+ }
591
+
592
+ // f · a link or path to a plan document, in its own words or its prompt.
593
+ const texts = [ownWords(t), ...prompts.map((f) => ctx.textOf(f) ?? '')].join('\n');
594
+ if (!followPaths(ctx, texts, 'it links', plans)) tried.push('no link or path to a plan document');
595
+
596
+ // g · the directory it sits in, when that is one plan's.
597
+ for (const dir of [...new Set(prompts.map((f) => f.path.split('/').slice(0, -1).join('/')))]) {
598
+ if (!dir) continue;
599
+ const key = `/${dir.toLowerCase()}/`;
600
+ const here = store.reference.filter((r) => r.category === 'Plan' && !GONE.has(r.validity) && r.sourceIds.some((sid) => {
601
+ const a = store.sources.get(sid)?.anchor;
602
+ if (a?.kind !== 'file') return false;
603
+ const p = slash(a.path).toLowerCase();
604
+ const at = p.indexOf(key);
605
+ return at >= 0 && !p.slice(at + key.length).includes('/');
606
+ }));
607
+ if (here.length === 1) plans.add(here[0]!.id, WEIGHT.faint, `it sits in ${dir}/, the directory of ${shortName(store, here[0]!.id)}'s document`);
608
+ }
609
+ return { plans: inTimeOf(ctx, ctx.time.own(t.id), 'this work item', plans.leads(), tried), areas: areas.leads(), tried };
610
+ }
611
+
612
+ /**
613
+ * CR: a work item or an execution decision does not go to a plan first written after it — the plan it executed existed at
614
+ * its date. Each dropped lead is noted in `tried`. Unknown dates keep the lead.
615
+ */
616
+ function inTimeOf(ctx: InferenceContext, own: string | null, self: string, leads: readonly Lead[], tried: string[]): Lead[] {
617
+ if (!own) return [...leads];
618
+ return leads.filter((l) => {
619
+ if (l.category !== 'Plan') return true;
620
+ const first = ctx.time.first(l.id);
621
+ if (!first || !laterThan(first, own)) return true;
622
+ tried.push(`leads to ${l.name} through ${l.through[0] ?? 'its records'}; ${l.name} was first written ${first}, after ${self} of ${own}`);
623
+ return false;
624
+ });
625
+ }
626
+
627
+ // ───────────────────────── the pointers of a decision or boundary ─────────────────────────
628
+
629
+ /** Where the records lead for a decision or boundary placed nowhere: the plan its arrangement executed, or the module it acts on. */
630
+ export function inferDecision(ctx: InferenceContext, d: ReferenceItem): Inference {
631
+ const { store } = ctx;
632
+ const plans = new Tally(store);
633
+ const areas = new Tally(store);
634
+ const tried: string[] = [];
635
+ const own = (d.ids[0] ?? '').toUpperCase();
636
+ const entry = entryTextOf(store, d);
637
+ // CR: its entry cites as of the entry's own date.
638
+ const ownDay = ctx.time.own(d.id);
639
+ const citing: Citing = { day: ownDay, how: 'its entry', self: 'this entry', text: entry };
640
+
641
+ // 1 · what its own entry names: a plan, a module, a contract, a ticket, a decision.
642
+ const names = ctx.index.named(entry).filter((n) => n.toUpperCase() !== own);
643
+ let named = false;
644
+ for (const n of names) {
645
+ for (const id of ctx.resolve(n)) {
646
+ if (!ctx.existed(citing, n, id, tried)) continue;
647
+ const r = store.reference.get(id);
648
+ if (r?.category === 'Plan') { plans.add(id, WEIGHT.written, `its entry names ${n}`); named = true; }
649
+ else if (r?.category === 'Area') { areas.add(id, WEIGHT.written, `its entry names ${n}`); named = true; }
650
+ else if (r && (r.category === 'Decision' || r.category === 'Boundary')) {
651
+ for (const lead of ctx.decisionLeads(r)) { plans.add(lead.id, WEIGHT.decision, `its entry names ${n}; ${lead.through}`); named = true; }
652
+ for (const note of ctx.decisionDrops(r)) { const x = `its entry names ${n}; ${note}`; if (!tried.includes(x)) tried.push(x); }
653
+ for (const a of ctx.reach.ofReference(r.id).areas) { areas.add(a, WEIGHT.decision, `its entry names ${n}, which is placed on ${shortName(store, a)}`); named = true; }
654
+ } else if (r || store.threads.has(id)) {
655
+ const at = ctx.placesOf(id);
656
+ for (const p of at.plans) { plans.add(p, WEIGHT.record / at.plans.length, `its entry names ${n}, which serves ${shortName(store, p)}`); named = true; }
657
+ for (const a of at.areas) { areas.add(a, WEIGHT.record / at.areas.length, `its entry names ${n}, which serves ${shortName(store, a)}`); named = true; }
658
+ }
659
+ }
660
+ }
661
+ if (!named) tried.push(names.length ? `its entry names ${listOf(names, 6)}, none placed` : 'its entry names no plan, module, number or section');
662
+
663
+ // 2 · the batch or row of an arrangement its entry names (`B2 合入`, `批次 A`).
664
+ if (!followRows(ctx, entry, 'its entry names', plans, citing, tried)) tried.push('its entry names no batch or row of an arrangement');
665
+
666
+ // 3 · a link or path in its entry to an arrangement or a plan document.
667
+ if (!followPaths(ctx, entry, 'its entry links', plans)) tried.push('its entry links no arrangement or plan document');
668
+
669
+ // 4 · the log's own plan: the entry stands in an arrangement document.
670
+ const logPaths = [...new Set(d.sourceIds.flatMap((sid) => { const a = store.sources.get(sid)?.anchor; return a?.kind === 'file' ? [slash(a.path)] : []; }))];
671
+ let ownPlan = false;
672
+ for (const abs of logPaths) {
673
+ const file = ctx.files.find((f) => abs.toLowerCase().endsWith(`/${f.path.toLowerCase()}`));
674
+ if (file && file.kind !== 'prompt' && file.kind !== 'receipt') {
675
+ for (const lead of ctx.planOf(file).slice(0, 1)) { plans.add(lead.id, WEIGHT.decision, `its log ${file.path} is the arrangement of ${lead.name}`); ownPlan = true; }
676
+ }
677
+ }
678
+ if (!ownPlan && logPaths.length) tried.push(`its log (${logPaths.map((p) => p.split('/').slice(-2).join('/')).join(', ')}) is no plan's own arrangement`);
679
+
680
+ // 5 · the current documents that cite it, each standing in a placed item (placing.ts `tracePlacement`).
681
+ // The names its entry writes were checked above; of the trace's drops only the citing lines are new.
682
+ const traceDrops: string[] = [];
683
+ const trace = tracePlacement(store, ctx.ledger, d, { reach: ctx.reach, index: ctx.index, lines: ctx.lines, time: ctx.time, dropped: traceDrops });
684
+ for (const note of traceDrops) if (!note.includes("'s entry names ") && !tried.includes(note)) tried.push(note);
685
+ for (const lead of trace.leads) {
686
+ const r = store.reference.get(lead.id);
687
+ if (!r) continue;
688
+ (r.category === 'Plan' ? plans : areas).add(lead.id, WEIGHT.faint * Math.min(lead.count, 3), trace.citations ? `the documents citing it lead to ${shortName(store, lead.id)} (${trace.cited[0] ?? `${trace.citations} citations`})` : `its entry leads to ${shortName(store, lead.id)}`);
689
+ }
690
+ if (!trace.citations) tried.push('no current document cites it from a placed item');
691
+ // An execution decision executed a plan that existed at its date; a product decision may shape a later plan.
692
+ return { plans: ctx.time.executionEntry(d) ? inTimeOf(ctx, ownDay, 'this entry', plans.leads(), tried) : plans.leads(), areas: areas.leads(), tried };
693
+ }
694
+
695
+ // ───────────────────────── the defined zeros ─────────────────────────
696
+
697
+ /** Whether the project has a plan layer at all: Plan items, or a Plan or Execution arrangement layer in the layer map (current or not). */
698
+ export function planLayerPresent(store: ProjectStore): boolean {
699
+ return store.reference.find((r) => r.category === 'Plan' && !GONE.has(r.validity)) !== undefined || store.layers.find((l) => l.layer === 'Plan' || l.layer === 'Execution arrangement') !== undefined;
700
+ }
701
+ /** Whether the project has Areas (modules) yet. */
702
+ export function areasPresent(store: ProjectStore): boolean {
703
+ return store.reference.find((r) => r.category === 'Area' && !GONE.has(r.validity) && r.validity !== 'Abandoned') !== undefined;
704
+ }
705
+ /** Whether the project has a contract layer: a Task contract layer in the layer map, or work that records what it implements. */
706
+ export function contractLayerPresent(store: ProjectStore): boolean {
707
+ return store.layers.find((l) => l.layer === 'Task contract') !== undefined || store.relations.find((r) => r.type === 'implements') !== undefined;
708
+ }
709
+
710
+ export const NO_PLAN_LAYER = 'not applicable: this project has no plan layer (no Plan items, no Plan or Execution arrangement layer), so no work item is in no plan and no noPlanWhy is needed';
711
+ export const NO_AREAS = 'not applicable: this project has no Areas yet, so no work item is in no module';
712
+ export const NO_CONTRACT_LAYER = 'not applicable: this project has no contract layer (no Task contract layer, and no work item implements a contract), so no ticket is missing its contract';
713
+
714
+ // ───────────────────────── the reasons the program can refuse ─────────────────────────
715
+
716
+ const chainOf = (lead: Lead): string => lead.through.join('; ');
717
+
718
+ /**
719
+ * Why `noPlanWhy` is refused for this work item, or null when the reason stands (the records lead nowhere, or the project
720
+ * has no plan layer). DB: `drawn` are the plans the workbench draws a band for (workbench-placement.ts); a lead to a plan
721
+ * with no band — not current, or an earlier generation's — places nothing, so the reason stands against it.
722
+ */
723
+ export function noPlanWhyRefusal(ctx: InferenceContext, t: WorkThread, drawn: ReadonlySet<string> | null = null): string | null {
724
+ if (!planLayerPresent(ctx.store)) return null;
725
+ const leads = inferThread(ctx, t).plans.filter((l) => !drawn || drawn.has(l.id));
726
+ const inf = { plans: leads };
727
+ if (!inf.plans.length) return null;
728
+ const top = pick(inf.plans) ?? inf.plans[0]!;
729
+ const others = inf.plans.filter((l) => l !== top).slice(0, 2);
730
+ return `noPlanWhy is refused: the records lead to ${top.name} — ${chainOf(top)}${others.length ? `; they also lead to ${others.map((l) => `${l.name} (${l.through[0]})`).join(' and ')}` : ''}. Place it there with the record (serves the Plan item), or move it with the record that says otherwise; a reason stands only where the records lead nowhere. Nothing was written.`;
731
+ }
732
+
733
+ /** Why `noAreaWhy` is refused for this work item, or null when the reason stands (no record names a module, or the project has no Areas). */
734
+ export function noAreaWhyRefusal(ctx: InferenceContext, t: WorkThread): string | null {
735
+ if (!areasPresent(ctx.store)) return null;
736
+ const inf = inferThread(ctx, t);
737
+ if (!inf.areas.length) return null;
738
+ const top = pick(inf.areas) ?? inf.areas[0]!;
739
+ const others = inf.areas.filter((l) => l !== top).slice(0, 2);
740
+ return `noAreaWhy is refused: the records name ${top.name} — ${chainOf(top)}${others.length ? `; they also name ${others.map((l) => `${l.name} (${l.through[0]})`).join(' and ')}` : ''}. Place it there with the record (serves the Area first), or move it with the record that says otherwise; a reason stands only where the records name no module. Nothing was written.`;
741
+ }
742
+
743
+ // ───────────────────────── the program places first ─────────────────────────
744
+
745
+ const PROGRAM_SUMMARY = 'Placed by the program (Inferred)';
746
+
747
+ /** Whether a trace summary is one of the program's own placements. */
748
+ export const isProgramPlacement = (summary: string): boolean => summary.startsWith(PROGRAM_SUMMARY);
749
+
750
+ export interface PlacedByProgram {
751
+ readonly placed: readonly InferredPlacement[];
752
+ /** Work items and decisions still unplaced after the pass — in no plan, in no module, or placed nowhere — each with the pointers tried. */
753
+ readonly empty: readonly { readonly id: string; readonly name: string; readonly needs: readonly ('plan' | 'module' | 'placement')[]; readonly tried: readonly string[]; readonly leads: readonly Lead[] }[];
754
+ }
755
+
756
+ /** The live work items in no plan or no module, and the decisions and boundaries placed nowhere (or on the Product with no reason). */
757
+ function unplacedNow(store: ProjectStore, reach: Reach): { threads: WorkThread[]; decisions: ReferenceItem[] } {
758
+ const inGeneration = earlierWork(store); // CZ: carried-on items are current work
759
+ const threads = store.threads.filter((t) => !GONE.has(t.validity)).filter((t) => {
760
+ const inPlan = inGeneration.has(t.id) || plansOf(store, t).length > 0;
761
+ const inArea = reach.ofThread(t).areas.length > 0 || Boolean(t.wholePlanWhy?.trim());
762
+ return (!inPlan && !t.noPlanWhy?.trim()) || (!inArea && !t.noAreaWhy?.trim());
763
+ });
764
+ const decisions = store.reference.filter((r) => (r.category === 'Decision' || r.category === 'Boundary') && !GONE.has(r.validity)).filter((d) => {
765
+ const at = reach.ofReference(d.id);
766
+ if (at.areas.length || at.plans.length) return false;
767
+ return !(at.product && d.wholeProductWhy?.trim());
768
+ });
769
+ return { threads, decisions };
770
+ }
771
+
772
+ /** The targets a decision's leads settle on: the heaviest, or a plan and an area tied; none on a tie within a kind. */
773
+ function decisionTargets(inf: Inference): Lead[] {
774
+ const all = [...inf.plans, ...inf.areas].sort((a, b) => b.weight - a.weight);
775
+ if (!all.length) return [];
776
+ const tied = all.filter((l) => l.weight === all[0]!.weight);
777
+ return tied.length === 1 || (tied.length === 2 && tied[0]!.category !== tied[1]!.category) ? tied : [];
778
+ }
779
+
780
+ /**
781
+ * Place what the records lead to, basis Inferred, and record each on the round (CQ, D104). Run as the main agent enters
782
+ * reconcile and the cross-check, and at its handover. Later passes pick up work items whose chain only closes once the
783
+ * decisions are placed, and decisions whose chain closes once the tickets they name are. Nothing already placed is
784
+ * touched; a work item with an accepted reason is left as written.
785
+ */
786
+ export function placeByProgram(store: ProjectStore, ledger: Ledger | null, round: Pick<ClerkRound, 'id'>, jobId: string | null, stage: string): PlacedByProgram {
787
+ const at = new Date().toISOString();
788
+ const placed: InferredPlacement[] = [];
789
+ const trace = (summary: string): TraceInfo => ({ jobId, basisSourceIds: [], summary });
790
+ const empty = new Map<string, { id: string; name: string; needs: ('plan' | 'module' | 'placement')[]; tried: readonly string[]; leads: readonly Lead[] }>();
791
+ // CS: a placement of any round that a write took off its item since has its result first, so it is not written again below.
792
+ settleProgramPlacements(store, { jobId, roundId: round.id });
793
+ // A placement a model moved away from is not written again: the model's word stands over the program's reading.
794
+ const movedAway = new Set(store.clerkRounds.all().flatMap((r) => (r.inferredPlacements ?? []).filter((p) => p.result?.kind === 'moved').map((p) => `${p.kind}:${p.id}:${p.targetId}`)));
795
+ const stands = (kind: InferredPlacement['kind'], id: string, lead: Lead | null): Lead | null => (lead && !movedAway.has(`${kind}:${id}:${lead.id}`) ? lead : null);
796
+
797
+ const pass = (ctx: InferenceContext, what: 'threads' | 'decisions'): number => {
798
+ let n = 0;
799
+ const now = unplacedNow(store, ctx.reach);
800
+ if (what === 'threads') {
801
+ for (const t of now.threads) {
802
+ const inPlan = earlierWork(store).has(t.id) || plansOf(store, t).length > 0;
803
+ const inArea = ctx.reach.ofThread(t).areas.length > 0 || Boolean(t.wholePlanWhy?.trim());
804
+ const needPlan = !inPlan && !t.noPlanWhy?.trim();
805
+ const needArea = !inArea && !t.noAreaWhy?.trim();
806
+ const inf = inferThread(ctx, t);
807
+ const plan = needPlan ? stands('thread', t.id, pick(inf.plans)) : null;
808
+ const area = needArea ? stands('thread', t.id, pick(inf.areas)) : null;
809
+ const stillPlan = needPlan && !plan;
810
+ const stillArea = needArea && !area;
811
+ if (stillPlan || stillArea) empty.set(t.id, { id: t.id, name: threadName(t), needs: [...(stillPlan ? ['plan' as const] : []), ...(stillArea ? ['module' as const] : [])], tried: inf.tried, leads: [...(stillPlan ? inf.plans : []), ...(stillArea ? inf.areas : [])] });
812
+ else empty.delete(t.id);
813
+ if (!plan && !area) continue;
814
+ const serves = [...t.serves];
815
+ if (area) serves.unshift({ referenceId: area.id, claim: chainOf(area), basis: 'Inferred' });
816
+ if (plan) serves.push({ referenceId: plan.id, claim: chainOf(plan), basis: 'Inferred' });
817
+ store.threads.put({ ...t, serves, updatedAt: at }, trace(`${PROGRAM_SUMMARY}: ${threadName(t)} → ${[area?.name, plan?.name].filter(Boolean).join(', ')}: ${[area, plan].filter((l): l is Lead => l !== null).map(chainOf).join(' | ')}`));
818
+ for (const lead of [area, plan]) if (lead) { placed.push({ kind: 'thread', id: t.id, name: threadName(t), targetId: lead.id, target: lead.name, targetCategory: lead.category, chain: chainOf(lead), at, stage, jobId }); n += 1; }
819
+ }
820
+ } else {
821
+ for (const d of now.decisions) {
822
+ const inf = inferDecision(ctx, d);
823
+ const targets = decisionTargets(inf).filter((l) => stands('reference', d.id, l) !== null);
824
+ if (!targets.length) { empty.set(d.id, { id: d.id, name: refName(d), needs: ['placement'], tried: inf.tried, leads: [...inf.plans, ...inf.areas].sort((a, b) => b.weight - a.weight) }); continue; }
825
+ empty.delete(d.id);
826
+ store.reference.put({ ...d, refines: [...d.refines, ...targets.map((l) => l.id).filter((id) => !d.refines.includes(id))], updatedAt: at }, trace(`${PROGRAM_SUMMARY}: ${refName(d)} → ${targets.map((l) => l.name).join(', ')}: ${targets.map(chainOf).join(' | ')}`));
827
+ for (const lead of targets) { placed.push({ kind: 'reference', id: d.id, name: refName(d), targetId: lead.id, target: lead.name, targetCategory: lead.category, chain: chainOf(lead), at, stage, jobId }); n += 1; }
828
+ }
829
+ }
830
+ return n;
831
+ };
832
+
833
+ // Each pass reads the store as it stands, so the next sees the last one's placements. It stops once a pass places nothing.
834
+ const time = chronology(store, ledger);
835
+ for (let i = 0; i < 3; i++) {
836
+ const ctx = inferenceContext(store, ledger, { time });
837
+ const a = pass(ctx, 'threads');
838
+ const b = pass(inferenceContext(store, ledger, { index: ctx.index, lines: ctx.lines, time }), 'decisions');
839
+ if (a + b === 0) break;
840
+ }
841
+ if (placed.length) {
842
+ const current = store.clerkRounds.get(round.id);
843
+ if (current) {
844
+ const kept = (current.inferredPlacements ?? []).filter((p) => !placed.some((x) => x.kind === p.kind && x.id === p.id && x.targetId === p.targetId));
845
+ store.clerkRounds.put({ ...current, inferredPlacements: [...kept, ...placed], updatedAt: at }, trace(`Round: the program placed ${placed.length} item${placed.length === 1 ? '' : 's'} from the records (Inferred) as the main agent entered ${stage}`));
846
+ }
847
+ }
848
+ return { placed, empty: [...empty.values()] };
849
+ }
850
+
851
+ /** The note `pk_stage` gives the main agent after the program placed: how many, where, and how to check them. */
852
+ export function placedNote(result: PlacedByProgram, stage: string): string | null {
853
+ if (!result.placed.length) return null;
854
+ const byTarget = new Map<string, number>();
855
+ for (const p of result.placed) byTarget.set(p.target, (byTarget.get(p.target) ?? 0) + 1);
856
+ const shown = result.placed.slice(0, 8).map((p) => `${p.name.slice(0, 50)} → ${p.target}`).join('; ');
857
+ return `=== Placed by the program (Inferred), entering ${stage}\n${result.placed.length} placement${result.placed.length === 1 ? '' : 's'} from the records, basis Inferred, the chain in each claim: ${[...byTarget].map(([t, n]) => `${n} → ${t}`).join(', ')}. ${shown}${result.placed.length > 8 ? `; and ${result.placed.length - 8} more` : ''}. pk_round_state({ list: "inferredPlacements" }) lists each with its chain: check it against the record, keep it, or move it with replaceServes / refines and the record that says otherwise.`;
858
+ }
859
+
860
+ // ───────────────────────── what became of each program placement ─────────────────────────
861
+
862
+ export interface InferredPlacementRow {
863
+ readonly id: string;
864
+ readonly kind: InferredPlacement['kind'];
865
+ readonly name: string;
866
+ readonly placedOn: string;
867
+ readonly chain: string;
868
+ /** The round that placed it, in words (`First usable round 1`), and the stage the program placed it in. */
869
+ readonly round: string;
870
+ readonly roundId: string;
871
+ readonly stage: string;
872
+ readonly at: string;
873
+ /** `left`: nobody confirmed or moved it yet; it stands as placed. Only these are listed. */
874
+ readonly result: 'left';
875
+ /** The Area or Plan it was placed on, by id. */
876
+ readonly targetId: string;
877
+ }
878
+
879
+ /** Who gives a program placement its result: the job writing (its round's trace shows the result) and that round. */
880
+ export interface ResultBy { readonly jobId: string | null; readonly roundId?: string | null }
881
+
882
+ /** How many placements the program wrote in every round of the project, and what became of them. */
883
+ export interface ProgramPlacementCounts { readonly placed: number; readonly confirmed: number; readonly moved: number; readonly unreviewed: number }
884
+
885
+ /**
886
+ * Whether a program placement is still on its item as the program wrote it: `stands` (the item still carries the target,
887
+ * basis Inferred), `kept` (it carries the target, written since as a model's own), or `gone` (a later write replaced or
888
+ * removed it, or the item is no longer there).
889
+ */
890
+ function standingOf(store: ProjectStore, p: InferredPlacement): 'stands' | 'kept' | 'gone' {
891
+ if (p.kind === 'thread') {
892
+ const s = store.threads.get(p.id)?.serves.find((x) => x.referenceId === p.targetId);
893
+ return !s ? 'gone' : s.basis === 'Inferred' ? 'stands' : 'kept';
894
+ }
895
+ return store.reference.get(p.id)?.refines.includes(p.targetId) ? 'stands' : 'gone';
896
+ }
897
+
898
+ const roundLabel = (r: Pick<ClerkRound, 'kind' | 'number'>): string => `${r.kind} round ${r.number}`;
899
+
900
+ /** Every placement the program wrote, in every round of the project, oldest first, each with what became of it. */
901
+ function programPlacements(store: ProjectStore): { readonly round: ClerkRound; readonly p: InferredPlacement; readonly state: 'left' | 'confirmed' | 'moved' }[] {
902
+ return store.clerkRounds.all().sort((a, b) => a.startedAt.localeCompare(b.startedAt) || a.number - b.number).flatMap((round) => (round.inferredPlacements ?? []).map((p) => {
903
+ if (p.result) return { round, p, state: p.result.kind };
904
+ const now = standingOf(store, p);
905
+ return { round, p, state: now === 'stands' ? 'left' as const : now === 'kept' ? 'confirmed' as const : 'moved' as const };
906
+ }));
907
+ }
908
+
909
+ /**
910
+ * The program's placements that still stand and have no result yet, whichever round placed them (CS; the owner's U86:
911
+ * 「U86按照落位就算,主agent可以核查,暂时不用改成逐条确认」): they stand as placed, and stay listed — for the main agent to check and
912
+ * for the spot-check to sample — until a write confirms or moves them. On the CQ run 11 of the 18 placements of the first
913
+ * usable round were never reviewed, and the deepening's list read 0 because it was kept per round.
914
+ */
915
+ export function inferredPlacementRows(store: ProjectStore): InferredPlacementRow[] {
916
+ return programPlacements(store).filter((x) => x.state === 'left').map(({ round, p }) => ({
917
+ id: p.id, kind: p.kind, name: p.name, placedOn: p.target, chain: p.chain, round: roundLabel(round), roundId: round.id, stage: p.stage, at: p.at, result: 'left' as const, targetId: p.targetId,
918
+ }));
919
+ }
920
+
921
+ /** The counts the handover note and the round's Result give: placed in all, confirmed, moved, still unreviewed. */
922
+ export function programPlacementCounts(store: ProjectStore): ProgramPlacementCounts {
923
+ const all = programPlacements(store);
924
+ const n = (state: string) => all.filter((x) => x.state === state).length;
925
+ return { placed: all.length, confirmed: n('confirmed'), moved: n('moved'), unreviewed: n('left') };
926
+ }
927
+
928
+ /** Where an item sits now, in short names, for a `moved` result. */
929
+ function sitsNow(store: ProjectStore, p: InferredPlacement): string {
930
+ const targets = p.kind === 'thread' ? store.threads.get(p.id)?.serves.map((s) => s.referenceId) ?? [] : store.reference.get(p.id)?.refines ?? [];
931
+ return targets.filter((x) => x !== p.targetId).map((x) => shortName(store, x)).join(', ');
932
+ }
933
+
934
+ /** Write results onto the records of the rounds that placed them, each round's change traced under the job that gave it. */
935
+ function writeResults(store: ProjectStore, by: ResultBy, how: 'write' | 'spot-check' | 'program', resultOf: (p: InferredPlacement) => { readonly kind: 'confirmed' | 'moved'; readonly to?: string } | null): number {
936
+ const at = new Date().toISOString();
937
+ let given = 0;
938
+ for (const round of store.clerkRounds.all()) {
939
+ const said: string[] = [];
940
+ const next = (round.inferredPlacements ?? []).map((p) => {
941
+ if (p.result) return p;
942
+ const r = resultOf(p);
943
+ if (!r) return p;
944
+ said.push(`${clip(p.name, 40)} on ${p.target}: ${r.kind}${r.to ? ` to ${r.to}` : ''}`);
945
+ return { ...p, result: { kind: r.kind, at, ...(r.to ? { to: r.to } : {}), ...(by.roundId ? { roundId: by.roundId } : {}), by: how } };
946
+ });
947
+ if (!said.length) continue;
948
+ given += said.length;
949
+ const who = how === 'spot-check' ? 'checked by the spot-check' : how === 'program' ? 'as the program finds them on the items now' : 'by a write';
950
+ store.clerkRounds.put({ ...round, inferredPlacements: next, updatedAt: at }, { jobId: by.jobId, basisSourceIds: [], summary: `Program placements of ${roundLabel(round)}, ${who}: ${said.slice(0, 6).join('; ')}${said.length > 6 ? `; and ${said.length - 6} more` : ''}` });
951
+ }
952
+ return given;
953
+ }
954
+
955
+ /**
956
+ * A model's write touched an item the program placed (CQ): record what became of each program placement — kept as the
957
+ * model's own (`confirmed`: its basis or claim rewritten), or replaced (`moved`, with where the item sits now). CS: the
958
+ * result lands on the record of the round that placed it, whichever round the write is in, and is traced under the
959
+ * writing job, so it shows in that round's trace. Returns the placements moved, for the writer's result and trace.
960
+ */
961
+ export function noteOverride(store: ProjectStore, kind: InferredPlacement['kind'], id: string, after: { readonly targets: readonly string[]; readonly kept: (targetId: string) => 'as written' | 'rewritten' | 'gone' }, by: ResultBy = { jobId: null }): string[] {
962
+ const moved: string[] = [];
963
+ writeResults(store, by, 'write', (p) => {
964
+ if (p.kind !== kind || p.id !== id) return null;
965
+ const state = after.kept(p.targetId);
966
+ if (state === 'as written') return null;
967
+ if (state === 'rewritten') return { kind: 'confirmed' };
968
+ const to = after.targets.filter((x) => x !== p.targetId).map((x) => shortName(store, x)).join(', ');
969
+ moved.push(`${p.target} (${clip(p.chain, 80)})`);
970
+ return { kind: 'moved', ...(to ? { to } : {}) };
971
+ });
972
+ return moved;
973
+ }
974
+
975
+ /**
976
+ * CS: a placement with no result that is no longer on its item as the program wrote it has its result — a writer that
977
+ * does not report to `noteOverride` (a range placed again, a merge) replaced or removed it, or wrote it as its own. The
978
+ * program records that on the round that placed it: `moved` with where the item sits now, or `confirmed`. Run with the
979
+ * program's own upkeep (placing as a stage is entered, counting the round for the main agent, starting the spot-check);
980
+ * a second run finds nothing.
981
+ */
982
+ export function settleProgramPlacements(store: ProjectStore, by: ResultBy): number {
983
+ return writeResults(store, by, 'program', (p) => {
984
+ const now = standingOf(store, p);
985
+ if (now === 'stands') return null;
986
+ if (now === 'kept') return { kind: 'confirmed' };
987
+ const to = sitsNow(store, p);
988
+ return { kind: 'moved', ...(to ? { to } : {}) };
989
+ });
990
+ }
991
+
992
+ /**
993
+ * CS: the spot-check checked a program placement against the records and found it right: that is its result
994
+ * (`confirmed`, by the spot-check, in the spot-check's round), so it leaves the list and is not sampled again. One it found
995
+ * wrong and corrected was moved by the correcting write; one found wrong and left stays with no result, and stays listed.
996
+ */
997
+ export function confirmBySpotCheck(store: ProjectStore, checked: readonly { readonly kind: InferredPlacement['kind']; readonly id: string; readonly targetId: string }[], by: ResultBy): number {
998
+ if (!checked.length) return 0;
999
+ const keys = new Set(checked.map((c) => `${c.kind}:${c.id}:${c.targetId}`));
1000
+ return writeResults(store, by, 'spot-check', (p) => (keys.has(`${p.kind}:${p.id}:${p.targetId}`) && standingOf(store, p) === 'stands' ? { kind: 'confirmed' } : null));
1001
+ }
1002
+
1003
+ /** The targets of the program's placements this item still carries. */
1004
+ export function programTargetsOf(store: ProjectStore, kind: InferredPlacement['kind'], id: string): string[] {
1005
+ return [...new Set(store.clerkRounds.all().flatMap((r) => (r.inferredPlacements ?? []).filter((p) => p.kind === kind && p.id === id && !p.result).map((p) => p.targetId)))];
1006
+ }
1007
+
1008
+ /** The pointers tried for an item still unplaced, in one line for the spot-check and the unplaced list. */
1009
+ export function triedLine(ctx: InferenceContext, item: WorkThread | ReferenceItem): string {
1010
+ const inf = 'serves' in item ? inferThread(ctx, item) : inferDecision(ctx, item);
1011
+ const leads = [...inf.plans, ...inf.areas];
1012
+ return [...(leads.length ? [`the records lead to ${leads.slice(0, 3).map((l) => `${l.name} (${l.through[0]})`).join('; ')}`] : []), ...inf.tried].join('; ');
1013
+ }
1014
+
1015
+ // ───────────────────────── an archived arrangement, as a generation candidate ─────────────────────────
1016
+
1017
+ /**
1018
+ * What an execution arrangement kept under a root (an archive directory, a not-current plan document) executes, as the
1019
+ * program reads its rows (CQ, D104): "its rows name work of plan(s) P1 (23 tickets, 69 log entries)". Orientation accepts
1020
+ * or rejects the candidate generation with that in hand; the rows place the work they list whatever the verdict. Null when
1021
+ * no arrangement under the root leads to a plan.
1022
+ */
1023
+ export function arrangementEvidence(ctx: InferenceContext, root: string): string | null {
1024
+ const rel = slash(root).replace(/\/+$/, '').toLowerCase();
1025
+ const files = ctx.files.filter((f) => (f.kind === 'plan' || f.kind === 'index') && (f.path.toLowerCase() === rel || f.path.toLowerCase().startsWith(`${rel}/`)));
1026
+ if (!files.length) return null;
1027
+ const plans = new Map<string, string>();
1028
+ const tickets = new Set<string>();
1029
+ for (const f of files) {
1030
+ const lead = ctx.planOf(f)[0];
1031
+ if (!lead) continue;
1032
+ plans.set(lead.id, lead.name);
1033
+ for (const row of f.rows) {
1034
+ for (const n of [rowToken(row.id), ...Object.values(row.cells).flatMap((c) => ctx.index.numbers(c))]) for (const id of ctx.resolve(n)) if (ctx.store.threads.has(id)) tickets.add(id);
1035
+ }
1036
+ }
1037
+ if (!plans.size) return null;
1038
+ let entries = 0;
1039
+ const paths = new Set(files.map((f) => f.path.toLowerCase()));
1040
+ for (const d of ctx.store.reference.filter((r) => (r.category === 'Decision' || r.category === 'Boundary') && !GONE.has(r.validity))) {
1041
+ const entry = entryTextOf(ctx.store, d);
1042
+ if (ctx.rowsNamedIn(entry).some((hit) => paths.has(hit.file.path.toLowerCase())) || pathsIn(entry).some((p) => { const f = ctx.fileByPath(p); return f !== null && paths.has(f.path.toLowerCase()); })) entries += 1;
1043
+ }
1044
+ return `its rows name work of plan(s) ${[...plans.values()].join(', ')} (${tickets.size} work item${tickets.size === 1 ? '' : 's'}, ${entries} log entr${entries === 1 ? 'y' : 'ies'})`;
1045
+ }