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
@@ -1,13 +1,19 @@
1
1
  // `rcf define readiness` subcommand handler (REQ-175; proposal
2
- // 2026-09-22 §2.4, §6.2 v3).
2
+ // 2026-09-22 §2.4, §6.2 v3; spec 2026-10-01 §2.3, §3, §4 for the
3
+ // 0.29.0 PR B additions).
3
4
  //
4
- // Prints the readiness object (§2.4) or its JSON envelope. `--check
5
- // <stage>` narrows the output to one stage; blocking stages exit 4 on
6
- // failure (matching `coverage`'s per-AC refusal exit); warn-with-ack
7
- // stages exit 0 with a visible `[warn]` line on unacknowledged
8
- // failure. The producer is wrapped with `runWithAdmissibilityGate`
9
- // (NV-BL-SR-03 addendum) so a chain that refuses admissibility short-
10
- // circuits with the same refusal envelope every other query verb has.
5
+ // Prints the readiness object (§2.4) or its JSON envelope. The 0.29.0
6
+ // train added two verdict lines at the top of the text report (shared
7
+ // with the Readiness tab via `formatVerdictLines`) and two new flags:
8
+ // `--level intent|build` which drives the exit code against
9
+ // `levels.<which>.ok` (stricter than `--check all` on warn-with-ack
10
+ // failures, per spec section 3.2 decision 4; the `[warn]` line still
11
+ // prints), and `--persona productOwner|engineer` which is a display
12
+ // filter and never affects the exit code or JSON content. `--check`
13
+ // behaviour is unchanged. The producer is wrapped with
14
+ // `runWithAdmissibilityGate` (NV-BL-SR-03 addendum) so a chain that
15
+ // refuses admissibility short-circuits with the same refusal envelope
16
+ // every other query verb has.
11
17
 
12
18
  import { parseArgs } from 'node:util';
13
19
  import { readFile } from 'node:fs/promises';
@@ -25,12 +31,20 @@ import {
25
31
  STAGE_SHORT_NAMES,
26
32
  stagePolicy,
27
33
  } from '../query/gates.js';
28
- import { computeReadiness, formatTreeLine } from '../query/readiness.js';
34
+ import {
35
+ computeReadiness,
36
+ formatTreeLine,
37
+ formatVerdictLines,
38
+ parseLevelFlag,
39
+ parsePersonaFlag,
40
+ } from '../query/readiness.js';
29
41
  import { runWithAdmissibilityGate } from '../query/index.js';
30
42
 
31
43
  const OPTION_SPEC = {
32
44
  json: { type: 'boolean' },
33
45
  check: { type: 'string' },
46
+ level: { type: 'string' },
47
+ persona: { type: 'string' },
34
48
  help: { type: 'boolean' },
35
49
  };
36
50
 
@@ -45,8 +59,15 @@ eight stage-gate checks (D1..D8), tree coverage and open decisions
45
59
  into one object. The viewer's Readiness tab (slice 5) will render the
46
60
  same object with no schema translation.
47
61
 
62
+ The text report prints two verdict lines at the top (shared with the
63
+ Readiness tab via formatVerdictLines): intent-complete (every
64
+ product-owner question answered) and ready-to-build (equals
65
+ freezeable). Blockers are grouped by persona, ordered by the
66
+ profile.md register marker.
67
+
48
68
  Options:
49
69
  --json Emit the full readiness object as JSON.
70
+ --persona is ignored under --json.
50
71
  --check <stage> Narrow the output to one stage. Accepts a
51
72
  stage id (D1..D8) or its short name:
52
73
  brief | skeleton | shapes | stories |
@@ -57,6 +78,25 @@ Options:
57
78
  unacknowledged warn-with-ack failure
58
79
  (D3 / D5 / D6). Use --check all to print
59
80
  every stage under this exit-code policy.
81
+ --level <intent|build> Pick which verdict the exit code follows.
82
+ --level intent exits 4 when
83
+ levels.intentComplete.ok is false
84
+ (any failing product-owner check); exits
85
+ 0 otherwise. --level build exits 4 when
86
+ levels.readyToBuild.ok is false, which is
87
+ stricter than --check all on unacked
88
+ warn-with-ack failures (the [warn] line
89
+ still prints). Combined with --check, the
90
+ invocation exits 4 if either policy
91
+ says 4. With neither flag the exit code
92
+ stays 0.
93
+ --persona <po|engineer> Display filter for the text report.
94
+ Values: productOwner | engineer. Collapses
95
+ the opposite-persona block to one line
96
+ with its check count and restricts the
97
+ per-stage detail to matching-persona
98
+ checks. Never affects the exit code or
99
+ the JSON content.
60
100
  --help Print this help.
61
101
  `;
62
102
 
@@ -86,6 +126,34 @@ async function readProfileText(projectRoot) {
86
126
  }
87
127
  }
88
128
 
129
+ /**
130
+ * Pick the profile register from the text, returning one of
131
+ * `productOwner` | `engineer` | `unstated`. The marker is a bare
132
+ * word match (same convention as `checkD1Brief`); the first marker
133
+ * found in the text wins. Absent or unrecognised yields `unstated`,
134
+ * which the text report orders as PO first (spec section 4).
135
+ *
136
+ * @param {string | null} text
137
+ * @returns {'productOwner' | 'engineer' | 'unstated'}
138
+ */
139
+ function pickRegister(text) {
140
+ if (typeof text !== 'string' || text.length === 0) return 'unstated';
141
+ // Scan in file-order so the first marker in the document wins;
142
+ // this matches how a human reads `profile.md`.
143
+ const markers = ['productOwner', 'engineer', 'unstated'];
144
+ let best = null;
145
+ let bestIdx = Infinity;
146
+ for (const m of markers) {
147
+ const idx = text.indexOf(m);
148
+ if (idx === -1) continue;
149
+ if (idx < bestIdx) {
150
+ bestIdx = idx;
151
+ best = m;
152
+ }
153
+ }
154
+ return /** @type {any} */ (best) ?? 'unstated';
155
+ }
156
+
89
157
  /**
90
158
  * @param {string[]} argv
91
159
  * @param {object} [deps]
@@ -123,6 +191,24 @@ export async function main(argv, deps = {}) {
123
191
  }
124
192
  }
125
193
 
194
+ /** @type {'intent' | 'build' | null} */
195
+ let level;
196
+ try {
197
+ level = parseLevelFlag(flags.level);
198
+ } catch (err) {
199
+ stderr.write(`[error] usage readiness: ${/** @type {Error} */ (err).message}\n`);
200
+ return 2;
201
+ }
202
+
203
+ /** @type {'productOwner' | 'engineer' | null} */
204
+ let persona;
205
+ try {
206
+ persona = parsePersonaFlag(flags.persona);
207
+ } catch (err) {
208
+ stderr.write(`[error] usage readiness: ${/** @type {Error} */ (err).message}\n`);
209
+ return 2;
210
+ }
211
+
126
212
  const projectRoot = await findProjectRoot(cwd);
127
213
  if (!projectRoot) {
128
214
  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');
@@ -177,39 +263,53 @@ export async function main(argv, deps = {}) {
177
263
 
178
264
  if (flags.json) {
179
265
  // JSON emission carries the full object plus the wall-time hint
180
- // as a stable side-band on `_meta` so consumers can spot
181
- // regressions without changing shape.
182
- const envelope = { ...result, _meta: { wallMs } };
266
+ // and the invocation level as a stable side-band on `_meta`.
267
+ // `_meta.level` is null when the flag was absent (spec section
268
+ // 3.3): consumers can tell "no --level given" from "--level
269
+ // build" explicitly, which matters for the ticket artefact.
270
+ const envelope = { ...result, _meta: { wallMs, level } };
183
271
  stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
184
- return checkStage ? decideExitCode(result, checkStage, stderr) : 0;
272
+ return decideExitCode(result, checkStage, level, stderr);
185
273
  }
186
274
 
187
- renderText(stdout, result, wallMs, checkStage);
188
- if (checkStage) {
189
- return decideExitCode(result, checkStage, stderr);
190
- }
191
- return 0;
275
+ renderText(stdout, stderr, result, wallMs, checkStage, level, persona, profileText);
276
+ return decideExitCode(result, checkStage, level, stderr);
192
277
  }
193
278
 
194
279
  /**
195
- * Emit the text summary: tree line, next action, chip line, then a
196
- * per-stage failing-items block. When `--check <stage>` names a
197
- * stage, only that stage's block is rendered.
280
+ * Emit the text summary (spec section 4):
281
+ * 1. Tree line.
282
+ * 2. Verdict pair (intent-complete, ready-to-build).
283
+ * 3. Next action, chip line, delta, coverage, decisions, freezeable
284
+ * (today's lines; omitted when --check narrows to one stage).
285
+ * 4. Product-owner block and engineer block, ordered by the
286
+ * profile.md register (PO first by default; engineer first when
287
+ * the register is `engineer`; `unstated`/absent is PO first).
288
+ * With --persona the opposite-persona block collapses to one
289
+ * line; with --check <stage> the persona blocks are limited to
290
+ * that stage.
291
+ * 5. Per-stage detail block (today's output; persona is now inside
292
+ * the parenthesis after `over`). With --persona only the
293
+ * matching-persona checks are rendered.
198
294
  *
199
295
  * @param {NodeJS.WritableStream} stdout
296
+ * @param {NodeJS.WritableStream} stderr
200
297
  * @param {import('../query/readiness.js').ReadinessResult} result
201
298
  * @param {number} wallMs
202
299
  * @param {string | null} checkStage
300
+ * @param {'intent' | 'build' | null} level
301
+ * @param {'productOwner' | 'engineer' | null} persona
302
+ * @param {string | null} profileText
203
303
  */
204
- function renderText(stdout, result, wallMs, checkStage) {
304
+ function renderText(stdout, stderr, result, wallMs, checkStage, level, persona, profileText) {
205
305
  stdout.write(`${formatTreeLine(result)}\n`);
306
+
307
+ const verdicts = formatVerdictLines(result);
308
+ stdout.write(`${verdicts.intentComplete}\n`);
309
+ stdout.write(`${verdicts.readyToBuild}\n`);
310
+
206
311
  if (!checkStage || checkStage === 'all') {
207
- if (result.nextAction) {
208
- const ids = result.nextAction.ids.length > 0 ? `; ids: ${result.nextAction.ids.join(', ')}` : '';
209
- stdout.write(`Next action: ${result.nextAction.stage} / ${result.nextAction.check}${ids}. Run \`${result.nextAction.command}\` after editing.\n`);
210
- } else {
211
- stdout.write('Next action: none; every gate passed, acknowledged or notApplicable. Freezeable.\n');
212
- }
312
+ renderNextActions(stdout, result, level);
213
313
  stdout.write(`Chips: ${formatChipLine(result)}\n`);
214
314
  stdout.write(`Delta: changed ${result.delta.changed.length}, added ${result.delta.added.length}, removed ${result.delta.removed.length}, briefSince ${result.delta.briefSince.length}, impacted ${result.delta.impacted.length}, impactedFbs ${result.delta.impactedFbs.length}.\n`);
215
315
  stdout.write(`Coverage: tree ${result.coverage.tree.totals.covered}/${result.coverage.tree.totals.requirements} covered (strict), ${result.coverage.delta.length} REQ ancestor(s) scoped from the delta.\n`);
@@ -217,12 +317,16 @@ function renderText(stdout, result, wallMs, checkStage) {
217
317
  stdout.write(`Freezeable: ${result.freezeable ? 'yes' : 'no'}. Compute: ${wallMs} ms.\n`);
218
318
  }
219
319
 
320
+ const register = pickRegister(profileText);
321
+ renderPersonaBlocks(stdout, result, persona, register, checkStage);
322
+
220
323
  for (const stage of result.stages) {
221
324
  if (checkStage && checkStage !== 'all' && stage.stage !== checkStage) continue;
222
- stdout.write(`\n${stage.stage} (${stage.gate}) -- ${STATE_LABEL[stage.state] ?? stage.state}${stage.reason ? ` (${stage.reason})` : ''}\n`);
325
+ stdout.write(`\n${stage.stage} (${stage.gate}): ${STATE_LABEL[stage.state] ?? stage.state}${stage.reason ? ` (${stage.reason})` : ''}\n`);
223
326
  for (const c of stage.checks) {
327
+ if (persona && c.persona !== persona) continue;
224
328
  const mark = c.ok ? 'ok' : 'FAIL';
225
- stdout.write(` [${mark}] ${c.name} (${c.over}): ${c.pass}/${c.total}\n`);
329
+ stdout.write(` [${mark}] ${c.name} (${c.over}, ${c.persona}): ${c.pass}/${c.total}\n`);
226
330
  for (const f of c.failing.slice(0, 20)) {
227
331
  stdout.write(` - ${f.id}: ${f.why}\n`);
228
332
  }
@@ -233,6 +337,83 @@ function renderText(stdout, result, wallMs, checkStage) {
233
337
  }
234
338
  }
235
339
 
340
+ /**
341
+ * Render the per-persona next-action lines (spec section 4). When a
342
+ * `--level` is given the matching persona's line heads the pair; when
343
+ * no `--level` is given the pair follows the register order. Each
344
+ * line is `Next action (<persona>): <stage> / <check>; ids: ...`.
345
+ *
346
+ * @param {NodeJS.WritableStream} stdout
347
+ * @param {import('../query/readiness.js').ReadinessResult} result
348
+ * @param {'intent' | 'build' | null} level
349
+ */
350
+ function renderNextActions(stdout, result, level) {
351
+ const po = result.personas.productOwner.nextAction;
352
+ const eng = result.personas.engineer.nextAction;
353
+ const writeAction = (label, action) => {
354
+ if (!action) {
355
+ stdout.write(`Next action (${label}): none.\n`);
356
+ return;
357
+ }
358
+ const ids = action.ids.length > 0 ? `; ids: ${action.ids.join(', ')}` : '';
359
+ stdout.write(`Next action (${label}): ${action.stage} / ${action.check}${ids}. Run \`${action.command}\` after editing.\n`);
360
+ };
361
+ writeAction('product owner', po);
362
+ writeAction('engineer', eng);
363
+ }
364
+
365
+ /**
366
+ * Render the two persona-grouped blocker blocks (spec section 4).
367
+ * `register` orders the pair; `persona` (the display filter) collapses
368
+ * the opposite block to one count line; `checkStage` (when not null
369
+ * or 'all') restricts both blocks to blockers in that stage.
370
+ *
371
+ * @param {NodeJS.WritableStream} stdout
372
+ * @param {import('../query/readiness.js').ReadinessResult} result
373
+ * @param {'productOwner' | 'engineer' | null} persona
374
+ * @param {'productOwner' | 'engineer' | 'unstated'} register
375
+ * @param {string | null} checkStage
376
+ */
377
+ function renderPersonaBlocks(stdout, result, persona, register, checkStage) {
378
+ const stageFilter = (bs) => (checkStage && checkStage !== 'all'
379
+ ? bs.filter((b) => b.stage === checkStage)
380
+ : bs);
381
+ // L1 blockedBy for the PO list (ignores stage state); L2 blockedBy
382
+ // minus PO entries for the engineer list, matching
383
+ // `personas.engineer.blockers`.
384
+ const poBlockers = stageFilter(result.personas.productOwner.blockers);
385
+ const engineerBlockers = stageFilter(result.personas.engineer.blockers);
386
+
387
+ const order = register === 'engineer'
388
+ ? [['engineer', engineerBlockers], ['productOwner', poBlockers]]
389
+ : [['productOwner', poBlockers], ['engineer', engineerBlockers]];
390
+
391
+ for (const [p, blockers] of order) {
392
+ stdout.write('\n');
393
+ const headerLabel = p === 'productOwner' ? 'Product owner' : 'Engineer';
394
+ const noun = p === 'productOwner' ? 'question' : 'check blocking';
395
+ const nounPlural = p === 'productOwner' ? 'questions' : 'checks blocking';
396
+ if (persona && p !== persona) {
397
+ stdout.write(`${headerLabel}: ${blockers.length} ${blockers.length === 1 ? noun : nounPlural} (hidden; run without --persona)\n`);
398
+ continue;
399
+ }
400
+ if (blockers.length === 0) {
401
+ stdout.write(`${headerLabel}: 0 ${nounPlural}.\n`);
402
+ continue;
403
+ }
404
+ stdout.write(`${headerLabel}: ${blockers.length} ${blockers.length === 1 ? noun : nounPlural}\n`);
405
+ for (const b of blockers) {
406
+ stdout.write(` ${b.stage} ${b.check}: ${b.question} ${b.failingCount}/${b.failingCount}\n`);
407
+ for (const id of b.ids.slice(0, 20)) {
408
+ stdout.write(` - ${id}\n`);
409
+ }
410
+ if (b.failingCount > b.ids.length) {
411
+ stdout.write(` ... ${b.failingCount - b.ids.length} more suppressed\n`);
412
+ }
413
+ }
414
+ }
415
+ }
416
+
236
417
  /**
237
418
  * Compose the chip line: "D1:passed D2:failing D3:acknowledged ..."
238
419
  *
@@ -244,34 +425,71 @@ function formatChipLine(result) {
244
425
  }
245
426
 
246
427
  /**
247
- * Decide the exit code for a `--check <stage>` invocation:
248
- * - passed / acknowledged / notApplicable -> 0
249
- * - failing on a blocking stage (D1/D2/D4/D7/D8) -> 4
250
- * - failing on a warn-with-ack stage (D3/D5/D6) -> 0 with a stderr [warn]
428
+ * Decide the exit code for an invocation (spec section 3.2 matrix):
429
+ * - No `--check` and no `--level`: always 0.
430
+ * - `--check` alone: today's stage policy (4 on blocking failure,
431
+ * 0 with `[warn]` on warn-with-ack failure).
432
+ * - `--level intent`: 4 iff `levels.intentComplete.ok === false`.
433
+ * - `--level build`: 4 iff `levels.readyToBuild.ok === false`
434
+ * (stricter than `--check all`; the warn line still prints).
435
+ * - Both: 4 if either policy says 4.
251
436
  *
252
- * The 'all' target folds the individual codes: if any blocking stage
253
- * is failing, exit 4; otherwise 0 (warn lines print for each
254
- * warn-with-ack failure).
437
+ * Warn lines are emitted whenever a warn-with-ack stage is failing,
438
+ * independent of exit code, so an operator always sees why.
255
439
  *
256
440
  * @param {import('../query/readiness.js').ReadinessResult} result
257
- * @param {string} checkStage
441
+ * @param {string | null} checkStage
442
+ * @param {'intent' | 'build' | null} level
258
443
  * @param {NodeJS.WritableStream} stderr
259
444
  * @returns {number}
260
445
  */
261
- function decideExitCode(result, checkStage, stderr) {
262
- const stages = checkStage === 'all'
263
- ? result.stages
264
- : result.stages.filter((s) => s.stage === checkStage);
446
+ function decideExitCode(result, checkStage, level, stderr) {
265
447
  let exitCode = 0;
266
- for (const s of stages) {
267
- if (s.state !== 'failing') continue;
268
- const policy = stagePolicy(s.stage);
269
- if (policy === 'blocking') {
270
- exitCode = 4;
271
- } else {
272
- stderr.write(`[warn] readiness: ${s.stage} (${s.gate}) is failing without an acknowledgement at the current tree hash. Run \`rcf define freeze --ack ${s.gate} --reason "<text>"\` to acknowledge, or edit the tree to clear the failure.\n`);
448
+
449
+ // --check policy (unchanged from PR A).
450
+ if (checkStage) {
451
+ const stages = checkStage === 'all'
452
+ ? result.stages
453
+ : result.stages.filter((s) => s.stage === checkStage);
454
+ for (const s of stages) {
455
+ if (s.state !== 'failing') continue;
456
+ const policy = stagePolicy(s.stage);
457
+ if (policy === 'blocking') {
458
+ exitCode = 4;
459
+ } else {
460
+ stderr.write(`[warn] readiness: ${s.stage} (${s.gate}) is failing without an acknowledgement at the current tree hash. Run \`rcf define freeze --ack ${s.gate} --reason "<text>"\` to acknowledge, or edit the tree to clear the failure.\n`);
461
+ }
273
462
  }
274
463
  }
464
+
465
+ // --level policy. When explicit, exit 4 if that level says "no".
466
+ // --level build is stricter than --check all: it exits 4 even on an
467
+ // unacknowledged warn-with-ack failure (spec decision 4). The warn
468
+ // lines are still emitted (above when --check all is also set, or
469
+ // here when --level build is given alone).
470
+ if (level === 'intent' && result.levels.intentComplete.ok === false) {
471
+ exitCode = 4;
472
+ } else if (level === 'build' && result.levels.readyToBuild.ok === false) {
473
+ exitCode = 4;
474
+ // When --level build is set without --check all, we have not yet
475
+ // emitted the per-stage warn lines. Emit them here so an operator
476
+ // still sees which warn-with-ack stage drove the "no".
477
+ if (!checkStage || checkStage !== 'all') {
478
+ const seen = new Set();
479
+ if (checkStage) {
480
+ // --level build + --check <stage>: the --check branch already
481
+ // emitted that stage's warn line (if any). Avoid duplicates.
482
+ seen.add(checkStage);
483
+ }
484
+ for (const s of result.stages) {
485
+ if (seen.has(s.stage)) continue;
486
+ if (s.state !== 'failing') continue;
487
+ if (stagePolicy(s.stage) === 'blocking') continue;
488
+ stderr.write(`[warn] readiness: ${s.stage} (${s.gate}) is failing without an acknowledgement at the current tree hash. Run \`rcf define freeze --ack ${s.gate} --reason "<text>"\` to acknowledge, or edit the tree to clear the failure.\n`);
489
+ }
490
+ }
491
+ }
492
+
275
493
  return exitCode;
276
494
  }
277
495
 
package/src/cli/view.js CHANGED
@@ -92,6 +92,13 @@ Options:
92
92
  and CI is unset).
93
93
  --verbose Log each watch event and each SSE broadcast to
94
94
  stderr.
95
+ --test-host Mount the wespa host fixture at /test-host.html
96
+ plus its client script at /test-host.js. Off by
97
+ default; the fixture is a test-suite proof that
98
+ the viewer honours Dex's embedding contract (viewer
99
+ UI refresh PR 9; TAC-4134, ADR-4136), not a shipped
100
+ UI, so wespa's reverse-proxy allow-list is
101
+ unaffected. Can also be set via RCF_VIEW_TEST_HOST=1.
95
102
  --help Print this help and exit.
96
103
 
97
104
  Security posture:
@@ -130,6 +137,7 @@ export function parseArgs(argv) {
130
137
  help: false,
131
138
  noOpen: false,
132
139
  port: null,
140
+ testHost: false,
133
141
  };
134
142
  const errors = [];
135
143
  for (let i = 0; i < argv.length; i += 1) {
@@ -148,6 +156,13 @@ export function parseArgs(argv) {
148
156
  case '-h':
149
157
  opts.help = true;
150
158
  break;
159
+ case '--test-host':
160
+ // Viewer UI refresh PR 9 (TAC-4134, ADR-4136): mount the wespa
161
+ // host fixture at /test-host.html plus its client at
162
+ // /test-host.js. Off by default; the fixture is a test-suite
163
+ // proof, not a shipped UI.
164
+ opts.testHost = true;
165
+ break;
151
166
  case '--port': {
152
167
  const next = argv[i + 1];
153
168
  if (next === undefined || next.startsWith('--')) {
@@ -170,6 +185,19 @@ export function parseArgs(argv) {
170
185
  return { opts, errors };
171
186
  }
172
187
 
188
+ /**
189
+ * Resolve the testHost gate: `--test-host` beats `RCF_VIEW_TEST_HOST=1`
190
+ * env beats default (false). Viewer UI refresh PR 9 (TAC-4134).
191
+ *
192
+ * @param {boolean} flagTestHost
193
+ * @param {NodeJS.ProcessEnv} env
194
+ * @returns {boolean}
195
+ */
196
+ export function resolveTestHost(flagTestHost, env) {
197
+ if (flagTestHost === true) return true;
198
+ return env.RCF_VIEW_TEST_HOST === '1';
199
+ }
200
+
173
201
  /**
174
202
  * Resolve port precedence: --port beats RCF_VIEW_PORT env beats default.
175
203
  *
@@ -294,6 +322,7 @@ export async function main(argv, deps = {}) {
294
322
  projectRoot,
295
323
  port: resolvedPort,
296
324
  log: logSink,
325
+ testHost: resolveTestHost(opts.testHost, env),
297
326
  });
298
327
  } catch (err) {
299
328
  if (/** @type {NodeJS.ErrnoException} */ (err).code === 'EADDRINUSE') {
@@ -27,6 +27,13 @@
27
27
  // override: null | { reason, by, at } // required (nullable)
28
28
  // }
29
29
  //
30
+ // `override` is required-nullable: the validator throws when the key
31
+ // is missing; every writer stamps `override: null` on a fresh freeze.
32
+ // The loader tolerates older records that lack the key by defaulting
33
+ // to null on read, so a pre-slice-3 freeze.json survives a load
34
+ // without a migration step (the next write converges the on-disk
35
+ // shape).
36
+ //
30
37
  // The record is EXTENSIBLE: later slices add fields (2 gates the freeze
31
38
  // verb wrote at freeze time, 4 the `bundle --next` refusal read at
32
39
  // override time). This slice validates the fields it consumes and
@@ -149,24 +156,23 @@ export function validateFreezeRecord(raw, filePath) {
149
156
  );
150
157
  }
151
158
 
152
- if (Object.prototype.hasOwnProperty.call(body, 'override')) {
153
- const override = body.override;
154
- if (override !== null) {
155
- if (override === undefined || typeof override !== 'object' || Array.isArray(override)) {
159
+ require('override');
160
+ const override = body.override;
161
+ if (override !== null) {
162
+ if (override === undefined || typeof override !== 'object' || Array.isArray(override)) {
163
+ throw new FreezeRecordError(
164
+ "Freeze record 'override' must be null or { reason, by, at }.",
165
+ { filePath, field: 'override' },
166
+ );
167
+ }
168
+ const o = /** @type {Record<string, unknown>} */ (override);
169
+ for (const f of ['reason', 'by', 'at']) {
170
+ if (typeof o[f] !== 'string' || o[f].length === 0) {
156
171
  throw new FreezeRecordError(
157
- "Freeze record 'override' must be null or { reason, by, at }.",
158
- { filePath, field: 'override' },
172
+ `Freeze record 'override.${f}' must be a non-empty string.`,
173
+ { filePath, field: `override.${f}` },
159
174
  );
160
175
  }
161
- const o = /** @type {Record<string, unknown>} */ (override);
162
- for (const f of ['reason', 'by', 'at']) {
163
- if (typeof o[f] !== 'string' || o[f].length === 0) {
164
- throw new FreezeRecordError(
165
- `Freeze record 'override.${f}' must be a non-empty string.`,
166
- { filePath, field: `override.${f}` },
167
- );
168
- }
169
- }
170
176
  }
171
177
  }
172
178
 
@@ -204,6 +210,15 @@ export async function loadFreezeRecord({ projectRoot }) {
204
210
  { filePath: relPath, code: 'parseFailure' },
205
211
  );
206
212
  }
213
+ // Back-compat: older freeze records were written before override
214
+ // became a required-nullable field. Fill in null on read so a
215
+ // pre-slice-3 record survives; the writer stamps override: null on
216
+ // every fresh freeze so the on-disk shape converges on the current
217
+ // schema without a migration step.
218
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)
219
+ && !Object.prototype.hasOwnProperty.call(parsed, 'override')) {
220
+ /** @type {Record<string, unknown>} */ (parsed).override = null;
221
+ }
207
222
  return validateFreezeRecord(parsed, relPath);
208
223
  }
209
224