rcf-lite 0.28.2 → 0.28.4

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 (196) hide show
  1. package/CHANGELOG.md +211 -0
  2. package/bin/rcf.js +4 -0
  3. package/fixtures/canary-manifest.json +9 -9
  4. package/package.json +1 -1
  5. package/rcf/adrs/adr-4120-canonical-json-hashing.json +30 -0
  6. package/rcf/adrs/adr-4121-define-sidecars.json +30 -0
  7. package/rcf/adrs/adr-4122-define-gates-blocking-and-optout.json +30 -0
  8. package/rcf/adrs/adr-4123-readiness-single-object.json +30 -0
  9. package/rcf/code-nodes/cn-700.json +23 -0
  10. package/rcf/code-nodes/cn-701.json +14 -0
  11. package/rcf/code-nodes/cn-702.json +14 -0
  12. package/rcf/code-nodes/cn-703.json +16 -0
  13. package/rcf/code-nodes/cn-720.json +15 -0
  14. package/rcf/code-nodes/cn-721.json +15 -0
  15. package/rcf/code-nodes/cn-722.json +15 -0
  16. package/rcf/code-nodes/cn-723.json +18 -0
  17. package/rcf/code-nodes/cn-724.json +14 -0
  18. package/rcf/code-nodes/cn-725.json +21 -0
  19. package/rcf/code-nodes/cn-726.json +17 -0
  20. package/rcf/code-nodes/cn-727.json +14 -0
  21. package/rcf/code-nodes/cn-728.json +21 -0
  22. package/rcf/code-nodes/cn-729.json +18 -0
  23. package/rcf/code-nodes/cn-730.json +14 -0
  24. package/rcf/fbs/fbs-190.json +37 -0
  25. package/rcf/fbs/fbs-191.json +34 -0
  26. package/rcf/fbs/fbs-192.json +17 -0
  27. package/rcf/fbs/fbs-193.json +37 -0
  28. package/rcf/fbs/fbs-194.json +35 -0
  29. package/rcf/fbs/fbs-195.json +37 -0
  30. package/rcf/fbs/fbs-196.json +37 -0
  31. package/rcf/fbs/fbs-197.json +41 -0
  32. package/rcf/requirements/req-001.json +2 -1
  33. package/rcf/requirements/req-002.json +2 -1
  34. package/rcf/requirements/req-003.json +2 -1
  35. package/rcf/requirements/req-004.json +2 -1
  36. package/rcf/requirements/req-005.json +2 -1
  37. package/rcf/requirements/req-006.json +2 -1
  38. package/rcf/requirements/req-007.json +2 -1
  39. package/rcf/requirements/req-008.json +2 -1
  40. package/rcf/requirements/req-009.json +2 -1
  41. package/rcf/requirements/req-010.json +5 -2
  42. package/rcf/requirements/req-011.json +2 -1
  43. package/rcf/requirements/req-012.json +2 -1
  44. package/rcf/requirements/req-013.json +2 -1
  45. package/rcf/requirements/req-014.json +2 -1
  46. package/rcf/requirements/req-015.json +2 -1
  47. package/rcf/requirements/req-016.json +5 -2
  48. package/rcf/requirements/req-017.json +6 -2
  49. package/rcf/requirements/req-018.json +5 -2
  50. package/rcf/requirements/req-019.json +5 -2
  51. package/rcf/requirements/req-020.json +5 -2
  52. package/rcf/requirements/req-021.json +5 -2
  53. package/rcf/requirements/req-022.json +5 -2
  54. package/rcf/requirements/req-023.json +6 -4
  55. package/rcf/requirements/req-024.json +5 -2
  56. package/rcf/requirements/req-025.json +5 -2
  57. package/rcf/requirements/req-026.json +5 -2
  58. package/rcf/requirements/req-027.json +5 -2
  59. package/rcf/requirements/req-028.json +5 -2
  60. package/rcf/requirements/req-029.json +5 -2
  61. package/rcf/requirements/req-030.json +5 -2
  62. package/rcf/requirements/req-040.json +5 -2
  63. package/rcf/requirements/req-041.json +5 -2
  64. package/rcf/requirements/req-042.json +5 -2
  65. package/rcf/requirements/req-043.json +5 -2
  66. package/rcf/requirements/req-050.json +5 -2
  67. package/rcf/requirements/req-051.json +5 -2
  68. package/rcf/requirements/req-052.json +5 -2
  69. package/rcf/requirements/req-053.json +5 -2
  70. package/rcf/requirements/req-054.json +5 -2
  71. package/rcf/requirements/req-060.json +5 -2
  72. package/rcf/requirements/req-061.json +5 -2
  73. package/rcf/requirements/req-062.json +5 -2
  74. package/rcf/requirements/req-063.json +5 -2
  75. package/rcf/requirements/req-070.json +5 -2
  76. package/rcf/requirements/req-071.json +5 -2
  77. package/rcf/requirements/req-072.json +5 -2
  78. package/rcf/requirements/req-073.json +5 -2
  79. package/rcf/requirements/req-074.json +5 -2
  80. package/rcf/requirements/req-075.json +5 -2
  81. package/rcf/requirements/req-076.json +5 -2
  82. package/rcf/requirements/req-077.json +5 -2
  83. package/rcf/requirements/req-080.json +5 -2
  84. package/rcf/requirements/req-081.json +5 -2
  85. package/rcf/requirements/req-082.json +5 -2
  86. package/rcf/requirements/req-083.json +5 -2
  87. package/rcf/requirements/req-084.json +5 -2
  88. package/rcf/requirements/req-085.json +5 -2
  89. package/rcf/requirements/req-086.json +5 -2
  90. package/rcf/requirements/req-090.json +5 -2
  91. package/rcf/requirements/req-091.json +5 -2
  92. package/rcf/requirements/req-092.json +5 -2
  93. package/rcf/requirements/req-093.json +5 -2
  94. package/rcf/requirements/req-094.json +5 -2
  95. package/rcf/requirements/req-100.json +5 -2
  96. package/rcf/requirements/req-101.json +5 -2
  97. package/rcf/requirements/req-102.json +5 -2
  98. package/rcf/requirements/req-103.json +5 -2
  99. package/rcf/requirements/req-104.json +5 -2
  100. package/rcf/requirements/req-110.json +5 -2
  101. package/rcf/requirements/req-111.json +5 -2
  102. package/rcf/requirements/req-112.json +5 -2
  103. package/rcf/requirements/req-113.json +5 -2
  104. package/rcf/requirements/req-114.json +5 -2
  105. package/rcf/requirements/req-115.json +5 -2
  106. package/rcf/requirements/req-120.json +5 -2
  107. package/rcf/requirements/req-121.json +5 -2
  108. package/rcf/requirements/req-122.json +5 -2
  109. package/rcf/requirements/req-123.json +5 -2
  110. package/rcf/requirements/req-124.json +5 -2
  111. package/rcf/requirements/req-125.json +5 -2
  112. package/rcf/requirements/req-130.json +6 -2
  113. package/rcf/requirements/req-131.json +6 -2
  114. package/rcf/requirements/req-132.json +6 -2
  115. package/rcf/requirements/req-133.json +6 -2
  116. package/rcf/requirements/req-134.json +6 -2
  117. package/rcf/requirements/req-140.json +5 -2
  118. package/rcf/requirements/req-145.json +5 -2
  119. package/rcf/requirements/req-150.json +6 -2
  120. package/rcf/requirements/req-151.json +6 -2
  121. package/rcf/requirements/req-152.json +6 -2
  122. package/rcf/requirements/req-153.json +6 -2
  123. package/rcf/requirements/req-154.json +5 -2
  124. package/rcf/requirements/req-155.json +4 -1
  125. package/rcf/requirements/req-156.json +4 -1
  126. package/rcf/requirements/req-157.json +4 -1
  127. package/rcf/requirements/req-158.json +4 -1
  128. package/rcf/requirements/req-159.json +4 -1
  129. package/rcf/requirements/req-160.json +4 -1
  130. package/rcf/requirements/req-161.json +4 -1
  131. package/rcf/requirements/req-162.json +4 -1
  132. package/rcf/requirements/req-163.json +5 -2
  133. package/rcf/requirements/req-164.json +5 -2
  134. package/rcf/requirements/req-165.json +5 -2
  135. package/rcf/requirements/req-170.json +33 -0
  136. package/rcf/requirements/req-171.json +44 -0
  137. package/rcf/requirements/req-172.json +25 -0
  138. package/rcf/requirements/req-173.json +25 -0
  139. package/rcf/requirements/req-174.json +25 -0
  140. package/rcf/requirements/req-175.json +25 -0
  141. package/rcf/tacs/tac-4120-define-delta-and-freeze.json +21 -0
  142. package/rcf/tacs/tac-4121-define-ledgers.json +23 -0
  143. package/rcf/tacs/tac-4122-define-gates.json +25 -0
  144. package/rcf/tacs/tac-4123-define-readiness.json +25 -0
  145. package/rcf/test-suites/ts-204.json +30 -0
  146. package/rcf/test-suites/ts-205.json +22 -0
  147. package/rcf/test-suites/ts-206.json +22 -0
  148. package/rcf/test-suites/ts-207.json +22 -0
  149. package/rcf/test-suites/ts-208.json +22 -0
  150. package/rcf/test-suites/ts-209.json +22 -0
  151. package/rcf/test-suites/ts-210.json +22 -0
  152. package/rcf/test-suites/ts-211.json +22 -0
  153. package/rcf/test-suites/ts-212.json +22 -0
  154. package/rcf/test-suites/ts-213.json +50 -0
  155. package/rcf/test-suites/ts-214.json +74 -0
  156. package/rcf/test-suites/ts-215.json +67 -0
  157. package/rcf/test-suites/ts-216.json +78 -0
  158. package/rcf/test-suites/ts-217.json +62 -0
  159. package/rcf/user-stories/us-15601.json +4 -4
  160. package/rcf/user-stories/us-17001.json +27 -0
  161. package/rcf/user-stories/us-17002.json +22 -0
  162. package/rcf/user-stories/us-17003.json +22 -0
  163. package/rcf/user-stories/us-17004.json +22 -0
  164. package/rcf/user-stories/us-17005.json +22 -0
  165. package/rcf/user-stories/us-17006.json +21 -0
  166. package/rcf/user-stories/us-17007.json +21 -0
  167. package/rcf/user-stories/us-17008.json +22 -0
  168. package/rcf/user-stories/us-17101.json +22 -0
  169. package/rcf/user-stories/us-17102.json +22 -0
  170. package/rcf/user-stories/us-17201.json +38 -0
  171. package/rcf/user-stories/us-17301.json +38 -0
  172. package/rcf/user-stories/us-17401.json +58 -0
  173. package/rcf/user-stories/us-17501.json +48 -0
  174. package/releases/releases.yaml +21 -1
  175. package/src/cli/feedback.js +126 -10
  176. package/src/cli/help.js +11 -0
  177. package/src/cli/ledger.js +399 -0
  178. package/src/cli/readiness.js +280 -0
  179. package/src/core/store/writer.js +86 -6
  180. package/src/define/freeze-record.js +232 -0
  181. package/src/define/ledgers.js +513 -0
  182. package/src/feedback/redact.js +105 -19
  183. package/src/query/delta.js +276 -0
  184. package/src/query/gates.js +832 -0
  185. package/src/query/index.js +36 -0
  186. package/src/query/readiness.js +333 -0
  187. package/src/server/index.js +37 -0
  188. package/src/server/routes.js +65 -3
  189. package/src/view/doc-renderers/helpers.js +2 -2
  190. package/src/view/doc-renderers/req.js +10 -2
  191. package/src/view/doc-renderers/user-story.js +10 -5
  192. package/src/view/html-page.js +347 -28
  193. package/src/view/index.js +14 -1
  194. package/src/view/live-client.js +22 -2
  195. package/src/view/product-map.js +751 -0
  196. package/src/view/style.css +378 -0
@@ -0,0 +1,832 @@
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 } 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
+ /** Warn-with-ack stages in 0.29.0 (ADR-4122). */
95
+ const WARN_WITH_ACK = new Set(['D3', 'D5', 'D6']);
96
+
97
+ /** Blocking stages in 0.29.0 (ADR-4122). */
98
+ const BLOCKING = new Set(['D1', 'D2', 'D4', 'D7', 'D8']);
99
+
100
+ /**
101
+ * Signals a stage exports so consumers can render policy in the pill
102
+ * vocabulary without touching internals. Kept as a helper so the CLI
103
+ * text output and the viewer share one source.
104
+ * @param {string} stage
105
+ * @returns {'blocking' | 'warnWithAck'}
106
+ */
107
+ export function stagePolicy(stage) {
108
+ return WARN_WITH_ACK.has(stage) ? 'warnWithAck' : 'blocking';
109
+ }
110
+
111
+ /**
112
+ * Scaffold TODO placeholder marker: uppercase "TODO" followed by a
113
+ * colon, matching every scaffold string `rcf init` and `rcf define
114
+ * create` write (see src/core/store/init.js and src/core/store/writer.js:
115
+ * every scaffold field is 'TODO: <text>'). Word-bounded and
116
+ * case-sensitive so incidental prose like "the scaffold TODO
117
+ * placeholder" or "no TODO markers" does not trip the gate. The
118
+ * validate CLI (src/cli/validate.js) keeps its own broader
119
+ * `\btodo\b/i` matcher for the informational tree-wide notice, so
120
+ * incidental prose still surfaces there without failing D2 / D4.
121
+ */
122
+ const TODO_RE = /\bTODO:/;
123
+
124
+ /** Bracketed AC-class prefix (Baz decision 6, proposal §3.2 D4). */
125
+ const AC_CLASS_RE = /^\[(happy|edge|failure|must-not|non-functional)\]/;
126
+
127
+ /**
128
+ * Parse the leading bracketed class marker on `ac.description`.
129
+ * Returns the class token, or null when no marker is present.
130
+ *
131
+ * @param {unknown} ac
132
+ * @returns {string | null}
133
+ */
134
+ export function parseAcClass(ac) {
135
+ const desc = ac && typeof ac === 'object' ? /** @type {any} */ (ac).description : null;
136
+ if (typeof desc !== 'string') return null;
137
+ const m = desc.trim().match(AC_CLASS_RE);
138
+ return m ? m[1] : null;
139
+ }
140
+
141
+ /**
142
+ * Build a helper `{ name, ok, over, pass, total, failing }` in one
143
+ * call. `failing` is normalised to the { id, why } shape and omitted
144
+ * when ok is true (the surface is stable either way; the array is
145
+ * always present for consumers that fold on length).
146
+ *
147
+ * @param {string} name
148
+ * @param {'delta' | 'tree'} over
149
+ * @param {number} total
150
+ * @param {Array<{ id: string, why: string }>} failing
151
+ * @param {number} [totalFailingCount] Untruncated failing count. Defaults to
152
+ * `failing.length`. Callers that truncate the failing array for display
153
+ * (e.g. `.slice(0, 20)` on a large validate-error set) pass the full
154
+ * count here so `pass = total - untruncatedFailing`; without this the
155
+ * fold `pass = total - failing.length` over-counts passes beyond the
156
+ * twentieth failure. `ok` is likewise derived from the untruncated
157
+ * count. The section 2.4 shape stays intact -- `failing` remains the
158
+ * display array; no new field is exported.
159
+ * @returns {{ name: string, ok: boolean, over: 'delta'|'tree', pass: number, total: number, failing: Array<{ id: string, why: string }> }}
160
+ */
161
+ function makeCheck(name, over, total, failing, totalFailingCount) {
162
+ const nFailing = typeof totalFailingCount === 'number' ? totalFailingCount : failing.length;
163
+ const pass = Math.max(0, total - nFailing);
164
+ return { name, ok: nFailing === 0, over, pass, total, failing: [...failing] };
165
+ }
166
+
167
+ /**
168
+ * Fold a checks[] into a stage envelope with the ADR-4122 state rules
169
+ * applied. Blocking stages ignore acknowledgement; warn-with-ack
170
+ * stages honour a freeze.gates[<gate>] entry at the current tree hash.
171
+ *
172
+ * @param {string} stage
173
+ * @param {string} gate
174
+ * @param {Array<ReturnType<typeof makeCheck>>} checks
175
+ * @param {object} [freezeCtx]
176
+ * @param {string | null} [freezeCtx.currentTreeHash]
177
+ * @param {Record<string, { state?: string, at?: { hash?: string } }> | undefined} [freezeCtx.freezeGates]
178
+ * @returns {{ stage: string, gate: string, state: 'passed'|'failing'|'acknowledged', checks: Array<ReturnType<typeof makeCheck>> }}
179
+ */
180
+ export function foldState(stage, gate, checks, freezeCtx = {}) {
181
+ const anyFailing = checks.some((c) => !c.ok);
182
+ if (!anyFailing) return { stage, gate, state: 'passed', checks };
183
+ if (WARN_WITH_ACK.has(stage)) {
184
+ const gates = freezeCtx.freezeGates ?? {};
185
+ const record = gates[gate];
186
+ const ackedAtHash = record?.state === 'acknowledged'
187
+ && typeof record?.at?.hash === 'string'
188
+ && record.at.hash === freezeCtx.currentTreeHash;
189
+ if (ackedAtHash) return { stage, gate, state: 'acknowledged', checks };
190
+ }
191
+ return { stage, gate, state: 'failing', checks };
192
+ }
193
+
194
+ /**
195
+ * `notApplicable` envelope helper.
196
+ * @param {string} stage
197
+ * @param {string} gate
198
+ * @param {string} reason
199
+ */
200
+ function notApplicable(stage, gate, reason) {
201
+ return {
202
+ stage, gate, state: 'notApplicable', checks: [
203
+ makeCheck(`stage:${stage}:scope`, 'delta', 0, []),
204
+ ], reason,
205
+ };
206
+ }
207
+
208
+ /**
209
+ * The set of ids in scope, as a Set for O(1) hit checks.
210
+ * @param {Iterable<string> | undefined} scope
211
+ * @returns {Set<string>}
212
+ */
213
+ function asSet(scope) {
214
+ return scope instanceof Set ? scope : new Set(scope ?? []);
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // D1 -- Brief intake and ledger (blocking in 0.29.0).
219
+ // ---------------------------------------------------------------------------
220
+
221
+ /**
222
+ * @typedef {object} StageContext
223
+ * @property {import('#core/store/walker.js').TreeModel} tree
224
+ * @property {import('./delta.js').LedgerBundle | undefined} ledgers
225
+ * @property {import('./delta.js').DeltaResult | undefined} delta
226
+ * @property {import('./delta.js').FreezeRecord | null | undefined} freeze
227
+ * @property {Iterable<string> | undefined} scope
228
+ * @property {Array<unknown> | undefined} validateErrors tree-wide walker + validate errors
229
+ * @property {number | undefined} probeOpenCount for D6 (probe ledger open entries)
230
+ * @property {import('./coverage.js').CoverageResult | undefined} coverageTree optional, unused by gates in 0.29.0
231
+ * @property {string | null | undefined} profileText rcf/.identity/profile.md contents (null when absent)
232
+ * @property {{ skipReviewFor?: string } | undefined} profile parsed profile switches (light shape)
233
+ * @property {string | null | undefined} currentTreeHash for warn-with-ack acknowledgement match
234
+ * @property {Array<{ state: string }> | undefined} priorStages D8 reads earlier stage states from this
235
+ */
236
+
237
+ /**
238
+ * D1 -- Brief intake and ledger.
239
+ *
240
+ * @param {StageContext} ctx
241
+ */
242
+ export function checkD1Brief(ctx) {
243
+ const gate = STAGE_GATES.D1;
244
+ const checks = [];
245
+ const brief = /** @type {any} */ (ctx.ledgers?.brief);
246
+ const statements = Array.isArray(brief?.statements) ? brief.statements : [];
247
+ const decisions = /** @type {any} */ (ctx.ledgers?.decisions);
248
+ const decisionEntries = Array.isArray(decisions?.decisions) ? decisions.decisions : [];
249
+
250
+ // Check 1: at least one statement since the freeze (proposal §3.2 D1
251
+ // "at least one statement since the freeze"). briefSince is the
252
+ // delta's own high-water-mark projection; on an unfrozen tree the
253
+ // rule is "the brief itself" so every statement counts.
254
+ const highWater = Number.isFinite(ctx.freeze?.briefStatements) ? Number(ctx.freeze?.briefStatements) : 0;
255
+ const since = statements.filter((s) => Number(s?.id) > highWater);
256
+ checks.push(makeCheck(
257
+ 'brief:sinceFreeze',
258
+ 'delta',
259
+ Math.max(since.length, 1),
260
+ since.length === 0 && (ctx.freeze ? true : statements.length === 0)
261
+ ? [{ id: 'brief-ledger', why: ctx.freeze
262
+ ? 'no new brief statement since the freeze'
263
+ : 'brief ledger holds no statements' }]
264
+ : [],
265
+ ));
266
+
267
+ // Check 2: every statement kinded from the closed set.
268
+ const badKind = [];
269
+ for (const s of statements) {
270
+ if (!s || typeof s.kind !== 'string' || !BRIEF_KINDS.includes(/** @type {any} */ (s.kind))) {
271
+ badKind.push({ id: `brief:${s?.id ?? '?'}`, why: `unknown kind ${s?.kind ?? '(missing)'}` });
272
+ }
273
+ }
274
+ checks.push(makeCheck('brief:kinds', 'tree', statements.length, badKind));
275
+
276
+ // Check 3: every openQuestion resolved or promoted to a decision.
277
+ const openQuestions = statements.filter((s) => s?.kind === 'openQuestion' && s?.status !== 'resolved');
278
+ // A promoted openQuestion is one whose text is quoted or referenced in
279
+ // a decisions-ledger entry's question field; a cheap presence check.
280
+ const promoted = new Set();
281
+ for (const q of openQuestions) {
282
+ const text = typeof q?.text === 'string' ? q.text.trim() : '';
283
+ if (!text) continue;
284
+ if (decisionEntries.some((d) => typeof d?.question === 'string' && d.question.includes(text))) {
285
+ promoted.add(q.id);
286
+ }
287
+ }
288
+ const unresolvedOpen = openQuestions.filter((q) => !promoted.has(q.id));
289
+ checks.push(makeCheck(
290
+ 'brief:openQuestions',
291
+ 'tree',
292
+ openQuestions.length,
293
+ unresolvedOpen.map((q) => ({ id: `brief:${q.id}`, why: 'openQuestion still open (not resolved and not promoted to a decision)' })),
294
+ ));
295
+
296
+ // Check 4: profile.md carries surface + register markers (light
297
+ // presence check; the fuller elicitation prompt lives in slice 6).
298
+ const surfaceMarkers = ['viewer', 'runningApp', 'prDiff'];
299
+ const registerMarkers = ['productOwner', 'engineer', 'unstated'];
300
+ const profileText = ctx.profileText ?? '';
301
+ const surfaceHit = surfaceMarkers.some((m) => profileText.includes(m));
302
+ const registerHit = registerMarkers.some((m) => profileText.includes(m));
303
+ const profileFail = [];
304
+ if (!surfaceHit) profileFail.push({ id: 'profile:surface', why: 'profile.md is missing a review-surface marker (viewer | runningApp | prDiff)' });
305
+ if (!registerHit) profileFail.push({ id: 'profile:register', why: 'profile.md is missing a register marker (productOwner | engineer | unstated)' });
306
+ checks.push(makeCheck('brief:profile', 'tree', 2, profileFail));
307
+
308
+ return foldState('D1', gate, checks, {
309
+ currentTreeHash: ctx.currentTreeHash ?? null,
310
+ freezeGates: /** @type {any} */ (ctx.freeze?.gates),
311
+ });
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // D2 -- Requirements and architecture skeleton (blocking in 0.29.0).
316
+ // ---------------------------------------------------------------------------
317
+
318
+ /** Kinds whose resolution D2 checks: capability / constraint / entity / actor / externalSystem / surface. */
319
+ const D2_RESOLVING_KINDS = new Set(['capability', 'constraint', 'entity', 'actor', 'externalSystem', 'surface']);
320
+
321
+ /**
322
+ * D2 -- Requirements and architecture skeleton.
323
+ *
324
+ * @param {StageContext} ctx
325
+ */
326
+ export function checkD2Skeleton(ctx) {
327
+ const gate = STAGE_GATES.D2;
328
+ const scope = asSet(ctx.scope);
329
+ const tree = ctx.tree;
330
+ const brief = /** @type {any} */ (ctx.ledgers?.brief);
331
+ const statements = Array.isArray(brief?.statements) ? brief.statements : [];
332
+ const highWater = Number.isFinite(ctx.freeze?.briefStatements) ? Number(ctx.freeze?.briefStatements) : 0;
333
+ const stmtsInScope = statements.filter((s) => Number(s?.id) > highWater);
334
+ const resolvingStmts = stmtsInScope.filter((s) => D2_RESOLVING_KINDS.has(String(s?.kind)));
335
+ const reqInScope = (tree.requirements ?? []).filter((r) => scope.has(r.reqId));
336
+ const prdInScope = tree.prd && scope.has(tree.prd.prdId ?? '');
337
+ const tadInScope = tree.tad && scope.has(tree.tad.tadId ?? '');
338
+
339
+ if (resolvingStmts.length === 0 && reqInScope.length === 0 && !prdInScope && !tadInScope) {
340
+ return notApplicable('D2', gate, 'no resolving brief statements and no REQ / PRD / TAD in scope');
341
+ }
342
+
343
+ const checks = [];
344
+
345
+ // Check 1: every capability/constraint/entity/... statement resolves
346
+ // (the statement carries `resolvedBy` naming a REQ / TAD entity / TAC
347
+ // / recorded omission id, OR its text matches an existing REQ title).
348
+ // Cheap check: a `resolvedBy` string or a name-hit on titles.
349
+ const resolvedByStmt = (s) => {
350
+ const rb = s?.resolvedBy;
351
+ if (typeof rb === 'string' && rb.length > 0) return true;
352
+ if (rb && typeof rb === 'object' && Object.keys(rb).length > 0) return true;
353
+ const text = typeof s?.text === 'string' ? s.text.trim().toLowerCase() : '';
354
+ if (!text) return false;
355
+ for (const req of tree.requirements ?? []) {
356
+ const title = typeof req?.title === 'string' ? req.title.toLowerCase() : '';
357
+ if (title && text.includes(title)) return true;
358
+ }
359
+ return false;
360
+ };
361
+ const unresolvedStmts = resolvingStmts.filter((s) => !resolvedByStmt(s));
362
+ checks.push(makeCheck(
363
+ 'skeleton:resolvedBy',
364
+ 'delta',
365
+ resolvingStmts.length,
366
+ unresolvedStmts.map((s) => ({ id: `brief:${s.id}`, why: `${s.kind} statement has no resolvedBy and no REQ-title hit` })),
367
+ ));
368
+
369
+ // Check 2: every REQ in scope has description, shapeClassification, domain.
370
+ const reqShapeFail = [];
371
+ for (const req of reqInScope) {
372
+ if (typeof req.description !== 'string' || req.description.length === 0 || TODO_RE.test(req.description)) {
373
+ reqShapeFail.push({ id: req.reqId, why: 'description missing or contains TODO placeholder' });
374
+ continue;
375
+ }
376
+ if (!req.shapeClassification || !Array.isArray(req.shapeClassification.shapes)) {
377
+ reqShapeFail.push({ id: req.reqId, why: 'shapeClassification.shapes missing' });
378
+ continue;
379
+ }
380
+ if (typeof req.domain !== 'string' || req.domain.length === 0) {
381
+ reqShapeFail.push({ id: req.reqId, why: 'domain missing' });
382
+ }
383
+ }
384
+ checks.push(makeCheck('skeleton:reqFields', 'delta', reqInScope.length, reqShapeFail));
385
+
386
+ // Check 3: TAD.dataStores + coreEntities present when any persistence
387
+ // REQ exists tree-wide.
388
+ const persistenceReq = (tree.requirements ?? []).some((r) => Array.isArray(r?.shapeClassification?.shapes) && r.shapeClassification.shapes.includes('persistence'));
389
+ const tadFail = [];
390
+ if (persistenceReq) {
391
+ const tad = tree.tad ?? {};
392
+ const dataStores = Array.isArray(tad.dataStores) ? tad.dataStores : [];
393
+ const coreEntities = Array.isArray(tad.coreEntities) ? tad.coreEntities : [];
394
+ if (dataStores.length === 0) tadFail.push({ id: 'TAD.dataStores', why: 'TAD.dataStores empty while persistence REQ exists' });
395
+ if (coreEntities.length === 0) tadFail.push({ id: 'TAD.coreEntities', why: 'TAD.coreEntities empty while persistence REQ exists' });
396
+ }
397
+ checks.push(makeCheck('skeleton:tadPersistence', 'tree', persistenceReq ? 2 : 0, tadFail));
398
+
399
+ // Check 4: exactly one deploy or deferral ADR tree-wide. Cheap
400
+ // detection by title prefix (proposal §11 assumption).
401
+ const deployAdrs = (tree.adrs ?? []).filter((a) => {
402
+ const t = typeof a?.title === 'string' ? a.title : '';
403
+ return t.startsWith('Deploy target:') || t.startsWith('Deploy deferral:');
404
+ });
405
+ const deployFail = [];
406
+ if (deployAdrs.length === 0) deployFail.push({ id: 'ADR:deploy', why: 'no Deploy target or Deploy deferral ADR found (title-prefix scan)' });
407
+ else if (deployAdrs.length > 1) deployFail.push({ id: 'ADR:deploy', why: `${deployAdrs.length} Deploy ADRs found; expected exactly one` });
408
+ checks.push(makeCheck('skeleton:deployAdr', 'tree', 1, deployFail));
409
+
410
+ return foldState('D2', gate, checks, {
411
+ currentTreeHash: ctx.currentTreeHash ?? null,
412
+ freezeGates: /** @type {any} */ (ctx.freeze?.gates),
413
+ });
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // D3 -- Interface contracts and shapes (warn-with-ack in 0.29.0).
418
+ // ---------------------------------------------------------------------------
419
+
420
+ /** Set form of INTERFACE_KINDS for O(1) checks. */
421
+ const INTERFACE_KINDS_SET = new Set(INTERFACE_KINDS);
422
+
423
+ /**
424
+ * D3 -- Interface contracts and shapes. 0.29.0 runs only the cheap
425
+ * presence + closed-vocabulary checks.
426
+ *
427
+ * SEAM 0.30: template markers per kind (recordShape.fields:, httpRoute
428
+ * method/path/request/response/errors, fixture.instances:); entity-name
429
+ * join across TAD.coreEntities and TAC.interfaces[]; ownerRef and
430
+ * `authoredAt` path resolution. Those add checks to this stage; the
431
+ * fold shape stays the same.
432
+ *
433
+ * @param {StageContext} ctx
434
+ */
435
+ export function checkD3Shapes(ctx) {
436
+ const gate = STAGE_GATES.D3;
437
+ const scope = asSet(ctx.scope);
438
+ const tree = ctx.tree;
439
+ const tacsInScope = (tree.tacs ?? []).filter((t) => scope.has(t.tacId));
440
+ const shapedReqInScope = (tree.requirements ?? []).filter((r) => {
441
+ if (!scope.has(r.reqId)) return false;
442
+ const shapes = r.shapeClassification?.shapes ?? [];
443
+ return shapes.includes('httpApi') || shapes.includes('persistence') || shapes.includes('auth');
444
+ });
445
+
446
+ if (tacsInScope.length === 0 && shapedReqInScope.length === 0) {
447
+ return notApplicable('D3', gate, 'no TAC and no shaped REQ (httpApi/persistence/auth) in scope');
448
+ }
449
+
450
+ const checks = [];
451
+
452
+ // Check 1: every TAC in scope has at least one interface.
453
+ const noIfaceTacs = tacsInScope.filter((t) => !Array.isArray(t.interfaces) || t.interfaces.length === 0);
454
+ checks.push(makeCheck(
455
+ 'shapes:tacHasInterface',
456
+ 'delta',
457
+ tacsInScope.length,
458
+ noIfaceTacs.map((t) => ({ id: t.tacId, why: 'no interfaces authored' })),
459
+ ));
460
+
461
+ // Check 2: every interface kind is in the closed vocabulary.
462
+ let totalInterfaces = 0;
463
+ const badKinds = [];
464
+ for (const tac of tacsInScope) {
465
+ for (const iface of tac.interfaces ?? []) {
466
+ totalInterfaces += 1;
467
+ const kind = iface?.kind;
468
+ if (typeof kind !== 'string' || !INTERFACE_KINDS_SET.has(kind)) {
469
+ badKinds.push({ id: `${tac.tacId}:${iface?.name ?? '(unnamed)'}`, why: `unknown interface kind ${JSON.stringify(kind)}` });
470
+ }
471
+ }
472
+ }
473
+ checks.push(makeCheck('shapes:kindVocabulary', 'delta', totalInterfaces, badKinds));
474
+
475
+ return foldState('D3', gate, checks, {
476
+ currentTreeHash: ctx.currentTreeHash ?? null,
477
+ freezeGates: /** @type {any} */ (ctx.freeze?.gates),
478
+ });
479
+ }
480
+
481
+ // ---------------------------------------------------------------------------
482
+ // D4 -- Stories and criteria (blocking floors in 0.29.0).
483
+ // ---------------------------------------------------------------------------
484
+
485
+ /**
486
+ * D4 -- Stories and criteria floors. 0.29.0 enforces the four floors
487
+ * proposal §3.2 blocks on: every REQ has a US, every US has a testable
488
+ * AC, a failure AC (or opt-out per class), a must-not AC (or opt-out),
489
+ * tacIds non-empty and resolving, no scaffold TODO placeholder.
490
+ *
491
+ * SEAM 0.30: closed-set scan across enumerations; ownerRef resolution
492
+ * to TAC.interfaces[<name>]; template-marker parse. Warn-only notices
493
+ * in 0.29.0 (see D3).
494
+ *
495
+ * @param {StageContext} ctx
496
+ */
497
+ export function checkD4Stories(ctx) {
498
+ const gate = STAGE_GATES.D4;
499
+ const scope = asSet(ctx.scope);
500
+ const tree = ctx.tree;
501
+ const manifest = /** @type {any} */ (tree.manifest);
502
+ const reqInScope = (tree.requirements ?? []).filter((r) => scope.has(r.reqId));
503
+ const usInScope = (tree.userStories ?? []).filter((us) => scope.has(us.usId));
504
+
505
+ if (reqInScope.length === 0 && usInScope.length === 0) {
506
+ return notApplicable('D4', gate, 'no REQ and no US in scope');
507
+ }
508
+
509
+ const checks = [];
510
+
511
+ // Check 1: every REQ in scope has at least one US.
512
+ const reqWithoutUs = [];
513
+ for (const req of reqInScope) {
514
+ const hasUs = (tree.userStories ?? []).some((us) => us.reqId === req.reqId);
515
+ if (!hasUs) reqWithoutUs.push({ id: req.reqId, why: 'no owning user story' });
516
+ }
517
+ checks.push(makeCheck('stories:reqHasUs', 'delta', reqInScope.length, reqWithoutUs));
518
+
519
+ // Check 2: every US in scope carries the class floors + tacIds + testable AC + no TODO.
520
+ const usFail = [];
521
+ for (const us of usInScope) {
522
+ const acs = Array.isArray(us.acceptanceCriteria) ? us.acceptanceCriteria : [];
523
+ // testable
524
+ const hasTestable = acs.some((ac) => ac?.testable !== false && typeof ac?.description === 'string' && ac.description.length > 0);
525
+ if (!hasTestable) usFail.push({ id: us.usId, why: 'no testable acceptance criterion' });
526
+ // failure class
527
+ const hasFailure = acs.some((ac) => parseAcClass(ac) === 'failure');
528
+ const failureOpted = isOptedOut(manifest, us.reqId, 'defineD4:failure');
529
+ if (!hasFailure && !failureOpted) {
530
+ usFail.push({ id: us.usId, why: 'no [failure] class AC and no defineD4:failure opt-out on this REQ' });
531
+ }
532
+ // must-not class
533
+ const hasMustNot = acs.some((ac) => parseAcClass(ac) === 'must-not');
534
+ const mustNotOpted = isOptedOut(manifest, us.reqId, 'defineD4:mustNot');
535
+ if (!hasMustNot && !mustNotOpted) {
536
+ usFail.push({ id: us.usId, why: 'no [must-not] class AC and no defineD4:mustNot opt-out on this REQ' });
537
+ }
538
+ // tacIds non-empty and resolving
539
+ const tacIds = Array.isArray(us.tacIds) ? us.tacIds : [];
540
+ if (tacIds.length === 0) {
541
+ usFail.push({ id: us.usId, why: 'tacIds is empty' });
542
+ } else {
543
+ const unresolved = tacIds.filter((id) => tree.kindById?.get(id) !== 'tac');
544
+ if (unresolved.length > 0) {
545
+ usFail.push({ id: us.usId, why: `tacIds names non-TAC ids: ${unresolved.join(', ')}` });
546
+ }
547
+ }
548
+ // no TODO placeholder in any AC description
549
+ const todoAc = acs.find((ac) => typeof ac?.description === 'string' && TODO_RE.test(ac.description));
550
+ if (todoAc) usFail.push({ id: us.usId, why: `scaffold TODO placeholder in ${todoAc.id ?? '(ac)'}` });
551
+ }
552
+ checks.push(makeCheck('stories:usFloors', 'delta', usInScope.length, usFail));
553
+
554
+ return foldState('D4', gate, checks, {
555
+ currentTreeHash: ctx.currentTreeHash ?? null,
556
+ freezeGates: /** @type {any} */ (ctx.freeze?.gates),
557
+ });
558
+ }
559
+
560
+ // ---------------------------------------------------------------------------
561
+ // D5 -- Cross-cutting weave (warn-with-ack in 0.29.0).
562
+ // ---------------------------------------------------------------------------
563
+
564
+ /**
565
+ * D5 -- Cross-cutting weave. 0.29.0 runs cheap presence checks.
566
+ *
567
+ * SEAM 0.30: full concern catalogue with per-shape applicability;
568
+ * per-(concern, REQ) pair enumeration; TAD securityArchitecture and
569
+ * operationalConcerns structure checks beyond presence.
570
+ *
571
+ * @param {StageContext} ctx
572
+ */
573
+ export function checkD5Crosscut(ctx) {
574
+ const gate = STAGE_GATES.D5;
575
+ const scope = asSet(ctx.scope);
576
+ const tree = ctx.tree;
577
+ const reqInScope = (tree.requirements ?? []).filter((r) => scope.has(r.reqId));
578
+
579
+ if (reqInScope.length === 0) {
580
+ return notApplicable('D5', gate, 'no REQ in scope');
581
+ }
582
+
583
+ const checks = [];
584
+
585
+ // Check 1: TAD.securityArchitecture non-empty when any REQ carries shape 'auth' or 'httpApi'.
586
+ const authApi = (tree.requirements ?? []).some((r) => {
587
+ const s = r.shapeClassification?.shapes ?? [];
588
+ return s.includes('auth') || s.includes('httpApi');
589
+ });
590
+ const tad = /** @type {any} */ (tree.tad ?? {});
591
+ const secFail = [];
592
+ if (authApi) {
593
+ const sa = tad.securityArchitecture;
594
+ if (!sa || (typeof sa === 'string' ? sa.length === 0 : Object.keys(sa).length === 0)) {
595
+ secFail.push({ id: 'TAD.securityArchitecture', why: 'empty while auth or httpApi REQ exists' });
596
+ }
597
+ }
598
+ checks.push(makeCheck('crosscut:securityArchitecture', 'tree', authApi ? 1 : 0, secFail));
599
+
600
+ // Check 2: TAD.operationalConcerns non-empty when any AC description
601
+ // carries the '[deployed]' marker (a cheap presence signal that
602
+ // proposal §3.2 D5 lists as 'deployed-scope AC').
603
+ const hasDeployedAc = (tree.userStories ?? []).some((us) => (us.acceptanceCriteria ?? []).some((ac) => typeof ac?.description === 'string' && ac.description.includes('[deployed]')));
604
+ const opFail = [];
605
+ if (hasDeployedAc) {
606
+ const oc = tad.operationalConcerns;
607
+ if (!oc || (typeof oc === 'string' ? oc.length === 0 : Object.keys(oc).length === 0)) {
608
+ opFail.push({ id: 'TAD.operationalConcerns', why: 'empty while deployed-scope AC exists' });
609
+ }
610
+ }
611
+ checks.push(makeCheck('crosscut:operationalConcerns', 'tree', hasDeployedAc ? 1 : 0, opFail));
612
+
613
+ // Check 3: no open concern-ledger entry on a REQ in scope.
614
+ const concerns = /** @type {any} */ (ctx.ledgers?.concerns);
615
+ const concernEntries = Array.isArray(concerns?.concerns) ? concerns.concerns : [];
616
+ const openInScope = concernEntries.filter((c) => c?.status === 'open' && scope.has(c?.reqId));
617
+ checks.push(makeCheck(
618
+ 'crosscut:concernsResolved',
619
+ 'delta',
620
+ concernEntries.filter((c) => scope.has(c?.reqId)).length,
621
+ openInScope.map((c) => ({ id: `concern:${c.id}`, why: `open concern on ${c.reqId}: ${c.concern}` })),
622
+ ));
623
+
624
+ return foldState('D5', gate, checks, {
625
+ currentTreeHash: ctx.currentTreeHash ?? null,
626
+ freezeGates: /** @type {any} */ (ctx.freeze?.gates),
627
+ });
628
+ }
629
+
630
+ // ---------------------------------------------------------------------------
631
+ // D6 -- Consistency and satisfiability probe (warn-with-ack in 0.29.0).
632
+ // ---------------------------------------------------------------------------
633
+
634
+ /**
635
+ * D6 -- Consistency and satisfiability probe. 0.29.0 runs only the
636
+ * validate-clean tree-wide check and the probe-ledger open count.
637
+ *
638
+ * SEAM 0.30: contradiction-lite across criteria on one story and
639
+ * across their TACs' ADRs; unsatisfiable-lite (a `then` naming
640
+ * undefined state); duplicate scan; orphan-interface scan;
641
+ * regeneration probe runner injectable like `mutationRunner` in
642
+ * src/review/index.js.
643
+ *
644
+ * @param {StageContext} ctx
645
+ */
646
+ export function checkD6Consistency(ctx) {
647
+ const gate = STAGE_GATES.D6;
648
+ const checks = [];
649
+
650
+ const validateErrors = Array.isArray(ctx.validateErrors) ? ctx.validateErrors : [];
651
+ checks.push(makeCheck(
652
+ 'consistency:validateClean',
653
+ 'tree',
654
+ // "Expected zero" pattern: total stays at Math.max(N, 1) so the
655
+ // reported ratio reads sensibly on a clean tree ("1/1") and on a
656
+ // dirty one ("0/N"). Truncate the failing array for display but
657
+ // fold `pass` from the untruncated count so beyond-20 failures do
658
+ // not turn into false passes.
659
+ Math.max(validateErrors.length, 1),
660
+ validateErrors.slice(0, 20).map((e, i) => ({
661
+ id: /** @type {any} */ (e)?.documentId ?? `validate:${i}`,
662
+ why: /** @type {any} */ (e)?.message ?? 'validate error',
663
+ })),
664
+ validateErrors.length,
665
+ ));
666
+
667
+ const probeOpenCount = Number.isFinite(ctx.probeOpenCount) ? Number(ctx.probeOpenCount) : 0;
668
+ const probes = /** @type {any} */ (ctx.ledgers?.probes);
669
+ const probeEntries = Array.isArray(probes?.probes) ? probes.probes : [];
670
+ const openProbes = probeEntries.filter((p) => p?.status === 'open');
671
+ // Prefer caller-supplied probeOpenCount when set; fall back to bundle scan.
672
+ const effectiveOpen = ctx.probeOpenCount !== undefined ? probeOpenCount : openProbes.length;
673
+ const failing = [];
674
+ if (effectiveOpen > 0) {
675
+ const list = ctx.probeOpenCount !== undefined
676
+ ? [{ id: 'probe-ledger', why: `${effectiveOpen} open probe finding(s)` }]
677
+ : openProbes.slice(0, 20).map((p) => ({ id: `probe:${p.id}`, why: `open probe on ${p.reqId}: ${p.finding}` }));
678
+ failing.push(...list);
679
+ }
680
+ // Fold `pass` from the untruncated open-probe count so a 25-open-
681
+ // probes ledger reports 0/25, not 5/25. The failing array itself
682
+ // stays truncated to 20 for display; the caller-supplied count path
683
+ // uses a single representative entry regardless.
684
+ checks.push(makeCheck('consistency:probeCount', 'tree', Math.max(effectiveOpen, 1), failing, effectiveOpen));
685
+
686
+ return foldState('D6', gate, checks, {
687
+ currentTreeHash: ctx.currentTreeHash ?? null,
688
+ freezeGates: /** @type {any} */ (ctx.freeze?.gates),
689
+ });
690
+ }
691
+
692
+ // ---------------------------------------------------------------------------
693
+ // D7 -- Decisions and review (blocking in 0.29.0).
694
+ // ---------------------------------------------------------------------------
695
+
696
+ /**
697
+ * D7 -- Decisions and review.
698
+ *
699
+ * @param {StageContext} ctx
700
+ */
701
+ export function checkD7Decisions(ctx) {
702
+ const gate = STAGE_GATES.D7;
703
+ const decisions = /** @type {any} */ (ctx.ledgers?.decisions);
704
+ const decisionEntries = Array.isArray(decisions?.decisions) ? decisions.decisions : [];
705
+ const openDecisions = decisionEntries.filter((d) => d?.status === 'open');
706
+
707
+ const delta = ctx.delta ?? { changed: [], added: [] };
708
+ const deltaDocs = new Set([...(delta.changed ?? []), ...(delta.added ?? [])]);
709
+ const deltaSize = [...deltaDocs].filter((id) => !id.startsWith('ledger:')).length;
710
+ const skipSingle = ctx.profile?.skipReviewFor === 'singleDocument' && deltaSize === 1;
711
+ if (openDecisions.length === 0 && skipSingle) {
712
+ return notApplicable('D7', gate, 'no open decisions and profile skipReviewFor:singleDocument on a one-document delta');
713
+ }
714
+
715
+ const checks = [];
716
+ checks.push(makeCheck(
717
+ 'decisions:allAnswered',
718
+ 'tree',
719
+ decisionEntries.length,
720
+ openDecisions.map((d) => ({ id: `decision:${d.id}`, why: `open: ${d.question ?? '(no question)'}` })),
721
+ ));
722
+
723
+ return foldState('D7', gate, checks, {
724
+ currentTreeHash: ctx.currentTreeHash ?? null,
725
+ freezeGates: /** @type {any} */ (ctx.freeze?.gates),
726
+ });
727
+ }
728
+
729
+ // ---------------------------------------------------------------------------
730
+ // D8 -- Freeze (blocking in 0.29.0).
731
+ // ---------------------------------------------------------------------------
732
+
733
+ /**
734
+ * D8 -- Freeze. Tree-wide, mechanical: prior gates passed or
735
+ * acknowledged, every AC owned by exactly one FBS, queue head
736
+ * actionable, validate clean.
737
+ *
738
+ * @param {StageContext} ctx
739
+ */
740
+ export function checkD8Freeze(ctx) {
741
+ const gate = STAGE_GATES.D8;
742
+ const tree = ctx.tree;
743
+ const checks = [];
744
+
745
+ // Check 1: prior stages passed or acknowledged.
746
+ const priorStages = Array.isArray(ctx.priorStages) ? ctx.priorStages : [];
747
+ const priorBad = priorStages
748
+ .filter((s) => s.state !== 'passed' && s.state !== 'acknowledged' && s.state !== 'notApplicable')
749
+ .map((s) => ({ id: /** @type {any} */ (s).stage ?? '(?)', why: `state ${s.state}` }));
750
+ checks.push(makeCheck('freeze:priorGates', 'tree', priorStages.length, priorBad));
751
+
752
+ // Check 2: every AC owned by exactly one FBS. Walker gives us
753
+ // fbsByAcId inversion; we assert length === 1 for every known AC.
754
+ const acIds = collectAllAcIds(tree);
755
+ const acFail = [];
756
+ for (const acId of acIds) {
757
+ const owners = tree.fbsByAcId?.get(acId) ?? [];
758
+ if (owners.length === 0) acFail.push({ id: acId, why: 'no owning FBS' });
759
+ else if (owners.length > 1) acFail.push({ id: acId, why: `${owners.length} FBS own this AC: ${owners.join(', ')}` });
760
+ }
761
+ checks.push(makeCheck('freeze:acFbsOwnership', 'tree', acIds.size, acFail));
762
+
763
+ // Check 3: queue head actionable and not a placeholder.
764
+ const queue = computeQueue(tree);
765
+ const queueFail = [];
766
+ if (!queue.nextActionable) {
767
+ queueFail.push({ id: 'queue:head', why: 'no actionable FBS at the queue head' });
768
+ } else {
769
+ const head = (tree.fbsItems ?? []).find((f) => f.fbsId === queue.nextActionable);
770
+ const title = typeof head?.title === 'string' ? head.title.trim() : '';
771
+ if (!title || /^todo\b/i.test(title)) {
772
+ queueFail.push({ id: queue.nextActionable, why: 'queue head title is empty or a TODO placeholder' });
773
+ }
774
+ }
775
+ checks.push(makeCheck('freeze:queueHead', 'tree', 1, queueFail));
776
+
777
+ // Check 4: validate clean tree-wide (same signal as D6 but blocking here).
778
+ // Truncate the failing array for display; fold `pass` from the
779
+ // untruncated count so beyond-20 failures do not turn into false passes.
780
+ const validateErrors = Array.isArray(ctx.validateErrors) ? ctx.validateErrors : [];
781
+ const validateFail = validateErrors.slice(0, 20).map((e, i) => ({
782
+ id: /** @type {any} */ (e)?.documentId ?? `validate:${i}`,
783
+ why: /** @type {any} */ (e)?.message ?? 'validate error',
784
+ }));
785
+ checks.push(makeCheck('freeze:validateClean', 'tree', Math.max(validateErrors.length, 1), validateFail, validateErrors.length));
786
+
787
+ return foldState('D8', gate, checks, {
788
+ currentTreeHash: ctx.currentTreeHash ?? null,
789
+ freezeGates: /** @type {any} */ (ctx.freeze?.gates),
790
+ });
791
+ }
792
+
793
+ /**
794
+ * Collect every AC id in the tree (walker keeps them inline on the
795
+ * parent US). Kept here to avoid depending on the private helper in
796
+ * `walker.js`.
797
+ *
798
+ * @param {import('#core/store/walker.js').TreeModel} tree
799
+ * @returns {Set<string>}
800
+ */
801
+ function collectAllAcIds(tree) {
802
+ const out = new Set();
803
+ for (const us of tree.userStories ?? []) {
804
+ for (const ac of us.acceptanceCriteria ?? []) {
805
+ if (typeof ac?.id === 'string') out.add(ac.id);
806
+ }
807
+ }
808
+ return out;
809
+ }
810
+
811
+ /**
812
+ * Dispatch by stage id (D1..D8) or by short name (brief..freeze).
813
+ * Consumers call `runStage('D4', ctx)` or `runStage('stories', ctx)`.
814
+ *
815
+ * @param {string} stageOrShort
816
+ * @param {StageContext} ctx
817
+ */
818
+ export function runStage(stageOrShort, ctx) {
819
+ const stage = STAGE_ALIASES[stageOrShort];
820
+ if (!stage) throw new TypeError(`Unknown stage '${stageOrShort}'`);
821
+ switch (stage) {
822
+ case 'D1': return checkD1Brief(ctx);
823
+ case 'D2': return checkD2Skeleton(ctx);
824
+ case 'D3': return checkD3Shapes(ctx);
825
+ case 'D4': return checkD4Stories(ctx);
826
+ case 'D5': return checkD5Crosscut(ctx);
827
+ case 'D6': return checkD6Consistency(ctx);
828
+ case 'D7': return checkD7Decisions(ctx);
829
+ case 'D8': return checkD8Freeze(ctx);
830
+ default: throw new TypeError(`Unhandled stage '${stage}'`);
831
+ }
832
+ }