rcf-lite 0.32.5 → 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 (88) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/fixtures/canary-manifest.json +9 -9
  3. package/guidance/discovery/discovery-viewer-command-page-1-layout.html +191 -0
  4. package/guidance/discovery/discovery-viewer-command-page-2-coverage.html +193 -0
  5. package/guidance/discovery/discovery-viewer-command-page-3-trace-matrix.html +195 -0
  6. package/guidance/discovery/discovery-viewer-command-page-4-tables.html +196 -0
  7. package/guidance/discovery/discovery-viewer-command-page-5-subtabs.html +224 -0
  8. package/guidance/discovery/discovery-viewer-command-page-6-one-page.html +280 -0
  9. package/guidance/discovery/discovery-viewer-command-page.md +301 -0
  10. package/package.json +1 -1
  11. package/rcf/adrs/adr-4139-readiness-tables-read-only-no-commands.json +30 -0
  12. package/rcf/adrs/adr-4140-view-query-routes-per-pivot-matrix-no-mermaid.json +30 -0
  13. package/rcf/adrs/adr-4141-verify-modes.json +25 -0
  14. package/rcf/code-nodes/cn-771.json +3 -2
  15. package/rcf/code-nodes/cn-779.json +19 -0
  16. package/rcf/code-nodes/cn-780.json +14 -0
  17. package/rcf/code-nodes/cn-781.json +14 -0
  18. package/rcf/code-nodes/cn-782.json +15 -0
  19. package/rcf/code-nodes/cn-783.json +17 -0
  20. package/rcf/code-nodes/cn-784.json +20 -0
  21. package/rcf/code-nodes/cn-785.json +14 -0
  22. package/rcf/code-nodes/cn-786.json +14 -0
  23. package/rcf/code-nodes/cn-787.json +18 -0
  24. package/rcf/code-nodes/cn-788.json +14 -0
  25. package/rcf/code-nodes/cn-789.json +14 -0
  26. package/rcf/code-nodes/cn-790.json +15 -0
  27. package/rcf/code-nodes/cn-791.json +14 -0
  28. package/rcf/code-nodes/cn-792.json +17 -0
  29. package/rcf/code-nodes/cn-793.json +17 -0
  30. package/rcf/code-nodes/cn-794.json +18 -0
  31. package/rcf/code-nodes/cn-795.json +15 -0
  32. package/rcf/code-nodes/cn-796.json +14 -0
  33. package/rcf/code-nodes/cn-797.json +14 -0
  34. package/rcf/code-nodes/cn-798.json +14 -0
  35. package/rcf/define/brief-ledger.json +47 -0
  36. package/rcf/define/decisions-ledger.json +113 -0
  37. package/rcf/fbs/fbs-204.json +46 -0
  38. package/rcf/fbs/fbs-205.json +46 -0
  39. package/rcf/fbs/fbs-206.json +49 -0
  40. package/rcf/fbs/fbs-207.json +43 -0
  41. package/rcf/fbs/fbs-208.json +47 -0
  42. package/rcf/fbs/fbs-209.json +22 -0
  43. package/rcf/requirements/req-002.json +5 -2
  44. package/rcf/requirements/req-180.json +6 -3
  45. package/rcf/tacs/tac-4127-readiness-view.json +2 -2
  46. package/rcf/tacs/tac-4133-readiness-po-layer.json +2 -2
  47. package/rcf/tacs/tac-4135-readiness-command-page.json +94 -0
  48. package/rcf/tacs/tac-4136-view-query-routes.json +68 -0
  49. package/rcf/tacs/tac-4141-verify-modes.json +25 -0
  50. package/rcf/test-suites/ts-223.json +7 -7
  51. package/rcf/test-suites/ts-237.json +78 -0
  52. package/rcf/test-suites/ts-238.json +70 -0
  53. package/rcf/test-suites/ts-239.json +78 -0
  54. package/rcf/test-suites/ts-240.json +70 -0
  55. package/rcf/test-suites/ts-241.json +70 -0
  56. package/rcf/test-suites/ts-242.json +46 -0
  57. package/rcf/user-stories/us-16503.json +51 -0
  58. package/rcf/user-stories/us-18001.json +4 -4
  59. package/rcf/user-stories/us-18003.json +79 -0
  60. package/rcf/user-stories/us-18004.json +70 -0
  61. package/rcf/user-stories/us-18005.json +79 -0
  62. package/rcf/user-stories/us-208.json +74 -0
  63. package/rcf/user-stories/us-209.json +70 -0
  64. package/releases/releases.yaml +10 -1
  65. package/src/cli/finalise.js +141 -2
  66. package/src/cli/view.js +13 -1
  67. package/src/finalise/spawn.js +2 -0
  68. package/src/server/index.js +104 -39
  69. package/src/server/routes.js +169 -0
  70. package/src/verify/cli/run.js +40 -0
  71. package/src/verify/engine/deterministic.js +150 -0
  72. package/src/verify/engine/index.js +27 -4
  73. package/src/view/doc-renderers/req.js +11 -9
  74. package/src/view/html-page.js +15 -11
  75. package/src/view/index.js +7 -1
  76. package/src/view/live-client.js +4 -0
  77. package/src/view/page-init.js +842 -1
  78. package/src/view/readiness/composite-id.js +35 -0
  79. package/src/view/readiness/coverage-summary.js +293 -0
  80. package/src/view/readiness/delta-counts.js +156 -0
  81. package/src/view/readiness/grid.js +175 -0
  82. package/src/view/readiness/po-layer.js +110 -220
  83. package/src/view/readiness/tables.js +500 -0
  84. package/src/view/readiness/thin-reqs.js +237 -0
  85. package/src/view/readiness/trace-matrix.js +392 -0
  86. package/src/view/readiness.js +359 -370
  87. package/src/view/style.css +594 -0
  88. package/src/watch/index.js +75 -17
@@ -0,0 +1,500 @@
1
+ // Readiness command-page tables (TAC-4135 renderQuestionsTable,
2
+ // renderBlockingTable, QuestionRow, BlockingRow, FilterBar; ADR-4139;
3
+ // FBS-204, US-18003 AC-18003-1..5, AC-18003-7, AC-18003-8).
4
+ //
5
+ // Two pure HTML-string renderers over the ReadinessResult the viewer
6
+ // already has, read-only: no CLI command text, no write control, no
7
+ // Freeze now (AC-18003-7, ADR-4139). Every row carries the item id,
8
+ // the plain-English ask or finding, the chain location, a link that
9
+ // opens the item in full context, and what resolved looks like
10
+ // (ADR-4139 decision line). Composite failing ids split on the first
11
+ // colon so every blocking link resolves through findByDocId (AC-18003-4).
12
+ //
13
+ // Shared with FBS-205..208: the questions and blocking tables are the
14
+ // "linked tables over the readiness object" the ADR pins. The
15
+ // Copy-for-your-agent handle (AC-18003-5) uses the shared toast helper
16
+ // in page-init.js; wiring sits there, markup here (data-rcf-copy-for).
17
+ //
18
+ // No em-dashes anywhere in this file. The strings are plain product
19
+ // words; nothing routes to a CLI command.
20
+
21
+ import { escapeHtml } from '../doc-renderers/helpers.js';
22
+ import { phrasebookEntry } from './phrasebook.js';
23
+ import { splitCompositeId } from './composite-id.js';
24
+
25
+ /**
26
+ * @typedef {object} QuestionRow
27
+ * @property {number} number 1-based row number across the table
28
+ * @property {string} itemId the failing item id (brief:ledger, profile:surface, REQ-012, ...)
29
+ * @property {string} group the source span label used for the group-column sort key
30
+ * @property {string} ask plain-English sentence the owner answers
31
+ * @property {string} settles what shape of answer resolves this row
32
+ * @property {string} state 'open' | 'optional' (the chain's state for the question)
33
+ * @property {string} locationHref the hash: #tab=readiness&sub=questions&entity=<itemId>
34
+ * @property {string} locationLabel short human label naming the chain location
35
+ * @property {string} briefHandle one line "<itemId>: <ask>" with no command text
36
+ * @property {string} checkId '<stage>/<check>' for the hover title and stage-ref
37
+ * @property {string} stage Dn stage key
38
+ */
39
+
40
+ /**
41
+ * @typedef {object} BlockingRow
42
+ * @property {string} stage Dn stage key
43
+ * @property {string} check check name (brief:sinceFreeze, stories:reqHasUs, ...)
44
+ * @property {string} rawId the failing id as it appears on the chain (may be composite)
45
+ * @property {string} docId `splitCompositeId(rawId).docId`
46
+ * @property {string} fragment `splitCompositeId(rawId).fragment`
47
+ * @property {string} href `#<docId>` when docId is set, else `#rcf-readiness-row-<rawId-slug>`
48
+ * @property {string} why gate explanation from `stages[].checks[].failing[].why`
49
+ * @property {string} resolved plain words naming what the row looks like when it clears
50
+ * @property {string} persona 'productOwner' | 'engineer'
51
+ * @property {string} question the check's question (surfaces as a readable tooltip)
52
+ */
53
+
54
+ /**
55
+ * @typedef {object} TablesOpts
56
+ * @property {{stage?: string, persona?: string}} [filter] optional FilterBar state for the blocking table
57
+ * @property {object | null} [freezeRecord] freeze record on disk (for the freeze-state block)
58
+ */
59
+
60
+ // Safe DOM-id token for an itemId that may hold colons, slashes,
61
+ // angle brackets or spaces (profile:surface, brief:openQuestions,
62
+ // TAC-4122-define-gates:checkPersona). Mirrors page-init.js
63
+ // slugifyEntity so the router can find the row by data-rcf-entity.
64
+ export function entityDomId(itemId) {
65
+ const s = String(itemId ?? '');
66
+ return `rcf-readiness-row-${s.replace(/[^A-Za-z0-9-]/g, '_')}`;
67
+ }
68
+
69
+ // Document-id prefixes the viewer's hash router can resolve to a
70
+ // rendered node via findByDocId. Non-document prefixes (profile, ADR,
71
+ // D1..D8, TAD.*) render as plain text in the blocking table because
72
+ // their anchor would be dead. Dave ruling 2026-10-08: chain ids are
73
+ // content, rendered as a prefix link (when the prefix is a resolvable
74
+ // doc id) with the full composite id in a data attribute, never as a
75
+ // visible shell command fragment.
76
+ const RESOLVABLE_DOC_PREFIX = /^(REQ|AC|US|TAC|TS|TC|FBS|ADR|TAD|CN|PRD)-/;
77
+ function isResolvableDocId(id) {
78
+ return typeof id === 'string' && RESOLVABLE_DOC_PREFIX.test(id);
79
+ }
80
+ export { isResolvableDocId };
81
+
82
+ // Document-id itemId used in question rows. Reaches the "opens the
83
+ // item in full context" clause of AC-18003-1: the bare hash resolves
84
+ // to the item's own tab (see page-init.js resolveHash fast path).
85
+ // Non-document itemIds (brief:ledger, profile:surface, open-decision:5)
86
+ // stay on the readiness tab with the questions sub-view, since that
87
+ // is where the row lives.
88
+ function pickLocationHref(itemId) {
89
+ if (typeof itemId !== 'string' || itemId.length === 0) {
90
+ return '#tab=readiness&sub=questions';
91
+ }
92
+ if (isResolvableDocId(itemId)) return `#${itemId}`;
93
+ return `#tab=readiness&sub=questions&entity=${encodeURIComponent(itemId)}`;
94
+ }
95
+
96
+ const SETTLES_FALLBACK = 'Answer the ask or record a decision; the row clears when the chain updates.';
97
+ const RESOLVED_FALLBACK = 'The gate passes when the named failing items clear on the chain.';
98
+
99
+ const RESOLVED_BY_CHECK = {
100
+ 'brief:sinceFreeze': 'A brief statement is written, or the operator records text for one.',
101
+ 'brief:kinds': 'Every statement carries a kind (capability, constraint, actor, record, system, surface, outOfScope, question).',
102
+ 'brief:openQuestions': 'Every open question in the brief is answered or promoted to a decision.',
103
+ 'brief:profile': 'The profile names a review surface and a register.',
104
+ 'brief:profile:surface': 'The profile names a review surface (viewer, runningApp or prDiff).',
105
+ 'brief:profile:register': 'The profile names a register (productOwner or engineer).',
106
+ 'skeleton:resolvedBy': 'The statement is placed under a requirement (new or existing) or marked outOfScope.',
107
+ 'skeleton:reqIntent': 'The requirement carries a plain description and an area.',
108
+ 'stories:reqHasUs': 'At least one user story is written under the requirement.',
109
+ 'stories:closedSets': 'The story set under the requirement is closed (no open hooks).',
110
+ 'stories:usFloors': 'The user story has the minimum acceptance criteria the chain requires.',
111
+ 'shapes:tacHasInterface': 'Every TAC carries at least one interface.',
112
+ 'shapes:pathsResolve': 'Every interface path resolves to a file on disk.',
113
+ 'shapes:tacComplete': 'Every TAC carries interfaces, responsibilities and dependencies.',
114
+ 'crosscut:securityArchitecture': 'The TAD security architecture is written.',
115
+ 'crosscut:performanceArchitecture': 'The TAD performance architecture is written.',
116
+ 'crosscut:observability': 'The TAD observability section is written.',
117
+ 'crosscut:operationalConcerns': 'The operational concerns section is complete.',
118
+ 'consistency:contradictions': 'The named contradictions in the chain are resolved.',
119
+ 'consistency:unsatisfiable': 'The unsatisfiable statement is rewritten or retired.',
120
+ 'decisions:wellFormed': 'Every open decision has two or more options and a named default.',
121
+ 'decisions:resolved': 'Every open decision is resolved.',
122
+ 'freeze:treeHash': 'The frozen tree hash matches the current tree hash.',
123
+ };
124
+
125
+ const SETTLES_BY_CHECK = {
126
+ 'brief:sinceFreeze': 'A document path or a few sentences.',
127
+ 'brief:kinds': 'Pick a kind for the statement.',
128
+ 'brief:openQuestions': 'Give an answer or list the options and a default.',
129
+ 'brief:profile:surface': 'Pick viewer, runningApp or prDiff.',
130
+ 'brief:profile:register': 'Pick productOwner or engineer.',
131
+ 'skeleton:resolvedBy': 'Say which requirement this belongs to, or mark it out of scope.',
132
+ 'skeleton:reqIntent': 'One or two plain sentences, and the area.',
133
+ 'stories:reqHasUs': 'One sentence: as a who, I want what, so that why.',
134
+ 'decisions:wellFormed': 'Two or more options and a named default.',
135
+ };
136
+
137
+ // Plain words for a stage's product name. Mirrors stage-legend titles
138
+ // so the row surface uses the same vocabulary readers already learned.
139
+ const STAGE_LABEL = {
140
+ D1: 'Brief',
141
+ D2: 'Skeleton',
142
+ D3: 'Shapes',
143
+ D4: 'Stories',
144
+ D5: 'Crosscut',
145
+ D6: 'Consistency',
146
+ D7: 'Decisions',
147
+ D8: 'Freeze',
148
+ };
149
+
150
+ function stageLabel(stage) {
151
+ return STAGE_LABEL[stage] ?? stage;
152
+ }
153
+
154
+ function sourceSpanLabel(span) {
155
+ if (typeof span !== 'string' || span.length === 0) return 'The tree';
156
+ if (span === 'tree') return 'The tree';
157
+ if (span.startsWith('brief:')) {
158
+ const n = span.slice('brief:'.length);
159
+ return /^\d+$/.test(n) ? `Brief statement ${n}` : 'Brief';
160
+ }
161
+ if (/^REQ-/.test(span)) return `Requirement ${span}`;
162
+ if (/^US-/.test(span)) return `User story ${span}`;
163
+ if (/^AC-/.test(span)) return `Acceptance criterion ${span}`;
164
+ if (/^TAC-/.test(span)) return `Technical area ${span}`;
165
+ return span;
166
+ }
167
+
168
+ function pickItemId(q) {
169
+ if (!q || typeof q !== 'object') return '';
170
+ if (typeof q.itemId === 'string' && q.itemId.length > 0) return q.itemId;
171
+ if (typeof q.id === 'string' && /^(brief:|profile:|decision:|REQ-|AC-|TAC-|US-|TS-|TC-|FBS-|ADR-|TAD-)/.test(q.id)) return q.id;
172
+ return '';
173
+ }
174
+
175
+ function pickGroupLabel(q) {
176
+ if (!q || typeof q !== 'object') return 'The tree';
177
+ if (typeof q.sourceSpanLabel === 'string' && q.sourceSpanLabel.length > 0) return q.sourceSpanLabel;
178
+ const ctx = q.context && typeof q.context === 'object' ? q.context : null;
179
+ const stmtSrc = ctx && typeof ctx.statement === 'object' && ctx.statement
180
+ ? ctx.statement.source
181
+ : null;
182
+ const span = typeof q.sourceSpan === 'string' && q.sourceSpan.length > 0
183
+ ? q.sourceSpan
184
+ : (typeof stmtSrc === 'string' && stmtSrc.length > 0
185
+ ? stmtSrc
186
+ : (ctx && typeof ctx.sourceSpan === 'string' ? ctx.sourceSpan : ''));
187
+ return sourceSpanLabel(span);
188
+ }
189
+
190
+ function pickState(q) {
191
+ if (q && q.optional === true) return 'optional';
192
+ const blocks = typeof q?.blocks === 'string' ? q.blocks : '';
193
+ if (blocks === 'build') return 'open-build';
194
+ return 'open';
195
+ }
196
+
197
+ function buildBriefHandle(itemId, ask) {
198
+ const a = typeof ask === 'string' ? ask.trim() : '';
199
+ const id = typeof itemId === 'string' ? itemId : '';
200
+ if (!id) return a;
201
+ if (!a) return id;
202
+ return `${id}: ${a}`;
203
+ }
204
+
205
+ /**
206
+ * Project `readiness.questions` into QuestionRow[]. Falls back to the
207
+ * phrasebook when a question record is missing ask/heading (older
208
+ * computeQuestions outputs or synthetic test fixtures).
209
+ *
210
+ * @param {Array<object>} questions
211
+ * @returns {QuestionRow[]}
212
+ */
213
+ export function buildQuestionRows(questions) {
214
+ if (!Array.isArray(questions)) return [];
215
+ /** @type {QuestionRow[]} */
216
+ const rows = [];
217
+ let n = 0;
218
+ for (const raw of questions) {
219
+ if (!raw || typeof raw !== 'object') continue;
220
+ const q = /** @type {Record<string, unknown>} */ (raw);
221
+ n += 1;
222
+ const checkId = typeof q.checkId === 'string'
223
+ ? q.checkId
224
+ : (typeof q.check === 'string' ? q.check : '');
225
+ const stage = typeof q.stage === 'string' ? q.stage : '';
226
+ const itemId = pickItemId(q);
227
+ const pb = phrasebookEntry(checkId, itemId);
228
+ const ask = typeof q.ask === 'string' && q.ask.length > 0 ? q.ask : pb.ask;
229
+ const settles = typeof q.hint === 'string' && q.hint.length > 0
230
+ ? q.hint
231
+ : (SETTLES_BY_CHECK[checkId] ?? pb.hint ?? SETTLES_FALLBACK);
232
+ rows.push({
233
+ number: n,
234
+ itemId,
235
+ group: pickGroupLabel(q),
236
+ ask,
237
+ settles,
238
+ state: pickState(q),
239
+ locationHref: pickLocationHref(itemId),
240
+ locationLabel: itemId || `${stage}/${checkId}`,
241
+ briefHandle: buildBriefHandle(itemId, ask),
242
+ checkId: stage && checkId ? `${stage}/${checkId}` : (checkId || stage),
243
+ stage,
244
+ });
245
+ }
246
+ return rows;
247
+ }
248
+
249
+ /**
250
+ * Project every failing check across stages into BlockingRow[]. One
251
+ * row per failing item (never capped; AC-18003-3). Rows carry the
252
+ * composite split so the link resolves through findByDocId.
253
+ *
254
+ * @param {Array<object>} stages
255
+ * @returns {BlockingRow[]}
256
+ */
257
+ export function buildBlockingRows(stages) {
258
+ if (!Array.isArray(stages)) return [];
259
+ /** @type {BlockingRow[]} */
260
+ const rows = [];
261
+ for (const s of stages) {
262
+ if (!s || typeof s !== 'object') continue;
263
+ const stage = typeof s.stage === 'string' ? s.stage : '';
264
+ const checks = Array.isArray(s.checks) ? s.checks : [];
265
+ for (const c of checks) {
266
+ if (!c || typeof c !== 'object' || c.ok) continue;
267
+ const check = typeof c.name === 'string' ? c.name : '';
268
+ const persona = typeof c.persona === 'string' ? c.persona : 'engineer';
269
+ const question = typeof c.question === 'string' ? c.question : '';
270
+ const failing = Array.isArray(c.failing) ? c.failing : [];
271
+ for (const f of failing) {
272
+ if (!f || typeof f !== 'object') continue;
273
+ const rawId = typeof f.id === 'string' ? f.id : '';
274
+ const why = typeof f.why === 'string' ? f.why : '';
275
+ const split = splitCompositeId(rawId);
276
+ const href = split.docId ? `#${split.docId}` : `#${entityDomId(rawId)}`;
277
+ rows.push({
278
+ stage,
279
+ check,
280
+ rawId,
281
+ docId: split.docId,
282
+ fragment: split.fragment,
283
+ href,
284
+ why,
285
+ resolved: RESOLVED_BY_CHECK[check] ?? RESOLVED_FALLBACK,
286
+ persona,
287
+ question,
288
+ });
289
+ }
290
+ }
291
+ }
292
+ return rows;
293
+ }
294
+
295
+ // ---- renderQuestionsTable ------------------------------------------
296
+
297
+ /**
298
+ * Render the one questions table (AC-18003-1, -2, -5, -8). The caller
299
+ * passes the already-projected rows (via `buildQuestionRows`) or raw
300
+ * `readiness.questions` and `rows` wins when both are set.
301
+ *
302
+ * @param {object} args
303
+ * @param {Array<object>} [args.questions] raw readiness.questions[]
304
+ * @param {QuestionRow[]} [args.rows] pre-projected rows (test shim)
305
+ * @returns {string}
306
+ */
307
+ export function renderQuestionsTable(args = {}) {
308
+ const rows = Array.isArray(args.rows) ? args.rows : buildQuestionRows(args.questions ?? []);
309
+ const total = rows.length;
310
+ const headerCount = `<span class="rcf-badge rcf-badge--count">${total}</span>`;
311
+ if (total === 0) {
312
+ // AC-18003-8: both tables must be present. Render the <table>
313
+ // skeleton with the thead so the "both tables present" check is
314
+ // literally true, and surface the empty-state caption in-table.
315
+ return `<section class="rcf-cmd-table rcf-cmd-table--questions" data-rcf-table="questions" data-rcf-empty="yes" aria-labelledby="rcf-readiness-questions-heading">
316
+ <header class="rcf-cmd-table__head"><h3 id="rcf-readiness-questions-heading">Questions for you</h3> ${headerCount}</header>
317
+ <p class="muted small">Nothing on this tree needs your answer right now; this page updates on its own as the tree changes.</p>
318
+ <table class="rcf-cmd-table__table" aria-describedby="rcf-readiness-questions-heading">
319
+ <caption class="rcf-sr-only">On me: no items</caption>
320
+ <thead>
321
+ <tr>
322
+ <th scope="col" data-rcf-col="number" data-rcf-sortable="yes">#</th>
323
+ <th scope="col" data-rcf-col="group" data-rcf-sortable="yes">Group</th>
324
+ <th scope="col" data-rcf-col="ask">Ask</th>
325
+ <th scope="col" data-rcf-col="location" data-rcf-sortable="yes">Chain location</th>
326
+ <th scope="col" data-rcf-col="settles">What resolves it</th>
327
+ <th scope="col" data-rcf-col="state" data-rcf-sortable="yes">State</th>
328
+ <th scope="col" data-rcf-col="trace"><span class="rcf-sr-only">Trace this id</span></th>
329
+ <th scope="col" data-rcf-col="copy"><span class="rcf-sr-only">Copy for your agent</span></th>
330
+ </tr>
331
+ </thead>
332
+ <tbody></tbody>
333
+ </table>
334
+ </section>`;
335
+ }
336
+ const body = rows.map(renderQuestionRow).join('');
337
+ return `<section class="rcf-cmd-table rcf-cmd-table--questions" data-rcf-table="questions" aria-labelledby="rcf-readiness-questions-heading">
338
+ <header class="rcf-cmd-table__head"><h3 id="rcf-readiness-questions-heading">Questions for you</h3> ${headerCount}</header>
339
+ <p class="muted small">Each row names an item the owner answers; the copy button hands your agent the id and the ask with no command text. The Trace action opens the matrix for the item.</p>
340
+ <table class="rcf-cmd-table__table" aria-describedby="rcf-readiness-questions-heading">
341
+ <thead>
342
+ <tr>
343
+ <th scope="col" data-rcf-col="number" data-rcf-sortable="yes">#</th>
344
+ <th scope="col" data-rcf-col="group" data-rcf-sortable="yes">Group</th>
345
+ <th scope="col" data-rcf-col="ask">Ask</th>
346
+ <th scope="col" data-rcf-col="location" data-rcf-sortable="yes">Chain location</th>
347
+ <th scope="col" data-rcf-col="settles">What resolves it</th>
348
+ <th scope="col" data-rcf-col="state" data-rcf-sortable="yes">State</th>
349
+ <th scope="col" data-rcf-col="trace"><span class="rcf-sr-only">Trace this id</span></th>
350
+ <th scope="col" data-rcf-col="copy"><span class="rcf-sr-only">Copy for your agent</span></th>
351
+ </tr>
352
+ </thead>
353
+ <tbody>${body}</tbody>
354
+ </table>
355
+ </section>`;
356
+ }
357
+
358
+ // FBS-208 (AC-209-3): the Trace action links to the Trace sub-tab for
359
+ // the row's id. Non-document ids (brief:ledger, profile:surface, ...)
360
+ // have no chain pivot and emit a disabled span instead of a dead link.
361
+ function traceActionFor(id) {
362
+ if (typeof id !== 'string' || id.length === 0) {
363
+ return `<span class="rcf-trace-action rcf-trace-action--na" data-rcf-trace="no" aria-label="No trace available for this item">-</span>`;
364
+ }
365
+ if (!isResolvableDocId(id)) {
366
+ return `<span class="rcf-trace-action rcf-trace-action--na" data-rcf-trace="no" aria-label="No trace available for this item">-</span>`;
367
+ }
368
+ const href = `#tab=readiness&sub=trace&entity=${encodeURIComponent(id)}`;
369
+ return `<a class="rcf-trace-action" data-rcf-trace-for="${escapeHtml(id)}" href="${escapeHtml(href)}">Trace</a>`;
370
+ }
371
+
372
+ function renderQuestionRow(r) {
373
+ const stageAttr = r.stage ? ` data-rcf-stage="${escapeHtml(r.stage)}"` : '';
374
+ const locationLink = `<a href="${escapeHtml(r.locationHref)}" data-rcf-location="${escapeHtml(r.itemId)}">${escapeHtml(r.locationLabel)}</a>`;
375
+ const copyLabel = 'Copy for your agent';
376
+ return `<tr id="${escapeHtml(entityDomId(r.itemId))}" data-rcf-entity="${escapeHtml(r.itemId)}" data-rcf-state="${escapeHtml(r.state)}" data-rcf-group="${escapeHtml(r.group)}"${stageAttr}>
377
+ <td data-rcf-col="number">${r.number}</td>
378
+ <td data-rcf-col="group">${escapeHtml(r.group)}</td>
379
+ <td data-rcf-col="ask">${escapeHtml(r.ask)}</td>
380
+ <td data-rcf-col="location">${locationLink}</td>
381
+ <td data-rcf-col="settles">${escapeHtml(r.settles)}</td>
382
+ <td data-rcf-col="state">${escapeHtml(r.state)}</td>
383
+ <td data-rcf-col="trace">${traceActionFor(r.itemId)}</td>
384
+ <td data-rcf-col="copy"><button type="button" class="rcf-cmd-copy" data-rcf-copy-for="${escapeHtml(r.itemId)}" data-rcf-copy-text="${escapeHtml(r.briefHandle)}" title="${escapeHtml(copyLabel)}">${escapeHtml(copyLabel)}</button></td>
385
+ </tr>`;
386
+ }
387
+
388
+ // ---- renderBlockingTable -------------------------------------------
389
+
390
+ /**
391
+ * Render the one blocking table (AC-18003-3, -4, -7, -8). FilterBar
392
+ * state is markup only in this file; the wiring lives in page-init.js
393
+ * and reads `data-rcf-stage` / `data-rcf-persona` on each row. Rows
394
+ * are not capped (every failing id across every failing check is a row).
395
+ *
396
+ * @param {object} args
397
+ * @param {Array<object>} [args.stages] readiness.stages
398
+ * @param {BlockingRow[]} [args.rows] pre-projected rows (test shim)
399
+ * @param {{stage?: string, persona?: string}} [args.filter] initial FilterBar state
400
+ * @returns {string}
401
+ */
402
+ export function renderBlockingTable(args = {}) {
403
+ const rows = Array.isArray(args.rows) ? args.rows : buildBlockingRows(args.stages ?? []);
404
+ const total = rows.length;
405
+ const filter = args.filter ?? {};
406
+ const headerCount = `<span class="rcf-badge rcf-badge--count">${total}</span>`;
407
+ if (total === 0) {
408
+ // AC-18003-8: both tables must be present even when there is
409
+ // nothing to list. Render the <table> skeleton with the thead so
410
+ // the "both tables present" check is literally true.
411
+ return `<section class="rcf-cmd-table rcf-cmd-table--blocking" data-rcf-table="blocking" data-rcf-empty="yes" aria-labelledby="rcf-readiness-blocking-heading">
412
+ <header class="rcf-cmd-table__head"><h3 id="rcf-readiness-blocking-heading">Blocking items</h3> ${headerCount}</header>
413
+ <p class="muted small">No failing items; every check passed on the current tree.</p>
414
+ <table class="rcf-cmd-table__table" aria-describedby="rcf-readiness-blocking-heading">
415
+ <caption class="rcf-sr-only">On engineers: no items</caption>
416
+ <thead>
417
+ <tr>
418
+ <th scope="col" data-rcf-col="stage">Stage</th>
419
+ <th scope="col" data-rcf-col="check">Check</th>
420
+ <th scope="col" data-rcf-col="docId">Item</th>
421
+ <th scope="col" data-rcf-col="why">Why it is failing</th>
422
+ <th scope="col" data-rcf-col="resolved">What resolves it</th>
423
+ <th scope="col" data-rcf-col="persona">On</th>
424
+ <th scope="col" data-rcf-col="trace"><span class="rcf-sr-only">Trace this id</span></th>
425
+ </tr>
426
+ </thead>
427
+ <tbody></tbody>
428
+ </table>
429
+ </section>`;
430
+ }
431
+ const stageOptions = Array.from(new Set(rows.map((r) => r.stage).filter(Boolean))).sort();
432
+ const personaOptions = Array.from(new Set(rows.map((r) => r.persona).filter(Boolean))).sort();
433
+ const stageSel = filter.stage && stageOptions.includes(filter.stage) ? filter.stage : '';
434
+ const personaSel = filter.persona && personaOptions.includes(filter.persona) ? filter.persona : '';
435
+ const body = rows.map((r) => renderBlockingRow(r, { stageSel, personaSel })).join('');
436
+ return `<section class="rcf-cmd-table rcf-cmd-table--blocking" data-rcf-table="blocking" aria-labelledby="rcf-readiness-blocking-heading">
437
+ <header class="rcf-cmd-table__head"><h3 id="rcf-readiness-blocking-heading">Blocking items</h3> ${headerCount}</header>
438
+ <div class="rcf-cmd-filterbar" data-rcf-filterbar="readiness-blocking" role="search">
439
+ <label class="rcf-cmd-filterbar__field">
440
+ <span>Stage</span>
441
+ <select data-filter-key="stage">
442
+ <option value="">All</option>
443
+ ${stageOptions.map((s) => `<option value="${escapeHtml(s)}"${s === stageSel ? ' selected' : ''}>${escapeHtml(stageLabel(s))} (${escapeHtml(s)})</option>`).join('')}
444
+ </select>
445
+ </label>
446
+ <label class="rcf-cmd-filterbar__field">
447
+ <span>Persona</span>
448
+ <select data-filter-key="persona">
449
+ <option value="">All</option>
450
+ ${personaOptions.map((p) => `<option value="${escapeHtml(p)}"${p === personaSel ? ' selected' : ''}>${escapeHtml(p === 'productOwner' ? 'Product owner' : 'Engineer')}</option>`).join('')}
451
+ </select>
452
+ </label>
453
+ </div>
454
+ <table class="rcf-cmd-table__table" aria-describedby="rcf-readiness-blocking-heading">
455
+ <thead>
456
+ <tr>
457
+ <th scope="col" data-rcf-col="stage">Stage</th>
458
+ <th scope="col" data-rcf-col="check">Check</th>
459
+ <th scope="col" data-rcf-col="docId">Item</th>
460
+ <th scope="col" data-rcf-col="why">Why it is failing</th>
461
+ <th scope="col" data-rcf-col="resolved">What resolves it</th>
462
+ <th scope="col" data-rcf-col="persona">On</th>
463
+ <th scope="col" data-rcf-col="trace"><span class="rcf-sr-only">Trace this id</span></th>
464
+ </tr>
465
+ </thead>
466
+ <tbody>${body}</tbody>
467
+ </table>
468
+ </section>`;
469
+ }
470
+
471
+ function renderBlockingRow(r, { stageSel, personaSel }) {
472
+ const hidden = (stageSel && stageSel !== r.stage) || (personaSel && personaSel !== r.persona);
473
+ const hiddenAttr = hidden ? ' hidden' : '';
474
+ const title = r.question ? ` title="${escapeHtml(r.question)}"` : '';
475
+ // Dave ruling 2026-10-08 (AC-18003-7 amended): no shell command text
476
+ // visible; chain ids render as a prefix link with a plain-English
477
+ // label (the row's check/why/resolved cells carry that label),
478
+ // and the full composite id stays in the DOM in a data attribute
479
+ // (data-rcf-raw-id below). Fragments like "rcf define freeze" or
480
+ // "surface" that live after the first colon are NOT rendered as
481
+ // visible text. A non-document prefix (profile, ADR, D1..D8) renders
482
+ // as a plain <span> rather than a dead anchor.
483
+ const docId = r.docId || r.rawId;
484
+ const link = isResolvableDocId(docId)
485
+ ? `<a href="${escapeHtml(r.href)}" data-rcf-prefix-link="yes">${escapeHtml(docId)}</a>`
486
+ : `<span class="rcf-cmd-table__prefix" data-rcf-prefix-text="yes">${escapeHtml(docId)}</span>`;
487
+ const personaWord = r.persona === 'productOwner' ? 'On me' : 'On engineers';
488
+ const stageAttr = r.stage ? ` data-rcf-stage="${escapeHtml(r.stage)}"` : '';
489
+ const personaAttr = r.persona ? ` data-rcf-persona="${escapeHtml(r.persona)}"` : '';
490
+ const stageText = r.stage ? `${stageLabel(r.stage)} (${r.stage})` : '';
491
+ return `<tr${stageAttr}${personaAttr} data-rcf-raw-id="${escapeHtml(r.rawId)}" data-rcf-doc-id="${escapeHtml(r.docId)}" data-rcf-fragment="${escapeHtml(r.fragment)}"${hiddenAttr}${title}>
492
+ <td data-rcf-col="stage">${escapeHtml(stageText)}</td>
493
+ <td data-rcf-col="check">${escapeHtml(r.check)}</td>
494
+ <td data-rcf-col="docId">${link}</td>
495
+ <td data-rcf-col="why">${escapeHtml(r.why)}</td>
496
+ <td data-rcf-col="resolved">${escapeHtml(r.resolved)}</td>
497
+ <td data-rcf-col="persona">${escapeHtml(personaWord)}</td>
498
+ <td data-rcf-col="trace">${traceActionFor(r.docId || r.rawId)}</td>
499
+ </tr>`;
500
+ }