rcf-lite 0.32.4 → 0.33.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 (90) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/fixtures/canary-manifest.json +9 -9
  3. package/guidance/discovery/README.md +22 -0
  4. package/guidance/discovery/discovery-viewer-command-page-1-layout.html +191 -0
  5. package/guidance/discovery/discovery-viewer-command-page-2-coverage.html +193 -0
  6. package/guidance/discovery/discovery-viewer-command-page-3-trace-matrix.html +195 -0
  7. package/guidance/discovery/discovery-viewer-command-page-4-tables.html +196 -0
  8. package/guidance/discovery/discovery-viewer-command-page-5-subtabs.html +224 -0
  9. package/guidance/discovery/discovery-viewer-command-page-6-one-page.html +280 -0
  10. package/guidance/discovery/discovery-viewer-command-page.md +301 -0
  11. package/package.json +1 -1
  12. package/rcf/adrs/adr-4139-readiness-tables-read-only-no-commands.json +30 -0
  13. package/rcf/adrs/adr-4140-view-query-routes-per-pivot-matrix-no-mermaid.json +30 -0
  14. package/rcf/adrs/adr-4141-verify-modes.json +25 -0
  15. package/rcf/code-nodes/cn-771.json +3 -2
  16. package/rcf/code-nodes/cn-779.json +19 -0
  17. package/rcf/code-nodes/cn-780.json +14 -0
  18. package/rcf/code-nodes/cn-781.json +14 -0
  19. package/rcf/code-nodes/cn-782.json +15 -0
  20. package/rcf/code-nodes/cn-783.json +17 -0
  21. package/rcf/code-nodes/cn-784.json +20 -0
  22. package/rcf/code-nodes/cn-785.json +14 -0
  23. package/rcf/code-nodes/cn-786.json +14 -0
  24. package/rcf/code-nodes/cn-787.json +18 -0
  25. package/rcf/code-nodes/cn-788.json +14 -0
  26. package/rcf/code-nodes/cn-789.json +14 -0
  27. package/rcf/code-nodes/cn-790.json +15 -0
  28. package/rcf/code-nodes/cn-791.json +14 -0
  29. package/rcf/code-nodes/cn-792.json +17 -0
  30. package/rcf/code-nodes/cn-793.json +17 -0
  31. package/rcf/code-nodes/cn-794.json +18 -0
  32. package/rcf/code-nodes/cn-795.json +15 -0
  33. package/rcf/code-nodes/cn-796.json +14 -0
  34. package/rcf/code-nodes/cn-797.json +14 -0
  35. package/rcf/code-nodes/cn-798.json +14 -0
  36. package/rcf/define/brief-ledger.json +47 -0
  37. package/rcf/define/decisions-ledger.json +113 -0
  38. package/rcf/fbs/fbs-204.json +46 -0
  39. package/rcf/fbs/fbs-205.json +46 -0
  40. package/rcf/fbs/fbs-206.json +49 -0
  41. package/rcf/fbs/fbs-207.json +43 -0
  42. package/rcf/fbs/fbs-208.json +47 -0
  43. package/rcf/fbs/fbs-209.json +22 -0
  44. package/rcf/requirements/req-002.json +5 -2
  45. package/rcf/requirements/req-180.json +6 -3
  46. package/rcf/tacs/tac-4127-readiness-view.json +2 -2
  47. package/rcf/tacs/tac-4133-readiness-po-layer.json +2 -2
  48. package/rcf/tacs/tac-4135-readiness-command-page.json +94 -0
  49. package/rcf/tacs/tac-4136-view-query-routes.json +68 -0
  50. package/rcf/tacs/tac-4141-verify-modes.json +25 -0
  51. package/rcf/test-suites/ts-223.json +7 -7
  52. package/rcf/test-suites/ts-237.json +78 -0
  53. package/rcf/test-suites/ts-238.json +70 -0
  54. package/rcf/test-suites/ts-239.json +78 -0
  55. package/rcf/test-suites/ts-240.json +70 -0
  56. package/rcf/test-suites/ts-241.json +70 -0
  57. package/rcf/test-suites/ts-242.json +46 -0
  58. package/rcf/user-stories/us-16503.json +51 -0
  59. package/rcf/user-stories/us-18001.json +4 -4
  60. package/rcf/user-stories/us-18003.json +79 -0
  61. package/rcf/user-stories/us-18004.json +70 -0
  62. package/rcf/user-stories/us-18005.json +79 -0
  63. package/rcf/user-stories/us-208.json +74 -0
  64. package/rcf/user-stories/us-209.json +70 -0
  65. package/releases/releases.yaml +18 -1
  66. package/src/cli/finalise.js +141 -2
  67. package/src/cli/view.js +13 -1
  68. package/src/define/ledgers.js +9 -1
  69. package/src/finalise/spawn.js +2 -0
  70. package/src/server/index.js +104 -39
  71. package/src/server/routes.js +169 -0
  72. package/src/verify/cli/run.js +40 -0
  73. package/src/verify/engine/deterministic.js +150 -0
  74. package/src/verify/engine/index.js +27 -4
  75. package/src/view/doc-renderers/req.js +11 -9
  76. package/src/view/html-page.js +15 -11
  77. package/src/view/index.js +7 -1
  78. package/src/view/live-client.js +4 -0
  79. package/src/view/page-init.js +842 -1
  80. package/src/view/readiness/composite-id.js +35 -0
  81. package/src/view/readiness/coverage-summary.js +293 -0
  82. package/src/view/readiness/delta-counts.js +156 -0
  83. package/src/view/readiness/grid.js +175 -0
  84. package/src/view/readiness/po-layer.js +110 -220
  85. package/src/view/readiness/tables.js +500 -0
  86. package/src/view/readiness/thin-reqs.js +237 -0
  87. package/src/view/readiness/trace-matrix.js +392 -0
  88. package/src/view/readiness.js +359 -370
  89. package/src/view/style.css +594 -0
  90. package/src/watch/index.js +75 -17
@@ -1,24 +1,33 @@
1
- // Readiness tab renderer (REQ-180, TAC-4127-readiness-view, ADR-4127).
2
- // Pure HTML string builder: takes the ReadinessResult (as produced by
3
- // `computeReadiness` in `src/query/readiness.js`) and the operator
4
- // profile text, emits the tabpanel body. Never recomputes readiness
5
- // and never touches disk; the server loads the inputs once per
6
- // rewalk (see `renderModelToPage`) so the Readiness tab and
7
- // `rcf define readiness --json` cannot disagree for the same tree.
1
+ // Readiness tab renderer (REQ-180, TAC-4135-readiness-command-page,
2
+ // TAC-4133, TAC-4127, ADR-4139). Pure HTML string builder; takes the
3
+ // ReadinessResult (as produced by `computeReadiness` in
4
+ // `src/query/readiness.js`) plus the operator profile text and the
5
+ // walker tree model, emits the tabpanel body.
8
6
  //
9
- // Layout follows DEFINE step 2 spec section 5, top to bottom:
10
- // 1 tree line; 2 verdict pills; 3 next-action per persona;
11
- // 4 D1..D8 state chips; 5 blockers by persona; 6 delta list;
12
- // 7 per-stage check detail; 8 coverage tree-vs-delta;
13
- // 9 decisions outstanding; 10 freeze-now control; 11 freeze record.
7
+ // FBS-206 reshape: the panel is a SubTabStrip (Overview, Questions,
8
+ // Blocking, Coverage, Trace) with five sub-panels; one is visible at
9
+ // a time. For-engineers DocRow, stage chips and stage-detail retired
10
+ // (viewer-read-only ADR-4139 and Dave's FBS-204/206 binding ruling).
11
+ // "On me" / "On engineers" vocabulary replaces persona pills in the
12
+ // user-facing text. The freeze state renders in plain words with
13
+ // what resolves each failing gate. The Trace sub-view is an empty
14
+ // shell here; FBS-208 fills it.
15
+ //
16
+ // Every id (passing or failing) still lives in the DOM in some
17
+ // sub-panel so AC-18001-5 and AC-18002-3 ("no id leaves the DOM")
18
+ // stay true: a hidden sub-panel is `[hidden]`, not removed.
14
19
 
15
20
  import { escapeHtml } from './doc-renderers/helpers.js';
16
21
  import { formatTreeLine, formatVerdictLines, shortHash } from '../query/readiness.js';
17
- import { pill } from './components/pill.js';
18
- import { findingsList } from './components/findings-list.js';
19
- import { diff } from './components/diff.js';
20
- import { renderReadinessPO } from './readiness/po-layer.js';
21
- import { renderStageLegend, stageTitle } from './readiness/stage-legend.js';
22
+ import { renderSubTabStrip } from './components/sub-tab-strip.js';
23
+ import { renderVerdictCards } from './readiness/po-layer.js';
24
+ import { renderQuestionsTable, renderBlockingTable, buildQuestionRows, buildBlockingRows } from './readiness/tables.js';
25
+ import { renderCoverageSummary } from './readiness/coverage-summary.js';
26
+ import { buildThinReqRows, renderThinReqsTable } from './readiness/thin-reqs.js';
27
+ import { renderVerdictGrid } from './readiness/grid.js';
28
+ import { renderDeltaCounts } from './readiness/delta-counts.js';
29
+ import { renderStageLegend } from './readiness/stage-legend.js';
30
+ import { toQuestions, preferReadinessQuestions } from './readiness/question-adapter.js';
22
31
 
23
32
  /**
24
33
  * @typedef {import('../query/readiness.js').ReadinessResult} ReadinessResult
@@ -27,11 +36,15 @@ import { renderStageLegend, stageTitle } from './readiness/stage-legend.js';
27
36
  * @typedef {import('../query/readiness.js').Blocker} Blocker
28
37
  */
29
38
 
39
+ /** Sub-tab keys in authoring order (first = default). */
40
+ export const READINESS_SUBS = ['overview', 'questions', 'blocking', 'coverage', 'trace'];
41
+
30
42
  /**
31
43
  * Decide the register ordering from `profile.md` text. Same contract
32
- * as the CLI's `pickRegister` helper: first marker in the text wins;
33
- * absent / unrecognised yields `unstated`, which the tab orders as
34
- * productOwner-first (spec section 5).
44
+ * as the CLI's `pickRegister` helper. The register no longer flips
45
+ * the layout: the Overview is the default landing and the sub-tab
46
+ * router restores sub= from the hash. The register is still a
47
+ * content hint (text uses "On me" / "On engineers" based on it).
35
48
  *
36
49
  * @param {string | null | undefined} text
37
50
  * @returns {'productOwner'|'engineer'|'unstated'}
@@ -55,12 +68,14 @@ export function pickRegister(text) {
55
68
  /**
56
69
  * Build the full Readiness tabpanel inner HTML.
57
70
  *
58
- * @param {ReadinessResult | null} result - the computed readiness
59
- * object, or null when the tree could not be computed
71
+ * @param {ReadinessResult | null} result
60
72
  * @param {object} [opts]
61
- * @param {string | null} [opts.profile] - profile.md text (register)
62
- * @param {object | null} [opts.freezeRecord] - loaded freeze record,
63
- * for the Freeze record block (section 11)
73
+ * @param {string | null} [opts.profile]
74
+ * @param {object | null} [opts.freezeRecord]
75
+ * @param {object | null} [opts.tree]
76
+ * @param {string} [opts.activeSub] sub-tab key the server should mark active
77
+ * (defaults to 'overview'; the client router
78
+ * takes over after load)
64
79
  * @returns {string}
65
80
  */
66
81
  export function renderReadinessPanel(result, opts = {}) {
@@ -69,370 +84,248 @@ export function renderReadinessPanel(result, opts = {}) {
69
84
  }
70
85
  const register = pickRegister(opts.profile ?? null);
71
86
  const freezeRecord = opts.freezeRecord ?? null;
87
+ const tree = opts.tree ?? null;
88
+ const activeSub = READINESS_SUBS.includes(opts.activeSub) ? opts.activeSub : 'overview';
89
+
90
+ const subTabs = renderSubTabStrip({
91
+ hashKey: 'readiness',
92
+ items: [
93
+ { key: 'overview', label: 'Overview', controls: 'rcf-readiness-sub-overview' },
94
+ { key: 'questions', label: 'Questions', controls: 'rcf-readiness-sub-questions' },
95
+ { key: 'blocking', label: 'Blocking', controls: 'rcf-readiness-sub-blocking' },
96
+ { key: 'coverage', label: 'Coverage', controls: 'rcf-readiness-sub-coverage' },
97
+ { key: 'trace', label: 'Trace', controls: 'rcf-readiness-sub-trace' },
98
+ ],
99
+ active: activeSub,
100
+ });
101
+
72
102
  const verdictLines = formatVerdictLines(result);
103
+ const q = toQuestions(result);
104
+ const questionsSource = preferReadinessQuestions(result) ? 'readiness.questions' : 'blockers';
105
+ const questionRows = buildQuestionRowsForTable(q);
106
+ const blockingRows = buildBlockingRows(Array.isArray(result.stages) ? result.stages : []);
107
+ const thinRows = buildThinReqRows(result, tree);
108
+
109
+ const overview = renderOverviewSub({
110
+ result,
111
+ verdictLines,
112
+ q,
113
+ freezeRecord,
114
+ register,
115
+ active: activeSub === 'overview',
116
+ });
117
+ const questions = renderQuestionsSub({
118
+ questionRows,
119
+ questionsSource,
120
+ active: activeSub === 'questions',
121
+ });
122
+ const blocking = renderBlockingSub({
123
+ blockingRows,
124
+ active: activeSub === 'blocking',
125
+ });
126
+ const coverage = renderCoverageSub({
127
+ result,
128
+ tree,
129
+ thinRows,
130
+ active: activeSub === 'coverage',
131
+ });
132
+ const trace = renderTraceSub({ active: activeSub === 'trace' });
133
+
134
+ // AC-18003-8: engineer register renders the Blocking sub-panel
135
+ // before the Questions sub-panel in the DOM so the engineer surface
136
+ // takes DOM precedence. Product owner and the default keep Questions
137
+ // first (matches AC-18001-4). The SubTabStrip's visible labels keep
138
+ // their authoring order; the ARIA aria-controls links each tab to
139
+ // its panel by id, so swapping panel order does not break the strip
140
+ // or the router.
141
+ const paintOrder = register === 'engineer'
142
+ ? [overview, blocking, questions, coverage, trace]
143
+ : [overview, questions, blocking, coverage, trace];
73
144
 
74
- const engineerBody = [
75
- renderTreeLine(result),
76
- renderVerdicts(result, verdictLines),
77
- renderNextActions(result, register),
78
- renderStageChips(result, freezeRecord),
79
- renderBlockersByPersona(result, register),
80
- renderDelta(result, freezeRecord),
81
- renderStageDetail(result),
82
- renderCoverage(result),
83
- renderDecisions(result),
84
- renderFreezeNow(result),
85
- renderFreezeRecord(freezeRecord),
86
- ].join('\n');
87
-
88
- const poLayer = renderReadinessPO(result, { persona: register, engineerBody });
89
145
  const legend = renderStageLegend();
90
- return `${poLayer}\n${legend}`;
91
- }
92
-
93
- // --- Block 1: tree line ------------------------------------------------
94
-
95
- function renderTreeLine(result) {
96
- return `<div class="rcf-readiness-tree"><p>${escapeHtml(formatTreeLine(result))}</p></div>`;
146
+ return `<div class="rcf-readiness-panel" data-rcf-register="${escapeHtml(register)}" data-rcf-source="${escapeHtml(questionsSource)}">`
147
+ + subTabs
148
+ + paintOrder.join('')
149
+ + legend
150
+ + `</div>`;
97
151
  }
98
152
 
99
- // --- Block 2: verdict pills --------------------------------------------
100
-
101
- function renderVerdicts(result, verdictLines) {
102
- const l = result.levels;
103
- const intentValue = l.intentComplete.ok ? 'yes' : 'no';
104
- const buildValue = l.readyToBuild.ok ? 'yes' : 'no';
105
- const intentPill = pill({
106
- value: verdictLines.intentComplete,
107
- variant: 'level-verdict-intent',
108
- title: `Intent complete: ${intentValue} (${l.intentComplete.blockedBy.length} blocker${l.intentComplete.blockedBy.length === 1 ? '' : 's'})`,
109
- });
110
- const buildPill = pill({
111
- value: verdictLines.readyToBuild,
112
- variant: 'level-verdict-build',
113
- title: `Ready to build: ${buildValue} (${l.readyToBuild.blockedBy.length} blocker${l.readyToBuild.blockedBy.length === 1 ? '' : 's'})`,
114
- });
115
- return `<div class="rcf-readiness-verdicts" data-rcf-intent-ok="${intentValue}" data-rcf-build-ok="${buildValue}">`
116
- + `<div class="rcf-readiness-verdict rcf-readiness-verdict--intent">${intentPill}`
117
- + ` <span class="rcf-readiness-verdict__count">${l.intentComplete.blockedBy.length}</span></div>`
118
- + `<div class="rcf-readiness-verdict rcf-readiness-verdict--build">${buildPill}`
119
- + ` <span class="rcf-readiness-verdict__count">${l.readyToBuild.blockedBy.length}</span></div>`
153
+ // ---- Overview sub-view ---------------------------------------------------
154
+ //
155
+ // Carries: tree line, two VerdictCards (count-links into Questions /
156
+ // Blocking sub-tabs), the stage verdict grid, the delta counts (per-
157
+ // document list behind a collapsed <details>), decisions outstanding,
158
+ // freeze state (plain words), freeze record. The CLI verdictLines
159
+ // render inside the Overview too so AC-18002-3 ("every id in the DOM")
160
+ // stays true across sub-views and the --json parity test
161
+ // (AC-18001-7) keeps passing.
162
+
163
+ function renderOverviewSub({ result, verdictLines, q, freezeRecord, register, active }) {
164
+ const cards = renderVerdictCards(result, verdictLines, q);
165
+ const treeLine = renderTreeLine(result);
166
+ const grid = renderVerdictGrid({ stages: result.stages, freezeRecord });
167
+ const deltaCounts = renderDeltaCounts({ delta: result.delta, freezeRecord, expanded: false });
168
+ const decisions = renderDecisions(result);
169
+ const nextActions = renderNextActionBlock(result, register);
170
+ const freezeState = renderFreezeState(result, register);
171
+ const freezeRec = renderFreezeRecord(freezeRecord);
172
+ const verdictParity = renderCliVerdictLines(verdictLines);
173
+ const hidden = active ? '' : ' hidden';
174
+ return `<div class="rcf-readiness-subpanel rcf-readiness-sub-overview" id="rcf-readiness-sub-overview" data-rcf-subpanel="overview" role="tabpanel"${hidden}>`
175
+ + treeLine
176
+ + cards
177
+ + verdictParity
178
+ + grid
179
+ + deltaCounts
180
+ + decisions
181
+ + nextActions
182
+ + freezeState
183
+ + freezeRec
120
184
  + `</div>`;
121
185
  }
122
186
 
123
- // --- Block 3: next action per persona ----------------------------------
124
-
125
- function renderNextActions(result, register) {
126
- const po = result.personas.productOwner.nextAction;
127
- const eng = result.personas.engineer.nextAction;
128
- const po_line = renderNextActionLine('product owner', po, 'productOwner');
129
- const eng_line = renderNextActionLine('engineer', eng, 'engineer');
130
- const order = register === 'engineer' ? [eng_line, po_line] : [po_line, eng_line];
131
- return `<div class="rcf-readiness-next-actions" data-rcf-register="${register}">`
187
+ // ---- Next actions (AC-18005-4: engineer surface carries a named
188
+ // next-action block) ------------------------------------------------
189
+ //
190
+ // Renders personas.productOwner.nextAction and personas.engineer.nextAction
191
+ // as plain words: "resolve the <plainLabel> failing items in the
192
+ // blocking table" with the id list named inline. No command text, no
193
+ // Run button; the chain fragment (stage/check) rides in data- attributes
194
+ // so the engineer or product owner reads what the row says and acts in
195
+ // their own agent session.
196
+
197
+ function renderNextActionBlock(result, register) {
198
+ const po = result.personas && result.personas.productOwner ? result.personas.productOwner.nextAction : null;
199
+ const eng = result.personas && result.personas.engineer ? result.personas.engineer.nextAction : null;
200
+ const poLine = renderNextActionLine('product owner', po, 'productOwner');
201
+ const engLine = renderNextActionLine('engineer', eng, 'engineer');
202
+ const order = register === 'engineer' ? [engLine, poLine] : [poLine, engLine];
203
+ return `<section class="rcf-readiness-next-actions" data-rcf-register="${escapeHtml(register)}">`
204
+ + `<h3>Next action</h3>`
132
205
  + order.join('')
133
- + `</div>`;
206
+ + `</section>`;
134
207
  }
135
208
 
136
209
  function renderNextActionLine(label, action, persona) {
210
+ const ownerLabel = persona === 'engineer' ? 'engineer' : 'product owner';
137
211
  if (!action) {
138
- return `<p class="rcf-readiness-next-action rcf-readiness-next-action--${persona}">`
139
- + `<strong>Next action (${escapeHtml(label)}):</strong> none.`
212
+ return `<p class="rcf-readiness-next-action rcf-readiness-next-action--${escapeHtml(persona)}" data-rcf-persona="${escapeHtml(persona)}">`
213
+ + `<strong>${escapeHtml(ownerLabel)}:</strong> none.`
140
214
  + `</p>`;
141
215
  }
142
- const ids = action.ids && action.ids.length > 0 ? ` ids: ${action.ids.join(', ')}` : '';
143
- const anchor = `#rcf-readiness-check-${encodeURIComponent(`${action.stage}:${action.check}`)}`;
144
- return `<p class="rcf-readiness-next-action rcf-readiness-next-action--${persona}">`
145
- + `<strong>Next action (${escapeHtml(label)}):</strong> `
146
- + `<a href="${escapeHtml(anchor)}">${escapeHtml(action.stage)} / ${escapeHtml(action.check)}</a>`
147
- + `${escapeHtml(ids)}. `
148
- + `Run <code>${escapeHtml(action.command)}</code> after editing.`
216
+ const stage = typeof action.stage === 'string' ? action.stage : '';
217
+ const check = typeof action.check === 'string' ? action.check : '';
218
+ const plain = checkToPlainLabel(check);
219
+ const ids = Array.isArray(action.ids) ? action.ids.filter((x) => typeof x === 'string') : [];
220
+ const idText = ids.length > 0 ? ` on ${ids.join(', ')}` : '';
221
+ const resolver = plain
222
+ ? `resolve the ${plain} failing items in the blocking table`
223
+ : `resolve the failing items named in the blocking table`;
224
+ return `<p class="rcf-readiness-next-action rcf-readiness-next-action--${escapeHtml(persona)}" data-rcf-persona="${escapeHtml(persona)}" data-rcf-stage="${escapeHtml(stage)}" data-rcf-check="${escapeHtml(check)}">`
225
+ + `<strong>${escapeHtml(ownerLabel)}:</strong> ${escapeHtml(resolver)}${escapeHtml(idText)}.`
149
226
  + `</p>`;
150
227
  }
151
228
 
152
- // --- Block 4: stage chips D1..D8 ---------------------------------------
153
-
154
- function renderStageChips(result, freezeRecord) {
155
- const chips = result.stages.map((s) => {
156
- const stateClass = chipStateClass(s.state);
157
- const legendTitle = stageTitle(s.stage);
158
- let title = `${s.stage} ${s.gate}: ${s.state}`;
159
- if (legendTitle && legendTitle !== s.stage) title = `${legendTitle} (${s.state})`;
160
- if (s.state === 'notApplicable' && s.reason) title = `${title} - ${s.reason}`;
161
- if (s.state === 'acknowledged' && freezeRecord && freezeRecord.gates && freezeRecord.gates[s.gate]) {
162
- const ack = freezeRecord.gates[s.gate];
163
- if (ack && typeof ack.reason === 'string' && ack.reason.length > 0) {
164
- title = `${title} - ${ack.reason}`;
165
- }
166
- }
167
- const label = `${s.stage}: ${s.state}`;
168
- return `<li class="rcf-readiness-chip rcf-readiness-chip--${stateClass}" data-rcf-stage-ref="${s.stage}">`
169
- + pill({ value: label, variant: 'gate-state', title })
170
- + `</li>`;
171
- }).join('');
172
- return `<ul class="rcf-readiness-chips">${chips}</ul>`;
173
- }
174
-
175
- function chipStateClass(state) {
176
- switch (state) {
177
- case 'passed':
178
- return 'green';
179
- case 'failing':
180
- return 'red';
181
- case 'acknowledged':
182
- return 'amber';
183
- case 'notApplicable':
184
- return 'grey';
185
- default:
186
- return 'grey';
187
- }
229
+ function renderTreeLine(result) {
230
+ return `<div class="rcf-readiness-tree"><p>${escapeHtml(formatTreeLine(result))}</p></div>`;
188
231
  }
189
232
 
190
- // --- Block 5: blockers by persona --------------------------------------
191
-
192
- export function renderBlockersByPersona(result, register) {
193
- const po = result.personas.productOwner.blockers;
194
- const eng = result.personas.engineer.blockers;
195
- const stages = Array.isArray(result.stages) ? result.stages : [];
196
- const poBlock = renderPersonaGroup('productOwner', 'Product owner', 'question', po, register, 'open', stages);
197
- const engBlock = renderPersonaGroup('engineer', 'Engineer', 'check blocking', eng, register, 'closed', stages);
198
- // When the register is engineer, the engineer group opens first and
199
- // the PO group collapses; `unstated` and `productOwner` keep PO open.
200
- const [first, second] = register === 'engineer'
201
- ? [renderPersonaGroup('engineer', 'Engineer', 'check blocking', eng, register, 'open', stages),
202
- renderPersonaGroup('productOwner', 'Product owner', 'question', po, register, 'closed', stages)]
203
- : [poBlock, engBlock];
204
- return `<section class="rcf-readiness-blockers-by-persona" data-rcf-register="${register}">`
205
- + first + second
206
- + `</section>`;
207
- }
233
+ // The CLI verdict strings render as a muted parity block inside
234
+ // Overview. AC-18001-7 (every id on the tab appears in the --json for
235
+ // the same tree) and AC-18002-3 (engineer ids never leave the DOM)
236
+ // both rely on these strings being present in the rendered HTML.
208
237
 
209
- /**
210
- * @param {'productOwner'|'engineer'} persona
211
- * @param {string} heading
212
- * @param {string} singular - 'question' | 'check blocking'
213
- * @param {Blocker[]} blockers
214
- * @param {'productOwner'|'engineer'|'unstated'} register
215
- * @param {'open'|'closed'} state
216
- * @param {StageResult[]} stages - threaded so each blocker's ids can
217
- * carry the matching failing `why` from `stages[].checks[].failing[]`
218
- * (spec section 5 block 5).
219
- */
220
- function renderPersonaGroup(persona, heading, singular, blockers, register, state, stages) {
221
- const plural = singular === 'question' ? 'questions' : 'checks blocking';
222
- const count = blockers.length;
223
- const noun = count === 1 ? singular : plural;
224
- const summary = `${heading}: ${count} ${noun}`;
225
- const openAttr = state === 'open' ? ' open' : '';
226
- if (count === 0) {
227
- return `<details class="rcf-readiness-persona rcf-readiness-persona--${persona}" data-rcf-persona-state="${state}"${openAttr}>`
228
- + `<summary><strong>${escapeHtml(heading)}:</strong> 0 ${plural}.</summary>`
229
- + `</details>`;
230
- }
231
- const items = blockers.map((b) => renderBlockerCard(b, stages)).join('');
232
- return `<details class="rcf-readiness-persona rcf-readiness-persona--${persona}" data-rcf-persona-state="${state}"${openAttr}>`
233
- + `<summary><strong>${escapeHtml(summary)}</strong></summary>`
234
- + items
235
- + `</details>`;
238
+ function renderCliVerdictLines(verdictLines) {
239
+ return `<p class="rcf-readiness-cli-verdict muted small" data-rcf-verdict="parity">`
240
+ + `<code>${escapeHtml(verdictLines.intentComplete)}</code> `
241
+ + `<code>${escapeHtml(verdictLines.readyToBuild)}</code>`
242
+ + `</p>`;
236
243
  }
237
244
 
238
- /**
239
- * Build an id→why map for a blocker by looking up the matching
240
- * `stages[].checks[].failing[]` entries. Ids missing a `why` map to
241
- * the empty string so the findings-list renders the id alone.
242
- *
243
- * @param {Blocker} b
244
- * @param {StageResult[]} stages
245
- * @returns {Record<string, string>}
246
- */
247
- function buildIdWhyMap(b, stages) {
248
- /** @type {Record<string, string>} */
249
- const map = {};
250
- const stage = Array.isArray(stages) ? stages.find((s) => s && s.stage === b.stage) : null;
251
- if (!stage || !Array.isArray(stage.checks)) return map;
252
- const check = stage.checks.find((c) => c && c.name === b.check);
253
- if (!check || !Array.isArray(check.failing)) return map;
254
- for (const f of check.failing) {
255
- if (!f || typeof f !== 'object') continue;
256
- const id = typeof f.id === 'string' ? f.id : null;
257
- if (!id) continue;
258
- map[id] = typeof f.why === 'string' ? f.why : '';
259
- }
260
- return map;
261
- }
245
+ // ---- Questions sub-view --------------------------------------------------
262
246
 
263
- /**
264
- * @param {Blocker} b
265
- * @param {StageResult[]} stages
266
- */
267
- function renderBlockerCard(b, stages) {
268
- // Each id is a document id; the hash routing in html-page.js resolves
269
- // `#<id>` to the right tab. The `why` per id lives on the matching
270
- // `stages[].checks[].failing[]` entry — spec section 5 block 5.
271
- const whyById = buildIdWhyMap(b, stages);
272
- const items = (b.ids ?? []).slice(0, 20).map((id) => ({
273
- id,
274
- why: whyById[id] ?? '',
275
- href: `#${id}`,
276
- }));
277
- return `<div class="rcf-readiness-blocker" data-rcf-stage="${escapeHtml(b.stage)}" data-rcf-check="${escapeHtml(b.check)}" data-rcf-persona="${escapeHtml(b.persona)}">`
278
- + `<div class="rcf-readiness-blocker__meta">`
279
- + `<span class="rcf-readiness-blocker__stage">${escapeHtml(b.stage)}</span> `
280
- + `<span class="rcf-readiness-blocker__check">${escapeHtml(b.check)}</span> `
281
- + pill({ value: b.persona === 'productOwner' ? 'PO' : 'eng', variant: 'persona', title: b.persona })
282
- + ` <span class="rcf-readiness-blocker__over">over ${escapeHtml(b.over)}</span>`
283
- + `</div>`
284
- + findingsList({ heading: b.question, count: b.failingCount, items })
247
+ function renderQuestionsSub({ questionRows, questionsSource, active }) {
248
+ const hidden = active ? '' : ' hidden';
249
+ const table = renderQuestionsTable({ rows: questionRows });
250
+ return `<div class="rcf-readiness-subpanel rcf-readiness-sub-questions" id="rcf-readiness-sub-questions" data-rcf-subpanel="questions" data-rcf-source="${escapeHtml(questionsSource)}" role="tabpanel"${hidden}>`
251
+ + table
285
252
  + `</div>`;
286
253
  }
287
254
 
288
- // --- Block 6: delta list -----------------------------------------------
289
-
290
- function renderDelta(result, freezeRecord) {
291
- const d = result.delta;
292
- const briefCount = Array.isArray(d.briefSince) ? d.briefSince.length : 0;
293
- const changedCount = (d.changed?.length ?? 0) + (d.added?.length ?? 0) + (d.removed?.length ?? 0);
294
- const frozenHashes = (freezeRecord && typeof freezeRecord === 'object' && freezeRecord.docHashes && typeof freezeRecord.docHashes === 'object')
295
- ? freezeRecord.docHashes
296
- : {};
297
- const currentHashes = (d && typeof d.currentDocHashes === 'object' && d.currentDocHashes !== null)
298
- ? d.currentDocHashes
299
- : {};
300
- const briefBlock = briefCount > 0
301
- ? `<section class="rcf-readiness-delta__group rcf-readiness-delta__group--brief">`
302
- + `<h4>Brief statements since freeze <span class="rcf-readiness-delta__count">${briefCount}</span></h4>`
303
- + `<ul>${d.briefSince.map((n) => `<li>#${escapeHtml(String(n))}</li>`).join('')}</ul>`
304
- + `</section>`
305
- : '';
306
- const changedBlock = changedCount > 0
307
- ? `<section class="rcf-readiness-delta__group rcf-readiness-delta__group--documents">`
308
- + `<h4>Documents changed <span class="rcf-readiness-delta__count">${changedCount}</span></h4>`
309
- + `<ul>`
310
- + (d.added ?? []).map((id) => `<li><a href="#${escapeHtml(id)}">${escapeHtml(id)}</a> <em>added</em>${renderDocDiff(id, frozenHashes, currentHashes, 'added')}</li>`).join('')
311
- + (d.changed ?? []).map((id) => `<li><a href="#${escapeHtml(id)}">${escapeHtml(id)}</a> <em>changed</em>${renderDocDiff(id, frozenHashes, currentHashes, 'changed')}</li>`).join('')
312
- + (d.removed ?? []).map((id) => `<li>${escapeHtml(id)} <em>removed</em>${renderDocDiff(id, frozenHashes, currentHashes, 'removed')}</li>`).join('')
313
- + `</ul>`
314
- + `</section>`
315
- : '';
316
- if (!briefBlock && !changedBlock) {
317
- return `<section class="rcf-readiness-delta"><p><em>No delta since the last freeze.</em></p></section>`;
255
+ function buildQuestionRowsForTable(adapterResult) {
256
+ const groups = Array.isArray(adapterResult?.groups) ? adapterResult.groups : [];
257
+ const optional = adapterResult?.optional ?? null;
258
+ const synthetic = [];
259
+ for (const g of groups) {
260
+ const items = Array.isArray(g?.items) ? g.items : [];
261
+ for (const it of items) {
262
+ synthetic.push({
263
+ itemId: it.itemId ?? it.id ?? '',
264
+ ask: it.ask,
265
+ hint: it.hint,
266
+ sourceSpanLabel: g.label,
267
+ checkId: it.checkId,
268
+ optional: Boolean(it.optional),
269
+ });
270
+ }
318
271
  }
319
- return `<section class="rcf-readiness-delta">${briefBlock}${changedBlock}</section>`;
320
- }
321
-
322
- /**
323
- * Document-level hash diff for one changed / added / removed id.
324
- * Spec section 5 block 6 pins a real `diff` component here; section 10
325
- * decision 1 scopes 0.29.0 to document-level only (criterion-level
326
- * deferred). The freeze record does not persist document bodies in
327
- * 0.29.0 (freeze-record.js schema: `docHashes` only — no `docs` /
328
- * `snapshot` section), so this ships the hash-only variant: both
329
- * columns show the short-hash subtitle and the frozen side carries
330
- * a muted note stating the body was not captured. NOTES in the brief.
331
- */
332
- function renderDocDiff(id, frozenHashes, currentHashes, kind) {
333
- const frozenHash = typeof frozenHashes?.[id] === 'string' ? frozenHashes[id] : null;
334
- const currentHash = typeof currentHashes?.[id] === 'string' ? currentHashes[id] : null;
335
- const beforeNote = 'frozen body not captured in freeze record 0.29.0';
336
- const before = kind === 'added'
337
- ? { note: 'not in the frozen tree' }
338
- : { hash: frozenHash ?? '', note: beforeNote };
339
- const after = kind === 'removed'
340
- ? { note: 'removed from the live tree' }
341
- : { hash: currentHash ?? '', note: 'current body available on the document tab' };
342
- return ` ${diff(before, after)}`;
272
+ if (optional && Array.isArray(optional.items)) {
273
+ for (const it of optional.items) {
274
+ synthetic.push({
275
+ itemId: it.itemId ?? '',
276
+ ask: it.ask,
277
+ hint: it.hint,
278
+ sourceSpanLabel: optional.label,
279
+ checkId: it.checkId,
280
+ optional: true,
281
+ });
282
+ }
283
+ }
284
+ return buildQuestionRows(synthetic);
343
285
  }
344
286
 
345
- // --- Block 7: per-stage check detail -----------------------------------
287
+ // ---- Blocking sub-view ---------------------------------------------------
346
288
 
347
- function renderStageDetail(result) {
348
- const stageBlocks = result.stages.map((s) => renderStage(s)).join('');
349
- return `<section class="rcf-readiness-stage-detail">`
350
- + `<h3>Stage detail</h3>`
351
- + stageBlocks
352
- + `</section>`;
289
+ function renderBlockingSub({ blockingRows, active }) {
290
+ const hidden = active ? '' : ' hidden';
291
+ const table = renderBlockingTable({ rows: blockingRows });
292
+ return `<div class="rcf-readiness-subpanel rcf-readiness-sub-blocking" id="rcf-readiness-sub-blocking" data-rcf-subpanel="blocking" role="tabpanel"${hidden}>`
293
+ + table
294
+ + `</div>`;
353
295
  }
354
296
 
355
- /**
356
- * @param {StageResult} s
357
- */
358
- function renderStage(s) {
359
- const chip = pill({ value: s.state, variant: 'gate-state', title: `${s.stage} ${s.gate}` });
360
- const checks = s.checks.map((c) => renderCheck(s, c)).join('');
361
- return `<section class="rcf-readiness-stage" id="rcf-readiness-stage-${escapeHtml(s.stage)}">`
362
- + `<h4>${escapeHtml(s.stage)} <code>${escapeHtml(s.gate)}</code> ${chip}</h4>`
363
- + checks
364
- + `</section>`;
365
- }
297
+ // ---- Coverage sub-view ---------------------------------------------------
366
298
 
367
- /**
368
- * @param {StageResult} s
369
- * @param {CheckResult} c
370
- */
371
- function renderCheck(s, c) {
372
- const checkId = `rcf-readiness-check-${encodeURIComponent(`${s.stage}:${c.name}`)}`;
373
- const personaPill = pill({
374
- value: c.persona === 'productOwner' ? 'PO' : 'eng',
375
- variant: 'persona',
376
- title: c.persona,
377
- });
378
- const statusLabel = c.ok ? 'ok' : 'fail';
379
- const items = c.ok
380
- ? ''
381
- : findingsList({
382
- heading: c.question,
383
- count: c.failing.length,
384
- items: c.failing.map((f) => ({ id: f.id, why: f.why, href: `#${f.id}` })),
385
- });
386
- return `<div class="rcf-readiness-check" id="${escapeHtml(checkId)}" data-rcf-check-ok="${c.ok ? 'yes' : 'no'}">`
387
- + `<div class="rcf-readiness-check__meta">`
388
- + `<strong>${escapeHtml(c.name)}</strong> ${personaPill} `
389
- + `<span class="rcf-readiness-check__over">over ${escapeHtml(c.over)}</span> `
390
- + `<span class="rcf-readiness-check__counts">${c.pass}/${c.total}</span> `
391
- + `<span class="rcf-readiness-check__status rcf-readiness-check__status--${statusLabel}">${statusLabel}</span>`
392
- + `</div>`
393
- + items
299
+ function renderCoverageSub({ result, tree, thinRows, active }) {
300
+ const hidden = active ? '' : ' hidden';
301
+ const summary = renderCoverageSummary({ coverage: result.coverage, tree });
302
+ const thin = renderThinReqsTable({ rows: thinRows });
303
+ return `<div class="rcf-readiness-subpanel rcf-readiness-sub-coverage" id="rcf-readiness-sub-coverage" data-rcf-subpanel="coverage" role="tabpanel"${hidden}>`
304
+ + summary
305
+ + thin
394
306
  + `</div>`;
395
307
  }
396
308
 
397
- // --- Block 8: coverage tree-vs-delta -----------------------------------
398
-
399
- function renderCoverage(result) {
400
- const t = result.coverage?.tree ?? null;
401
- const d = result.coverage?.delta ?? [];
402
- const impactedCount = result.delta?.impacted?.length ?? 0;
403
- const impactedFbsCount = result.delta?.impactedFbs?.length ?? 0;
404
- const treeCol = t
405
- ? `<pre class="rcf-readiness-coverage__tree">pass ${escapeHtml(String(t.pass ?? ''))} / ${escapeHtml(String(t.total ?? ''))}</pre>`
406
- : `<p><em>No tree-wide coverage result.</em></p>`;
407
- // Filter rows where every meaningful field is nullish / empty — a
408
- // coverage shim with no reqId/scope and no pass/total totals produces
409
- // "delta: / " markup otherwise (P2 polish; spec §5 block 8).
410
- const liveDelta = Array.isArray(d) ? d.filter(isMeaningfulCoverageRow) : [];
411
- const deltaRows = liveDelta.length > 0
412
- ? `<ul>${liveDelta.map((cv) => `<li>${escapeHtml(cv.reqId ?? cv.scope ?? 'delta')}: ${escapeHtml(String(cv.pass ?? ''))} / ${escapeHtml(String(cv.total ?? ''))}</li>`).join('')}</ul>`
413
- : `<p><em>No per-REQ delta coverage.</em></p>`;
414
- return `<section class="rcf-readiness-coverage">`
415
- + `<h3>Coverage</h3>`
416
- + `<div class="rcf-readiness-coverage__cols">`
417
- + `<div class="rcf-readiness-coverage__col rcf-readiness-coverage__col--tree"><h4>Tree</h4>${treeCol}</div>`
418
- + `<div class="rcf-readiness-coverage__col rcf-readiness-coverage__col--delta"><h4>Delta</h4>${deltaRows}</div>`
309
+ // ---- Trace sub-view (FBS-208, US-209, TAC-4136, ADR-4140) ----------------
310
+
311
+ function renderTraceSub({ active }) {
312
+ const hidden = active ? '' : ' hidden';
313
+ // Initial markup: the empty-state renderer (no pivot yet). The
314
+ // page-init.js `wireTraceMatrix` fetches `./trace.json` and
315
+ // `./coverage.json` and re-renders the inner `[data-rcf-trace-root]`
316
+ // when the hash carries `#tab=readiness&sub=trace&entity=<id>`.
317
+ return `<div class="rcf-readiness-subpanel rcf-readiness-sub-trace" id="rcf-readiness-sub-trace" data-rcf-subpanel="trace" role="tabpanel"${hidden}>`
318
+ + `<div data-rcf-trace-root="yes">`
319
+ + `<section class="rcf-trace-matrix rcf-trace-matrix--empty" data-rcf-trace-matrix="empty" data-rcf-trace-reason="no-pivot" aria-labelledby="rcf-readiness-trace-heading">`
320
+ + `<header class="rcf-trace-matrix__head"><h3 id="rcf-readiness-trace-heading">Pick an id to trace</h3></header>`
321
+ + `<p>Pick a requirement, story or criterion through the Trace action on any readiness row, or open the lookup to search by id.</p>`
322
+ + `<p><button type="button" class="rcf-trace-matrix__lookup" data-rcf-trace-open-lookup="yes">Open the lookup</button></p>`
323
+ + `</section>`
419
324
  + `</div>`
420
- + `<p class="rcf-readiness-coverage__counts">`
421
- + `re-verify: ${impactedCount} &middot; re-execute: ${impactedFbsCount}`
422
- + `</p>`
423
- + `</section>`;
424
- }
425
-
426
- function isMeaningfulCoverageRow(cv) {
427
- if (!cv || typeof cv !== 'object') return false;
428
- const hasLabel = (typeof cv.reqId === 'string' && cv.reqId.length > 0)
429
- || (typeof cv.scope === 'string' && cv.scope.length > 0);
430
- const passSet = cv.pass !== null && cv.pass !== undefined && cv.pass !== '';
431
- const totalSet = cv.total !== null && cv.total !== undefined && cv.total !== '';
432
- return hasLabel || passSet || totalSet;
325
+ + `</div>`;
433
326
  }
434
327
 
435
- // --- Block 9: decisions outstanding ------------------------------------
328
+ // ---- Decisions outstanding -----------------------------------------------
436
329
 
437
330
  function renderDecisions(result) {
438
331
  const items = Array.isArray(result.decisions) ? result.decisions : [];
@@ -451,31 +344,122 @@ function renderDecisions(result) {
451
344
  return `<section class="rcf-readiness-decisions"><h3>Decisions outstanding</h3><ol>${list}</ol></section>`;
452
345
  }
453
346
 
454
- // --- Block 10: freeze now ----------------------------------------------
347
+ // ---- Freeze state (plain words, no command, no control) -----------------
348
+ //
349
+ // AC-18005-5: readyToBuild=no names each failing gate with what
350
+ // resolves it; readyToBuild=yes states that the tree can be frozen
351
+ // and what a freeze would record. "On me" / "On engineers" vocab
352
+ // replaces persona pills.
455
353
 
456
- function renderFreezeNow(result) {
354
+ function renderFreezeState(result, register) {
457
355
  const ok = result.levels.readyToBuild.ok;
458
- const disabledAttr = ok ? '' : ' disabled';
459
- const failingGates = ok
460
- ? []
461
- : [...new Set(result.levels.readyToBuild.blockedBy.map((b) => b.gate))].sort();
462
- const failingList = failingGates.length > 0
463
- ? ` <span class="rcf-readiness-freeze-now__failing">Failing gates: ${failingGates.map((g) => escapeHtml(g)).join(', ')}</span>`
464
- : '';
465
- const cmd = `rcf define freeze`;
466
- const willRecord = ok
467
- ? `<p>Will record: tree hash <code>${escapeHtml(shortHash(result.tree.currentTreeHash))}</code>, timestamp, note, counts and acknowledged gates with reasons.</p>`
468
- : '';
469
- return `<section class="rcf-readiness-freeze-now" data-rcf-freezeable="${ok ? 'yes' : 'no'}">`
470
- + `<h3>Freeze now</h3>`
471
- + `<button type="button" class="rcf-readiness-freeze-now__btn"${disabledAttr}>Freeze</button>`
472
- + ` <code>${escapeHtml(cmd)}</code>`
473
- + failingList
474
- + willRecord
356
+ const freezeableAttr = ok ? 'yes' : 'no';
357
+ if (ok) {
358
+ const shortCurrent = escapeHtml(shortHash(result.tree.currentTreeHash));
359
+ return `<section class="rcf-readiness-freeze-state" data-rcf-freezeable="${freezeableAttr}">`
360
+ + `<h3>Freeze state</h3>`
361
+ + `<p>Ready to freeze. A freeze would record the current tree hash <code>${shortCurrent}</code>, the timestamp, the note, the counts and the acknowledged gates with their reasons.</p>`
362
+ + `</section>`;
363
+ }
364
+ const blockers = Array.isArray(result.levels.readyToBuild.blockedBy) ? result.levels.readyToBuild.blockedBy : [];
365
+ const grouped = groupBlockersByGate(blockers);
366
+ if (grouped.length === 0) {
367
+ return `<section class="rcf-readiness-freeze-state" data-rcf-freezeable="${freezeableAttr}">`
368
+ + `<h3>Freeze state</h3>`
369
+ + `<p>Not ready to freeze.</p>`
370
+ + `</section>`;
371
+ }
372
+ const items = grouped.map((g) => {
373
+ const owner = g.ownerLabel;
374
+ const resolver = resolverSentence(g.gate, g.checks);
375
+ const stageWord = gateToPlainStage(g.gate);
376
+ const lead = stageWord
377
+ ? `the ${stageWord} stage is held`
378
+ : 'this stage is held';
379
+ // Each item leads with a plain-English sentence naming the stage
380
+ // and what resolves it (AC-18005-5). The chain gate id follows
381
+ // in <code> so an engineer scanning the block can jump into the
382
+ // chain (AC-18001-6 pins this locator).
383
+ return `<li data-rcf-gate="${escapeHtml(g.gate)}" data-rcf-owner="${escapeHtml(g.owner)}">`
384
+ + `<span class="muted small">(${escapeHtml(owner)})</span> ${escapeHtml(lead)}: ${escapeHtml(resolver)} `
385
+ + `<code>${escapeHtml(g.gate)}</code>`
386
+ + `</li>`;
387
+ }).join('');
388
+ // The gate labels are fixed: 'On me' names the product owner's
389
+ // own gates, 'On engineers' names the gates handed over. The lead
390
+ // sentence states that invariant verbatim for both registers so
391
+ // the vocabulary never flips when the engineer surface renders.
392
+ const lead = 'The build is held by these gates. "On me" names the product owner\'s own gates, "On engineers" names the ones handed over.';
393
+ return `<section class="rcf-readiness-freeze-state" data-rcf-freezeable="${freezeableAttr}">`
394
+ + `<h3>Freeze state</h3>`
395
+ + `<p>Not ready to freeze. ${escapeHtml(lead)}</p>`
396
+ + `<ul class="rcf-readiness-freeze-state__gates">${items}</ul>`
475
397
  + `</section>`;
476
398
  }
477
399
 
478
- // --- Block 11: freeze record -------------------------------------------
400
+ /** Group blockers by gate, folding owner (persona) to the shared label. */
401
+ function groupBlockersByGate(blockers) {
402
+ const byGate = new Map();
403
+ for (const b of blockers) {
404
+ if (!b || typeof b !== 'object') continue;
405
+ const gate = typeof b.gate === 'string' ? b.gate : '';
406
+ if (!gate) continue;
407
+ const slot = byGate.get(gate) || { gate, owners: new Set(), checks: new Set() };
408
+ if (b.persona === 'productOwner' || b.persona === 'engineer') slot.owners.add(b.persona);
409
+ if (typeof b.check === 'string') slot.checks.add(b.check);
410
+ byGate.set(gate, slot);
411
+ }
412
+ const rows = [];
413
+ for (const slot of byGate.values()) {
414
+ const owners = Array.from(slot.owners);
415
+ const owner = owners.length === 1 ? owners[0] : (owners.includes('productOwner') ? 'productOwner' : 'engineer');
416
+ rows.push({
417
+ gate: slot.gate,
418
+ owner,
419
+ ownerLabel: owner === 'productOwner' ? 'On me' : 'On engineers',
420
+ checks: Array.from(slot.checks).sort(),
421
+ });
422
+ }
423
+ rows.sort((a, b) => a.gate.localeCompare(b.gate));
424
+ return rows;
425
+ }
426
+
427
+ /**
428
+ * Plain-English sentence naming what resolves the gate, derived from
429
+ * its failing checks. Keeps the viewer command-free: the engineer or
430
+ * product owner reads what the row says and acts in their own agent
431
+ * session.
432
+ */
433
+ function resolverSentence(gate, checks) {
434
+ if (checks.length === 0) return `the ${gate} gate still has failing items in the blocking table.`;
435
+ const labels = checks.map(checkToPlainLabel).filter(Boolean);
436
+ const uniq = Array.from(new Set(labels));
437
+ if (uniq.length === 0) return `resolve the failing items for ${gate} named in the blocking table.`;
438
+ if (uniq.length === 1) return `resolve the ${uniq[0]} failing items in the blocking table.`;
439
+ const last = uniq.pop();
440
+ return `resolve the ${uniq.join(', ')} and ${last} failing items in the blocking table.`;
441
+ }
442
+
443
+ function checkToPlainLabel(check) {
444
+ if (typeof check !== 'string' || check.length === 0) return '';
445
+ // e.g. "brief:sinceFreeze" -> "brief", "stories:usFloors" -> "stories",
446
+ // "crosscut:securityArchitecture" -> "crosscut".
447
+ const idx = check.indexOf(':');
448
+ return idx === -1 ? check : check.slice(0, idx);
449
+ }
450
+
451
+ // Map a gate id ("define.brief", "define.stories", "define.crosscut",
452
+ // "define.probe", "define.freeze", ...) to a short plain-English stage
453
+ // word for the freeze-state items. Returns the second segment lowercased
454
+ // (or the full id when there is no separator) so the user-facing text
455
+ // reads "the brief stage is held" rather than citing a chain id.
456
+ function gateToPlainStage(gate) {
457
+ if (typeof gate !== 'string' || gate.length === 0) return '';
458
+ const idx = gate.indexOf('.');
459
+ return idx === -1 ? gate : gate.slice(idx + 1);
460
+ }
461
+
462
+ // ---- Freeze record -------------------------------------------------------
479
463
 
480
464
  function renderFreezeRecord(freezeRecord) {
481
465
  if (!freezeRecord) {
@@ -491,7 +475,12 @@ function renderFreezeRecord(freezeRecord) {
491
475
  : '';
492
476
  const gates = freezeRecord.gates && typeof freezeRecord.gates === 'object'
493
477
  ? Object.entries(freezeRecord.gates)
494
- .map(([k, v]) => `<li><code>${escapeHtml(k)}</code>: ${escapeHtml(v && typeof v === 'object' && typeof v.reason === 'string' ? v.reason : String(v ?? ''))}</li>`)
478
+ .map(([k, v]) => {
479
+ const atReason = v && typeof v === 'object' && v.at && typeof v.at === 'object' && typeof v.at.reason === 'string' ? v.at.reason : null;
480
+ const flatReason = v && typeof v === 'object' && typeof v.reason === 'string' ? v.reason : null;
481
+ const reason = atReason !== null && atReason.length > 0 ? atReason : (flatReason !== null && flatReason.length > 0 ? flatReason : String(v ?? ''));
482
+ return `<li><code>${escapeHtml(k)}</code>: ${escapeHtml(reason)}</li>`;
483
+ })
495
484
  .join('')
496
485
  : '';
497
486
  return `<section class="rcf-readiness-freeze-record">`