rcf-lite 0.28.4 → 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 (168) hide show
  1. package/CHANGELOG.md +107 -1
  2. package/bin/rcf.js +4 -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-4124-define-freeze-verb.json +30 -0
  15. package/rcf/adrs/adr-4126-persona-tagged-blockers-two-levels.json +40 -0
  16. package/rcf/adrs/adr-4127-readiness-tab-order-and-register.json +40 -0
  17. package/rcf/adrs/adr-4129-questions-harness-driven-loop.json +25 -0
  18. package/rcf/adrs/adr-4130-intake-harness-split-tool-scanned.json +20 -0
  19. package/rcf/adrs/adr-4132-prototypes-in-discovery-as-evidence.json +20 -0
  20. package/rcf/adrs/adr-4133-dag-dependency-depth-layout.json +40 -0
  21. package/rcf/adrs/adr-4134-index-json-route.json +35 -0
  22. package/rcf/adrs/adr-4135-readiness-po-register-and-adapter.json +35 -0
  23. package/rcf/adrs/adr-4136-embedding-host-fixture.json +35 -0
  24. package/rcf/code-nodes/cn-725.json +18 -2
  25. package/rcf/code-nodes/cn-726.json +10 -2
  26. package/rcf/code-nodes/cn-727.json +13 -4
  27. package/rcf/code-nodes/cn-728.json +18 -2
  28. package/rcf/code-nodes/cn-729.json +10 -2
  29. package/rcf/code-nodes/cn-730.json +13 -3
  30. package/rcf/code-nodes/cn-731.json +20 -0
  31. package/rcf/code-nodes/cn-732.json +14 -0
  32. package/rcf/code-nodes/cn-733.json +14 -0
  33. package/rcf/code-nodes/cn-734.json +14 -0
  34. package/rcf/code-nodes/cn-735.json +20 -0
  35. package/rcf/code-nodes/cn-736.json +14 -0
  36. package/rcf/code-nodes/cn-737.json +14 -0
  37. package/rcf/code-nodes/cn-738.json +14 -0
  38. package/rcf/code-nodes/cn-739.json +18 -0
  39. package/rcf/code-nodes/cn-740.json +19 -0
  40. package/rcf/code-nodes/cn-741.json +16 -0
  41. package/rcf/code-nodes/cn-742.json +16 -0
  42. package/rcf/code-nodes/cn-743.json +15 -0
  43. package/rcf/code-nodes/cn-744.json +22 -0
  44. package/rcf/code-nodes/cn-745.json +16 -0
  45. package/rcf/code-nodes/cn-746.json +14 -0
  46. package/rcf/code-nodes/cn-747.json +18 -0
  47. package/rcf/code-nodes/cn-748.json +16 -0
  48. package/rcf/code-nodes/cn-749.json +16 -0
  49. package/rcf/code-nodes/cn-750.json +15 -0
  50. package/rcf/code-nodes/cn-751.json +18 -0
  51. package/rcf/code-nodes/cn-752.json +14 -0
  52. package/rcf/fbs/fbs-196.json +13 -5
  53. package/rcf/fbs/fbs-198.json +42 -0
  54. package/rcf/fbs/fbs-199.json +59 -0
  55. package/rcf/fbs/fbs-200.json +46 -0
  56. package/rcf/fbs/fbs-201.json +49 -0
  57. package/rcf/fbs/fbs-202.json +110 -0
  58. package/rcf/fbs/fbs-203.json +100 -0
  59. package/rcf/requirements/req-002.json +2 -2
  60. package/rcf/requirements/req-173.json +2 -2
  61. package/rcf/requirements/req-174.json +10 -4
  62. package/rcf/requirements/req-175.json +10 -4
  63. package/rcf/requirements/req-176.json +25 -0
  64. package/rcf/requirements/req-180.json +31 -0
  65. package/rcf/requirements/req-181.json +31 -0
  66. package/rcf/requirements/req-182.json +25 -0
  67. package/rcf/requirements/req-186.json +25 -0
  68. package/rcf/requirements/req-187.json +31 -0
  69. package/rcf/requirements/req-188.json +25 -0
  70. package/rcf/tacs/tac-4121-define-ledgers.json +15 -3
  71. package/rcf/tacs/tac-4122-define-gates.json +40 -4
  72. package/rcf/tacs/tac-4123-define-readiness.json +39 -3
  73. package/rcf/tacs/tac-4124-define-freeze.json +28 -0
  74. package/rcf/tacs/tac-4127-readiness-view.json +49 -0
  75. package/rcf/tacs/tac-4128-define-guidance.json +53 -0
  76. package/rcf/tacs/tac-4129-define-questions.json +45 -0
  77. package/rcf/tacs/tac-4130-define-intake-brief.json +35 -0
  78. package/rcf/tacs/tac-4131-build-dag-view.json +39 -0
  79. package/rcf/tacs/tac-4132-id-lookup.json +42 -0
  80. package/rcf/tacs/tac-4133-readiness-po-layer.json +37 -0
  81. package/rcf/tacs/tac-4134-wespa-host-fixture.json +54 -0
  82. package/rcf/test-suites/ts-016.json +3 -3
  83. package/rcf/test-suites/ts-215.json +148 -4
  84. package/rcf/test-suites/ts-216.json +19 -3
  85. package/rcf/test-suites/ts-218.json +78 -0
  86. package/rcf/test-suites/ts-219.json +70 -0
  87. package/rcf/test-suites/ts-220.json +78 -0
  88. package/rcf/test-suites/ts-221.json +78 -0
  89. package/rcf/test-suites/ts-222.json +100 -0
  90. package/rcf/test-suites/ts-223.json +94 -0
  91. package/rcf/test-suites/ts-224.json +70 -0
  92. package/rcf/test-suites/ts-225.json +78 -0
  93. package/rcf/test-suites/ts-226.json +158 -0
  94. package/rcf/test-suites/ts-227.json +38 -0
  95. package/rcf/test-suites/ts-228.json +86 -0
  96. package/rcf/test-suites/ts-229.json +83 -0
  97. package/rcf/user-stories/us-17302.json +50 -0
  98. package/rcf/user-stories/us-17401.json +14 -2
  99. package/rcf/user-stories/us-17402.json +55 -0
  100. package/rcf/user-stories/us-17403.json +60 -0
  101. package/rcf/user-stories/us-17502.json +60 -0
  102. package/rcf/user-stories/us-17503.json +65 -0
  103. package/rcf/user-stories/us-17601.json +58 -0
  104. package/rcf/user-stories/us-18001.json +55 -0
  105. package/rcf/user-stories/us-18002.json +35 -0
  106. package/rcf/user-stories/us-18101.json +35 -0
  107. package/rcf/user-stories/us-18202.json +40 -0
  108. package/rcf/user-stories/us-18601.json +55 -0
  109. package/rcf/user-stories/us-18602.json +50 -0
  110. package/rcf/user-stories/us-18603.json +45 -0
  111. package/rcf/user-stories/us-18701.json +50 -0
  112. package/rcf/user-stories/us-18702.json +50 -0
  113. package/rcf/user-stories/us-18801.json +45 -0
  114. package/rcf/user-stories/us-204.json +59 -0
  115. package/rcf/user-stories/us-205.json +64 -0
  116. package/rcf/user-stories/us-206.json +54 -0
  117. package/releases/releases.yaml +11 -1
  118. package/src/cli/freeze.js +730 -0
  119. package/src/cli/help.js +12 -0
  120. package/src/cli/ledger.js +228 -12
  121. package/src/cli/questions.js +288 -0
  122. package/src/cli/readiness.js +267 -49
  123. package/src/cli/view.js +29 -0
  124. package/src/define/freeze-record.js +30 -15
  125. package/src/define/ledgers.js +210 -11
  126. package/src/intake/index.js +1 -1
  127. package/src/intake/orchestrator.js +117 -0
  128. package/src/mcp/tools.js +354 -0
  129. package/src/query/delta.js +6 -0
  130. package/src/query/gates.js +314 -39
  131. package/src/query/index.js +16 -2
  132. package/src/query/questions.js +438 -0
  133. package/src/query/readiness.js +248 -7
  134. package/src/server/index.js +24 -2
  135. package/src/server/routes.js +68 -1
  136. package/src/setup/identity-seed.js +17 -0
  137. package/src/view/build-dag.js +563 -0
  138. package/src/view/components/badge.js +41 -0
  139. package/src/view/components/diff.js +72 -0
  140. package/src/view/components/doc-row.js +62 -0
  141. package/src/view/components/empty-state.js +35 -0
  142. package/src/view/components/entity-selector.js +137 -0
  143. package/src/view/components/filter-bar.js +97 -0
  144. package/src/view/components/findings-list.js +49 -0
  145. package/src/view/components/index.js +12 -0
  146. package/src/view/components/pill.js +46 -0
  147. package/src/view/components/sub-tab-strip.js +43 -0
  148. package/src/view/components/toast.js +25 -0
  149. package/src/view/components-fixture.js +265 -0
  150. package/src/view/doc-renderers/architecture.js +304 -0
  151. package/src/view/doc-renderers/build.js +160 -0
  152. package/src/view/doc-renderers/index.js +2 -0
  153. package/src/view/doc-renderers/prd.js +35 -6
  154. package/src/view/html-page.js +700 -602
  155. package/src/view/index.js +96 -3
  156. package/src/view/live-client.js +39 -6
  157. package/src/view/needs-work.js +79 -0
  158. package/src/view/page-init.js +2193 -0
  159. package/src/view/readiness/phrasebook.js +115 -0
  160. package/src/view/readiness/po-layer.js +288 -0
  161. package/src/view/readiness/question-adapter.js +329 -0
  162. package/src/view/readiness/stage-legend.js +71 -0
  163. package/src/view/readiness.js +505 -0
  164. package/src/view/style.css +2017 -382
  165. package/src/view/test-host/embed-client.js +121 -0
  166. package/src/view/test-host/fixture.js +183 -0
  167. package/src/view/test-host/lints.js +220 -0
  168. package/src/view/view-index.js +323 -0
@@ -0,0 +1,730 @@
1
+ // `rcf define freeze` subcommand handler (REQ-176; proposal
2
+ // 2026-09-22 §2.1, §2.5, §3.2 D8, §8.2 v3; Baz 2026-09-24 renumbering
3
+ // puts this verb in 0.29.0).
4
+ //
5
+ // The freeze verb is the only close verb: no change-object is minted,
6
+ // nothing else changes state. It runs `computeReadiness` (REQ-175)
7
+ // once over the live tree, freeze record and four ledgers; refuses
8
+ // (exit 4) on any stage state 'failing' after applying any --ack
9
+ // arguments; and, on success, writes `rcf/define/freeze.json` via
10
+ // `saveFreezeRecord` (REQ-172) plus the proposal §2.5 summary line
11
+ // the operator signs off.
12
+ //
13
+ // D8 queue work (appending new FBS, re-opening re-execute FBS) is
14
+ // the agent's authoring work per the playbook, NOT this verb; the
15
+ // verb only gates on D8's checks in `gates.js`.
16
+ //
17
+ // --ack is accepted only for the three warn-with-ack gates
18
+ // (define.shapes / D3 / shapes, define.crosscut / D5 / crosscut,
19
+ // define.consistency / D6 / consistency) per ADR-4124. --ack on any
20
+ // other gate is a usage refusal (exit 2); --ack without a paired
21
+ // --reason is a usage refusal.
22
+ //
23
+ // Admissibility refusal is informational per ADR-4123 / ADR-4124: the
24
+ // wrap runs first so the ruleset toolScope guard is honoured, and on
25
+ // refuse the CLI prints a stderr [warn] line but the freeze compute
26
+ // still runs and writes when the tree is freezeable.
27
+
28
+ import { parseArgs } from 'node:util';
29
+ import { readFile } from 'node:fs/promises';
30
+ import { createRequire } from 'node:module';
31
+ import { join } from 'node:path';
32
+
33
+ import { resolveTestPointers, walkTree } from '#core/store';
34
+ import { checkCodeNodeResolution } from '#core/store';
35
+ import { findProjectRoot } from '../view/index.js';
36
+ import { loadFreezeRecord, saveFreezeRecord } from '../define/freeze-record.js';
37
+ import { loadAllLedgers } from '../define/ledgers.js';
38
+ import {
39
+ STAGE_ALIASES,
40
+ STAGE_GATES,
41
+ STAGE_ORDER,
42
+ STAGE_SHORT_NAMES,
43
+ parseAcClass,
44
+ } from '../query/gates.js';
45
+ import { computeDelta, hashDocument } from '../query/delta.js';
46
+ import { computeReadiness, shortHash } from '../query/readiness.js';
47
+ import { runWithAdmissibilityGate } from '../query/index.js';
48
+
49
+ const OPTION_SPEC = {
50
+ ack: { type: 'string', multiple: true },
51
+ reason: { type: 'string', multiple: true },
52
+ note: { type: 'string' },
53
+ 'litmus-attested': { type: 'string' },
54
+ status: { type: 'boolean' },
55
+ json: { type: 'boolean' },
56
+ help: { type: 'boolean' },
57
+ };
58
+
59
+ /** Gates for which --ack is legal (proposal §2.5, ADR-4124). */
60
+ const ACKABLE_GATES = new Set([
61
+ STAGE_GATES.D3, STAGE_GATES.D5, STAGE_GATES.D6,
62
+ ]);
63
+
64
+ /** Reverse alias map: any accepted alias for the three ackable gates. */
65
+ const ACKABLE_ALIASES = /** @type {const} */ (() => {
66
+ /** @type {Record<string, string>} */
67
+ const out = {};
68
+ for (const stage of ['D3', 'D5', 'D6']) {
69
+ const gate = STAGE_GATES[stage];
70
+ const short = STAGE_SHORT_NAMES[stage];
71
+ out[gate] = gate;
72
+ out[stage] = gate;
73
+ out[short] = gate;
74
+ }
75
+ return out;
76
+ })();
77
+
78
+ export const HELP = `Usage: rcf define freeze [options]
79
+
80
+ Close a change on the tree by freezing again. Runs computeReadiness
81
+ once, refuses (exit 4) on any failing stage, and writes
82
+ rcf/define/freeze.json with fresh hashes plus the freeze context
83
+ slice-4's bundle --next and slice-5's viewer read.
84
+
85
+ The freeze verb is the only close verb: nothing is minted, nothing
86
+ else changes state. Editing the tree afterwards drives readiness to a
87
+ non-empty delta; closing that delta means running this verb again.
88
+
89
+ D8's queue-authoring work (appending new FBS for added ACs, re-opening
90
+ FBS the delta re-executes) is the agent's authoring work per the
91
+ playbook, not this verb. The verb only gates on D8's checks reported
92
+ by computeReadiness.
93
+
94
+ Options:
95
+ --ack <gate> Acknowledge a warn-with-ack gate's failure
96
+ at the current tree hash. Only three gates
97
+ are ackable: define.shapes (D3 / shapes),
98
+ define.crosscut (D5 / crosscut) and
99
+ define.consistency (D6 / consistency). Ack
100
+ on any other gate is a usage refusal.
101
+ Repeat --ack for multiple gates; every
102
+ --ack must be paired with a --reason.
103
+ --reason <text> Reason for the paired --ack. Repeatable
104
+ alongside --ack; the arities must match.
105
+ --note <text> Optional note to record on the freeze
106
+ (mnemonic for the change; readable via
107
+ --status).
108
+ --litmus-attested <D3,..> Comma-separated D-names (D3 / D6) for
109
+ which the agent attests to having read the
110
+ elicitation prompts in the current session
111
+ (proposal §9 decision 7). Recorded in
112
+ freeze.litmus.attestedAt.
113
+ --status Print the current freeze record and the
114
+ live delta summary; run no gates; write
115
+ nothing. Exits 0 even when unfrozen.
116
+ --json Emit machine-readable JSON. On a freeze
117
+ success emits { record, summary, delta };
118
+ on --status emits { record, delta };
119
+ never printed on a refusal.
120
+ --help Print this help.
121
+
122
+ Exit codes:
123
+ 0 Success (record written; --status; --help).
124
+ 2 Usage error (unknown --ack gate, --ack without --reason,
125
+ unknown flag).
126
+ 4 Refused: at least one stage state is failing and no --ack
127
+ covered it.
128
+ `;
129
+
130
+ /** @typedef {'blocking' | 'warnWithAck'} StagePolicy */
131
+
132
+ /**
133
+ * Public entry.
134
+ * @param {string[]} argv
135
+ * @param {object} [deps]
136
+ * @returns {Promise<number>}
137
+ */
138
+ export async function main(argv, deps = {}) {
139
+ const stdout = deps.stdout ?? process.stdout;
140
+ const stderr = deps.stderr ?? process.stderr;
141
+ const cwd = deps.cwd ?? process.cwd();
142
+ const now = deps.now ?? (() => new Date());
143
+
144
+ let parsed;
145
+ try {
146
+ parsed = parseArgs({ args: argv, options: OPTION_SPEC, allowPositionals: false, strict: true });
147
+ } catch (err) {
148
+ stderr.write(`[error] usage ${err.message}\n`);
149
+ stderr.write(HELP);
150
+ return 2;
151
+ }
152
+ const flags = parsed.values;
153
+ if (flags.help) {
154
+ stdout.write(HELP);
155
+ return 0;
156
+ }
157
+
158
+ // --ack / --reason parsing (usage validation before any I/O).
159
+ /** @type {Array<{ gate: string, reason: string }>} */
160
+ let ackPairs;
161
+ try {
162
+ ackPairs = parseAckPairs(flags.ack, flags.reason);
163
+ } catch (err) {
164
+ stderr.write(`[error] usage ${err.message}\n`);
165
+ return 2;
166
+ }
167
+
168
+ const projectRoot = await findProjectRoot(cwd);
169
+ if (!projectRoot) {
170
+ stderr.write('[error] usage no project root found (no rcf/manifest.json in this directory or any ancestor). Run `npx rcf init` to create and wire a project.\n');
171
+ return 2;
172
+ }
173
+
174
+ // --status branch: read-only. No gates, no writes.
175
+ if (flags.status) {
176
+ return runStatus({ projectRoot, flags, stdout, stderr });
177
+ }
178
+
179
+ // Freeze branch.
180
+ return runFreeze({
181
+ projectRoot, flags, ackPairs, now, stdout, stderr,
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Walk --ack and --reason multi-valued arrays in parallel; refuse on
187
+ * unknown gate names, missing --reason, or count mismatch. Returns
188
+ * the paired list normalised to canonical gate ids.
189
+ *
190
+ * @param {string[]|undefined} acks
191
+ * @param {string[]|undefined} reasons
192
+ * @returns {Array<{ gate: string, reason: string }>}
193
+ */
194
+ function parseAckPairs(acks, reasons) {
195
+ const ackArr = Array.isArray(acks) ? acks : [];
196
+ const reasonArr = Array.isArray(reasons) ? reasons : [];
197
+ if (ackArr.length !== reasonArr.length) {
198
+ throw new Error(`--ack requires a matching --reason (got ${ackArr.length} ack(s) and ${reasonArr.length} reason(s))`);
199
+ }
200
+ /** @type {Array<{ gate: string, reason: string }>} */
201
+ const out = [];
202
+ const acceptedList = Object.keys(ACKABLE_ALIASES).sort().join(' | ');
203
+ for (let i = 0; i < ackArr.length; i += 1) {
204
+ const raw = ackArr[i];
205
+ const canonical = ACKABLE_ALIASES[raw];
206
+ if (!canonical) {
207
+ throw new Error(`--ack ${raw} is not an ackable gate; accepted: ${acceptedList}`);
208
+ }
209
+ const reason = reasonArr[i];
210
+ if (typeof reason !== 'string' || reason.length === 0) {
211
+ throw new Error(`--ack ${raw} requires a non-empty --reason`);
212
+ }
213
+ out.push({ gate: canonical, reason });
214
+ }
215
+ return out;
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // --status branch
220
+ // ---------------------------------------------------------------------------
221
+
222
+ /**
223
+ * @param {object} args
224
+ * @param {string} args.projectRoot
225
+ * @param {Record<string, unknown>} args.flags
226
+ * @param {NodeJS.WritableStream} args.stdout
227
+ * @param {NodeJS.WritableStream} args.stderr
228
+ * @returns {Promise<number>}
229
+ */
230
+ async function runStatus({ projectRoot, flags, stdout, stderr }) {
231
+ let record;
232
+ try {
233
+ record = await loadFreezeRecord({ projectRoot });
234
+ } catch (err) {
235
+ stderr.write(`[error] freeze: ${/** @type {Error} */ (err).message}\n`);
236
+ return 1;
237
+ }
238
+ const { tree } = await walkTree({ projectRoot });
239
+ const ledgers = await loadAllLedgers({ projectRoot });
240
+ const delta = computeDelta(tree, record, ledgers);
241
+
242
+ if (flags.json) {
243
+ stdout.write(`${JSON.stringify({ record, delta }, null, 2)}\n`);
244
+ return 0;
245
+ }
246
+ if (!record) {
247
+ stdout.write('never frozen\n');
248
+ stdout.write(`Delta: ${delta.added.length} added, ${delta.changed.length} changed, ${delta.removed.length} removed (unfrozen tree; every id is 'added').\n`);
249
+ return 0;
250
+ }
251
+ stdout.write(`${formatStatusLine(record)}\n`);
252
+ stdout.write(`Delta: ${delta.changed.length} changed, ${delta.added.length} added, ${delta.removed.length} removed, ${delta.briefSince.length} brief statement(s) since.\n`);
253
+ return 0;
254
+ }
255
+
256
+ /**
257
+ * Compose the one-block text summary for `--status`.
258
+ *
259
+ * @param {import('../define/freeze-record.js').default | Record<string, unknown>} record
260
+ * @returns {string}
261
+ */
262
+ function formatStatusLine(record) {
263
+ const r = /** @type {Record<string, unknown>} */ (record);
264
+ const treeHash = typeof r.treeHash === 'string' ? r.treeHash : null;
265
+ const frozenAt = typeof r.frozenAt === 'string' ? r.frozenAt : '(unknown)';
266
+ const note = typeof r.note === 'string' && r.note.length > 0 ? r.note : '(none)';
267
+ const counts = /** @type {any} */ (r.counts) ?? {};
268
+ const litmus = /** @type {any} */ (r.litmus) ?? {};
269
+ const versions = /** @type {any} */ (r.versions) ?? {};
270
+ const gates = /** @type {any} */ (r.gates) ?? {};
271
+ const acked = Object.entries(gates)
272
+ .filter(([, g]) => g && g.state === 'acknowledged')
273
+ .map(([gate, g]) => `${gate} ("${g.at?.reason ?? ''}")`);
274
+ const ackedLine = acked.length > 0 ? acked.join('; ') : '(none)';
275
+ const attested = Array.isArray(litmus.attestedAt) ? litmus.attestedAt.join(', ') : '';
276
+ const overrideVal = r.override;
277
+ const overrideStr = overrideVal === null || overrideVal === undefined
278
+ ? 'null'
279
+ : `${/** @type {any} */ (overrideVal).reason} by ${/** @type {any} */ (overrideVal).by} at ${/** @type {any} */ (overrideVal).at}`;
280
+ const parts = [
281
+ `Frozen at ${shortHash(treeHash)} on ${frozenAt}.`,
282
+ `Note: ${note}.`,
283
+ `Counts: ${counts.req ?? 0} REQ / ${counts.us ?? 0} US / ${counts.ac ?? 0} AC / ${counts.tac ?? 0} TAC / ${counts.adr ?? 0} ADR / ${counts.fbs ?? 0} FBS.`,
284
+ `Acknowledged: ${ackedLine}.`,
285
+ `Litmus: attestedAt [${attested}], readers ${litmus.readers ?? 0}.`,
286
+ `Versions: rcfLite ${versions.rcfLite ?? '?'} / ruleset ${versions.ruleset ?? '?'} / schemas ${versions.schemas ?? '?'}.`,
287
+ `Override: ${overrideStr}.`,
288
+ ];
289
+ return parts.join(' ');
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Freeze branch
294
+ // ---------------------------------------------------------------------------
295
+
296
+ /**
297
+ * @param {object} args
298
+ * @param {string} args.projectRoot
299
+ * @param {Record<string, unknown>} args.flags
300
+ * @param {Array<{ gate: string, reason: string }>} args.ackPairs
301
+ * @param {() => Date} args.now
302
+ * @param {NodeJS.WritableStream} args.stdout
303
+ * @param {NodeJS.WritableStream} args.stderr
304
+ * @returns {Promise<number>}
305
+ */
306
+ async function runFreeze({
307
+ projectRoot, flags, ackPairs, now, stdout, stderr,
308
+ }) {
309
+ const { tree, errors } = await walkTree({ projectRoot });
310
+ const staleErrors = await checkCodeNodeResolution({ projectRoot, tree });
311
+ const validateErrors = [...errors, ...staleErrors];
312
+
313
+ const [priorFreeze, ledgers, testPointers, profileText] = await Promise.all([
314
+ loadFreezeRecord({ projectRoot }),
315
+ loadAllLedgers({ projectRoot }),
316
+ resolveTestPointers({ projectRoot, tree }),
317
+ readProfileText(projectRoot),
318
+ ]);
319
+
320
+ const chainRulesetVersion = typeof tree.manifest?.rulesetVersion === 'string'
321
+ ? tree.manifest.rulesetVersion
322
+ : null;
323
+
324
+ // Compute the tree hash up front so we can synthesise a freeze
325
+ // context that carries any --ack acknowledgements at the current
326
+ // hash. The readiness compute then folds D3 / D5 / D6 to
327
+ // 'acknowledged' in the same pass, which is what D8's priorStages
328
+ // check reads. Applying acks post-readiness would leave D8 seeing
329
+ // stale prior states and refusing on freeze:priorGates.
330
+ const preHashDelta = computeDelta(tree, priorFreeze, ledgers);
331
+ const currentTreeHash = preHashDelta.currentTreeHash;
332
+
333
+ const ackedReasons = new Map(ackPairs.map((p) => [p.gate, p.reason]));
334
+ const freezeForCompute = ackedReasons.size === 0
335
+ ? priorFreeze
336
+ : mergeAckedGates(priorFreeze, ackedReasons, currentTreeHash);
337
+
338
+ const gated = await runWithAdmissibilityGate({
339
+ tree,
340
+ chainRulesetVersion,
341
+ produce: () => computeReadiness(tree, {
342
+ freeze: freezeForCompute,
343
+ ledgers,
344
+ profileText,
345
+ testPointers,
346
+ validateErrors,
347
+ }),
348
+ });
349
+ if (gated.status === 'refused-admissibility') {
350
+ stderr.write(`[warn] freeze: chain admissibility refused (informational; the freeze still ran). ${gated.refusal}\n`);
351
+ }
352
+ /** @type {import('../query/readiness.js').ReadinessResult} */
353
+ const readiness = gated.status === 'refused-admissibility'
354
+ ? computeReadiness(tree, {
355
+ freeze: freezeForCompute, ledgers, profileText, testPointers, validateErrors,
356
+ })
357
+ : gated.payload;
358
+
359
+ // The stages array already carries acknowledged states for any acked
360
+ // gate at the current tree hash. Stamp the ack reasons onto the
361
+ // matching stage entries so the freeze record and the summary block
362
+ // carry the reason text.
363
+ const stagesAfterAck = readiness.stages.map((stage) => {
364
+ const reason = ackedReasons.get(stage.gate);
365
+ if (!reason) return stage;
366
+ return { ...stage, reason };
367
+ });
368
+ const freezeableAfterAck = stagesAfterAck.every(
369
+ (s) => s.state === 'passed' || s.state === 'acknowledged' || s.state === 'notApplicable',
370
+ );
371
+
372
+ const preFreezeDelta = readiness.delta;
373
+
374
+ if (!freezeableAfterAck) {
375
+ printFailingBlock(stderr, stagesAfterAck);
376
+ return 4;
377
+ }
378
+
379
+ // Build the freeze record body.
380
+ const frozenAt = now().toISOString();
381
+ // Sanity: the tree hash the readiness compute reports must match the
382
+ // one we pre-computed. Never diverges in practice (both hash the
383
+ // same live tree the same way); the assertion documents the
384
+ // invariant for future maintainers.
385
+ if (readiness.tree.currentTreeHash !== currentTreeHash) {
386
+ stderr.write(`[error] freeze: internal invariant: tree hash mismatch between pre-compute and readiness (${currentTreeHash} vs ${readiness.tree.currentTreeHash})\n`);
387
+ return 1;
388
+ }
389
+ const docHashes = computeDocHashes(tree, ledgers);
390
+ const briefStatements = countBriefStatements(ledgers);
391
+ const gates = buildGatesEntry(stagesAfterAck, currentTreeHash, frozenAt);
392
+ const counts = deriveCounts(tree);
393
+ const litmusAttested = parseLitmusAttested(flags['litmus-attested']);
394
+ const litmus = { attestedAt: litmusAttested, readers: 0 };
395
+ const versions = await readVersions(projectRoot);
396
+ const note = typeof flags.note === 'string' ? flags.note : null;
397
+
398
+ /** @type {Record<string, unknown>} */
399
+ const record = {
400
+ frozenAt,
401
+ treeHash: currentTreeHash,
402
+ docHashes,
403
+ briefStatements,
404
+ gates,
405
+ counts,
406
+ litmus,
407
+ versions,
408
+ note,
409
+ override: null,
410
+ };
411
+
412
+ await saveFreezeRecord({ projectRoot, record: /** @type {any} */ (record) });
413
+
414
+ const summary = formatSummary({
415
+ firstFreeze: priorFreeze === null,
416
+ currentTreeHash,
417
+ preFreezeDelta,
418
+ counts,
419
+ });
420
+ if (flags.json) {
421
+ stdout.write(`${JSON.stringify({ record, summary, delta: preFreezeDelta }, null, 2)}\n`);
422
+ } else {
423
+ stdout.write(`${summary}\n`);
424
+ }
425
+ return 0;
426
+ }
427
+
428
+ /**
429
+ * Print the failing-stage block: one line per failing stage, capped
430
+ * at 20 ids per stage; a hint at the bottom naming the readiness
431
+ * command for the operator to inspect further.
432
+ *
433
+ * @param {NodeJS.WritableStream} stderr
434
+ * @param {import('../query/readiness.js').StageResult[]} stages
435
+ */
436
+ function printFailingBlock(stderr, stages) {
437
+ const failing = stages.filter((s) => s.state === 'failing');
438
+ if (failing.length === 0) return;
439
+ stderr.write('[error] freeze: refused; the following stage(s) are failing:\n');
440
+ for (const s of failing) {
441
+ const firstFailingCheck = s.checks.find((c) => !c.ok);
442
+ const checkName = firstFailingCheck?.name ?? '(no check)';
443
+ const ids = firstFailingCheck?.failing.slice(0, 20).map((f) => f.id) ?? [];
444
+ stderr.write(` ${s.stage} (${s.gate}): failing (${checkName}); failing ids: ${ids.join(', ') || '(none)'}\n`);
445
+ const shortName = STAGE_SHORT_NAMES[s.stage] ?? s.stage;
446
+ stderr.write(` Run \`rcf define readiness --check ${shortName}\` to inspect.\n`);
447
+ }
448
+ }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // Record-body helpers
452
+ // ---------------------------------------------------------------------------
453
+
454
+ /**
455
+ * Recompute the docHashes map (standalone ids plus ledger:<name>
456
+ * entries) to write into the freeze record. Matches the shape
457
+ * computeDelta uses internally; the treeHash is already the whole
458
+ * readiness result's currentTreeHash.
459
+ *
460
+ * @param {import('#core/store/walker.js').TreeModel} tree
461
+ * @param {import('../query/delta.js').LedgerBundle} ledgers
462
+ * @returns {Record<string, string>}
463
+ */
464
+ function computeDocHashes(tree, ledgers) {
465
+ /** @type {Record<string, string>} */
466
+ const out = {};
467
+ const ids = tree.byId ? [...tree.byId.keys()].sort() : [];
468
+ for (const id of ids) out[id] = hashDocument(tree.byId.get(id));
469
+ for (const [name, body] of Object.entries(ledgers ?? {})) {
470
+ if (body === undefined || body === null) continue;
471
+ out[`ledger:${name}`] = hashDocument(body);
472
+ }
473
+ return out;
474
+ }
475
+
476
+ /**
477
+ * Count of statements in the brief ledger (0 when absent).
478
+ *
479
+ * @param {import('../query/delta.js').LedgerBundle} ledgers
480
+ * @returns {number}
481
+ */
482
+ function countBriefStatements(ledgers) {
483
+ const brief = /** @type {any} */ (ledgers?.brief);
484
+ if (!brief || !Array.isArray(brief.statements)) return 0;
485
+ return brief.statements.length;
486
+ }
487
+
488
+ /**
489
+ * Return a freeze context that layers the requested acknowledgements
490
+ * onto the prior freeze record's `gates` map at the current tree
491
+ * hash. Consumed by `computeReadiness` so its foldState folds D3 / D5
492
+ * / D6 to 'acknowledged' in the same pass. The returned object is a
493
+ * shallow clone of the prior record with the gates map augmented; it
494
+ * is used only for the compute and is not written to disk.
495
+ *
496
+ * When priorFreeze is null (first freeze), a stub `{ gates }` object
497
+ * is returned. computeDelta still treats it as unfrozen (no
498
+ * docHashes / no briefStatements), so the delta is the whole tree
499
+ * exactly as it would be without the stub.
500
+ *
501
+ * @param {any} priorFreeze
502
+ * @param {Map<string, string>} ackedReasons - gate id -> reason
503
+ * @param {string} currentTreeHash
504
+ * @returns {any}
505
+ */
506
+ function mergeAckedGates(priorFreeze, ackedReasons, currentTreeHash) {
507
+ const priorGates = /** @type {any} */ (priorFreeze?.gates) ?? {};
508
+ /** @type {Record<string, any>} */
509
+ const gates = { ...priorGates };
510
+ for (const [gate, reason] of ackedReasons) {
511
+ gates[gate] = {
512
+ state: 'acknowledged',
513
+ at: { hash: currentTreeHash, reason },
514
+ };
515
+ }
516
+ if (priorFreeze === null || priorFreeze === undefined) {
517
+ return { gates };
518
+ }
519
+ return { ...priorFreeze, gates };
520
+ }
521
+
522
+ /**
523
+ * Assemble the freeze.gates map. Every stage in D1..D8 is recorded
524
+ * so slice-5 viewer sees a consistent shape. Acknowledged stages
525
+ * store their { hash, at, reason } stamp; passed / notApplicable
526
+ * stages record their state with `at: null`.
527
+ *
528
+ * @param {import('../query/readiness.js').StageResult[]} stages
529
+ * @param {string} currentTreeHash
530
+ * @param {string} frozenAt
531
+ * @returns {Record<string, { state: string, at: null | { hash: string, at: string, reason: string }, failing: Array<{ id: string, why: string }> }>}
532
+ */
533
+ function buildGatesEntry(stages, currentTreeHash, frozenAt) {
534
+ /** @type {Record<string, any>} */
535
+ const out = {};
536
+ for (const stage of stages) {
537
+ const failing = [];
538
+ for (const check of stage.checks) {
539
+ for (const f of check.failing) failing.push({ id: f.id, why: f.why });
540
+ }
541
+ if (stage.state === 'acknowledged') {
542
+ out[stage.gate] = {
543
+ state: 'acknowledged',
544
+ at: {
545
+ hash: currentTreeHash,
546
+ at: frozenAt,
547
+ reason: stage.reason ?? '(none)',
548
+ },
549
+ failing,
550
+ };
551
+ } else {
552
+ out[stage.gate] = {
553
+ state: stage.state,
554
+ at: null,
555
+ failing,
556
+ };
557
+ }
558
+ }
559
+ return out;
560
+ }
561
+
562
+ /**
563
+ * Derive count aggregates from the walker tree. Counts:
564
+ * req / us / ac / acByClass / tac / interfaces (by kind) / adr / fbs
565
+ *
566
+ * @param {import('#core/store/walker.js').TreeModel} tree
567
+ * @returns {Record<string, unknown>}
568
+ */
569
+ function deriveCounts(tree) {
570
+ const req = (tree.requirements ?? []).length;
571
+ const userStories = tree.userStories ?? [];
572
+ const us = userStories.length;
573
+ let ac = 0;
574
+ const acByClass = { happy: 0, edge: 0, failure: 0, mustNot: 0, nonFunctional: 0, unclassified: 0 };
575
+ for (const story of userStories) {
576
+ const list = Array.isArray(story?.acceptanceCriteria) ? story.acceptanceCriteria : [];
577
+ ac += list.length;
578
+ for (const acEntry of list) {
579
+ const cls = parseAcClass(acEntry);
580
+ switch (cls) {
581
+ case 'happy': acByClass.happy += 1; break;
582
+ case 'edge': acByClass.edge += 1; break;
583
+ case 'failure': acByClass.failure += 1; break;
584
+ case 'must-not': acByClass.mustNot += 1; break;
585
+ case 'non-functional': acByClass.nonFunctional += 1; break;
586
+ default: acByClass.unclassified += 1;
587
+ }
588
+ }
589
+ }
590
+ const tacs = tree.tacs ?? [];
591
+ const tac = tacs.length;
592
+ /** @type {Record<string, number>} */
593
+ const interfaces = {};
594
+ for (const t of tacs) {
595
+ const list = Array.isArray(t?.interfaces) ? t.interfaces : [];
596
+ for (const iface of list) {
597
+ const kind = typeof iface?.kind === 'string' ? iface.kind : 'unknown';
598
+ interfaces[kind] = (interfaces[kind] ?? 0) + 1;
599
+ }
600
+ }
601
+ const adr = (tree.adrs ?? []).length;
602
+ const fbs = (tree.fbsItems ?? []).length;
603
+ return { req, us, ac, acByClass, tac, interfaces, adr, fbs };
604
+ }
605
+
606
+ /**
607
+ * Parse the --litmus-attested comma-separated D-names.
608
+ * @param {unknown} raw
609
+ * @returns {string[]}
610
+ */
611
+ function parseLitmusAttested(raw) {
612
+ if (typeof raw !== 'string' || raw.length === 0) return [];
613
+ return raw.split(',').map((t) => t.trim()).filter((t) => t.length > 0);
614
+ }
615
+
616
+ /**
617
+ * Read rcfLite, ruleset and schemas versions.
618
+ *
619
+ * rcfLite: packages/rcf-lite/package.json (this package). Located via
620
+ * import.meta.url; falls back to '(unknown)' on any failure.
621
+ * ruleset: src/ruleset/ruleset.json's rulesetVersion field when
622
+ * present, else the rcfLite version (NV-BL-SR-02: ruleset version
623
+ * follows the umbrella).
624
+ * schemas: node_modules/@stravica-ai/rcf-schemas/package.json,
625
+ * discovered via createRequire so the resolution obeys the working
626
+ * project's node_modules.
627
+ *
628
+ * @param {string} projectRoot
629
+ * @returns {Promise<{ rcfLite: string, ruleset: string, schemas: string }>}
630
+ */
631
+ async function readVersions(projectRoot) {
632
+ let rcfLite = '(unknown)';
633
+ try {
634
+ const pkgJson = await readFile(
635
+ new URL('../../package.json', import.meta.url),
636
+ 'utf8',
637
+ );
638
+ const pkg = JSON.parse(pkgJson);
639
+ if (typeof pkg.version === 'string') rcfLite = pkg.version;
640
+ } catch { /* leave '(unknown)' */ }
641
+
642
+ let ruleset = rcfLite;
643
+ try {
644
+ const rulesetJson = await readFile(
645
+ new URL('../ruleset/ruleset.json', import.meta.url),
646
+ 'utf8',
647
+ );
648
+ const body = JSON.parse(rulesetJson);
649
+ if (typeof body.rulesetVersion === 'string') ruleset = body.rulesetVersion;
650
+ } catch { /* leave rcfLite */ }
651
+
652
+ let schemas = '(unknown)';
653
+ try {
654
+ const req = createRequire(join(projectRoot, 'noop.js'));
655
+ const schemasPkg = req('@stravica-ai/rcf-schemas/package.json');
656
+ if (schemasPkg && typeof schemasPkg.version === 'string') schemas = schemasPkg.version;
657
+ } catch {
658
+ // Fall back to walking up from this module.
659
+ try {
660
+ const req = createRequire(import.meta.url);
661
+ const schemasPkg = req('@stravica-ai/rcf-schemas/package.json');
662
+ if (schemasPkg && typeof schemasPkg.version === 'string') schemas = schemasPkg.version;
663
+ } catch { /* leave '(unknown)' */ }
664
+ }
665
+ return { rcfLite, ruleset, schemas };
666
+ }
667
+
668
+ /**
669
+ * Read `rcf/.identity/profile.md` when it exists; null otherwise.
670
+ * @param {string} projectRoot
671
+ * @returns {Promise<string|null>}
672
+ */
673
+ async function readProfileText(projectRoot) {
674
+ try {
675
+ return await readFile(join(projectRoot, 'rcf', '.identity', 'profile.md'), 'utf8');
676
+ } catch { return null; }
677
+ }
678
+
679
+ /**
680
+ * Compose the proposal §2.5 summary line.
681
+ *
682
+ * First freeze: "first freeze at <short>: <req> REQ, <us> stories,
683
+ * <ac> criteria, <interfaces> interfaces, <tac> TAC,
684
+ * <adr> ADR, <fbs> FBS."
685
+ *
686
+ * Incremental: "frozen at <short>: <n> REQ, <n> stories, <n> criteria,
687
+ * <n> interfaces added; <amended-ids> amended;
688
+ * <impactedFbs> re-execute."
689
+ *
690
+ * @param {object} args
691
+ * @param {boolean} args.firstFreeze
692
+ * @param {string} args.currentTreeHash
693
+ * @param {import('../query/readiness.js').ReadinessDelta} args.preFreezeDelta
694
+ * @param {Record<string, unknown>} args.counts
695
+ * @returns {string}
696
+ */
697
+ function formatSummary({
698
+ firstFreeze, currentTreeHash, preFreezeDelta, counts,
699
+ }) {
700
+ const shortH = shortHash(currentTreeHash);
701
+ if (firstFreeze) {
702
+ const ifaces = Object.values(/** @type {any} */ (counts.interfaces) ?? {})
703
+ .reduce((a, b) => a + b, 0);
704
+ return `first freeze at ${shortH}: ${counts.req ?? 0} REQ, ${counts.us ?? 0} stories, ${counts.ac ?? 0} criteria, ${ifaces} interfaces, ${counts.tac ?? 0} TAC, ${counts.adr ?? 0} ADR, ${counts.fbs ?? 0} FBS.`;
705
+ }
706
+ // Incremental: derive added-by-kind from delta.added ids (skipping
707
+ // 'ledger:*' entries and ids the tree does not have a kind for).
708
+ const addedByKind = { req: 0, us: 0, ac: 0, tac: 0, adr: 0, fbs: 0, interfaces: 0 };
709
+ for (const id of preFreezeDelta.added) {
710
+ if (id.startsWith('ledger:')) continue;
711
+ if (id.startsWith('REQ-')) addedByKind.req += 1;
712
+ else if (id.startsWith('US-')) addedByKind.us += 1;
713
+ else if (id.startsWith('AC-')) addedByKind.ac += 1;
714
+ else if (id.startsWith('TAC-')) addedByKind.tac += 1;
715
+ else if (id.startsWith('ADR-')) addedByKind.adr += 1;
716
+ else if (id.startsWith('FBS-')) addedByKind.fbs += 1;
717
+ }
718
+ // AC is inline under US; the proposal example counts criteria too.
719
+ // We estimate criteria-added from the changed/added US: since inline
720
+ // ACs ride inside the parent hash, standalone AC counts here are
721
+ // usually zero unless the walker later exposes them. Keep the
722
+ // proposal's four-kind sequence and treat interfaces-added as 0 for
723
+ // now (interfaces ride inside TAC).
724
+ const amended = preFreezeDelta.changed.filter((id) => !id.startsWith('ledger:'));
725
+ const impactedFbsCount = preFreezeDelta.impactedFbs.length;
726
+ return `frozen at ${shortH}: ${addedByKind.req} REQ, ${addedByKind.us} stories, ${addedByKind.ac} criteria, ${addedByKind.interfaces} interfaces added; ${amended.join(', ') || '(none)'} amended; ${impactedFbsCount} re-execute.`;
727
+ }
728
+
729
+ // Re-export the seams other modules use.
730
+ export { STAGE_GATES, STAGE_ORDER, STAGE_SHORT_NAMES, STAGE_ALIASES };