rcf-lite 0.28.1 → 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 (219) hide show
  1. package/CHANGELOG.md +310 -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-602.json +3 -2
  10. package/rcf/code-nodes/cn-700.json +23 -0
  11. package/rcf/code-nodes/cn-701.json +14 -0
  12. package/rcf/code-nodes/cn-702.json +14 -0
  13. package/rcf/code-nodes/cn-703.json +16 -0
  14. package/rcf/code-nodes/cn-720.json +15 -0
  15. package/rcf/code-nodes/cn-721.json +15 -0
  16. package/rcf/code-nodes/cn-722.json +15 -0
  17. package/rcf/code-nodes/cn-723.json +18 -0
  18. package/rcf/code-nodes/cn-724.json +14 -0
  19. package/rcf/code-nodes/cn-725.json +21 -0
  20. package/rcf/code-nodes/cn-726.json +17 -0
  21. package/rcf/code-nodes/cn-727.json +14 -0
  22. package/rcf/code-nodes/cn-728.json +21 -0
  23. package/rcf/code-nodes/cn-729.json +18 -0
  24. package/rcf/code-nodes/cn-730.json +14 -0
  25. package/rcf/fbs/fbs-181.json +3 -2
  26. package/rcf/fbs/fbs-190.json +37 -0
  27. package/rcf/fbs/fbs-191.json +34 -0
  28. package/rcf/fbs/fbs-192.json +17 -0
  29. package/rcf/fbs/fbs-193.json +37 -0
  30. package/rcf/fbs/fbs-194.json +35 -0
  31. package/rcf/fbs/fbs-195.json +37 -0
  32. package/rcf/fbs/fbs-196.json +37 -0
  33. package/rcf/fbs/fbs-197.json +41 -0
  34. package/rcf/requirements/req-001.json +2 -1
  35. package/rcf/requirements/req-002.json +2 -1
  36. package/rcf/requirements/req-003.json +2 -1
  37. package/rcf/requirements/req-004.json +2 -1
  38. package/rcf/requirements/req-005.json +2 -1
  39. package/rcf/requirements/req-006.json +2 -1
  40. package/rcf/requirements/req-007.json +2 -1
  41. package/rcf/requirements/req-008.json +2 -1
  42. package/rcf/requirements/req-009.json +2 -1
  43. package/rcf/requirements/req-010.json +5 -2
  44. package/rcf/requirements/req-011.json +2 -1
  45. package/rcf/requirements/req-012.json +2 -1
  46. package/rcf/requirements/req-013.json +2 -1
  47. package/rcf/requirements/req-014.json +2 -1
  48. package/rcf/requirements/req-015.json +2 -1
  49. package/rcf/requirements/req-016.json +5 -2
  50. package/rcf/requirements/req-017.json +6 -2
  51. package/rcf/requirements/req-018.json +5 -2
  52. package/rcf/requirements/req-019.json +5 -2
  53. package/rcf/requirements/req-020.json +5 -2
  54. package/rcf/requirements/req-021.json +5 -2
  55. package/rcf/requirements/req-022.json +5 -2
  56. package/rcf/requirements/req-023.json +6 -4
  57. package/rcf/requirements/req-024.json +5 -2
  58. package/rcf/requirements/req-025.json +5 -2
  59. package/rcf/requirements/req-026.json +5 -2
  60. package/rcf/requirements/req-027.json +5 -2
  61. package/rcf/requirements/req-028.json +5 -2
  62. package/rcf/requirements/req-029.json +5 -2
  63. package/rcf/requirements/req-030.json +5 -2
  64. package/rcf/requirements/req-040.json +5 -2
  65. package/rcf/requirements/req-041.json +5 -2
  66. package/rcf/requirements/req-042.json +5 -2
  67. package/rcf/requirements/req-043.json +5 -2
  68. package/rcf/requirements/req-050.json +5 -2
  69. package/rcf/requirements/req-051.json +5 -2
  70. package/rcf/requirements/req-052.json +5 -2
  71. package/rcf/requirements/req-053.json +5 -2
  72. package/rcf/requirements/req-054.json +5 -2
  73. package/rcf/requirements/req-060.json +5 -2
  74. package/rcf/requirements/req-061.json +5 -2
  75. package/rcf/requirements/req-062.json +5 -2
  76. package/rcf/requirements/req-063.json +5 -2
  77. package/rcf/requirements/req-070.json +5 -2
  78. package/rcf/requirements/req-071.json +5 -2
  79. package/rcf/requirements/req-072.json +5 -2
  80. package/rcf/requirements/req-073.json +5 -2
  81. package/rcf/requirements/req-074.json +5 -2
  82. package/rcf/requirements/req-075.json +5 -2
  83. package/rcf/requirements/req-076.json +5 -2
  84. package/rcf/requirements/req-077.json +5 -2
  85. package/rcf/requirements/req-080.json +5 -2
  86. package/rcf/requirements/req-081.json +5 -2
  87. package/rcf/requirements/req-082.json +5 -2
  88. package/rcf/requirements/req-083.json +5 -2
  89. package/rcf/requirements/req-084.json +5 -2
  90. package/rcf/requirements/req-085.json +5 -2
  91. package/rcf/requirements/req-086.json +5 -2
  92. package/rcf/requirements/req-090.json +5 -2
  93. package/rcf/requirements/req-091.json +5 -2
  94. package/rcf/requirements/req-092.json +5 -2
  95. package/rcf/requirements/req-093.json +5 -2
  96. package/rcf/requirements/req-094.json +5 -2
  97. package/rcf/requirements/req-100.json +5 -2
  98. package/rcf/requirements/req-101.json +5 -2
  99. package/rcf/requirements/req-102.json +5 -2
  100. package/rcf/requirements/req-103.json +5 -2
  101. package/rcf/requirements/req-104.json +5 -2
  102. package/rcf/requirements/req-110.json +5 -2
  103. package/rcf/requirements/req-111.json +5 -2
  104. package/rcf/requirements/req-112.json +5 -2
  105. package/rcf/requirements/req-113.json +5 -2
  106. package/rcf/requirements/req-114.json +5 -2
  107. package/rcf/requirements/req-115.json +5 -2
  108. package/rcf/requirements/req-120.json +5 -2
  109. package/rcf/requirements/req-121.json +5 -2
  110. package/rcf/requirements/req-122.json +5 -2
  111. package/rcf/requirements/req-123.json +5 -2
  112. package/rcf/requirements/req-124.json +5 -2
  113. package/rcf/requirements/req-125.json +5 -2
  114. package/rcf/requirements/req-130.json +6 -2
  115. package/rcf/requirements/req-131.json +6 -2
  116. package/rcf/requirements/req-132.json +6 -2
  117. package/rcf/requirements/req-133.json +6 -2
  118. package/rcf/requirements/req-134.json +6 -2
  119. package/rcf/requirements/req-140.json +5 -2
  120. package/rcf/requirements/req-145.json +5 -2
  121. package/rcf/requirements/req-150.json +6 -2
  122. package/rcf/requirements/req-151.json +6 -2
  123. package/rcf/requirements/req-152.json +6 -2
  124. package/rcf/requirements/req-153.json +6 -2
  125. package/rcf/requirements/req-154.json +5 -2
  126. package/rcf/requirements/req-155.json +4 -1
  127. package/rcf/requirements/req-156.json +4 -1
  128. package/rcf/requirements/req-157.json +4 -1
  129. package/rcf/requirements/req-158.json +4 -1
  130. package/rcf/requirements/req-159.json +4 -1
  131. package/rcf/requirements/req-160.json +4 -1
  132. package/rcf/requirements/req-161.json +4 -1
  133. package/rcf/requirements/req-162.json +4 -1
  134. package/rcf/requirements/req-163.json +5 -2
  135. package/rcf/requirements/req-164.json +5 -2
  136. package/rcf/requirements/req-165.json +5 -2
  137. package/rcf/requirements/req-170.json +33 -0
  138. package/rcf/requirements/req-171.json +44 -0
  139. package/rcf/requirements/req-172.json +25 -0
  140. package/rcf/requirements/req-173.json +25 -0
  141. package/rcf/requirements/req-174.json +25 -0
  142. package/rcf/requirements/req-175.json +25 -0
  143. package/rcf/tacs/tac-4103-feedback-redact.json +2 -2
  144. package/rcf/tacs/tac-4120-define-delta-and-freeze.json +21 -0
  145. package/rcf/tacs/tac-4121-define-ledgers.json +23 -0
  146. package/rcf/tacs/tac-4122-define-gates.json +25 -0
  147. package/rcf/tacs/tac-4123-define-readiness.json +25 -0
  148. package/rcf/test-suites/ts-188.json +10 -2
  149. package/rcf/test-suites/ts-191.json +19 -5
  150. package/rcf/test-suites/ts-204.json +30 -0
  151. package/rcf/test-suites/ts-205.json +22 -0
  152. package/rcf/test-suites/ts-206.json +22 -0
  153. package/rcf/test-suites/ts-207.json +22 -0
  154. package/rcf/test-suites/ts-208.json +22 -0
  155. package/rcf/test-suites/ts-209.json +22 -0
  156. package/rcf/test-suites/ts-210.json +22 -0
  157. package/rcf/test-suites/ts-211.json +22 -0
  158. package/rcf/test-suites/ts-212.json +22 -0
  159. package/rcf/test-suites/ts-213.json +50 -0
  160. package/rcf/test-suites/ts-214.json +74 -0
  161. package/rcf/test-suites/ts-215.json +67 -0
  162. package/rcf/test-suites/ts-216.json +78 -0
  163. package/rcf/test-suites/ts-217.json +62 -0
  164. package/rcf/user-stories/us-15601.json +10 -1
  165. package/rcf/user-stories/us-15701.json +4 -4
  166. package/rcf/user-stories/us-17001.json +27 -0
  167. package/rcf/user-stories/us-17002.json +22 -0
  168. package/rcf/user-stories/us-17003.json +22 -0
  169. package/rcf/user-stories/us-17004.json +22 -0
  170. package/rcf/user-stories/us-17005.json +22 -0
  171. package/rcf/user-stories/us-17006.json +21 -0
  172. package/rcf/user-stories/us-17007.json +21 -0
  173. package/rcf/user-stories/us-17008.json +22 -0
  174. package/rcf/user-stories/us-17101.json +22 -0
  175. package/rcf/user-stories/us-17102.json +22 -0
  176. package/rcf/user-stories/us-17201.json +38 -0
  177. package/rcf/user-stories/us-17301.json +38 -0
  178. package/rcf/user-stories/us-17401.json +58 -0
  179. package/rcf/user-stories/us-17501.json +48 -0
  180. package/releases/releases.yaml +31 -1
  181. package/src/browser-verify/manifest-writer.js +9 -5
  182. package/src/cli/browser-verify.js +9 -1
  183. package/src/cli/feedback.js +316 -32
  184. package/src/cli/help.js +11 -0
  185. package/src/cli/intake.js +25 -4
  186. package/src/cli/ledger.js +399 -0
  187. package/src/cli/preflight.js +24 -3
  188. package/src/cli/readiness.js +280 -0
  189. package/src/cli/review.js +9 -1
  190. package/src/cli/ui-baseline.js +14 -1
  191. package/src/core/store/index.js +5 -0
  192. package/src/core/store/validate-composed-record.js +95 -0
  193. package/src/core/store/writer.js +86 -6
  194. package/src/define/freeze-record.js +232 -0
  195. package/src/define/ledgers.js +513 -0
  196. package/src/feedback/gh.js +27 -1
  197. package/src/feedback/redact-allowlist.json +7 -0
  198. package/src/feedback/redact.js +126 -11
  199. package/src/feedback/render.js +34 -12
  200. package/src/intake/manifest-writer.js +6 -3
  201. package/src/intake/orchestrator.js +44 -7
  202. package/src/preflight/manifest-writer.js +11 -11
  203. package/src/query/delta.js +276 -0
  204. package/src/query/gates.js +832 -0
  205. package/src/query/index.js +36 -0
  206. package/src/query/readiness.js +333 -0
  207. package/src/req-baseline/sweep.js +34 -5
  208. package/src/server/index.js +37 -0
  209. package/src/server/routes.js +65 -3
  210. package/src/ui-baseline/manifest-writer.js +11 -10
  211. package/src/view/doc-renderers/helpers.js +39 -2
  212. package/src/view/doc-renderers/req.js +10 -2
  213. package/src/view/doc-renderers/tad.js +67 -6
  214. package/src/view/doc-renderers/user-story.js +10 -5
  215. package/src/view/html-page.js +347 -28
  216. package/src/view/index.js +14 -1
  217. package/src/view/live-client.js +22 -2
  218. package/src/view/product-map.js +751 -0
  219. package/src/view/style.css +383 -0
@@ -4,6 +4,42 @@
4
4
  export { computeCoverage, classifyCoverageScope } from './coverage.js';
5
5
  export { computeTrace, kindOf } from './trace.js';
6
6
  export { computeImpact, labelFor } from './impact.js';
7
+ // Detection model (REQ-172, proposal 2026-09-22 §2.2 v3). Pure hashes
8
+ // over the parsed tree; every downstream DEFINE gate reads this delta.
9
+ export {
10
+ canonicaliseJson,
11
+ hashDocument,
12
+ computeTreeHash,
13
+ computeDelta,
14
+ } from './delta.js';
15
+ // DEFINE stage gates (REQ-174; proposal §2.4, §3.2 v3). One check
16
+ // function per stage; foldState + parseAcClass helpers.
17
+ export {
18
+ STAGE_ORDER,
19
+ STAGE_GATES,
20
+ STAGE_SHORT_NAMES,
21
+ STAGE_ALIASES,
22
+ INTERFACE_KINDS,
23
+ stagePolicy,
24
+ parseAcClass,
25
+ foldState,
26
+ runStage,
27
+ checkD1Brief,
28
+ checkD2Skeleton,
29
+ checkD3Shapes,
30
+ checkD4Stories,
31
+ checkD5Crosscut,
32
+ checkD6Consistency,
33
+ checkD7Decisions,
34
+ checkD8Freeze,
35
+ } from './gates.js';
36
+ // Readiness composer (REQ-175; proposal §2.4, §6 v3).
37
+ export {
38
+ computeReadiness,
39
+ deriveNextAction,
40
+ shortHash,
41
+ formatTreeLine,
42
+ } from './readiness.js';
7
43
  export { formatTable } from './formatters/table.js';
8
44
  export { formatJson } from './formatters/json.js';
9
45
  export { formatMermaid } from './formatters/mermaid.js';
@@ -0,0 +1,333 @@
1
+ // Readiness compute (REQ-175; proposal 2026-09-22 §2.4, §6 v3).
2
+ //
3
+ // One pure composer over the DEFINE detection primitives:
4
+ // 1. `computeDelta` (slice 1) -> changed / added / removed / briefSince
5
+ // 2. `computeImpact` per id in changed union added -> fan-out
6
+ // 3. Eight gate check functions from `./gates.js` -> stage[]
7
+ // 4. `computeCoverage` tree-wide plus per REQ ancestor of the delta
8
+ // 5. Open decisions from the decisions ledger
9
+ // 6. Fold to `freezeable` (every stage passed / acknowledged /
10
+ // notApplicable) and `nextAction` (first failing stage + check)
11
+ //
12
+ // Return shape mirrors proposal §2.4 exactly. The CLI (rcf define
13
+ // readiness) and the viewer's Readiness tab (slice 5) render the same
14
+ // object; the freeze verb (slice 3) refuses on any failing stage.
15
+
16
+ import { computeQueue } from '../build/queue.js';
17
+ import { computeCoverage } from './coverage.js';
18
+ import { computeDelta } from './delta.js';
19
+ import { computeImpact } from './impact.js';
20
+ import {
21
+ STAGE_GATES,
22
+ STAGE_ORDER,
23
+ STAGE_SHORT_NAMES,
24
+ checkD1Brief,
25
+ checkD2Skeleton,
26
+ checkD3Shapes,
27
+ checkD4Stories,
28
+ checkD5Crosscut,
29
+ checkD6Consistency,
30
+ checkD7Decisions,
31
+ checkD8Freeze,
32
+ } from './gates.js';
33
+
34
+ /**
35
+ * @typedef {object} ReadinessTree
36
+ * @property {boolean} frozen
37
+ * @property {string | null} frozenAt
38
+ * @property {string | null} treeHash
39
+ * @property {string} currentTreeHash
40
+ * @property {string | null} buildAt fbsId at the queue head, or null
41
+ * @property {number} fbsTotal
42
+ */
43
+
44
+ /**
45
+ * @typedef {object} ReadinessDelta
46
+ * @property {string[]} changed
47
+ * @property {string[]} added
48
+ * @property {string[]} removed
49
+ * @property {number[]} briefSince
50
+ * @property {import('./impact.js').ImpactNode[]} impacted
51
+ * @property {string[]} impactedFbs
52
+ */
53
+
54
+ /**
55
+ * @typedef {object} StageResult
56
+ * @property {string} stage
57
+ * @property {string} gate
58
+ * @property {'passed'|'failing'|'acknowledged'|'notApplicable'} state
59
+ * @property {Array<{ name: string, ok: boolean, over: 'delta'|'tree', pass: number, total: number, failing: Array<{ id: string, why: string }> }>} checks
60
+ * @property {string} [reason]
61
+ */
62
+
63
+ /**
64
+ * @typedef {object} NextAction
65
+ * @property {string} stage
66
+ * @property {string} check
67
+ * @property {string[]} ids
68
+ * @property {string} command
69
+ */
70
+
71
+ /**
72
+ * @typedef {object} ReadinessResult
73
+ * @property {ReadinessTree} tree
74
+ * @property {ReadinessDelta} delta
75
+ * @property {StageResult[]} stages
76
+ * @property {NextAction | null} nextAction
77
+ * @property {{ tree: import('./coverage.js').CoverageResult, delta: import('./coverage.js').CoverageResult[] }} coverage
78
+ * @property {Array<Record<string, unknown>>} decisions
79
+ * @property {boolean} freezeable
80
+ */
81
+
82
+ /**
83
+ * Compute the readiness object.
84
+ *
85
+ * Pure over its inputs. Callers supply the ledgers bundle, freeze
86
+ * record, testPointers map (resolveTestPointers output) and optional
87
+ * profile switches; nothing else touches the filesystem.
88
+ *
89
+ * @param {import('#core/store/walker.js').TreeModel} tree
90
+ * @param {object} args
91
+ * @param {import('./delta.js').FreezeRecord | null | undefined} [args.freeze]
92
+ * @param {import('./delta.js').LedgerBundle | undefined} [args.ledgers]
93
+ * @param {{ skipReviewFor?: string } | undefined} [args.profile]
94
+ * @param {string | null | undefined} [args.profileText] rcf/.identity/profile.md contents
95
+ * @param {Map<string, unknown> | undefined} [args.testPointers]
96
+ * @param {Array<unknown> | undefined} [args.validateErrors] tree-wide validate + walker errors
97
+ * @returns {ReadinessResult}
98
+ */
99
+ export function computeReadiness(tree, args = {}) {
100
+ const {
101
+ freeze = null,
102
+ ledgers = {},
103
+ profile = undefined,
104
+ profileText = null,
105
+ testPointers = undefined,
106
+ validateErrors = [],
107
+ } = args;
108
+
109
+ // 1. Delta.
110
+ const delta = computeDelta(tree, freeze, ledgers);
111
+
112
+ // 2. Fan-out via computeImpact per id in changed union added.
113
+ // Standalone-document ids only; ledger:<name> entries have no fan-out.
114
+ const pivotIds = [
115
+ ...delta.changed.filter((id) => !id.startsWith('ledger:')),
116
+ ...delta.added.filter((id) => !id.startsWith('ledger:')),
117
+ ];
118
+ const pivotSet = new Set(pivotIds);
119
+ /** @type {import('./impact.js').ImpactNode[]} */
120
+ const impactedNodes = [];
121
+ const impactedSeen = new Set();
122
+ const impactedFbsSeen = new Set();
123
+ // Skip fan-out when the tree is unfrozen: the delta IS the whole tree
124
+ // (proposal §2.2 "before the first freeze the delta is the whole
125
+ // tree"), so a per-id fan-out is quadratic for no signal.
126
+ const runFanOut = delta.frozen === true;
127
+ if (runFanOut) {
128
+ for (const id of pivotIds) {
129
+ const impact = computeImpact(tree, { id });
130
+ if (!impact.found || !Array.isArray(impact.nodes)) continue;
131
+ for (const node of impact.nodes) {
132
+ if (node.role === 'pivot') continue;
133
+ if (pivotSet.has(node.id)) continue;
134
+ if (!impactedSeen.has(node.id)) {
135
+ impactedSeen.add(node.id);
136
+ impactedNodes.push(node);
137
+ }
138
+ if (node.kind === 'fbs' && node.actionNeeded === 're-execute') {
139
+ impactedFbsSeen.add(node.id);
140
+ }
141
+ }
142
+ }
143
+ }
144
+ const impactedFbs = [...impactedFbsSeen].sort();
145
+
146
+ // 3. Compose scope = changed union added union impacted (dedup, sort).
147
+ const scopeSet = new Set([
148
+ ...delta.changed.filter((id) => !id.startsWith('ledger:')),
149
+ ...delta.added.filter((id) => !id.startsWith('ledger:')),
150
+ ...impactedSeen,
151
+ ]);
152
+
153
+ // 4. Coverage: tree-wide plus per REQ ancestor of the delta.
154
+ const coverageTree = computeCoverage(tree, { testPointers, strict: true });
155
+ const deltaReqIds = collectDeltaReqAncestors(tree, pivotIds);
156
+ const coverageDelta = [];
157
+ for (const reqId of deltaReqIds) {
158
+ coverageDelta.push(computeCoverage(tree, { testPointers, strict: true, scopeId: reqId }));
159
+ }
160
+
161
+ // 5. Run the eight gate check functions in order. D8 reads the
162
+ // prior stage states, so compose sequentially.
163
+ /** @type {StageResult[]} */
164
+ const stages = [];
165
+ const currentTreeHash = delta.currentTreeHash;
166
+ /** @type {import('./gates.js').StageContext} */
167
+ const ctx = {
168
+ tree,
169
+ ledgers,
170
+ delta,
171
+ freeze,
172
+ scope: scopeSet,
173
+ validateErrors,
174
+ // Prefer the caller-supplied probe count when it is passed; falls
175
+ // back to the ledger scan inside checkD6Consistency.
176
+ probeOpenCount: undefined,
177
+ coverageTree,
178
+ profileText,
179
+ profile,
180
+ currentTreeHash,
181
+ priorStages: undefined,
182
+ };
183
+ stages.push(checkD1Brief(ctx));
184
+ stages.push(checkD2Skeleton(ctx));
185
+ stages.push(checkD3Shapes(ctx));
186
+ stages.push(checkD4Stories(ctx));
187
+ stages.push(checkD5Crosscut(ctx));
188
+ stages.push(checkD6Consistency(ctx));
189
+ stages.push(checkD7Decisions(ctx));
190
+ stages.push(checkD8Freeze({ ...ctx, priorStages: stages.map((s) => ({ stage: s.stage, state: s.state })) }));
191
+
192
+ // 6. Open decisions in the #241 format for the surface consumer.
193
+ const decisions = /** @type {any} */ (ledgers?.decisions);
194
+ const decisionEntries = Array.isArray(decisions?.decisions) ? decisions.decisions : [];
195
+ const openDecisions = decisionEntries.filter((d) => d?.status === 'open');
196
+
197
+ // 7. Fold freezeable + nextAction.
198
+ const freezeable = stages.every((s) => s.state === 'passed' || s.state === 'acknowledged' || s.state === 'notApplicable');
199
+ const nextAction = freezeable ? null : deriveNextAction(stages);
200
+
201
+ // 8. Build the tree summary line inputs.
202
+ const queue = computeQueue(tree);
203
+ const fbsTotal = (tree.fbsItems ?? []).length;
204
+
205
+ /** @type {ReadinessResult} */
206
+ return {
207
+ tree: {
208
+ frozen: delta.frozen,
209
+ frozenAt: delta.frozenAt,
210
+ treeHash: delta.treeHash,
211
+ currentTreeHash: delta.currentTreeHash,
212
+ buildAt: queue.nextActionable ?? null,
213
+ fbsTotal,
214
+ },
215
+ delta: {
216
+ changed: delta.changed,
217
+ added: delta.added,
218
+ removed: delta.removed,
219
+ briefSince: delta.briefSince,
220
+ impacted: impactedNodes,
221
+ impactedFbs,
222
+ },
223
+ stages,
224
+ nextAction,
225
+ coverage: { tree: coverageTree, delta: coverageDelta },
226
+ decisions: openDecisions,
227
+ freezeable,
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Walk back from every delta pivot to its REQ ancestor and collect
233
+ * the unique REQ ids. The walker records parent-child edges under
234
+ * `parentByChild`; the walk terminates at the first REQ hit.
235
+ *
236
+ * @param {import('#core/store/walker.js').TreeModel} tree
237
+ * @param {string[]} pivotIds
238
+ * @returns {string[]}
239
+ */
240
+ function collectDeltaReqAncestors(tree, pivotIds) {
241
+ const seen = new Set();
242
+ for (const id of pivotIds) {
243
+ let cursor = id;
244
+ let hops = 0;
245
+ while (cursor && hops < 32) {
246
+ hops += 1;
247
+ const kind = tree.kindById?.get(cursor);
248
+ if (kind === 'req') {
249
+ seen.add(cursor);
250
+ break;
251
+ }
252
+ // Some kinds carry an explicit reqId; use that when the parent
253
+ // chain skips the REQ (e.g. a TC leaf whose parent is a TS).
254
+ const doc = tree.byId?.get(cursor);
255
+ if (doc && typeof doc === 'object' && typeof /** @type {any} */ (doc).reqId === 'string') {
256
+ seen.add(/** @type {any} */ (doc).reqId);
257
+ break;
258
+ }
259
+ const parent = tree.parentByChild?.get(cursor);
260
+ if (!parent) break;
261
+ cursor = parent;
262
+ }
263
+ }
264
+ return [...seen].sort();
265
+ }
266
+
267
+ /**
268
+ * Pick the first failing stage in D1..D8 order and the first failing
269
+ * check inside it, then compose a `NextAction`.
270
+ *
271
+ * @param {StageResult[]} stages
272
+ * @returns {NextAction | null}
273
+ */
274
+ export function deriveNextAction(stages) {
275
+ const byStage = new Map(stages.map((s) => [s.stage, s]));
276
+ for (const stageId of STAGE_ORDER) {
277
+ const s = byStage.get(stageId);
278
+ if (!s || s.state !== 'failing') continue;
279
+ // First failing check; tie-break by the largest failing list so
280
+ // the operator sees the most impactful gap first.
281
+ const failing = s.checks.filter((c) => !c.ok);
282
+ if (failing.length === 0) continue;
283
+ const check = failing.reduce((a, b) => (b.failing.length > a.failing.length ? b : a), failing[0]);
284
+ const ids = [...new Set(check.failing.map((f) => f.id))].slice(0, 20);
285
+ const shortName = STAGE_SHORT_NAMES[stageId] ?? stageId;
286
+ return {
287
+ stage: stageId,
288
+ check: check.name,
289
+ ids,
290
+ command: `rcf define readiness --check ${shortName}`,
291
+ };
292
+ }
293
+ return null;
294
+ }
295
+
296
+ /**
297
+ * Short 8-character prefix of a `sha256:<hex>` string, or '(none)' when
298
+ * absent. Kept exported so text formatters share one convention.
299
+ *
300
+ * @param {string | null | undefined} hash
301
+ * @returns {string}
302
+ */
303
+ export function shortHash(hash) {
304
+ if (typeof hash !== 'string' || !hash.startsWith('sha256:')) return '(none)';
305
+ return hash.slice('sha256:'.length, 'sha256:'.length + 8);
306
+ }
307
+
308
+ /**
309
+ * One-line tree summary matching proposal §6.2:
310
+ * "Frozen at 9f3a2c on 2026-09-20. 4 documents changed since, 11
311
+ * impacted. Build at FBS-031 of 41; 2 FBS impacted."
312
+ * "Unfrozen. 30 documents; the whole tree is the delta."
313
+ *
314
+ * @param {ReadinessResult} result
315
+ * @returns {string}
316
+ */
317
+ export function formatTreeLine(result) {
318
+ const t = result.tree;
319
+ const d = result.delta;
320
+ if (!t.frozen) {
321
+ const total = d.added.length + d.changed.length;
322
+ return `Unfrozen. ${total} document${total === 1 ? '' : 's'}; the whole tree is the delta.`;
323
+ }
324
+ const changedCount = d.changed.length + d.added.length + d.removed.length;
325
+ const impactedCount = d.impacted.length;
326
+ const buildAt = t.buildAt ?? '(none)';
327
+ const impactedFbs = d.impactedFbs.length;
328
+ const at = t.frozenAt ? t.frozenAt.split('T')[0] : '(no timestamp)';
329
+ return `Frozen at ${shortHash(t.treeHash)} on ${at}. ${changedCount} document${changedCount === 1 ? '' : 's'} changed since, ${impactedCount} impacted. Build at ${buildAt} of ${t.fbsTotal}; ${impactedFbs} FBS impacted.`;
330
+ }
331
+
332
+ // Re-export for CLI convenience.
333
+ export { STAGE_GATES, STAGE_ORDER, STAGE_SHORT_NAMES };
@@ -87,10 +87,20 @@ export async function applySweepDecisions({ projectRoot, tree, decisions, now =
87
87
  const usDoc = tree.byId.get(usId);
88
88
  if (!usDoc) continue;
89
89
 
90
+ // 0.28.2 (issue #231): a per-US allocator seeded ONCE from the
91
+ // current maxN on `usDoc.acceptanceCriteria`, then handed to each
92
+ // `composeBaselineAc` for the run. `nextAcId` was stateless
93
+ // across iterations, so every candidate accepted for one US
94
+ // received the identical `AC-<usNum>-<maxN+1>` and the writer's
95
+ // post-write validation refused the tree with a duplicate-id
96
+ // breakage. Ordinal allocation is monotone within a batch and
97
+ // across batches (a later sweep on the same US resumes from the
98
+ // new maxN in the tree).
99
+ const allocator = makeAcIdAllocator(usDoc);
90
100
  const acsToAppend = [];
91
101
  for (const d of list) {
92
102
  if (d.action === 'accept') {
93
- const ac = composeBaselineAc({ usDoc, candidate: d.candidate, now });
103
+ const ac = composeBaselineAc({ usDoc, candidate: d.candidate, now, idOverride: allocator() });
94
104
  acsToAppend.push(ac);
95
105
  writtenAcIds.push(ac.id);
96
106
  accepted += 1;
@@ -172,9 +182,9 @@ export async function applyPendingBaselinesForUs({ tree, usId }) {
172
182
  * @param {Date} args.now
173
183
  * @returns {object}
174
184
  */
175
- export function composeBaselineAc({ usDoc, candidate, now }) {
185
+ export function composeBaselineAc({ usDoc, candidate, now, idOverride = null }) {
176
186
  const isoNow = now.toISOString();
177
- const id = nextAcId(usDoc);
187
+ const id = typeof idOverride === 'string' && idOverride.length > 0 ? idOverride : nextAcId(usDoc);
178
188
  const canonicalText = candidate.canonicalText;
179
189
  const gwt = candidate.given || candidate.when || candidate.then
180
190
  ? { given: candidate.given ?? '', when: candidate.when ?? '', then: candidate.then ?? '' }
@@ -200,8 +210,22 @@ export function composeBaselineAc({ usDoc, candidate, now }) {
200
210
  const AC_HIER_ID_RE = /^AC-(\d{3,})-(\d+)$/;
201
211
 
202
212
  function nextAcId(usDoc) {
213
+ const alloc = makeAcIdAllocator(usDoc);
214
+ return alloc();
215
+ }
216
+
217
+ /**
218
+ * 0.28.2 (issue #231): return a monotone AC-id allocator seeded once
219
+ * from `usDoc.acceptanceCriteria`. Every subsequent call returns
220
+ * `AC-<usNum>-<n++>`; a caller that accepts N baseline candidates for
221
+ * one US in one sweep now gets N distinct ids, and the writer's
222
+ * duplicate-id refusal on `updateDocument` no longer bites.
223
+ *
224
+ * @param {object} usDoc
225
+ * @returns {() => string}
226
+ */
227
+ export function makeAcIdAllocator(usDoc) {
203
228
  const acs = Array.isArray(usDoc.acceptanceCriteria) ? usDoc.acceptanceCriteria : [];
204
- // Prefer hierarchical form matching the US id, e.g. US-201 → AC-201-1.
205
229
  const usNum = (usDoc.usId ?? '').match(/^US-(\d+)$/)?.[1];
206
230
  let maxN = 0;
207
231
  for (const ac of acs) {
@@ -212,7 +236,12 @@ function nextAcId(usDoc) {
212
236
  const n = Number.parseInt(tail, 10);
213
237
  if (Number.isFinite(n) && n > maxN) maxN = n;
214
238
  }
215
- return usNum ? `AC-${usNum}-${maxN + 1}` : `AC-${(Math.floor(Math.random() * 999)).toString().padStart(3, '0')}-1`;
239
+ let counter = maxN;
240
+ return () => {
241
+ counter += 1;
242
+ if (!usNum) return `AC-${(Math.floor(Math.random() * 999)).toString().padStart(3, '0')}-${counter}`;
243
+ return `AC-${usNum}-${counter}`;
244
+ };
216
245
  }
217
246
 
218
247
  function extractGwt(text) {
@@ -11,6 +11,7 @@
11
11
  // on top; the server itself does not `process.exit`.
12
12
 
13
13
  import { createServer } from 'node:http';
14
+ import { readFile } from 'node:fs/promises';
14
15
  import { join } from 'node:path';
15
16
 
16
17
  import { LIVE_CLIENT_PATH, STYLE_CSS_PATH, VENDORED_MERMAID_PATH, renderModelToPage } from '../view/index.js';
@@ -42,6 +43,25 @@ import { createSseHub } from './sse.js';
42
43
  * rewalk: () => Promise<void>,
43
44
  * }>}
44
45
  */
46
+
47
+ /**
48
+ * Issue #248 (0.28.3): read a shipped static asset once and return
49
+ * `{ buffer, size, contentType }`. Any read failure surfaces here so
50
+ * `startServer` can fail fast with an ioFailure before it binds.
51
+ *
52
+ * @param {string} path
53
+ * @param {string} contentType
54
+ */
55
+ async function loadStaticAsset(path, contentType) {
56
+ // Codex P2-1: derive size from the actual captured buffer, not from
57
+ // a parallel stat that could snapshot a different revision if the
58
+ // file is being replaced concurrently. A wrong content-length header
59
+ // would cause truncation or mis-alignment for the entire server
60
+ // lifetime because we cache both values together.
61
+ const buffer = await readFile(path);
62
+ return { buffer, size: buffer.length, contentType };
63
+ }
64
+
45
65
  export async function startServer(args) {
46
66
  const projectRoot = args.projectRoot;
47
67
  const port = typeof args.port === 'number' ? args.port : 4373;
@@ -76,6 +96,7 @@ export async function startServer(args) {
76
96
  fullPageHtml: result.fullPageHtml,
77
97
  contentHtml: result.contentHtml,
78
98
  errors: result.errors,
99
+ pmPartials: result.pmPartials,
79
100
  };
80
101
  sse.broadcast('tree-update', { version, contentHtml: result.contentHtml });
81
102
  if (result.errors && result.errors.length > 0) {
@@ -97,10 +118,26 @@ export async function startServer(args) {
97
118
  // and the first SSE connect gets a real payload.
98
119
  await rewalk();
99
120
 
121
+ // Issue #248 (0.28.3): snapshot the shipped static assets at startup
122
+ // so a checkout branch switch during the server's lifetime cannot
123
+ // change what gets served. The tree walker is the only path that
124
+ // re-reads disk on rcf/ change; every other asset is fixed at boot.
125
+ const [styleAsset, mermaidAsset, liveClientAsset] = await Promise.all([
126
+ loadStaticAsset(STYLE_CSS_PATH, 'text/css; charset=utf-8'),
127
+ loadStaticAsset(VENDORED_MERMAID_PATH, 'application/javascript; charset=utf-8'),
128
+ loadStaticAsset(LIVE_CLIENT_PATH, 'application/javascript; charset=utf-8'),
129
+ ]);
130
+
100
131
  const scopeHandler = createScopeHandler({ projectRoot });
101
132
  const router = createRouter({
102
133
  currentState: () => state,
103
134
  sse,
135
+ // Snapshotted assets take precedence; the legacy path props are
136
+ // still passed so tests that mount the router directly with a
137
+ // path (no asset load) keep working.
138
+ styleAsset,
139
+ mermaidAsset,
140
+ liveClientAsset,
104
141
  stylePath: STYLE_CSS_PATH,
105
142
  mermaidPath: VENDORED_MERMAID_PATH,
106
143
  liveClientPath: LIVE_CLIENT_PATH,
@@ -12,6 +12,22 @@
12
12
 
13
13
  import { readFile, stat } from 'node:fs/promises';
14
14
 
15
+ // Issue #248 (0.28.3): the shipped static assets (style.css,
16
+ // mermaid.min.js, live-client.js) MUST be resolved once at server
17
+ // startup, not read from disk on every HTTP request. If the checkout
18
+ // switches branches or the file mutates while the server runs, per-
19
+ // request reads return the new bytes even though the HTML that
20
+ // references them was rendered from a walk taken at startup. The
21
+ // asymmetry produced the 2026-09-22 Product Map "unstyled preview"
22
+ // regression: HTML from the store walker was old, style.css was
23
+ // current, and the browser combined them.
24
+ //
25
+ // `deps.styleAsset`, `deps.mermaidAsset` and `deps.liveClientAsset`
26
+ // carry `{ buffer, size, contentType }` snapshotted at startup. The
27
+ // legacy `stylePath`/`mermaidPath`/`liveClientPath` props are kept
28
+ // for backwards compatibility with tests that pass a path; the
29
+ // router reads on-demand in that fallback path.
30
+
15
31
  const MIME = {
16
32
  html: 'text/html; charset=utf-8',
17
33
  css: 'text/css; charset=utf-8',
@@ -68,15 +84,33 @@ export function createRouter(deps) {
68
84
  return;
69
85
  }
70
86
  if (path === '/style.css') {
71
- serveFile(res, deps.stylePath, MIME.css).catch((err) => fail(res, err));
87
+ serveAsset(res, deps.styleAsset, deps.stylePath, MIME.css).catch((err) => fail(res, err));
72
88
  return;
73
89
  }
74
90
  if (path === '/mermaid.min.js') {
75
- serveFile(res, deps.mermaidPath, MIME.js).catch((err) => fail(res, err));
91
+ serveAsset(res, deps.mermaidAsset, deps.mermaidPath, MIME.js).catch((err) => fail(res, err));
76
92
  return;
77
93
  }
78
94
  if (path === '/live-client.js') {
79
- serveFile(res, deps.liveClientPath, MIME.js).catch((err) => fail(res, err));
95
+ serveAsset(res, deps.liveClientAsset, deps.liveClientPath, MIME.js).catch((err) => fail(res, err));
96
+ return;
97
+ }
98
+ if (path.startsWith('/product-map/')) {
99
+ const state = deps.currentState();
100
+ if (!state || !state.pmPartials) {
101
+ res.writeHead(503, { 'content-type': MIME.txt });
102
+ res.end('view server initialising\n');
103
+ return;
104
+ }
105
+ const group = path.slice('/product-map/'.length);
106
+ const html = state.pmPartials[group];
107
+ if (typeof html !== 'string') {
108
+ res.writeHead(404, { 'content-type': MIME.txt });
109
+ res.end('unknown grouping\n');
110
+ return;
111
+ }
112
+ res.writeHead(200, { 'content-type': MIME.html, 'cache-control': 'no-store' });
113
+ res.end(html);
80
114
  return;
81
115
  }
82
116
  if (path === '/scope.json') {
@@ -94,6 +128,34 @@ export function createRouter(deps) {
94
128
  };
95
129
  }
96
130
 
131
+ /**
132
+ * Issue #248 (0.28.3): serve a snapshotted asset when the server has
133
+ * one, otherwise fall through to the legacy per-request read. The
134
+ * happy path never touches the filesystem after startup, so a checkout
135
+ * switch during the server's lifetime cannot change what gets served.
136
+ *
137
+ * @param {import('node:http').ServerResponse} res
138
+ * @param {{ buffer: Buffer, size: number, contentType?: string } | undefined | null} asset
139
+ * @param {string | undefined} fallbackPath
140
+ * @param {string} contentType
141
+ */
142
+ async function serveAsset(res, asset, fallbackPath, contentType) {
143
+ if (asset && asset.buffer) {
144
+ res.writeHead(200, {
145
+ 'content-type': asset.contentType ?? contentType,
146
+ 'content-length': String(asset.size),
147
+ });
148
+ res.end(asset.buffer);
149
+ return;
150
+ }
151
+ if (fallbackPath) {
152
+ await serveFile(res, fallbackPath, contentType);
153
+ return;
154
+ }
155
+ res.writeHead(404, { 'content-type': MIME.txt });
156
+ res.end('not found\n');
157
+ }
158
+
97
159
  async function serveFile(res, path, contentType) {
98
160
  let body;
99
161
  let size;
@@ -18,7 +18,7 @@ import { mkdir, rename, unlink, writeFile } from 'node:fs/promises';
18
18
  import { dirname, join } from 'node:path';
19
19
 
20
20
  import { rcfError } from '#core/errors';
21
- import { validateDocument } from '#core/store';
21
+ import { buildNextManifest, validateDocument } from '#core/store';
22
22
 
23
23
  import { composeDefaults, deepGet, deepSet } from './defaults.js';
24
24
  import { CATALOGUE_V1 } from '../preflight/design-shapes.js';
@@ -150,15 +150,16 @@ export function preflightSeamOverrides(manifest, catalogue = CATALOGUE_V1) {
150
150
  export async function writeUiBaselineRecord({
151
151
  projectRoot, tree, record, options = {},
152
152
  }) {
153
- const manifest = tree.manifest ?? {};
154
- const nextManifest = { ...manifest };
155
- const priorBaseline = nextManifest.uiBaseline ?? null;
156
-
157
- if (priorBaseline && options.reset) {
158
- const history = Array.isArray(nextManifest.uiBaselineHistory) ? nextManifest.uiBaselineHistory : [];
159
- nextManifest.uiBaselineHistory = [...history, priorBaseline];
160
- }
161
- nextManifest.uiBaseline = record;
153
+ // 0.28.2 (Codex review follow-up on the shared helper): compose via
154
+ // the shared `buildNextManifest` helper so write and dry-run
155
+ // produce the same shape. `options.reset` still drives the
156
+ // history-move branch inside the helper.
157
+ const nextManifest = buildNextManifest({
158
+ manifest: tree.manifest ?? {},
159
+ record,
160
+ verb: 'uiBaselineInit',
161
+ options: { reset: Boolean(options.reset) },
162
+ });
162
163
 
163
164
  const relPath = 'rcf/manifest.json';
164
165
  const validation = validateDocument({ doc: nextManifest, kind: 'manifest', filePath: relPath });