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,1107 @@
1
+ // DEFINE stage gates D1..D8 (REQ-174; proposal 2026-09-22 §2.4, §3.2,
2
+ // §5.2 v3; Baz 2026-09-24 renumbering: warn-with-ack D3/D5/D6 ships in
3
+ // 0.29.0, the fuller template parser / catalogue / probe runner in
4
+ // 0.30).
5
+ //
6
+ // One exported check function per stage. Each is pure: no filesystem
7
+ // I/O, no state, no time. The caller (computeReadiness) assembles the
8
+ // context: the walker tree, the four ledgers, the freeze record, the
9
+ // delta, the scope (changed union added union fan-out), validate
10
+ // errors, probe-ledger open count, tree-wide coverage rollup, and the
11
+ // operator profile string.
12
+ //
13
+ // Every function returns:
14
+ //
15
+ // {
16
+ // stage: 'D1'..'D8',
17
+ // gate: 'define.brief' | 'define.skeleton' | ...,
18
+ // state: 'passed' | 'failing' | 'acknowledged' | 'notApplicable',
19
+ // checks: [
20
+ // {
21
+ // name,
22
+ // ok, // boolean
23
+ // over, // 'delta' | 'tree'
24
+ // pass, total, // integers
25
+ // failing: [ { id, why } ]
26
+ // }, ...
27
+ // ]
28
+ // }
29
+ //
30
+ // State fold (ADR-4122):
31
+ // - Blocking stages (D1, D2, D4, D7, D8): 'passed' when every
32
+ // check.ok is true; 'failing' otherwise. 'notApplicable' is
33
+ // decided by the stage before checks run.
34
+ // - Warn-with-ack stages (D3, D5, D6): 'passed' when every check.ok
35
+ // is true; 'acknowledged' when any check is failing AND
36
+ // freeze.gates[<gate>].state === 'acknowledged' AND
37
+ // freeze.gates[<gate>].at.hash === currentTreeHash; 'failing'
38
+ // otherwise.
39
+ //
40
+ // Warn-with-ack stages in 0.29.0 run only the cheap data-backed
41
+ // checks proposal §3.2 lists; the template parser, entity join,
42
+ // catalogue walk, contradiction / unsatisfiable scans and probe
43
+ // runner are 0.30 and left as named seams (`SEAM 0.30:` comments
44
+ // mark them).
45
+
46
+ import { computeQueue } from '../build/queue.js';
47
+ import { isOptedOut } from '../req-baseline/opt-out.js';
48
+ import { BRIEF_KINDS, parseResolvedBy } from '../define/ledgers.js';
49
+
50
+ /** Canonical stage order (proposal §3.2). */
51
+ export const STAGE_ORDER = /** @type {const} */ (['D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7', 'D8']);
52
+
53
+ /** Stage -> gate id map (used by the freeze verb and the CLI --check flag). */
54
+ export const STAGE_GATES = /** @type {const} */ ({
55
+ D1: 'define.brief',
56
+ D2: 'define.skeleton',
57
+ D3: 'define.shapes',
58
+ D4: 'define.stories',
59
+ D5: 'define.crosscut',
60
+ D6: 'define.consistency',
61
+ D7: 'define.decisions',
62
+ D8: 'define.freeze',
63
+ });
64
+
65
+ /** Stage -> operator-friendly short name (--check <short> equivalent to --check <D>). */
66
+ export const STAGE_SHORT_NAMES = /** @type {const} */ ({
67
+ D1: 'brief',
68
+ D2: 'skeleton',
69
+ D3: 'shapes',
70
+ D4: 'stories',
71
+ D5: 'crosscut',
72
+ D6: 'consistency',
73
+ D7: 'decisions',
74
+ D8: 'freeze',
75
+ });
76
+
77
+ /** Reverse: short name / D-name -> D-stage. Consumers accept either. */
78
+ export const STAGE_ALIASES = /** @type {const} */ (() => {
79
+ /** @type {Record<string, string>} */
80
+ const out = {};
81
+ for (const stage of STAGE_ORDER) {
82
+ out[stage] = stage;
83
+ out[STAGE_SHORT_NAMES[stage]] = stage;
84
+ }
85
+ return out;
86
+ })();
87
+
88
+ /** Closed vocabulary for TAC.interfaces[].kind (proposal §5.2 v1). */
89
+ export const INTERFACE_KINDS = /** @type {const} */ ([
90
+ 'recordShape', 'httpRoute', 'event', 'cliCommand', 'uiRoute',
91
+ 'port', 'fileFormat', 'fixture', 'other',
92
+ ]);
93
+
94
+ /**
95
+ * Owning persona for every stage-gate check in 0.29.0 (ADR-4126).
96
+ *
97
+ * Two values: `productOwner` (the person who owns what the product
98
+ * must do) and `engineer` (the person who owns how it is built).
99
+ * There is no `either` / `shared` / `both`; a check that would need
100
+ * both is split, which is what happens to `skeleton:reqFields`
101
+ * (becomes `skeleton:reqIntent` + `skeleton:reqShape`) and
102
+ * `decisions:allAnswered` (becomes `decisions:wellFormed` +
103
+ * `decisions:allAnswered`). A failing item inherits its check's
104
+ * persona; there is no per-item override.
105
+ *
106
+ * The `notApplicable` placeholder (`stage:<D>:scope`) carries the
107
+ * per-stage fallback persona from `STAGE_FALLBACK_PERSONA` below.
108
+ * It is always ok so it never affects a level verdict.
109
+ */
110
+ export const CHECK_PERSONA = /** @type {const} */ ({
111
+ // D1 Brief: every check is PO (the brief is the PO's statement of intent).
112
+ 'brief:sinceFreeze': 'productOwner',
113
+ 'brief:kinds': 'productOwner',
114
+ 'brief:openQuestions': 'productOwner',
115
+ 'brief:profile': 'productOwner',
116
+ // D2 Skeleton: resolvedBy + reqIntent are PO; reqShape, tadPersistence, deployAdr are engineer.
117
+ 'skeleton:resolvedBy': 'productOwner',
118
+ 'skeleton:reqIntent': 'productOwner',
119
+ 'skeleton:reqShape': 'engineer',
120
+ 'skeleton:tadPersistence': 'engineer',
121
+ 'skeleton:deployAdr': 'engineer',
122
+ // D3 Shapes: every check is engineer (shapes are the engineer's first act after hand-over).
123
+ 'shapes:tacHasInterface': 'engineer',
124
+ 'shapes:kindVocabulary': 'engineer',
125
+ 'shapes:draftSettled': 'engineer',
126
+ // D4 Stories: reqHasUs is PO ("what does someone do with this?"); the floors are engineer.
127
+ 'stories:reqHasUs': 'productOwner',
128
+ 'stories:usFloors': 'engineer',
129
+ // D5 Crosscut: TAD + concern-ledger content is engineering.
130
+ 'crosscut:securityArchitecture': 'engineer',
131
+ 'crosscut:operationalConcerns': 'engineer',
132
+ 'crosscut:concernsResolved': 'engineer',
133
+ // D6 Consistency: schema validity and probes are engineer.
134
+ 'consistency:validateClean': 'engineer',
135
+ 'consistency:probeCount': 'engineer',
136
+ // D7 Decisions: enumeration is PO (the PO answers or lets the default stand);
137
+ // zero-open is the engineer's hand-over condition.
138
+ 'decisions:wellFormed': 'productOwner',
139
+ 'decisions:allAnswered': 'engineer',
140
+ // D8 Freeze: every check is mechanical / engineering.
141
+ 'freeze:priorGates': 'engineer',
142
+ 'freeze:acFbsOwnership': 'engineer',
143
+ 'freeze:queueHead': 'engineer',
144
+ 'freeze:validateClean': 'engineer',
145
+ });
146
+
147
+ /**
148
+ * Per-check heading question in the owning persona's register
149
+ * (ADR-4126, proposal section 1.1). The text report and the
150
+ * Readiness tab print the question as the heading of each check's
151
+ * findings list. Static per check in 0.29.0; per-item plain-English
152
+ * wording is step 3's guided elicitation.
153
+ */
154
+ export const CHECK_QUESTION = /** @type {const} */ ({
155
+ 'brief:sinceFreeze': 'What has changed, or what is this project for?',
156
+ 'brief:kinds': 'Is this a capability, a constraint, an actor, or something else?',
157
+ 'brief:openQuestions': 'Any open questions in the brief, resolved or promoted to a decision?',
158
+ 'brief:profile': 'How do you want to review, and who are you writing for?',
159
+ 'skeleton:resolvedBy': 'Does this statement become a requirement, an entity, or an omission?',
160
+ 'skeleton:reqIntent': 'What does this requirement mean and where does it sit?',
161
+ 'skeleton:reqShape': 'Every requirement in scope carries a shape classification.',
162
+ 'skeleton:tadPersistence': 'TAD data architecture lists dataStores and coreEntities for persistence REQs.',
163
+ 'skeleton:deployAdr': 'Exactly one Deploy target or Deploy deferral ADR exists.',
164
+ 'shapes:tacHasInterface': 'Every TAC in scope has at least one interface.',
165
+ 'shapes:kindVocabulary': 'Every interface kind is in the closed vocabulary.',
166
+ 'shapes:draftSettled': 'Every draft interface has been settled (the [draft] marker is removed).',
167
+ 'stories:reqHasUs': 'What does someone do with this requirement?',
168
+ 'stories:usFloors': 'Every story in scope meets the class, tacIds and testability floors.',
169
+ 'crosscut:securityArchitecture': 'TAD.securityArchitecture is present when an auth or httpApi REQ exists.',
170
+ 'crosscut:operationalConcerns': 'TAD.operationalConcerns is present when a deployed-scope AC exists.',
171
+ 'crosscut:concernsResolved': 'Every concern-ledger entry on a REQ in scope is applied or waived.',
172
+ 'consistency:validateClean': 'Validate is clean tree-wide.',
173
+ 'consistency:probeCount': 'Zero open probe-ledger entries.',
174
+ 'decisions:wellFormed': 'Every decision is enumerated (question, two or more options, a default).',
175
+ 'decisions:allAnswered': 'Zero decisions are open.',
176
+ 'freeze:priorGates': 'Every prior stage passed or is acknowledged at the current hash.',
177
+ 'freeze:acFbsOwnership': 'Every AC is owned by exactly one FBS.',
178
+ 'freeze:queueHead': 'Queue head is actionable and not a placeholder.',
179
+ 'freeze:validateClean': 'Validate is clean tree-wide.',
180
+ });
181
+
182
+ /**
183
+ * Per-stage fallback persona for the `stage:<D>:scope` placeholder
184
+ * check that `notApplicable` envelopes carry. The placeholder is
185
+ * always ok so this never affects a level, but keeps "every check
186
+ * has a persona" true for consumers and the table test (ADR-4126
187
+ * decision 10).
188
+ */
189
+ export const STAGE_FALLBACK_PERSONA = /** @type {const} */ ({
190
+ D1: 'productOwner',
191
+ D2: 'productOwner',
192
+ D3: 'engineer',
193
+ D4: 'productOwner',
194
+ D5: 'engineer',
195
+ D6: 'engineer',
196
+ D7: 'productOwner',
197
+ D8: 'engineer',
198
+ });
199
+
200
+ /**
201
+ * Look up the question heading for a check name.
202
+ * @param {string} name
203
+ * @returns {string}
204
+ */
205
+ export function checkQuestion(name) {
206
+ return /** @type {any} */ (CHECK_QUESTION)[name] ?? '';
207
+ }
208
+
209
+ /**
210
+ * Look up the owning persona for a check name.
211
+ * @param {string} name
212
+ * @returns {'productOwner' | 'engineer' | null}
213
+ */
214
+ export function checkPersona(name) {
215
+ const v = /** @type {any} */ (CHECK_PERSONA)[name];
216
+ return v === 'productOwner' || v === 'engineer' ? v : null;
217
+ }
218
+
219
+ /**
220
+ * Draft marker (ADR-4126 for `TAC.interfaces[].description` in 0.29.0;
221
+ * REQ-188 extends it to `TAC.purpose` and `coreEntities[].description`
222
+ * in 0.30.0). A description that starts with `[draft]` after optional
223
+ * leading whitespace is pre-populated engineer entry material; the
224
+ * engineer removes the marker when the shape is settled. Decision 6
225
+ * uses the same bracket-prefix convention for AC class.
226
+ */
227
+ const INTERFACE_DRAFT_RE = /^\[draft\]/;
228
+
229
+ /**
230
+ * True when a string carries the leading `[draft]` marker
231
+ * (whitespace tolerated). Pure string test used by every draft-home
232
+ * reader below.
233
+ *
234
+ * @param {unknown} value
235
+ * @returns {boolean}
236
+ */
237
+ export function hasDraftMarker(value) {
238
+ if (typeof value !== 'string') return false;
239
+ return INTERFACE_DRAFT_RE.test(value.trimStart());
240
+ }
241
+
242
+ /**
243
+ * True when an interface's `description` carries the leading
244
+ * `[draft]` marker (whitespace tolerated). Thin wrapper over
245
+ * `hasDraftMarker` kept for ADR-4126 call sites; REQ-188 adds
246
+ * `parseTacPurposeDraft` and `parseCoreEntityDraft` for the two new
247
+ * homes.
248
+ *
249
+ * @param {unknown} iface
250
+ * @returns {boolean}
251
+ */
252
+ export function parseInterfaceDraft(iface) {
253
+ if (!iface || typeof iface !== 'object') return false;
254
+ return hasDraftMarker(/** @type {any} */ (iface).description);
255
+ }
256
+
257
+ /**
258
+ * True when a TAC's `purpose` carries the leading `[draft]` marker
259
+ * (whitespace tolerated). REQ-188 draft home; the engineer removes
260
+ * the marker when the TAC's purpose is owned.
261
+ *
262
+ * @param {unknown} tac
263
+ * @returns {boolean}
264
+ */
265
+ export function parseTacPurposeDraft(tac) {
266
+ if (!tac || typeof tac !== 'object') return false;
267
+ return hasDraftMarker(/** @type {any} */ (tac).purpose);
268
+ }
269
+
270
+ /**
271
+ * True when a coreEntities entry's `description` carries the
272
+ * leading `[draft]` marker (whitespace tolerated). REQ-188 draft
273
+ * home; the engineer removes the marker when the entity is owned.
274
+ *
275
+ * @param {unknown} entity
276
+ * @returns {boolean}
277
+ */
278
+ export function parseCoreEntityDraft(entity) {
279
+ if (!entity || typeof entity !== 'object') return false;
280
+ return hasDraftMarker(/** @type {any} */ (entity).description);
281
+ }
282
+
283
+ /** Warn-with-ack stages in 0.29.0 (ADR-4122). */
284
+ const WARN_WITH_ACK = new Set(['D3', 'D5', 'D6']);
285
+
286
+ /** Blocking stages in 0.29.0 (ADR-4122). */
287
+ const BLOCKING = new Set(['D1', 'D2', 'D4', 'D7', 'D8']);
288
+
289
+ /**
290
+ * Signals a stage exports so consumers can render policy in the pill
291
+ * vocabulary without touching internals. Kept as a helper so the CLI
292
+ * text output and the viewer share one source.
293
+ * @param {string} stage
294
+ * @returns {'blocking' | 'warnWithAck'}
295
+ */
296
+ export function stagePolicy(stage) {
297
+ return WARN_WITH_ACK.has(stage) ? 'warnWithAck' : 'blocking';
298
+ }
299
+
300
+ /**
301
+ * Scaffold TODO placeholder marker: uppercase "TODO" followed by a
302
+ * colon, matching every scaffold string `rcf init` and `rcf define
303
+ * create` write (see src/core/store/init.js and src/core/store/writer.js:
304
+ * every scaffold field is 'TODO: <text>'). Word-bounded and
305
+ * case-sensitive so incidental prose like "the scaffold TODO
306
+ * placeholder" or "no TODO markers" does not trip the gate. The
307
+ * validate CLI (src/cli/validate.js) keeps its own broader
308
+ * `\btodo\b/i` matcher for the informational tree-wide notice, so
309
+ * incidental prose still surfaces there without failing D2 / D4.
310
+ */
311
+ const TODO_RE = /\bTODO:/;
312
+
313
+ /** Bracketed AC-class prefix (Baz decision 6, proposal §3.2 D4). */
314
+ const AC_CLASS_RE = /^\[(happy|edge|failure|must-not|non-functional)\]/;
315
+
316
+ /**
317
+ * Parse the leading bracketed class marker on `ac.description`.
318
+ * Returns the class token, or null when no marker is present.
319
+ *
320
+ * @param {unknown} ac
321
+ * @returns {string | null}
322
+ */
323
+ export function parseAcClass(ac) {
324
+ const desc = ac && typeof ac === 'object' ? /** @type {any} */ (ac).description : null;
325
+ if (typeof desc !== 'string') return null;
326
+ const m = desc.trim().match(AC_CLASS_RE);
327
+ return m ? m[1] : null;
328
+ }
329
+
330
+ /**
331
+ * Build a helper `{ name, ok, over, pass, total, failing }` in one
332
+ * call. `failing` is normalised to the { id, why } shape and omitted
333
+ * when ok is true (the surface is stable either way; the array is
334
+ * always present for consumers that fold on length).
335
+ *
336
+ * @param {string} name
337
+ * @param {'delta' | 'tree'} over
338
+ * @param {number} total
339
+ * @param {Array<{ id: string, why: string }>} failing
340
+ * @param {number} [totalFailingCount] Untruncated failing count. Defaults to
341
+ * `failing.length`. Callers that truncate the failing array for display
342
+ * (e.g. `.slice(0, 20)` on a large validate-error set) pass the full
343
+ * count here so `pass = total - untruncatedFailing`; without this the
344
+ * fold `pass = total - failing.length` over-counts passes beyond the
345
+ * twentieth failure. `ok` is likewise derived from the untruncated
346
+ * count. The section 2.4 shape stays intact -- `failing` remains the
347
+ * display array; no new field is exported.
348
+ * @returns {{ name: string, ok: boolean, over: 'delta'|'tree', pass: number, total: number, failing: Array<{ id: string, why: string }>, persona: 'productOwner'|'engineer', question: string }}
349
+ */
350
+ function makeCheck(name, over, total, failing, totalFailingCount) {
351
+ const nFailing = typeof totalFailingCount === 'number' ? totalFailingCount : failing.length;
352
+ const pass = Math.max(0, total - nFailing);
353
+ // Resolve persona + question for the check. For stage placeholders
354
+ // (`stage:<D>:scope`) the persona comes from STAGE_FALLBACK_PERSONA.
355
+ // Unknown names fall back to engineer with an empty question so
356
+ // consumers never see null; the table test catches any new check
357
+ // that forgets to register in CHECK_PERSONA / CHECK_QUESTION.
358
+ let persona = /** @type {any} */ (CHECK_PERSONA)[name];
359
+ if (!persona) {
360
+ const stageMatch = /^stage:(D[1-8]):scope$/.exec(name);
361
+ persona = stageMatch
362
+ ? /** @type {any} */ (STAGE_FALLBACK_PERSONA)[stageMatch[1]]
363
+ : 'engineer';
364
+ }
365
+ const question = /** @type {any} */ (CHECK_QUESTION)[name]
366
+ ?? (name.startsWith('stage:') ? 'No documents of this stage’s type are in scope.' : '');
367
+ return { name, ok: nFailing === 0, over, pass, total, failing: [...failing], persona, question };
368
+ }
369
+
370
+ /**
371
+ * Fold a checks[] into a stage envelope with the ADR-4122 state rules
372
+ * applied. Blocking stages ignore acknowledgement; warn-with-ack
373
+ * stages honour a freeze.gates[<gate>] entry at the current tree hash.
374
+ *
375
+ * @param {string} stage
376
+ * @param {string} gate
377
+ * @param {Array<ReturnType<typeof makeCheck>>} checks
378
+ * @param {object} [freezeCtx]
379
+ * @param {string | null} [freezeCtx.currentTreeHash]
380
+ * @param {Record<string, { state?: string, at?: { hash?: string } }> | undefined} [freezeCtx.freezeGates]
381
+ * @returns {{ stage: string, gate: string, state: 'passed'|'failing'|'acknowledged', checks: Array<ReturnType<typeof makeCheck>> }}
382
+ */
383
+ export function foldState(stage, gate, checks, freezeCtx = {}) {
384
+ const anyFailing = checks.some((c) => !c.ok);
385
+ if (!anyFailing) return { stage, gate, state: 'passed', checks };
386
+ if (WARN_WITH_ACK.has(stage)) {
387
+ const gates = freezeCtx.freezeGates ?? {};
388
+ const record = gates[gate];
389
+ const ackedAtHash = record?.state === 'acknowledged'
390
+ && typeof record?.at?.hash === 'string'
391
+ && record.at.hash === freezeCtx.currentTreeHash;
392
+ if (ackedAtHash) return { stage, gate, state: 'acknowledged', checks };
393
+ }
394
+ return { stage, gate, state: 'failing', checks };
395
+ }
396
+
397
+ /**
398
+ * `notApplicable` envelope helper.
399
+ * @param {string} stage
400
+ * @param {string} gate
401
+ * @param {string} reason
402
+ */
403
+ function notApplicable(stage, gate, reason) {
404
+ return {
405
+ stage, gate, state: 'notApplicable', checks: [
406
+ makeCheck(`stage:${stage}:scope`, 'delta', 0, []),
407
+ ], reason,
408
+ };
409
+ }
410
+
411
+ /**
412
+ * The set of ids in scope, as a Set for O(1) hit checks.
413
+ * @param {Iterable<string> | undefined} scope
414
+ * @returns {Set<string>}
415
+ */
416
+ function asSet(scope) {
417
+ return scope instanceof Set ? scope : new Set(scope ?? []);
418
+ }
419
+
420
+ // ---------------------------------------------------------------------------
421
+ // D1 -- Brief intake and ledger (blocking in 0.29.0).
422
+ // ---------------------------------------------------------------------------
423
+
424
+ /**
425
+ * @typedef {object} StageContext
426
+ * @property {import('#core/store/walker.js').TreeModel} tree
427
+ * @property {import('./delta.js').LedgerBundle | undefined} ledgers
428
+ * @property {import('./delta.js').DeltaResult | undefined} delta
429
+ * @property {import('./delta.js').FreezeRecord | null | undefined} freeze
430
+ * @property {Iterable<string> | undefined} scope
431
+ * @property {Array<unknown> | undefined} validateErrors tree-wide walker + validate errors
432
+ * @property {number | undefined} probeOpenCount for D6 (probe ledger open entries)
433
+ * @property {import('./coverage.js').CoverageResult | undefined} coverageTree optional, unused by gates in 0.29.0
434
+ * @property {string | null | undefined} profileText rcf/.identity/profile.md contents (null when absent)
435
+ * @property {{ skipReviewFor?: string } | undefined} profile parsed profile switches (light shape)
436
+ * @property {string | null | undefined} currentTreeHash for warn-with-ack acknowledgement match
437
+ * @property {Array<{ state: string }> | undefined} priorStages D8 reads earlier stage states from this
438
+ */
439
+
440
+ /**
441
+ * D1 -- Brief intake and ledger.
442
+ *
443
+ * @param {StageContext} ctx
444
+ */
445
+ export function checkD1Brief(ctx) {
446
+ const gate = STAGE_GATES.D1;
447
+ const checks = [];
448
+ const brief = /** @type {any} */ (ctx.ledgers?.brief);
449
+ const statements = Array.isArray(brief?.statements) ? brief.statements : [];
450
+ const decisions = /** @type {any} */ (ctx.ledgers?.decisions);
451
+ const decisionEntries = Array.isArray(decisions?.decisions) ? decisions.decisions : [];
452
+
453
+ // Check 1: at least one statement since the freeze (proposal §3.2 D1
454
+ // "at least one statement since the freeze"). briefSince is the
455
+ // delta's own high-water-mark projection; on an unfrozen tree the
456
+ // rule is "the brief itself" so every statement counts.
457
+ const highWater = Number.isFinite(ctx.freeze?.briefStatements) ? Number(ctx.freeze?.briefStatements) : 0;
458
+ const since = statements.filter((s) => Number(s?.id) > highWater);
459
+ checks.push(makeCheck(
460
+ 'brief:sinceFreeze',
461
+ 'delta',
462
+ Math.max(since.length, 1),
463
+ since.length === 0 && (ctx.freeze ? true : statements.length === 0)
464
+ ? [{ id: 'brief-ledger', why: ctx.freeze
465
+ ? 'no new brief statement since the freeze'
466
+ : 'brief ledger holds no statements' }]
467
+ : [],
468
+ ));
469
+
470
+ // Check 2: every statement kinded from the closed set.
471
+ const badKind = [];
472
+ for (const s of statements) {
473
+ if (!s || typeof s.kind !== 'string' || !BRIEF_KINDS.includes(/** @type {any} */ (s.kind))) {
474
+ badKind.push({ id: `brief:${s?.id ?? '?'}`, why: `unknown kind ${s?.kind ?? '(missing)'}` });
475
+ }
476
+ }
477
+ checks.push(makeCheck('brief:kinds', 'tree', statements.length, badKind));
478
+
479
+ // Check 3: every openQuestion resolved or promoted to a decision.
480
+ const openQuestions = statements.filter((s) => s?.kind === 'openQuestion' && s?.status !== 'resolved');
481
+ // A promoted openQuestion is one whose text is quoted or referenced in
482
+ // a decisions-ledger entry's question field; a cheap presence check.
483
+ const promoted = new Set();
484
+ for (const q of openQuestions) {
485
+ const text = typeof q?.text === 'string' ? q.text.trim() : '';
486
+ if (!text) continue;
487
+ if (decisionEntries.some((d) => typeof d?.question === 'string' && d.question.includes(text))) {
488
+ promoted.add(q.id);
489
+ }
490
+ }
491
+ const unresolvedOpen = openQuestions.filter((q) => !promoted.has(q.id));
492
+ checks.push(makeCheck(
493
+ 'brief:openQuestions',
494
+ 'tree',
495
+ openQuestions.length,
496
+ unresolvedOpen.map((q) => ({ id: `brief:${q.id}`, why: 'openQuestion still open (not resolved and not promoted to a decision)' })),
497
+ ));
498
+
499
+ // Check 4: profile.md carries surface + register markers (light
500
+ // presence check; the fuller elicitation prompt lives in slice 6).
501
+ const surfaceMarkers = ['viewer', 'runningApp', 'prDiff'];
502
+ const registerMarkers = ['productOwner', 'engineer', 'unstated'];
503
+ const profileText = ctx.profileText ?? '';
504
+ const surfaceHit = surfaceMarkers.some((m) => profileText.includes(m));
505
+ const registerHit = registerMarkers.some((m) => profileText.includes(m));
506
+ const profileFail = [];
507
+ if (!surfaceHit) profileFail.push({ id: 'profile:surface', why: 'profile.md is missing a review-surface marker (viewer | runningApp | prDiff)' });
508
+ if (!registerHit) profileFail.push({ id: 'profile:register', why: 'profile.md is missing a register marker (productOwner | engineer | unstated)' });
509
+ checks.push(makeCheck('brief:profile', 'tree', 2, profileFail));
510
+
511
+ return foldState('D1', gate, checks, {
512
+ currentTreeHash: ctx.currentTreeHash ?? null,
513
+ freezeGates: /** @type {any} */ (ctx.freeze?.gates),
514
+ });
515
+ }
516
+
517
+ // ---------------------------------------------------------------------------
518
+ // D2 -- Requirements and architecture skeleton (blocking in 0.29.0).
519
+ // ---------------------------------------------------------------------------
520
+
521
+ /** Kinds whose resolution D2 checks: capability / constraint / entity / actor / externalSystem / surface. */
522
+ const D2_RESOLVING_KINDS = new Set(['capability', 'constraint', 'entity', 'actor', 'externalSystem', 'surface']);
523
+
524
+ /**
525
+ * D2 -- Requirements and architecture skeleton.
526
+ *
527
+ * @param {StageContext} ctx
528
+ */
529
+ export function checkD2Skeleton(ctx) {
530
+ const gate = STAGE_GATES.D2;
531
+ const scope = asSet(ctx.scope);
532
+ const tree = ctx.tree;
533
+ const brief = /** @type {any} */ (ctx.ledgers?.brief);
534
+ const statements = Array.isArray(brief?.statements) ? brief.statements : [];
535
+ const highWater = Number.isFinite(ctx.freeze?.briefStatements) ? Number(ctx.freeze?.briefStatements) : 0;
536
+ const stmtsInScope = statements.filter((s) => Number(s?.id) > highWater);
537
+ const resolvingStmts = stmtsInScope.filter((s) => D2_RESOLVING_KINDS.has(String(s?.kind)));
538
+ const reqInScope = (tree.requirements ?? []).filter((r) => scope.has(r.reqId));
539
+ const prdInScope = tree.prd && scope.has(tree.prd.prdId ?? '');
540
+ const tadInScope = tree.tad && scope.has(tree.tad.tadId ?? '');
541
+
542
+ if (resolvingStmts.length === 0 && reqInScope.length === 0 && !prdInScope && !tadInScope) {
543
+ return notApplicable('D2', gate, 'no resolving brief statements and no REQ / PRD / TAD in scope');
544
+ }
545
+
546
+ const checks = [];
547
+
548
+ // Check 1 (0.30.0): every capability/constraint/entity/... statement
549
+ // resolves to a closed-grammar pointer in `resolvedBy`:
550
+ // REQ-nnn | TAD.entity:<name> | PRD.user:<name>
551
+ // | TAD.system:<name> | TAC-nnnn | omitted:<reason>
552
+ // The 0.29.0 REQ-title fallback is dropped (decision 3; a title hit
553
+ // becomes a suggestion the PR 2 computeQuestions surface returns).
554
+ // A statement with no `resolvedBy` fails; a statement with a
555
+ // `resolvedBy` outside the grammar fails with 'pointer does not
556
+ // resolve'.
557
+ /** @type {Array<{ id: string, why: string }>} */
558
+ const unresolvedFailing = [];
559
+ for (const s of resolvingStmts) {
560
+ const rb = s?.resolvedBy;
561
+ if (typeof rb !== 'string' || rb.length === 0) {
562
+ unresolvedFailing.push({ id: `brief:${s.id}`, why: `${s.kind} statement has no resolvedBy` });
563
+ continue;
564
+ }
565
+ const parsed = parseResolvedBy(rb, tree);
566
+ if (!parsed.ok) {
567
+ unresolvedFailing.push({ id: `brief:${s.id}`, why: 'pointer does not resolve' });
568
+ }
569
+ }
570
+ checks.push(makeCheck(
571
+ 'skeleton:resolvedBy',
572
+ 'delta',
573
+ resolvingStmts.length,
574
+ unresolvedFailing,
575
+ ));
576
+
577
+ // Checks 2a / 2b: the former `skeleton:reqFields` split into two
578
+ // homogeneous checks (ADR-4126, proposal section 1.3). Each
579
+ // evaluates every REQ in scope independently so a REQ with a TODO
580
+ // description still has its shapes reported, and vice versa.
581
+ const reqIntentFail = [];
582
+ const reqShapeFail = [];
583
+ for (const req of reqInScope) {
584
+ // reqIntent (productOwner): description without a TODO + domain
585
+ // (what the requirement means and where it sits).
586
+ if (typeof req.description !== 'string' || req.description.length === 0 || TODO_RE.test(req.description)) {
587
+ reqIntentFail.push({ id: req.reqId, why: 'description missing or contains TODO placeholder' });
588
+ } else if (typeof req.domain !== 'string' || req.domain.length === 0) {
589
+ reqIntentFail.push({ id: req.reqId, why: 'domain missing' });
590
+ }
591
+ // reqShape (engineer): shapeClassification.shapes classification.
592
+ if (!req.shapeClassification || !Array.isArray(req.shapeClassification.shapes)) {
593
+ reqShapeFail.push({ id: req.reqId, why: 'shapeClassification.shapes missing' });
594
+ }
595
+ }
596
+ checks.push(makeCheck('skeleton:reqIntent', 'delta', reqInScope.length, reqIntentFail));
597
+ checks.push(makeCheck('skeleton:reqShape', 'delta', reqInScope.length, reqShapeFail));
598
+
599
+ // Check 3: TAD.dataArchitecture.dataStores + coreEntities present when any
600
+ // REQ exists tree-wide.
601
+ const persistenceReq = (tree.requirements ?? []).some((r) => Array.isArray(r?.shapeClassification?.shapes) && r.shapeClassification.shapes.includes('persistence'));
602
+ const tadFail = [];
603
+ if (persistenceReq) {
604
+ const tad = tree.tad ?? {};
605
+ const dataArch = tad?.dataArchitecture ?? {};
606
+ const dataStores = Array.isArray(dataArch.dataStores) ? dataArch.dataStores : [];
607
+ const coreEntities = Array.isArray(dataArch.coreEntities) ? dataArch.coreEntities : [];
608
+ if (dataStores.length === 0) tadFail.push({ id: 'TAD.dataArchitecture.dataStores', why: 'TAD.dataArchitecture.dataStores empty while persistence REQ exists' });
609
+ if (coreEntities.length === 0) tadFail.push({ id: 'TAD.dataArchitecture.coreEntities', why: 'TAD.dataArchitecture.coreEntities empty while persistence REQ exists' });
610
+ }
611
+ checks.push(makeCheck('skeleton:tadPersistence', 'tree', persistenceReq ? 2 : 0, tadFail));
612
+
613
+ // Check 4: exactly one deploy or deferral ADR tree-wide. Cheap
614
+ // detection by title prefix (proposal §11 assumption).
615
+ const deployAdrs = (tree.adrs ?? []).filter((a) => {
616
+ const t = typeof a?.title === 'string' ? a.title : '';
617
+ return t.startsWith('Deploy target:') || t.startsWith('Deploy deferral:');
618
+ });
619
+ const deployFail = [];
620
+ if (deployAdrs.length === 0) deployFail.push({ id: 'ADR:deploy', why: 'no Deploy target or Deploy deferral ADR found (title-prefix scan)' });
621
+ else if (deployAdrs.length > 1) deployFail.push({ id: 'ADR:deploy', why: `${deployAdrs.length} Deploy ADRs found; expected exactly one` });
622
+ checks.push(makeCheck('skeleton:deployAdr', 'tree', 1, deployFail));
623
+
624
+ return foldState('D2', gate, checks, {
625
+ currentTreeHash: ctx.currentTreeHash ?? null,
626
+ freezeGates: /** @type {any} */ (ctx.freeze?.gates),
627
+ });
628
+ }
629
+
630
+ // ---------------------------------------------------------------------------
631
+ // D3 -- Interface contracts and shapes (warn-with-ack in 0.29.0).
632
+ // ---------------------------------------------------------------------------
633
+
634
+ /** Set form of INTERFACE_KINDS for O(1) checks. */
635
+ const INTERFACE_KINDS_SET = new Set(INTERFACE_KINDS);
636
+
637
+ /**
638
+ * D3 -- Interface contracts and shapes. 0.29.0 runs the cheap
639
+ * presence, closed-vocabulary and draft-marker checks
640
+ * (`shapes:draftSettled` per ADR-4126 scans the `[draft]` prefix).
641
+ * REQ-188 (0.30.0): the draft marker extends to `TAC.purpose` and
642
+ * `TAD.dataArchitecture.coreEntities[].description`; `shapes:draftSettled`
643
+ * enumerates all three homes with ids `<tacId>:<name>`, `<tacId>`
644
+ * and `TAD.entity:<name>` respectively. `tacHasInterface` and
645
+ * `kindVocabulary` apply to drafts unchanged.
646
+ *
647
+ * SEAM 0.30: template markers per kind (recordShape.fields:, httpRoute
648
+ * method/path/request/response/errors, fixture.instances:); entity-name
649
+ * join across TAD.dataArchitecture.coreEntities and TAC.interfaces[];
650
+ * ownerRef and `authoredAt` path resolution. Those add checks to this
651
+ * stage; the fold shape stays the same.
652
+ *
653
+ * @param {StageContext} ctx
654
+ */
655
+ export function checkD3Shapes(ctx) {
656
+ const gate = STAGE_GATES.D3;
657
+ const scope = asSet(ctx.scope);
658
+ const tree = ctx.tree;
659
+ const tacsInScope = (tree.tacs ?? []).filter((t) => scope.has(t.tacId));
660
+ const shapedReqInScope = (tree.requirements ?? []).filter((r) => {
661
+ if (!scope.has(r.reqId)) return false;
662
+ const shapes = r.shapeClassification?.shapes ?? [];
663
+ return shapes.includes('httpApi') || shapes.includes('persistence') || shapes.includes('auth');
664
+ });
665
+
666
+ // Core entities on TAD.dataArchitecture come from the whole tree,
667
+ // not the scope: an entity's draft marker is a tree-wide draft home
668
+ // (the engineer owns every entity in one place) and reporting it
669
+ // under D3 does not depend on which TAC a change touched.
670
+ const coreEntities = Array.isArray(tree.tad?.dataArchitecture?.coreEntities)
671
+ ? /** @type {any[]} */ (tree.tad.dataArchitecture.coreEntities)
672
+ : [];
673
+ const draftEntities = coreEntities.filter(parseCoreEntityDraft);
674
+
675
+ if (tacsInScope.length === 0 && shapedReqInScope.length === 0 && draftEntities.length === 0) {
676
+ return notApplicable('D3', gate, 'no TAC, no shaped REQ (httpApi/persistence/auth) and no [draft] core entity in scope');
677
+ }
678
+
679
+ const checks = [];
680
+
681
+ // Check 1: every TAC in scope has at least one interface.
682
+ const noIfaceTacs = tacsInScope.filter((t) => !Array.isArray(t.interfaces) || t.interfaces.length === 0);
683
+ checks.push(makeCheck(
684
+ 'shapes:tacHasInterface',
685
+ 'delta',
686
+ tacsInScope.length,
687
+ noIfaceTacs.map((t) => ({ id: t.tacId, why: 'no interfaces authored' })),
688
+ ));
689
+
690
+ // Check 2: every interface kind is in the closed vocabulary.
691
+ // Check 3: no description across the three draft homes still
692
+ // carries the `[draft]` marker. A draft shape is entry material
693
+ // the agent pre-populates after L1; the engineer removes the
694
+ // marker when the shape is settled. `tacHasInterface` and
695
+ // `kindVocabulary` apply equally to drafts.
696
+ let totalInterfaces = 0;
697
+ const badKinds = [];
698
+ const draftFindings = [];
699
+ for (const tac of tacsInScope) {
700
+ if (parseTacPurposeDraft(tac)) {
701
+ draftFindings.push({ id: tac.tacId, why: 'draft TAC purpose pre-populated at L1, not yet settled' });
702
+ }
703
+ for (const iface of tac.interfaces ?? []) {
704
+ totalInterfaces += 1;
705
+ const kind = iface?.kind;
706
+ if (typeof kind !== 'string' || !INTERFACE_KINDS_SET.has(kind)) {
707
+ badKinds.push({ id: `${tac.tacId}:${iface?.name ?? '(unnamed)'}`, why: `unknown interface kind ${JSON.stringify(kind)}` });
708
+ }
709
+ if (parseInterfaceDraft(iface)) {
710
+ draftFindings.push({ id: `${tac.tacId}:${iface?.name ?? '(unnamed)'}`, why: 'draft interface pre-populated at L1, not yet settled' });
711
+ }
712
+ }
713
+ }
714
+ for (const entity of draftEntities) {
715
+ draftFindings.push({ id: `TAD.entity:${entity?.name ?? '(unnamed)'}`, why: 'draft core entity pre-populated at L1, not yet settled' });
716
+ }
717
+ // Total for the draft check is every draft home that could carry
718
+ // the marker on an in-scope TAC plus every core entity (tree-wide):
719
+ // the TAC itself (its purpose) + every interface on it, plus the
720
+ // coreEntities list.
721
+ const totalDraftHomes = tacsInScope.length + totalInterfaces + coreEntities.length;
722
+ checks.push(makeCheck('shapes:kindVocabulary', 'delta', totalInterfaces, badKinds));
723
+ checks.push(makeCheck('shapes:draftSettled', 'delta', totalDraftHomes, draftFindings));
724
+
725
+ return foldState('D3', gate, checks, {
726
+ currentTreeHash: ctx.currentTreeHash ?? null,
727
+ freezeGates: /** @type {any} */ (ctx.freeze?.gates),
728
+ });
729
+ }
730
+
731
+ // ---------------------------------------------------------------------------
732
+ // D4 -- Stories and criteria (blocking floors in 0.29.0).
733
+ // ---------------------------------------------------------------------------
734
+
735
+ /**
736
+ * D4 -- Stories and criteria floors. 0.29.0 enforces the four floors
737
+ * proposal §3.2 blocks on: every REQ has a US, every US has a testable
738
+ * AC, a failure AC (or opt-out per class), a must-not AC (or opt-out),
739
+ * tacIds non-empty and resolving, no scaffold TODO placeholder.
740
+ *
741
+ * SEAM 0.30: closed-set scan across enumerations; ownerRef resolution
742
+ * to TAC.interfaces[<name>]; template-marker parse. Warn-only notices
743
+ * in 0.29.0 (see D3).
744
+ *
745
+ * @param {StageContext} ctx
746
+ */
747
+ export function checkD4Stories(ctx) {
748
+ const gate = STAGE_GATES.D4;
749
+ const scope = asSet(ctx.scope);
750
+ const tree = ctx.tree;
751
+ const manifest = /** @type {any} */ (tree.manifest);
752
+ const reqInScope = (tree.requirements ?? []).filter((r) => scope.has(r.reqId));
753
+ const usInScope = (tree.userStories ?? []).filter((us) => scope.has(us.usId));
754
+
755
+ if (reqInScope.length === 0 && usInScope.length === 0) {
756
+ return notApplicable('D4', gate, 'no REQ and no US in scope');
757
+ }
758
+
759
+ const checks = [];
760
+
761
+ // Check 1: every REQ in scope has at least one US.
762
+ const reqWithoutUs = [];
763
+ for (const req of reqInScope) {
764
+ const hasUs = (tree.userStories ?? []).some((us) => us.reqId === req.reqId);
765
+ if (!hasUs) reqWithoutUs.push({ id: req.reqId, why: 'no owning user story' });
766
+ }
767
+ checks.push(makeCheck('stories:reqHasUs', 'delta', reqInScope.length, reqWithoutUs));
768
+
769
+ // Check 2: every US in scope carries the class floors + tacIds + testable AC + no TODO.
770
+ const usFail = [];
771
+ for (const us of usInScope) {
772
+ const acs = Array.isArray(us.acceptanceCriteria) ? us.acceptanceCriteria : [];
773
+ // testable
774
+ const hasTestable = acs.some((ac) => ac?.testable !== false && typeof ac?.description === 'string' && ac.description.length > 0);
775
+ if (!hasTestable) usFail.push({ id: us.usId, why: 'no testable acceptance criterion' });
776
+ // failure class
777
+ const hasFailure = acs.some((ac) => parseAcClass(ac) === 'failure');
778
+ const failureOpted = isOptedOut(manifest, us.reqId, 'defineD4:failure');
779
+ if (!hasFailure && !failureOpted) {
780
+ usFail.push({ id: us.usId, why: 'no [failure] class AC and no defineD4:failure opt-out on this REQ' });
781
+ }
782
+ // must-not class
783
+ const hasMustNot = acs.some((ac) => parseAcClass(ac) === 'must-not');
784
+ const mustNotOpted = isOptedOut(manifest, us.reqId, 'defineD4:mustNot');
785
+ if (!hasMustNot && !mustNotOpted) {
786
+ usFail.push({ id: us.usId, why: 'no [must-not] class AC and no defineD4:mustNot opt-out on this REQ' });
787
+ }
788
+ // tacIds non-empty and resolving
789
+ const tacIds = Array.isArray(us.tacIds) ? us.tacIds : [];
790
+ if (tacIds.length === 0) {
791
+ usFail.push({ id: us.usId, why: 'tacIds is empty' });
792
+ } else {
793
+ const unresolved = tacIds.filter((id) => tree.kindById?.get(id) !== 'tac');
794
+ if (unresolved.length > 0) {
795
+ usFail.push({ id: us.usId, why: `tacIds names non-TAC ids: ${unresolved.join(', ')}` });
796
+ }
797
+ }
798
+ // no TODO placeholder in any AC description
799
+ const todoAc = acs.find((ac) => typeof ac?.description === 'string' && TODO_RE.test(ac.description));
800
+ if (todoAc) usFail.push({ id: us.usId, why: `scaffold TODO placeholder in ${todoAc.id ?? '(ac)'}` });
801
+ }
802
+ checks.push(makeCheck('stories:usFloors', 'delta', usInScope.length, usFail));
803
+
804
+ return foldState('D4', gate, checks, {
805
+ currentTreeHash: ctx.currentTreeHash ?? null,
806
+ freezeGates: /** @type {any} */ (ctx.freeze?.gates),
807
+ });
808
+ }
809
+
810
+ // ---------------------------------------------------------------------------
811
+ // D5 -- Cross-cutting weave (warn-with-ack in 0.29.0).
812
+ // ---------------------------------------------------------------------------
813
+
814
+ /**
815
+ * D5 -- Cross-cutting weave. 0.29.0 runs cheap presence checks.
816
+ *
817
+ * SEAM 0.30: full concern catalogue with per-shape applicability;
818
+ * per-(concern, REQ) pair enumeration; TAD securityArchitecture and
819
+ * operationalConcerns structure checks beyond presence.
820
+ *
821
+ * @param {StageContext} ctx
822
+ */
823
+ export function checkD5Crosscut(ctx) {
824
+ const gate = STAGE_GATES.D5;
825
+ const scope = asSet(ctx.scope);
826
+ const tree = ctx.tree;
827
+ const reqInScope = (tree.requirements ?? []).filter((r) => scope.has(r.reqId));
828
+
829
+ if (reqInScope.length === 0) {
830
+ return notApplicable('D5', gate, 'no REQ in scope');
831
+ }
832
+
833
+ const checks = [];
834
+
835
+ // Check 1: TAD.securityArchitecture non-empty when any REQ carries shape 'auth' or 'httpApi'.
836
+ const authApi = (tree.requirements ?? []).some((r) => {
837
+ const s = r.shapeClassification?.shapes ?? [];
838
+ return s.includes('auth') || s.includes('httpApi');
839
+ });
840
+ const tad = /** @type {any} */ (tree.tad ?? {});
841
+ const secFail = [];
842
+ if (authApi) {
843
+ const sa = tad.securityArchitecture;
844
+ if (!sa || (typeof sa === 'string' ? sa.length === 0 : Object.keys(sa).length === 0)) {
845
+ secFail.push({ id: 'TAD.securityArchitecture', why: 'empty while auth or httpApi REQ exists' });
846
+ }
847
+ }
848
+ checks.push(makeCheck('crosscut:securityArchitecture', 'tree', authApi ? 1 : 0, secFail));
849
+
850
+ // Check 2: TAD.operationalConcerns non-empty when any AC description
851
+ // carries the '[deployed]' marker (a cheap presence signal that
852
+ // proposal §3.2 D5 lists as 'deployed-scope AC').
853
+ const hasDeployedAc = (tree.userStories ?? []).some((us) => (us.acceptanceCriteria ?? []).some((ac) => typeof ac?.description === 'string' && ac.description.includes('[deployed]')));
854
+ const opFail = [];
855
+ if (hasDeployedAc) {
856
+ const oc = tad.operationalConcerns;
857
+ if (!oc || (typeof oc === 'string' ? oc.length === 0 : Object.keys(oc).length === 0)) {
858
+ opFail.push({ id: 'TAD.operationalConcerns', why: 'empty while deployed-scope AC exists' });
859
+ }
860
+ }
861
+ checks.push(makeCheck('crosscut:operationalConcerns', 'tree', hasDeployedAc ? 1 : 0, opFail));
862
+
863
+ // Check 3: no open concern-ledger entry on a REQ in scope.
864
+ const concerns = /** @type {any} */ (ctx.ledgers?.concerns);
865
+ const concernEntries = Array.isArray(concerns?.concerns) ? concerns.concerns : [];
866
+ const openInScope = concernEntries.filter((c) => c?.status === 'open' && scope.has(c?.reqId));
867
+ checks.push(makeCheck(
868
+ 'crosscut:concernsResolved',
869
+ 'delta',
870
+ concernEntries.filter((c) => scope.has(c?.reqId)).length,
871
+ openInScope.map((c) => ({ id: `concern:${c.id}`, why: `open concern on ${c.reqId}: ${c.concern}` })),
872
+ ));
873
+
874
+ return foldState('D5', gate, checks, {
875
+ currentTreeHash: ctx.currentTreeHash ?? null,
876
+ freezeGates: /** @type {any} */ (ctx.freeze?.gates),
877
+ });
878
+ }
879
+
880
+ // ---------------------------------------------------------------------------
881
+ // D6 -- Consistency and satisfiability probe (warn-with-ack in 0.29.0).
882
+ // ---------------------------------------------------------------------------
883
+
884
+ /**
885
+ * D6 -- Consistency and satisfiability probe. 0.29.0 runs only the
886
+ * validate-clean tree-wide check and the probe-ledger open count.
887
+ *
888
+ * SEAM 0.30: contradiction-lite across criteria on one story and
889
+ * across their TACs' ADRs; unsatisfiable-lite (a `then` naming
890
+ * undefined state); duplicate scan; orphan-interface scan;
891
+ * regeneration probe runner injectable like `mutationRunner` in
892
+ * src/review/index.js.
893
+ *
894
+ * @param {StageContext} ctx
895
+ */
896
+ export function checkD6Consistency(ctx) {
897
+ const gate = STAGE_GATES.D6;
898
+ const checks = [];
899
+
900
+ const validateErrors = Array.isArray(ctx.validateErrors) ? ctx.validateErrors : [];
901
+ checks.push(makeCheck(
902
+ 'consistency:validateClean',
903
+ 'tree',
904
+ // "Expected zero" pattern: total stays at Math.max(N, 1) so the
905
+ // reported ratio reads sensibly on a clean tree ("1/1") and on a
906
+ // dirty one ("0/N"). Truncate the failing array for display but
907
+ // fold `pass` from the untruncated count so beyond-20 failures do
908
+ // not turn into false passes.
909
+ Math.max(validateErrors.length, 1),
910
+ validateErrors.slice(0, 20).map((e, i) => ({
911
+ id: /** @type {any} */ (e)?.documentId ?? `validate:${i}`,
912
+ why: /** @type {any} */ (e)?.message ?? 'validate error',
913
+ })),
914
+ validateErrors.length,
915
+ ));
916
+
917
+ const probeOpenCount = Number.isFinite(ctx.probeOpenCount) ? Number(ctx.probeOpenCount) : 0;
918
+ const probes = /** @type {any} */ (ctx.ledgers?.probes);
919
+ const probeEntries = Array.isArray(probes?.probes) ? probes.probes : [];
920
+ const openProbes = probeEntries.filter((p) => p?.status === 'open');
921
+ // Prefer caller-supplied probeOpenCount when set; fall back to bundle scan.
922
+ const effectiveOpen = ctx.probeOpenCount !== undefined ? probeOpenCount : openProbes.length;
923
+ const failing = [];
924
+ if (effectiveOpen > 0) {
925
+ const list = ctx.probeOpenCount !== undefined
926
+ ? [{ id: 'probe-ledger', why: `${effectiveOpen} open probe finding(s)` }]
927
+ : openProbes.slice(0, 20).map((p) => ({ id: `probe:${p.id}`, why: `open probe on ${p.reqId}: ${p.finding}` }));
928
+ failing.push(...list);
929
+ }
930
+ // Fold `pass` from the untruncated open-probe count so a 25-open-
931
+ // probes ledger reports 0/25, not 5/25. The failing array itself
932
+ // stays truncated to 20 for display; the caller-supplied count path
933
+ // uses a single representative entry regardless.
934
+ checks.push(makeCheck('consistency:probeCount', 'tree', Math.max(effectiveOpen, 1), failing, effectiveOpen));
935
+
936
+ return foldState('D6', gate, checks, {
937
+ currentTreeHash: ctx.currentTreeHash ?? null,
938
+ freezeGates: /** @type {any} */ (ctx.freeze?.gates),
939
+ });
940
+ }
941
+
942
+ // ---------------------------------------------------------------------------
943
+ // D7 -- Decisions and review (blocking in 0.29.0).
944
+ // ---------------------------------------------------------------------------
945
+
946
+ /**
947
+ * D7 -- Decisions and review.
948
+ *
949
+ * @param {StageContext} ctx
950
+ */
951
+ export function checkD7Decisions(ctx) {
952
+ const gate = STAGE_GATES.D7;
953
+ const decisions = /** @type {any} */ (ctx.ledgers?.decisions);
954
+ const decisionEntries = Array.isArray(decisions?.decisions) ? decisions.decisions : [];
955
+ const openDecisions = decisionEntries.filter((d) => d?.status === 'open');
956
+
957
+ const delta = ctx.delta ?? { changed: [], added: [] };
958
+ const deltaDocs = new Set([...(delta.changed ?? []), ...(delta.added ?? [])]);
959
+ const deltaSize = [...deltaDocs].filter((id) => !id.startsWith('ledger:')).length;
960
+ const skipSingle = ctx.profile?.skipReviewFor === 'singleDocument' && deltaSize === 1;
961
+ if (openDecisions.length === 0 && skipSingle) {
962
+ return notApplicable('D7', gate, 'no open decisions and profile skipReviewFor:singleDocument on a one-document delta');
963
+ }
964
+
965
+ const checks = [];
966
+ // Check 1: `decisions:wellFormed` (productOwner, over tree) --
967
+ // every decision is enumerated (ADR-4126, proposal section 1.3):
968
+ // a non-empty question, at least two options, a default that
969
+ // names one of the option letters.
970
+ const wellFormedFail = [];
971
+ for (const d of decisionEntries) {
972
+ const why = 'decision is not enumerated (question, two or more options, a default)';
973
+ const id = `decision:${d?.id ?? '?'}`;
974
+ if (typeof d?.question !== 'string' || d.question.length === 0) {
975
+ wellFormedFail.push({ id, why });
976
+ continue;
977
+ }
978
+ if (!Array.isArray(d.options) || d.options.length < 2) {
979
+ wellFormedFail.push({ id, why });
980
+ continue;
981
+ }
982
+ const letters = new Set(d.options.map((o) => (o && typeof o === 'object') ? /** @type {any} */ (o).letter : undefined));
983
+ if (typeof d.default !== 'string' || !letters.has(d.default)) {
984
+ wellFormedFail.push({ id, why });
985
+ }
986
+ }
987
+ checks.push(makeCheck('decisions:wellFormed', 'tree', decisionEntries.length, wellFormedFail));
988
+
989
+ // Check 2: `decisions:allAnswered` (engineer, over tree) -- zero
990
+ // open decisions. The hand-over condition for build.
991
+ checks.push(makeCheck(
992
+ 'decisions:allAnswered',
993
+ 'tree',
994
+ decisionEntries.length,
995
+ openDecisions.map((d) => ({ id: `decision:${d.id}`, why: `open: ${d.question ?? '(no question)'}` })),
996
+ ));
997
+
998
+ return foldState('D7', gate, checks, {
999
+ currentTreeHash: ctx.currentTreeHash ?? null,
1000
+ freezeGates: /** @type {any} */ (ctx.freeze?.gates),
1001
+ });
1002
+ }
1003
+
1004
+ // ---------------------------------------------------------------------------
1005
+ // D8 -- Freeze (blocking in 0.29.0).
1006
+ // ---------------------------------------------------------------------------
1007
+
1008
+ /**
1009
+ * D8 -- Freeze. Tree-wide, mechanical: prior gates passed or
1010
+ * acknowledged, every AC owned by exactly one FBS, queue head
1011
+ * actionable, validate clean.
1012
+ *
1013
+ * @param {StageContext} ctx
1014
+ */
1015
+ export function checkD8Freeze(ctx) {
1016
+ const gate = STAGE_GATES.D8;
1017
+ const tree = ctx.tree;
1018
+ const checks = [];
1019
+
1020
+ // Check 1: prior stages passed or acknowledged.
1021
+ const priorStages = Array.isArray(ctx.priorStages) ? ctx.priorStages : [];
1022
+ const priorBad = priorStages
1023
+ .filter((s) => s.state !== 'passed' && s.state !== 'acknowledged' && s.state !== 'notApplicable')
1024
+ .map((s) => ({ id: /** @type {any} */ (s).stage ?? '(?)', why: `state ${s.state}` }));
1025
+ checks.push(makeCheck('freeze:priorGates', 'tree', priorStages.length, priorBad));
1026
+
1027
+ // Check 2: every AC owned by exactly one FBS. Walker gives us
1028
+ // fbsByAcId inversion; we assert length === 1 for every known AC.
1029
+ const acIds = collectAllAcIds(tree);
1030
+ const acFail = [];
1031
+ for (const acId of acIds) {
1032
+ const owners = tree.fbsByAcId?.get(acId) ?? [];
1033
+ if (owners.length === 0) acFail.push({ id: acId, why: 'no owning FBS' });
1034
+ else if (owners.length > 1) acFail.push({ id: acId, why: `${owners.length} FBS own this AC: ${owners.join(', ')}` });
1035
+ }
1036
+ checks.push(makeCheck('freeze:acFbsOwnership', 'tree', acIds.size, acFail));
1037
+
1038
+ // Check 3: queue head actionable and not a placeholder.
1039
+ const queue = computeQueue(tree);
1040
+ const queueFail = [];
1041
+ if (!queue.nextActionable) {
1042
+ queueFail.push({ id: 'queue:head', why: 'no actionable FBS at the queue head' });
1043
+ } else {
1044
+ const head = (tree.fbsItems ?? []).find((f) => f.fbsId === queue.nextActionable);
1045
+ const title = typeof head?.title === 'string' ? head.title.trim() : '';
1046
+ if (!title || /^todo\b/i.test(title)) {
1047
+ queueFail.push({ id: queue.nextActionable, why: 'queue head title is empty or a TODO placeholder' });
1048
+ }
1049
+ }
1050
+ checks.push(makeCheck('freeze:queueHead', 'tree', 1, queueFail));
1051
+
1052
+ // Check 4: validate clean tree-wide (same signal as D6 but blocking here).
1053
+ // Truncate the failing array for display; fold `pass` from the
1054
+ // untruncated count so beyond-20 failures do not turn into false passes.
1055
+ const validateErrors = Array.isArray(ctx.validateErrors) ? ctx.validateErrors : [];
1056
+ const validateFail = validateErrors.slice(0, 20).map((e, i) => ({
1057
+ id: /** @type {any} */ (e)?.documentId ?? `validate:${i}`,
1058
+ why: /** @type {any} */ (e)?.message ?? 'validate error',
1059
+ }));
1060
+ checks.push(makeCheck('freeze:validateClean', 'tree', Math.max(validateErrors.length, 1), validateFail, validateErrors.length));
1061
+
1062
+ return foldState('D8', gate, checks, {
1063
+ currentTreeHash: ctx.currentTreeHash ?? null,
1064
+ freezeGates: /** @type {any} */ (ctx.freeze?.gates),
1065
+ });
1066
+ }
1067
+
1068
+ /**
1069
+ * Collect every AC id in the tree (walker keeps them inline on the
1070
+ * parent US). Kept here to avoid depending on the private helper in
1071
+ * `walker.js`.
1072
+ *
1073
+ * @param {import('#core/store/walker.js').TreeModel} tree
1074
+ * @returns {Set<string>}
1075
+ */
1076
+ function collectAllAcIds(tree) {
1077
+ const out = new Set();
1078
+ for (const us of tree.userStories ?? []) {
1079
+ for (const ac of us.acceptanceCriteria ?? []) {
1080
+ if (typeof ac?.id === 'string') out.add(ac.id);
1081
+ }
1082
+ }
1083
+ return out;
1084
+ }
1085
+
1086
+ /**
1087
+ * Dispatch by stage id (D1..D8) or by short name (brief..freeze).
1088
+ * Consumers call `runStage('D4', ctx)` or `runStage('stories', ctx)`.
1089
+ *
1090
+ * @param {string} stageOrShort
1091
+ * @param {StageContext} ctx
1092
+ */
1093
+ export function runStage(stageOrShort, ctx) {
1094
+ const stage = STAGE_ALIASES[stageOrShort];
1095
+ if (!stage) throw new TypeError(`Unknown stage '${stageOrShort}'`);
1096
+ switch (stage) {
1097
+ case 'D1': return checkD1Brief(ctx);
1098
+ case 'D2': return checkD2Skeleton(ctx);
1099
+ case 'D3': return checkD3Shapes(ctx);
1100
+ case 'D4': return checkD4Stories(ctx);
1101
+ case 'D5': return checkD5Crosscut(ctx);
1102
+ case 'D6': return checkD6Consistency(ctx);
1103
+ case 'D7': return checkD7Decisions(ctx);
1104
+ case 'D8': return checkD8Freeze(ctx);
1105
+ default: throw new TypeError(`Unhandled stage '${stage}'`);
1106
+ }
1107
+ }