rcf-lite 0.28.2 → 0.29.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 (327) hide show
  1. package/CHANGELOG.md +317 -0
  2. package/bin/rcf.js +8 -0
  3. package/fixtures/canary-manifest.json +9 -9
  4. package/guidance/define-draft-shapes.md +89 -0
  5. package/guidance/define-intake.md +97 -0
  6. package/guidance/define-intent.md +107 -0
  7. package/guidance/discover-prototype.md +67 -0
  8. package/guidance/discovery-prototypes.md +69 -0
  9. package/guidance/harness-template.md +34 -0
  10. package/guidance/managed/agent-instructions-block.hash +1 -1
  11. package/guidance/managed/agent-instructions-block.md +34 -0
  12. package/guidance/manifest.json +9 -2
  13. package/package.json +1 -1
  14. package/rcf/adrs/adr-4120-canonical-json-hashing.json +30 -0
  15. package/rcf/adrs/adr-4121-define-sidecars.json +30 -0
  16. package/rcf/adrs/adr-4122-define-gates-blocking-and-optout.json +30 -0
  17. package/rcf/adrs/adr-4123-readiness-single-object.json +30 -0
  18. package/rcf/adrs/adr-4124-define-freeze-verb.json +30 -0
  19. package/rcf/adrs/adr-4126-persona-tagged-blockers-two-levels.json +40 -0
  20. package/rcf/adrs/adr-4127-readiness-tab-order-and-register.json +40 -0
  21. package/rcf/adrs/adr-4129-questions-harness-driven-loop.json +25 -0
  22. package/rcf/adrs/adr-4130-intake-harness-split-tool-scanned.json +20 -0
  23. package/rcf/adrs/adr-4132-prototypes-in-discovery-as-evidence.json +20 -0
  24. package/rcf/adrs/adr-4133-dag-dependency-depth-layout.json +40 -0
  25. package/rcf/adrs/adr-4134-index-json-route.json +35 -0
  26. package/rcf/adrs/adr-4135-readiness-po-register-and-adapter.json +35 -0
  27. package/rcf/adrs/adr-4136-embedding-host-fixture.json +35 -0
  28. package/rcf/code-nodes/cn-700.json +23 -0
  29. package/rcf/code-nodes/cn-701.json +14 -0
  30. package/rcf/code-nodes/cn-702.json +14 -0
  31. package/rcf/code-nodes/cn-703.json +16 -0
  32. package/rcf/code-nodes/cn-720.json +15 -0
  33. package/rcf/code-nodes/cn-721.json +15 -0
  34. package/rcf/code-nodes/cn-722.json +15 -0
  35. package/rcf/code-nodes/cn-723.json +18 -0
  36. package/rcf/code-nodes/cn-724.json +14 -0
  37. package/rcf/code-nodes/cn-725.json +37 -0
  38. package/rcf/code-nodes/cn-726.json +25 -0
  39. package/rcf/code-nodes/cn-727.json +23 -0
  40. package/rcf/code-nodes/cn-728.json +37 -0
  41. package/rcf/code-nodes/cn-729.json +26 -0
  42. package/rcf/code-nodes/cn-730.json +24 -0
  43. package/rcf/code-nodes/cn-731.json +20 -0
  44. package/rcf/code-nodes/cn-732.json +14 -0
  45. package/rcf/code-nodes/cn-733.json +14 -0
  46. package/rcf/code-nodes/cn-734.json +14 -0
  47. package/rcf/code-nodes/cn-735.json +20 -0
  48. package/rcf/code-nodes/cn-736.json +14 -0
  49. package/rcf/code-nodes/cn-737.json +14 -0
  50. package/rcf/code-nodes/cn-738.json +14 -0
  51. package/rcf/code-nodes/cn-739.json +18 -0
  52. package/rcf/code-nodes/cn-740.json +19 -0
  53. package/rcf/code-nodes/cn-741.json +16 -0
  54. package/rcf/code-nodes/cn-742.json +16 -0
  55. package/rcf/code-nodes/cn-743.json +15 -0
  56. package/rcf/code-nodes/cn-744.json +22 -0
  57. package/rcf/code-nodes/cn-745.json +16 -0
  58. package/rcf/code-nodes/cn-746.json +14 -0
  59. package/rcf/code-nodes/cn-747.json +18 -0
  60. package/rcf/code-nodes/cn-748.json +16 -0
  61. package/rcf/code-nodes/cn-749.json +16 -0
  62. package/rcf/code-nodes/cn-750.json +15 -0
  63. package/rcf/code-nodes/cn-751.json +18 -0
  64. package/rcf/code-nodes/cn-752.json +14 -0
  65. package/rcf/fbs/fbs-190.json +37 -0
  66. package/rcf/fbs/fbs-191.json +34 -0
  67. package/rcf/fbs/fbs-192.json +17 -0
  68. package/rcf/fbs/fbs-193.json +37 -0
  69. package/rcf/fbs/fbs-194.json +35 -0
  70. package/rcf/fbs/fbs-195.json +37 -0
  71. package/rcf/fbs/fbs-196.json +45 -0
  72. package/rcf/fbs/fbs-197.json +41 -0
  73. package/rcf/fbs/fbs-198.json +42 -0
  74. package/rcf/fbs/fbs-199.json +59 -0
  75. package/rcf/fbs/fbs-200.json +46 -0
  76. package/rcf/fbs/fbs-201.json +49 -0
  77. package/rcf/fbs/fbs-202.json +110 -0
  78. package/rcf/fbs/fbs-203.json +100 -0
  79. package/rcf/requirements/req-001.json +2 -1
  80. package/rcf/requirements/req-002.json +3 -2
  81. package/rcf/requirements/req-003.json +2 -1
  82. package/rcf/requirements/req-004.json +2 -1
  83. package/rcf/requirements/req-005.json +2 -1
  84. package/rcf/requirements/req-006.json +2 -1
  85. package/rcf/requirements/req-007.json +2 -1
  86. package/rcf/requirements/req-008.json +2 -1
  87. package/rcf/requirements/req-009.json +2 -1
  88. package/rcf/requirements/req-010.json +5 -2
  89. package/rcf/requirements/req-011.json +2 -1
  90. package/rcf/requirements/req-012.json +2 -1
  91. package/rcf/requirements/req-013.json +2 -1
  92. package/rcf/requirements/req-014.json +2 -1
  93. package/rcf/requirements/req-015.json +2 -1
  94. package/rcf/requirements/req-016.json +5 -2
  95. package/rcf/requirements/req-017.json +6 -2
  96. package/rcf/requirements/req-018.json +5 -2
  97. package/rcf/requirements/req-019.json +5 -2
  98. package/rcf/requirements/req-020.json +5 -2
  99. package/rcf/requirements/req-021.json +5 -2
  100. package/rcf/requirements/req-022.json +5 -2
  101. package/rcf/requirements/req-023.json +6 -4
  102. package/rcf/requirements/req-024.json +5 -2
  103. package/rcf/requirements/req-025.json +5 -2
  104. package/rcf/requirements/req-026.json +5 -2
  105. package/rcf/requirements/req-027.json +5 -2
  106. package/rcf/requirements/req-028.json +5 -2
  107. package/rcf/requirements/req-029.json +5 -2
  108. package/rcf/requirements/req-030.json +5 -2
  109. package/rcf/requirements/req-040.json +5 -2
  110. package/rcf/requirements/req-041.json +5 -2
  111. package/rcf/requirements/req-042.json +5 -2
  112. package/rcf/requirements/req-043.json +5 -2
  113. package/rcf/requirements/req-050.json +5 -2
  114. package/rcf/requirements/req-051.json +5 -2
  115. package/rcf/requirements/req-052.json +5 -2
  116. package/rcf/requirements/req-053.json +5 -2
  117. package/rcf/requirements/req-054.json +5 -2
  118. package/rcf/requirements/req-060.json +5 -2
  119. package/rcf/requirements/req-061.json +5 -2
  120. package/rcf/requirements/req-062.json +5 -2
  121. package/rcf/requirements/req-063.json +5 -2
  122. package/rcf/requirements/req-070.json +5 -2
  123. package/rcf/requirements/req-071.json +5 -2
  124. package/rcf/requirements/req-072.json +5 -2
  125. package/rcf/requirements/req-073.json +5 -2
  126. package/rcf/requirements/req-074.json +5 -2
  127. package/rcf/requirements/req-075.json +5 -2
  128. package/rcf/requirements/req-076.json +5 -2
  129. package/rcf/requirements/req-077.json +5 -2
  130. package/rcf/requirements/req-080.json +5 -2
  131. package/rcf/requirements/req-081.json +5 -2
  132. package/rcf/requirements/req-082.json +5 -2
  133. package/rcf/requirements/req-083.json +5 -2
  134. package/rcf/requirements/req-084.json +5 -2
  135. package/rcf/requirements/req-085.json +5 -2
  136. package/rcf/requirements/req-086.json +5 -2
  137. package/rcf/requirements/req-090.json +5 -2
  138. package/rcf/requirements/req-091.json +5 -2
  139. package/rcf/requirements/req-092.json +5 -2
  140. package/rcf/requirements/req-093.json +5 -2
  141. package/rcf/requirements/req-094.json +5 -2
  142. package/rcf/requirements/req-100.json +5 -2
  143. package/rcf/requirements/req-101.json +5 -2
  144. package/rcf/requirements/req-102.json +5 -2
  145. package/rcf/requirements/req-103.json +5 -2
  146. package/rcf/requirements/req-104.json +5 -2
  147. package/rcf/requirements/req-110.json +5 -2
  148. package/rcf/requirements/req-111.json +5 -2
  149. package/rcf/requirements/req-112.json +5 -2
  150. package/rcf/requirements/req-113.json +5 -2
  151. package/rcf/requirements/req-114.json +5 -2
  152. package/rcf/requirements/req-115.json +5 -2
  153. package/rcf/requirements/req-120.json +5 -2
  154. package/rcf/requirements/req-121.json +5 -2
  155. package/rcf/requirements/req-122.json +5 -2
  156. package/rcf/requirements/req-123.json +5 -2
  157. package/rcf/requirements/req-124.json +5 -2
  158. package/rcf/requirements/req-125.json +5 -2
  159. package/rcf/requirements/req-130.json +6 -2
  160. package/rcf/requirements/req-131.json +6 -2
  161. package/rcf/requirements/req-132.json +6 -2
  162. package/rcf/requirements/req-133.json +6 -2
  163. package/rcf/requirements/req-134.json +6 -2
  164. package/rcf/requirements/req-140.json +5 -2
  165. package/rcf/requirements/req-145.json +5 -2
  166. package/rcf/requirements/req-150.json +6 -2
  167. package/rcf/requirements/req-151.json +6 -2
  168. package/rcf/requirements/req-152.json +6 -2
  169. package/rcf/requirements/req-153.json +6 -2
  170. package/rcf/requirements/req-154.json +5 -2
  171. package/rcf/requirements/req-155.json +4 -1
  172. package/rcf/requirements/req-156.json +4 -1
  173. package/rcf/requirements/req-157.json +4 -1
  174. package/rcf/requirements/req-158.json +4 -1
  175. package/rcf/requirements/req-159.json +4 -1
  176. package/rcf/requirements/req-160.json +4 -1
  177. package/rcf/requirements/req-161.json +4 -1
  178. package/rcf/requirements/req-162.json +4 -1
  179. package/rcf/requirements/req-163.json +5 -2
  180. package/rcf/requirements/req-164.json +5 -2
  181. package/rcf/requirements/req-165.json +5 -2
  182. package/rcf/requirements/req-170.json +33 -0
  183. package/rcf/requirements/req-171.json +44 -0
  184. package/rcf/requirements/req-172.json +25 -0
  185. package/rcf/requirements/req-173.json +25 -0
  186. package/rcf/requirements/req-174.json +31 -0
  187. package/rcf/requirements/req-175.json +31 -0
  188. package/rcf/requirements/req-176.json +25 -0
  189. package/rcf/requirements/req-180.json +31 -0
  190. package/rcf/requirements/req-181.json +31 -0
  191. package/rcf/requirements/req-182.json +25 -0
  192. package/rcf/requirements/req-186.json +25 -0
  193. package/rcf/requirements/req-187.json +31 -0
  194. package/rcf/requirements/req-188.json +25 -0
  195. package/rcf/tacs/tac-4120-define-delta-and-freeze.json +21 -0
  196. package/rcf/tacs/tac-4121-define-ledgers.json +35 -0
  197. package/rcf/tacs/tac-4122-define-gates.json +61 -0
  198. package/rcf/tacs/tac-4123-define-readiness.json +61 -0
  199. package/rcf/tacs/tac-4124-define-freeze.json +28 -0
  200. package/rcf/tacs/tac-4127-readiness-view.json +49 -0
  201. package/rcf/tacs/tac-4128-define-guidance.json +53 -0
  202. package/rcf/tacs/tac-4129-define-questions.json +45 -0
  203. package/rcf/tacs/tac-4130-define-intake-brief.json +35 -0
  204. package/rcf/tacs/tac-4131-build-dag-view.json +39 -0
  205. package/rcf/tacs/tac-4132-id-lookup.json +42 -0
  206. package/rcf/tacs/tac-4133-readiness-po-layer.json +37 -0
  207. package/rcf/tacs/tac-4134-wespa-host-fixture.json +54 -0
  208. package/rcf/test-suites/ts-016.json +3 -3
  209. package/rcf/test-suites/ts-204.json +30 -0
  210. package/rcf/test-suites/ts-205.json +22 -0
  211. package/rcf/test-suites/ts-206.json +22 -0
  212. package/rcf/test-suites/ts-207.json +22 -0
  213. package/rcf/test-suites/ts-208.json +22 -0
  214. package/rcf/test-suites/ts-209.json +22 -0
  215. package/rcf/test-suites/ts-210.json +22 -0
  216. package/rcf/test-suites/ts-211.json +22 -0
  217. package/rcf/test-suites/ts-212.json +22 -0
  218. package/rcf/test-suites/ts-213.json +50 -0
  219. package/rcf/test-suites/ts-214.json +74 -0
  220. package/rcf/test-suites/ts-215.json +211 -0
  221. package/rcf/test-suites/ts-216.json +94 -0
  222. package/rcf/test-suites/ts-217.json +62 -0
  223. package/rcf/test-suites/ts-218.json +78 -0
  224. package/rcf/test-suites/ts-219.json +70 -0
  225. package/rcf/test-suites/ts-220.json +78 -0
  226. package/rcf/test-suites/ts-221.json +78 -0
  227. package/rcf/test-suites/ts-222.json +100 -0
  228. package/rcf/test-suites/ts-223.json +94 -0
  229. package/rcf/test-suites/ts-224.json +70 -0
  230. package/rcf/test-suites/ts-225.json +78 -0
  231. package/rcf/test-suites/ts-226.json +158 -0
  232. package/rcf/test-suites/ts-227.json +38 -0
  233. package/rcf/test-suites/ts-228.json +86 -0
  234. package/rcf/test-suites/ts-229.json +83 -0
  235. package/rcf/user-stories/us-15601.json +4 -4
  236. package/rcf/user-stories/us-17001.json +27 -0
  237. package/rcf/user-stories/us-17002.json +22 -0
  238. package/rcf/user-stories/us-17003.json +22 -0
  239. package/rcf/user-stories/us-17004.json +22 -0
  240. package/rcf/user-stories/us-17005.json +22 -0
  241. package/rcf/user-stories/us-17006.json +21 -0
  242. package/rcf/user-stories/us-17007.json +21 -0
  243. package/rcf/user-stories/us-17008.json +22 -0
  244. package/rcf/user-stories/us-17101.json +22 -0
  245. package/rcf/user-stories/us-17102.json +22 -0
  246. package/rcf/user-stories/us-17201.json +38 -0
  247. package/rcf/user-stories/us-17301.json +38 -0
  248. package/rcf/user-stories/us-17302.json +50 -0
  249. package/rcf/user-stories/us-17401.json +70 -0
  250. package/rcf/user-stories/us-17402.json +55 -0
  251. package/rcf/user-stories/us-17403.json +60 -0
  252. package/rcf/user-stories/us-17501.json +48 -0
  253. package/rcf/user-stories/us-17502.json +60 -0
  254. package/rcf/user-stories/us-17503.json +65 -0
  255. package/rcf/user-stories/us-17601.json +58 -0
  256. package/rcf/user-stories/us-18001.json +55 -0
  257. package/rcf/user-stories/us-18002.json +35 -0
  258. package/rcf/user-stories/us-18101.json +35 -0
  259. package/rcf/user-stories/us-18202.json +40 -0
  260. package/rcf/user-stories/us-18601.json +55 -0
  261. package/rcf/user-stories/us-18602.json +50 -0
  262. package/rcf/user-stories/us-18603.json +45 -0
  263. package/rcf/user-stories/us-18701.json +50 -0
  264. package/rcf/user-stories/us-18702.json +50 -0
  265. package/rcf/user-stories/us-18801.json +45 -0
  266. package/rcf/user-stories/us-204.json +59 -0
  267. package/rcf/user-stories/us-205.json +64 -0
  268. package/rcf/user-stories/us-206.json +54 -0
  269. package/releases/releases.yaml +31 -1
  270. package/src/cli/feedback.js +126 -10
  271. package/src/cli/freeze.js +730 -0
  272. package/src/cli/help.js +23 -0
  273. package/src/cli/ledger.js +615 -0
  274. package/src/cli/questions.js +288 -0
  275. package/src/cli/readiness.js +498 -0
  276. package/src/cli/view.js +29 -0
  277. package/src/core/store/writer.js +86 -6
  278. package/src/define/freeze-record.js +247 -0
  279. package/src/define/ledgers.js +712 -0
  280. package/src/feedback/redact.js +105 -19
  281. package/src/intake/index.js +1 -1
  282. package/src/intake/orchestrator.js +117 -0
  283. package/src/mcp/tools.js +354 -0
  284. package/src/query/delta.js +282 -0
  285. package/src/query/gates.js +1107 -0
  286. package/src/query/index.js +50 -0
  287. package/src/query/questions.js +438 -0
  288. package/src/query/readiness.js +574 -0
  289. package/src/server/index.js +60 -1
  290. package/src/server/routes.js +133 -4
  291. package/src/setup/identity-seed.js +17 -0
  292. package/src/view/build-dag.js +563 -0
  293. package/src/view/components/badge.js +41 -0
  294. package/src/view/components/diff.js +72 -0
  295. package/src/view/components/doc-row.js +62 -0
  296. package/src/view/components/empty-state.js +35 -0
  297. package/src/view/components/entity-selector.js +137 -0
  298. package/src/view/components/filter-bar.js +97 -0
  299. package/src/view/components/findings-list.js +49 -0
  300. package/src/view/components/index.js +12 -0
  301. package/src/view/components/pill.js +46 -0
  302. package/src/view/components/sub-tab-strip.js +43 -0
  303. package/src/view/components/toast.js +25 -0
  304. package/src/view/components-fixture.js +265 -0
  305. package/src/view/doc-renderers/architecture.js +304 -0
  306. package/src/view/doc-renderers/build.js +160 -0
  307. package/src/view/doc-renderers/helpers.js +2 -2
  308. package/src/view/doc-renderers/index.js +2 -0
  309. package/src/view/doc-renderers/prd.js +35 -6
  310. package/src/view/doc-renderers/req.js +10 -2
  311. package/src/view/doc-renderers/user-story.js +10 -5
  312. package/src/view/html-page.js +717 -300
  313. package/src/view/index.js +109 -3
  314. package/src/view/live-client.js +61 -8
  315. package/src/view/needs-work.js +79 -0
  316. package/src/view/page-init.js +2193 -0
  317. package/src/view/product-map.js +751 -0
  318. package/src/view/readiness/phrasebook.js +115 -0
  319. package/src/view/readiness/po-layer.js +288 -0
  320. package/src/view/readiness/question-adapter.js +329 -0
  321. package/src/view/readiness/stage-legend.js +71 -0
  322. package/src/view/readiness.js +505 -0
  323. package/src/view/style.css +2279 -266
  324. package/src/view/test-host/embed-client.js +121 -0
  325. package/src/view/test-host/fixture.js +183 -0
  326. package/src/view/test-host/lints.js +220 -0
  327. package/src/view/view-index.js +323 -0
@@ -0,0 +1,712 @@
1
+ // Sidecar ledgers under `rcf/define/` (REQ-173; proposal §2.5, §3.2,
2
+ // §8.2 v3).
3
+ //
4
+ // Four ledgers, each one JSON file, each owned by rcf-lite (local
5
+ // schema in this module until schemas 0.7, ADR-4121). Every entry is
6
+ // numbered in one sequence per ledger for the life of the project;
7
+ // numbers never reset, and are what the freeze record's
8
+ // `briefStatements` high-water mark counts against (proposal §2.5).
9
+ //
10
+ // rcf/define/brief-ledger.json D1 brief statements
11
+ // rcf/define/decisions-ledger.json D7 open / answered decisions
12
+ // rcf/define/concern-ledger.json D5 (concern, REQ) pair records
13
+ // rcf/define/probe-ledger.json D6 probe findings
14
+ //
15
+ // Kinds and fields:
16
+ //
17
+ // brief.statements[] { id, kind, text, source, addedAt, status,
18
+ // resolvedAt?, resolvedBy? }
19
+ // kind = capability | constraint | actor | entity | externalSystem
20
+ // | surface | outOfScope | openQuestion | amendment
21
+ //
22
+ // decisions.decisions[] { id, question, options: [{ letter, text }],
23
+ // default: <letter | null>, blocks, addedAt,
24
+ // status, answer?, answeredAt? }
25
+ // `decision list` prints the numbered format from #241: one
26
+ // decision per item; options with letters; the default; what it
27
+ // blocks. Slice 2 (Readiness) is the second consumer.
28
+ //
29
+ // concerns.concerns[] { id, concern, reqId, disposition, reason?,
30
+ // addedAt, status, resolvedAt? }
31
+ // disposition = applied | waived
32
+ //
33
+ // probes.probes[] { id, reqId, finding, severity, addedAt,
34
+ // status, resolvedAt? }
35
+ // severity = low | medium | high
36
+ //
37
+ // status on every entry is `open | resolved`. `resolve` flips it to
38
+ // resolved and stamps `resolvedAt`; `add` stamps `addedAt` and mints
39
+ // the next id (highest current + 1).
40
+ //
41
+ // Loader returns an empty ledger (`{ statements: [] }` etc.) when the
42
+ // file is missing so callers can compose without a per-ledger null
43
+ // check; a malformed file raises a clear error like the freeze
44
+ // record. Every write goes through the writer, which validates the
45
+ // full body first (a bad in-memory record never lands as JSON).
46
+
47
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
48
+ import { dirname, join } from 'node:path';
49
+
50
+ /**
51
+ * The four ledger identifiers. Ledger name -> filename convention.
52
+ * These are the values `rcf define ledger <name>` accepts.
53
+ */
54
+ export const LEDGER_NAMES = /** @type {const} */ (['brief', 'decisions', 'concerns', 'probes']);
55
+
56
+ /** @typedef {(typeof LEDGER_NAMES)[number]} LedgerName */
57
+
58
+ const RELATIVE_DIR = 'rcf/define';
59
+
60
+ /** Per-ledger config: filename, array key on the body, entry schema. */
61
+ const LEDGER_CONFIG = /** @type {const} */ ({
62
+ brief: { file: 'brief-ledger.json', arrayKey: 'statements' },
63
+ decisions: { file: 'decisions-ledger.json', arrayKey: 'decisions' },
64
+ concerns: { file: 'concern-ledger.json', arrayKey: 'concerns' },
65
+ probes: { file: 'probe-ledger.json', arrayKey: 'probes' },
66
+ });
67
+
68
+ export const BRIEF_KINDS = /** @type {const} */ ([
69
+ 'capability', 'constraint', 'actor', 'entity', 'externalSystem',
70
+ 'surface', 'outOfScope', 'openQuestion', 'amendment',
71
+ ]);
72
+
73
+ const CONCERN_DISPOSITIONS = /** @type {const} */ (['applied', 'waived']);
74
+ const PROBE_SEVERITIES = /** @type {const} */ (['low', 'medium', 'high']);
75
+ const STATUS_VALUES = /** @type {const} */ (['open', 'resolved']);
76
+
77
+ /**
78
+ * Local relative path of a ledger file (project-root-relative).
79
+ *
80
+ * @param {LedgerName} name
81
+ * @returns {string}
82
+ */
83
+ export function ledgerRelPath(name) {
84
+ const cfg = LEDGER_CONFIG[name];
85
+ if (!cfg) throw new TypeError(`Unknown ledger '${name}'.`);
86
+ return `${RELATIVE_DIR}/${cfg.file}`;
87
+ }
88
+
89
+ /**
90
+ * Absolute path of a ledger file for a project root.
91
+ *
92
+ * @param {string} projectRoot
93
+ * @param {LedgerName} name
94
+ * @returns {string}
95
+ */
96
+ export function ledgerPath(projectRoot, name) {
97
+ return join(projectRoot, ledgerRelPath(name));
98
+ }
99
+
100
+ export class LedgerError extends Error {
101
+ /**
102
+ * @param {string} message
103
+ * @param {object} [opts]
104
+ * @param {string} [opts.filePath]
105
+ * @param {'parseFailure' | 'schemaFailure' | 'ioFailure' | 'usage'} [opts.code]
106
+ * @param {string} [opts.field]
107
+ */
108
+ constructor(message, opts = {}) {
109
+ super(message);
110
+ this.name = 'LedgerError';
111
+ this.filePath = opts.filePath;
112
+ this.code = opts.code ?? 'schemaFailure';
113
+ this.field = opts.field;
114
+ }
115
+ }
116
+
117
+ const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
118
+
119
+ /**
120
+ * Empty body for a named ledger (matches the shape a loader returns
121
+ * when the file is missing).
122
+ *
123
+ * @param {LedgerName} name
124
+ * @returns {Record<string, unknown>}
125
+ */
126
+ export function emptyLedger(name) {
127
+ const cfg = LEDGER_CONFIG[name];
128
+ if (!cfg) throw new TypeError(`Unknown ledger '${name}'.`);
129
+ return { [cfg.arrayKey]: [] };
130
+ }
131
+
132
+ /**
133
+ * Validate one entry against its ledger schema. Throws `LedgerError`
134
+ * on the first violation.
135
+ *
136
+ * @param {LedgerName} name
137
+ * @param {unknown} entry
138
+ * @param {string} [filePath]
139
+ * @param {string} [where] path segment for error messages, e.g.
140
+ * 'statements[0]'
141
+ */
142
+ function validateEntry(name, entry, filePath, where = 'entry') {
143
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
144
+ throw new LedgerError(`${name} ${where} must be an object.`, {
145
+ filePath, field: where,
146
+ });
147
+ }
148
+ const e = /** @type {Record<string, unknown>} */ (entry);
149
+
150
+ if (typeof e.id !== 'number' || !Number.isInteger(e.id) || e.id < 1) {
151
+ throw new LedgerError(`${name} ${where}.id must be a positive integer.`, {
152
+ filePath, field: `${where}.id`,
153
+ });
154
+ }
155
+ if (typeof e.addedAt !== 'string' || !ISO_RE.test(e.addedAt)) {
156
+ throw new LedgerError(`${name} ${where}.addedAt must be an ISO-8601 string.`, {
157
+ filePath, field: `${where}.addedAt`,
158
+ });
159
+ }
160
+ if (typeof e.status !== 'string' || !STATUS_VALUES.includes(/** @type {any} */ (e.status))) {
161
+ throw new LedgerError(
162
+ `${name} ${where}.status must be one of: ${STATUS_VALUES.join(', ')}.`,
163
+ { filePath, field: `${where}.status` },
164
+ );
165
+ }
166
+ if (e.status === 'resolved') {
167
+ if (typeof e.resolvedAt !== 'string' || !ISO_RE.test(e.resolvedAt)) {
168
+ throw new LedgerError(
169
+ `${name} ${where}.resolvedAt must be an ISO-8601 string when status is resolved.`,
170
+ { filePath, field: `${where}.resolvedAt` },
171
+ );
172
+ }
173
+ }
174
+
175
+ if (name === 'brief') {
176
+ if (typeof e.kind !== 'string' || !BRIEF_KINDS.includes(/** @type {any} */ (e.kind))) {
177
+ throw new LedgerError(
178
+ `brief ${where}.kind must be one of: ${BRIEF_KINDS.join(', ')}.`,
179
+ { filePath, field: `${where}.kind` },
180
+ );
181
+ }
182
+ if (typeof e.text !== 'string' || e.text.length === 0) {
183
+ throw new LedgerError(`brief ${where}.text must be a non-empty string.`, {
184
+ filePath, field: `${where}.text`,
185
+ });
186
+ }
187
+ if (e.source !== undefined && typeof e.source !== 'string') {
188
+ throw new LedgerError(`brief ${where}.source must be a string when present.`, {
189
+ filePath, field: `${where}.source`,
190
+ });
191
+ }
192
+ if (e.resolvedBy !== undefined && typeof e.resolvedBy !== 'string') {
193
+ throw new LedgerError(`brief ${where}.resolvedBy must be a string when present.`, {
194
+ filePath, field: `${where}.resolvedBy`,
195
+ });
196
+ }
197
+ } else if (name === 'decisions') {
198
+ if (typeof e.question !== 'string' || e.question.length === 0) {
199
+ throw new LedgerError(`decisions ${where}.question must be non-empty.`, {
200
+ filePath, field: `${where}.question`,
201
+ });
202
+ }
203
+ if (!Array.isArray(e.options) || e.options.length === 0) {
204
+ throw new LedgerError(`decisions ${where}.options must be a non-empty array.`, {
205
+ filePath, field: `${where}.options`,
206
+ });
207
+ }
208
+ for (let i = 0; i < e.options.length; i += 1) {
209
+ const opt = e.options[i];
210
+ if (
211
+ opt === null || typeof opt !== 'object' || Array.isArray(opt)
212
+ || typeof (/** @type {any} */ (opt)).letter !== 'string'
213
+ || typeof (/** @type {any} */ (opt)).text !== 'string'
214
+ ) {
215
+ throw new LedgerError(
216
+ `decisions ${where}.options[${i}] must be { letter, text }.`,
217
+ { filePath, field: `${where}.options[${i}]` },
218
+ );
219
+ }
220
+ }
221
+ if (e.default !== null && e.default !== undefined && typeof e.default !== 'string') {
222
+ throw new LedgerError(
223
+ `decisions ${where}.default must be a letter string or null.`,
224
+ { filePath, field: `${where}.default` },
225
+ );
226
+ }
227
+ if (e.blocks !== undefined && typeof e.blocks !== 'string') {
228
+ throw new LedgerError(`decisions ${where}.blocks must be a string when present.`, {
229
+ filePath, field: `${where}.blocks`,
230
+ });
231
+ }
232
+ } else if (name === 'concerns') {
233
+ if (typeof e.concern !== 'string' || e.concern.length === 0) {
234
+ throw new LedgerError(`concerns ${where}.concern must be non-empty.`, {
235
+ filePath, field: `${where}.concern`,
236
+ });
237
+ }
238
+ if (typeof e.reqId !== 'string' || e.reqId.length === 0) {
239
+ throw new LedgerError(`concerns ${where}.reqId must be non-empty.`, {
240
+ filePath, field: `${where}.reqId`,
241
+ });
242
+ }
243
+ if (
244
+ typeof e.disposition !== 'string'
245
+ || !CONCERN_DISPOSITIONS.includes(/** @type {any} */ (e.disposition))
246
+ ) {
247
+ throw new LedgerError(
248
+ `concerns ${where}.disposition must be one of: ${CONCERN_DISPOSITIONS.join(', ')}.`,
249
+ { filePath, field: `${where}.disposition` },
250
+ );
251
+ }
252
+ } else if (name === 'probes') {
253
+ if (typeof e.reqId !== 'string' || e.reqId.length === 0) {
254
+ throw new LedgerError(`probes ${where}.reqId must be non-empty.`, {
255
+ filePath, field: `${where}.reqId`,
256
+ });
257
+ }
258
+ if (typeof e.finding !== 'string' || e.finding.length === 0) {
259
+ throw new LedgerError(`probes ${where}.finding must be non-empty.`, {
260
+ filePath, field: `${where}.finding`,
261
+ });
262
+ }
263
+ if (
264
+ typeof e.severity !== 'string'
265
+ || !PROBE_SEVERITIES.includes(/** @type {any} */ (e.severity))
266
+ ) {
267
+ throw new LedgerError(
268
+ `probes ${where}.severity must be one of: ${PROBE_SEVERITIES.join(', ')}.`,
269
+ { filePath, field: `${where}.severity` },
270
+ );
271
+ }
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Validate a full ledger body (the shape a file holds).
277
+ *
278
+ * @param {LedgerName} name
279
+ * @param {unknown} raw
280
+ * @param {string} [filePath]
281
+ * @returns {Record<string, unknown[]>}
282
+ */
283
+ export function validateLedger(name, raw, filePath) {
284
+ const cfg = LEDGER_CONFIG[name];
285
+ if (!cfg) throw new LedgerError(`Unknown ledger '${name}'.`, { filePath, code: 'usage' });
286
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
287
+ throw new LedgerError(`${name} ledger must be a JSON object.`, {
288
+ filePath, field: '(root)',
289
+ });
290
+ }
291
+ const body = /** @type {Record<string, unknown>} */ (raw);
292
+ const list = body[cfg.arrayKey];
293
+ if (!Array.isArray(list)) {
294
+ throw new LedgerError(
295
+ `${name} ledger '${cfg.arrayKey}' must be an array.`,
296
+ { filePath, field: cfg.arrayKey },
297
+ );
298
+ }
299
+ const seen = new Set();
300
+ for (let i = 0; i < list.length; i += 1) {
301
+ const entry = list[i];
302
+ validateEntry(name, entry, filePath, `${cfg.arrayKey}[${i}]`);
303
+ const id = /** @type {any} */ (entry).id;
304
+ if (seen.has(id)) {
305
+ throw new LedgerError(
306
+ `${name} ledger has duplicate id ${id}.`,
307
+ { filePath, field: `${cfg.arrayKey}[${i}].id` },
308
+ );
309
+ }
310
+ seen.add(id);
311
+ }
312
+ return /** @type {Record<string, unknown[]>} */ (body);
313
+ }
314
+
315
+ /**
316
+ * Load a ledger. Returns the empty ledger when the file is absent;
317
+ * throws `LedgerError` on parse / schema failure.
318
+ *
319
+ * @param {object} args
320
+ * @param {string} args.projectRoot
321
+ * @param {LedgerName} args.name
322
+ * @returns {Promise<Record<string, unknown[]>>}
323
+ */
324
+ export async function loadLedger({ projectRoot, name }) {
325
+ const relPath = ledgerRelPath(name);
326
+ const filePath = ledgerPath(projectRoot, name);
327
+ let raw;
328
+ try {
329
+ raw = await readFile(filePath, 'utf8');
330
+ } catch (err) {
331
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') {
332
+ return emptyLedger(name);
333
+ }
334
+ throw new LedgerError(
335
+ `Failed to read ${relPath}: ${/** @type {Error} */ (err).message}`,
336
+ { filePath: relPath, code: 'ioFailure' },
337
+ );
338
+ }
339
+ let parsed;
340
+ try {
341
+ parsed = JSON.parse(raw);
342
+ } catch (err) {
343
+ throw new LedgerError(
344
+ `${relPath} parse failed: ${/** @type {Error} */ (err).message}`,
345
+ { filePath: relPath, code: 'parseFailure' },
346
+ );
347
+ }
348
+ return validateLedger(name, parsed, relPath);
349
+ }
350
+
351
+ /**
352
+ * Save a ledger, validating first.
353
+ *
354
+ * @param {object} args
355
+ * @param {string} args.projectRoot
356
+ * @param {LedgerName} args.name
357
+ * @param {Record<string, unknown>} args.body
358
+ * @returns {Promise<{ filePath: string }>}
359
+ */
360
+ export async function saveLedger({ projectRoot, name, body }) {
361
+ const validated = validateLedger(name, body, ledgerRelPath(name));
362
+ const filePath = ledgerPath(projectRoot, name);
363
+ await mkdir(dirname(filePath), { recursive: true });
364
+ await writeFile(filePath, `${JSON.stringify(validated, null, 2)}\n`, 'utf8');
365
+ return { filePath };
366
+ }
367
+
368
+ /**
369
+ * Highest currently-used id on a ledger; 0 when empty.
370
+ *
371
+ * @param {LedgerName} name
372
+ * @param {Record<string, unknown[]>} body
373
+ * @returns {number}
374
+ */
375
+ export function nextIdFor(name, body) {
376
+ const cfg = LEDGER_CONFIG[name];
377
+ if (!cfg) throw new TypeError(`Unknown ledger '${name}'.`);
378
+ const list = /** @type {any[]} */ (body[cfg.arrayKey] ?? []);
379
+ let max = 0;
380
+ for (const e of list) {
381
+ const id = Number(e?.id);
382
+ if (Number.isFinite(id) && id > max) max = id;
383
+ }
384
+ return max + 1;
385
+ }
386
+
387
+ /**
388
+ * Append an entry to a ledger. Mints the next id, stamps `addedAt`,
389
+ * defaults `status: 'open'`, and validates before returning the
390
+ * updated body. Pure over its inputs (does not write to disk); the
391
+ * CLI calls `saveLedger` with the result.
392
+ *
393
+ * @param {object} args
394
+ * @param {LedgerName} args.name
395
+ * @param {Record<string, unknown[]>} args.body
396
+ * @param {Record<string, unknown>} args.entry - fields other than id / addedAt / status
397
+ * @param {string} [args.now] - ISO timestamp; defaults to Date.now()
398
+ * @returns {{ body: Record<string, unknown[]>, entry: Record<string, unknown> }}
399
+ */
400
+ export function addEntry({ name, body, entry, now }) {
401
+ const cfg = LEDGER_CONFIG[name];
402
+ if (!cfg) throw new LedgerError(`Unknown ledger '${name}'.`, { code: 'usage' });
403
+ const id = nextIdFor(name, body);
404
+ const full = {
405
+ id,
406
+ ...entry,
407
+ addedAt: entry.addedAt ?? now ?? new Date().toISOString(),
408
+ status: entry.status ?? 'open',
409
+ };
410
+ const nextBody = { ...body, [cfg.arrayKey]: [...(body[cfg.arrayKey] ?? []), full] };
411
+ validateLedger(name, nextBody, ledgerRelPath(name));
412
+ return { body: nextBody, entry: full };
413
+ }
414
+
415
+ /**
416
+ * Mark an entry `resolved` and stamp `resolvedAt`. Throws `LedgerError`
417
+ * (code `usage`) when the id is unknown. Idempotent-ish: resolving an
418
+ * already-resolved entry refreshes `resolvedAt`.
419
+ *
420
+ * @param {object} args
421
+ * @param {LedgerName} args.name
422
+ * @param {Record<string, unknown[]>} args.body
423
+ * @param {number} args.id
424
+ * @param {Record<string, unknown>} [args.patch] - extra fields (e.g. answer, resolvedBy)
425
+ * @param {string} [args.now]
426
+ * @returns {{ body: Record<string, unknown[]>, entry: Record<string, unknown> }}
427
+ */
428
+ export function resolveEntry({ name, body, id, patch = {}, now }) {
429
+ const cfg = LEDGER_CONFIG[name];
430
+ if (!cfg) throw new LedgerError(`Unknown ledger '${name}'.`, { code: 'usage' });
431
+ const list = /** @type {any[]} */ (body[cfg.arrayKey] ?? []);
432
+ const idx = list.findIndex((e) => Number(e?.id) === Number(id));
433
+ if (idx < 0) {
434
+ throw new LedgerError(`${name} ledger has no entry with id ${id}.`, {
435
+ filePath: ledgerRelPath(name), field: `id ${id}`, code: 'usage',
436
+ });
437
+ }
438
+ const updated = {
439
+ ...list[idx],
440
+ ...patch,
441
+ status: 'resolved',
442
+ resolvedAt: patch.resolvedAt ?? now ?? new Date().toISOString(),
443
+ };
444
+ const nextList = [...list];
445
+ nextList[idx] = updated;
446
+ const nextBody = { ...body, [cfg.arrayKey]: nextList };
447
+ validateLedger(name, nextBody, ledgerRelPath(name));
448
+ return { body: nextBody, entry: updated };
449
+ }
450
+
451
+ const RESOLVED_BY_GRAMMAR_RE = /^(REQ-\d+|TAD\.entity:.+|PRD\.user:.+|TAD\.system:.+|TAC-\d+(?:-[A-Za-z0-9-]+)?|omitted:.+)$/;
452
+
453
+ /**
454
+ * True when `pointer` matches the closed grammar for a `resolvedBy`
455
+ * value. Shape-only; does NOT verify that the pointed-to id or name
456
+ * resolves against a tree (that is D2's `skeleton:resolvedBy` check).
457
+ *
458
+ * @param {string} pointer
459
+ * @returns {boolean}
460
+ */
461
+ export function isResolvedByGrammar(pointer) {
462
+ if (typeof pointer !== 'string') return false;
463
+ return RESOLVED_BY_GRAMMAR_RE.test(pointer.trim());
464
+ }
465
+
466
+ const KIND_MARKER_RE = /^\[([^\]]+)\]\s*/;
467
+ const SOURCE_MARKER_RE = /\s*\(source:\s*([^)]+)\)\s*$/;
468
+ const QUESTION_PREFIX_RE = /^(TBC|TBD|Open|Question)\b[:.]?\s*/i;
469
+
470
+ /**
471
+ * Parse `--from <file>` content into brief-statement drafts: one entry
472
+ * per non-empty line. Each line optionally carries a leading `[kind]`
473
+ * marker and a trailing `(source: ...)` marker. A line without
474
+ * `[kind]` takes `opts.kind` (default `capability`); a line without
475
+ * `(source: ...)` takes `opts.source` or no source. Heuristics kind a
476
+ * line `openQuestion` when it ends with `?` or begins with `TBC`,
477
+ * `TBD`, `Open:` or `Question:`, unless a `[kind]` marker says
478
+ * otherwise. Unknown `[kind]` values raise `LedgerError` (code
479
+ * `usage`) before any write.
480
+ *
481
+ * @param {string} content
482
+ * @param {object} [opts]
483
+ * @param {string} [opts.kind] default kind for every unmarked line
484
+ * @param {string} [opts.source] default source for every unmarked line
485
+ * @returns {Array<{ text: string, kind: string, source?: string }>}
486
+ */
487
+ export function parseBriefFromFile(content, opts = {}) {
488
+ const defaultKind = opts.kind ?? 'capability';
489
+ /** @type {Array<{ text: string, kind: string, source?: string }>} */
490
+ const drafts = [];
491
+ for (const raw of content.split(/\r?\n/)) {
492
+ const trimmed = raw.trim();
493
+ if (!trimmed) continue;
494
+ let line = trimmed
495
+ .replace(/^[-*]\s+/, '')
496
+ .replace(/^\d+[.)]\s+/, '');
497
+ if (!line) continue;
498
+
499
+ // Trailing (source: ...) marker.
500
+ let source = opts.source;
501
+ const srcMatch = line.match(SOURCE_MARKER_RE);
502
+ if (srcMatch) {
503
+ source = srcMatch[1].trim();
504
+ line = line.slice(0, srcMatch.index).trimEnd();
505
+ }
506
+
507
+ // Leading [kind] marker.
508
+ let kind = null;
509
+ const kindMatch = line.match(KIND_MARKER_RE);
510
+ if (kindMatch) {
511
+ kind = kindMatch[1].trim();
512
+ line = line.slice(kindMatch[0].length).trim();
513
+ if (!BRIEF_KINDS.includes(/** @type {any} */ (kind))) {
514
+ throw new LedgerError(
515
+ `brief --from: unknown kind '${kind}' (expected one of: ${BRIEF_KINDS.join(', ')})`,
516
+ { field: 'kind', code: 'usage' },
517
+ );
518
+ }
519
+ }
520
+
521
+ if (!line) continue;
522
+
523
+ // Heuristics for openQuestion when no [kind] marker was given.
524
+ if (!kind) {
525
+ if (line.endsWith('?') || QUESTION_PREFIX_RE.test(line)) {
526
+ kind = 'openQuestion';
527
+ } else {
528
+ kind = defaultKind;
529
+ }
530
+ }
531
+
532
+ const entry = { text: line, kind };
533
+ if (source) entry.source = source;
534
+ drafts.push(entry);
535
+ }
536
+ return drafts;
537
+ }
538
+
539
+ /**
540
+ * Parse a `resolvedBy` pointer against the walker tree. The six
541
+ * accepted forms are `REQ-nnn`, `TAD.entity:<name>`, `PRD.user:<name>`,
542
+ * `TAD.system:<name>`, `TAC-nnnn`, and `omitted:<reason>`. Resolution
543
+ * checks the pointed-to id or name actually exists in the tree
544
+ * (except for `omitted:`, which always passes once the reason is
545
+ * non-empty).
546
+ *
547
+ * @param {string} pointer
548
+ * @param {object} tree walker tree (TreeModel-like)
549
+ * @returns {{ ok: true, kind: string, target: string } | { ok: false, why: string }}
550
+ */
551
+ export function parseResolvedBy(pointer, tree) {
552
+ if (typeof pointer !== 'string' || pointer.length === 0) {
553
+ return { ok: false, why: 'pointer must be a non-empty string' };
554
+ }
555
+ const p = pointer.trim();
556
+
557
+ // omitted:<reason>
558
+ const omittedMatch = p.match(/^omitted:(.+)$/);
559
+ if (omittedMatch) {
560
+ const reason = omittedMatch[1].trim();
561
+ if (!reason) return { ok: false, why: 'omitted: pointer needs a reason' };
562
+ return { ok: true, kind: 'omitted', target: reason };
563
+ }
564
+
565
+ // REQ-nnn
566
+ if (/^REQ-\d+$/.test(p)) {
567
+ const found = (tree?.requirements ?? []).some((r) => r?.reqId === p);
568
+ if (!found) return { ok: false, why: 'pointer does not resolve' };
569
+ return { ok: true, kind: 'req', target: p };
570
+ }
571
+
572
+ // TAC-nnnn (short form, resolves to a tree TAC whose id starts
573
+ // with that prefix) or TAC-nnnn-<slug> (exact-match long form).
574
+ // Codex review 2026-10-02: an unanchored /^TAC-\d+/ let arbitrary
575
+ // suffixes (TAC-4130-made-up) pass as long as the numeric prefix
576
+ // matched an existing TAC; anchor the regex and gate the prefix
577
+ // fallback to the short form only.
578
+ const tacMatch = p.match(/^TAC-\d+(?:-[A-Za-z0-9-]+)?$/);
579
+ if (tacMatch) {
580
+ const tacs = tree?.tacs ?? [];
581
+ const isShortForm = /^TAC-\d+$/.test(p);
582
+ const found = tacs.some((t) => {
583
+ if (!t?.tacId) return false;
584
+ if (t.tacId === p) return true;
585
+ if (!isShortForm) return false;
586
+ return t.tacId.split('-').slice(0, 2).join('-') === p;
587
+ });
588
+ if (!found) return { ok: false, why: 'pointer does not resolve' };
589
+ return { ok: true, kind: 'tac', target: p };
590
+ }
591
+
592
+ // TAD.entity:<name>
593
+ const entityMatch = p.match(/^TAD\.entity:(.+)$/);
594
+ if (entityMatch) {
595
+ const name = entityMatch[1].trim();
596
+ if (!name) return { ok: false, why: 'TAD.entity: pointer needs a name' };
597
+ const entities = tree?.tad?.dataArchitecture?.coreEntities ?? [];
598
+ const found = Array.isArray(entities)
599
+ && entities.some((e) => (typeof e === 'string' ? e : e?.name) === name);
600
+ if (!found) return { ok: false, why: 'pointer does not resolve' };
601
+ return { ok: true, kind: 'tadEntity', target: name };
602
+ }
603
+
604
+ // PRD.user:<name>
605
+ const userMatch = p.match(/^PRD\.user:(.+)$/);
606
+ if (userMatch) {
607
+ const name = userMatch[1].trim();
608
+ if (!name) return { ok: false, why: 'PRD.user: pointer needs a name' };
609
+ // Codex review 2026-10-02: the production PRD schema uses
610
+ // `targetUsers`; keep `users` as a fallback for the test
611
+ // fixtures but prefer the real path.
612
+ const users = tree?.prd?.targetUsers ?? tree?.prd?.users ?? [];
613
+ const found = Array.isArray(users)
614
+ && users.some((u) => (typeof u === 'string' ? u : u?.name) === name);
615
+ if (!found) return { ok: false, why: 'pointer does not resolve' };
616
+ return { ok: true, kind: 'prdUser', target: name };
617
+ }
618
+
619
+ // TAD.system:<name>
620
+ const systemMatch = p.match(/^TAD\.system:(.+)$/);
621
+ if (systemMatch) {
622
+ const name = systemMatch[1].trim();
623
+ if (!name) return { ok: false, why: 'TAD.system: pointer needs a name' };
624
+ // Codex review 2026-10-02: the production TAD schema puts
625
+ // externalSystems inside integrationArchitecture; keep the
626
+ // top-level path as a fallback for the test fixtures.
627
+ const systems = tree?.tad?.integrationArchitecture?.externalSystems ?? tree?.tad?.externalSystems ?? [];
628
+ const found = Array.isArray(systems)
629
+ && systems.some((s) => (typeof s === 'string' ? s : s?.name) === name);
630
+ if (!found) return { ok: false, why: 'pointer does not resolve' };
631
+ return { ok: true, kind: 'tadSystem', target: name };
632
+ }
633
+
634
+ return { ok: false, why: 'pointer outside grammar (REQ-nnn, TAD.entity:<name>, PRD.user:<name>, TAD.system:<name>, TAC-nnnn, omitted:<reason>)' };
635
+ }
636
+
637
+ /**
638
+ * Patch a ledger entry in place. Field patches honour `add`-side
639
+ * validation by re-running `validateLedger` on the resulting body.
640
+ * Throws `LedgerError` (code `usage`) when the id is unknown. The
641
+ * entry's `id` and `addedAt` are never touched; `status`, `addedAt`
642
+ * and `resolvedAt` are passed through from the current entry unless
643
+ * explicitly patched.
644
+ *
645
+ * @param {object} args
646
+ * @param {LedgerName} args.name
647
+ * @param {Record<string, unknown[]>} args.body
648
+ * @param {number} args.id
649
+ * @param {Record<string, unknown>} args.patch
650
+ * @returns {{ body: Record<string, unknown[]>, entry: Record<string, unknown> }}
651
+ */
652
+ export function updateEntry({ name, body, id, patch }) {
653
+ const cfg = LEDGER_CONFIG[name];
654
+ if (!cfg) throw new LedgerError(`Unknown ledger '${name}'.`, { code: 'usage' });
655
+ const list = /** @type {any[]} */ (body[cfg.arrayKey] ?? []);
656
+ const idx = list.findIndex((e) => Number(e?.id) === Number(id));
657
+ if (idx < 0) {
658
+ throw new LedgerError(`${name} ledger has no entry with id ${id}.`, {
659
+ filePath: ledgerRelPath(name), field: `id ${id}`, code: 'usage',
660
+ });
661
+ }
662
+ const current = list[idx];
663
+ const next = { ...current };
664
+ for (const [key, value] of Object.entries(patch)) {
665
+ if (value === undefined) continue;
666
+ if (key === 'id' || key === 'addedAt') continue;
667
+ next[key] = value;
668
+ }
669
+ const nextList = [...list];
670
+ nextList[idx] = next;
671
+ const nextBody = { ...body, [cfg.arrayKey]: nextList };
672
+ validateLedger(name, nextBody, ledgerRelPath(name));
673
+ return { body: nextBody, entry: next };
674
+ }
675
+
676
+ /**
677
+ * Load every ledger the project has actually authored, keyed by name.
678
+ *
679
+ * A project that has never authored a given ledger contributes no
680
+ * `<name>` entry to the returned bundle, so `computeDelta` does not
681
+ * record a `ledger:<name>` docHash for absent ledgers (slice-2 P3
682
+ * resolution: this loader agrees with the `LedgerBundle` typedef on
683
+ * `computeDelta` that "if a project has not authored any decisions,
684
+ * the caller passes no `decisions` key and the delta records no
685
+ * `ledger:decisions` docHash"). Absent-file detection is a `stat` on
686
+ * the ledger path; an ENOENT skips the ledger without loading. A
687
+ * present-but-empty file is still loaded (an operator can `rcf
688
+ * define ledger <name> list --json > <path>` a stub in place; empty
689
+ * ledgers are legal and DO contribute a hash).
690
+ *
691
+ * @param {object} args
692
+ * @param {string} args.projectRoot
693
+ * @returns {Promise<import('../query/delta.js').LedgerBundle>}
694
+ */
695
+ export async function loadAllLedgers({ projectRoot }) {
696
+ /** @type {import('../query/delta.js').LedgerBundle} */
697
+ const bundle = {};
698
+ for (const name of LEDGER_NAMES) {
699
+ const filePath = ledgerPath(projectRoot, name);
700
+ try {
701
+ // eslint-disable-next-line no-await-in-loop
702
+ await stat(filePath);
703
+ } catch (err) {
704
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') continue;
705
+ // Any other stat error (permission etc.) surfaces via loadLedger.
706
+ }
707
+ // eslint-disable-next-line no-await-in-loop
708
+ const body = await loadLedger({ projectRoot, name });
709
+ bundle[name] = body;
710
+ }
711
+ return bundle;
712
+ }