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
@@ -35,6 +35,15 @@ import { readFile, stat } from 'node:fs/promises';
35
35
 
36
36
  import { renderComponentsFixturePage } from '../view/components-fixture.js';
37
37
  import { renderTestHostPage } from '../view/test-host/fixture.js';
38
+ // FBS-207 (TAC-4136, ADR-4140): the viewer's three JSON query routes
39
+ // call the same pure composers the CLI calls, from the walked tree
40
+ // kept on `state`. Admissibility bypass: these routes do NOT call
41
+ // `runWithAdmissibilityGate` (the CLI coverage, impact and trace
42
+ // verbs do not either; issues 316 and 317). Only readiness and
43
+ // freeze consult that gate.
44
+ import { classifyCoverageScope, computeCoverage } from '../query/coverage.js';
45
+ import { computeImpact } from '../query/impact.js';
46
+ import { computeTrace, kindOf } from '../query/trace.js';
38
47
 
39
48
  // Issue #248 (0.28.3): the shipped static assets (style.css,
40
49
  // mermaid.min.js, live-client.js) MUST be resolved once at server
@@ -180,6 +189,18 @@ export function createRouter(deps) {
180
189
  res.end(body);
181
190
  return;
182
191
  }
192
+ if (path === '/trace.json') {
193
+ handleQueryRoute(req, res, deps, url, 'trace');
194
+ return;
195
+ }
196
+ if (path === '/impact.json') {
197
+ handleQueryRoute(req, res, deps, url, 'impact');
198
+ return;
199
+ }
200
+ if (path === '/coverage.json') {
201
+ handleQueryRoute(req, res, deps, url, 'coverage');
202
+ return;
203
+ }
183
204
  if (path === '/scope.json') {
184
205
  if (typeof deps.scope !== 'function') {
185
206
  res.writeHead(404, { 'content-type': MIME.txt });
@@ -251,3 +272,151 @@ function fail(res, err) {
251
272
  res.end(`internal error: ${err && err.message ? err.message : 'unknown'}\n`);
252
273
  } catch { /* swallow */ }
253
274
  }
275
+
276
+ /**
277
+ * FBS-207 (TAC-4136 interfaces[GET /trace.json], [GET /impact.json],
278
+ * [GET /coverage.json], [QueryRouteError]): one handler per route that
279
+ * reads deps.currentState(), returns 503 before the first walk, 404 or
280
+ * 400 QueryRouteError on known failures, and application/json with
281
+ * cache-control: no-store on success. The three routes NEVER call
282
+ * runWithAdmissibilityGate (admissibility bypass stated in TAC-4136:
283
+ * the CLI coverage, impact and trace verbs do not either, so a tree
284
+ * that fails admissibility still answers these three). Nothing is
285
+ * written under rcf/.
286
+ *
287
+ * Memoised per (version, route, pivot, direction|scope) in deps.query
288
+ * Cache, which the server replaces wholesale on every rewalk. The
289
+ * cached entry is the response body string so a repeated request
290
+ * writes bytes without recomputing or re-stringifying.
291
+ *
292
+ * @param {import('node:http').IncomingMessage} req
293
+ * @param {import('node:http').ServerResponse} res
294
+ * @param {RouterDeps & { queryCache?: {
295
+ * get(key: string): string | undefined,
296
+ * set(key: string, value: string): void,
297
+ * has(key: string): boolean,
298
+ * size(): number,
299
+ * }}} deps
300
+ * @param {URL} url
301
+ * @param {'trace' | 'impact' | 'coverage'} route
302
+ */
303
+ function handleQueryRoute(req, res, deps, url, route) {
304
+ const state = deps.currentState();
305
+ // AC-208-7: 503 "view server initialising" before the first walk
306
+ // completes, exactly as /index.json answers today.
307
+ if (!state || !state.tree) {
308
+ res.writeHead(503, { 'content-type': MIME.txt });
309
+ res.end('view server initialising\n');
310
+ return;
311
+ }
312
+
313
+ // AC-208-1: for /trace.json the query id and direction come from
314
+ // the URL. Default direction is forward (matches the CLI default).
315
+ const id = url.searchParams.get('id');
316
+ const direction = url.searchParams.get('direction') ?? 'forward';
317
+ const scope = url.searchParams.get('scope');
318
+ // FBS-208: opt-in CN layer for the Trace matrix - without it, the
319
+ // trace response excludes codeNode cross-links (back-compat with
320
+ // every pre-FBS-208 caller).
321
+ const includeCode = url.searchParams.get('includeCode') === '1';
322
+
323
+ if (route === 'trace' || route === 'impact') {
324
+ // AC-208-4 unknown-id: an id missing or not found in the tree is
325
+ // 404 with QueryRouteError.error "unknown-id" and the request id
326
+ // echoed back (missing is echoed as null).
327
+ if (!id) {
328
+ sendQueryError(res, 404, { error: 'unknown-id', id: null });
329
+ return;
330
+ }
331
+ if (!kindOf(state.tree, id)) {
332
+ sendQueryError(res, 404, { error: 'unknown-id', id });
333
+ return;
334
+ }
335
+ }
336
+ if (route === 'trace') {
337
+ // AC-208-4 bad-direction: anything outside forward | back | both
338
+ // is 400 with QueryRouteError.error "bad-direction".
339
+ if (direction !== 'forward' && direction !== 'back' && direction !== 'both') {
340
+ sendQueryError(res, 400, { error: 'bad-direction', id, direction });
341
+ return;
342
+ }
343
+ }
344
+ if (route === 'coverage' && scope !== null) {
345
+ // AC-208-4 bad-scope: a scope positional that is below AC or
346
+ // unknown is 400 with QueryRouteError.error "bad-scope". Reuses
347
+ // the exact classifier the CLI uses (src/cli/coverage.js).
348
+ const classification = classifyCoverageScope(state.tree, scope);
349
+ if (classification !== 'valid') {
350
+ sendQueryError(res, 400, { error: 'bad-scope', scope });
351
+ return;
352
+ }
353
+ }
354
+
355
+ // AC-208-6: memo key covers the state version and the route-shaped
356
+ // request params so each (version, pivot, direction|scope) computes
357
+ // once and serves from the memo thereafter. The cache is replaced
358
+ // wholesale on each rewalk, so a new version never serves a prior
359
+ // version's bytes.
360
+ const key = route === 'trace'
361
+ ? `${state.version}:trace:${id}:${direction}:${includeCode ? 'c1' : 'c0'}`
362
+ : route === 'impact'
363
+ ? `${state.version}:impact:${id}:-`
364
+ : `${state.version}:coverage:-:${scope ?? ''}`;
365
+
366
+ const cache = deps.queryCache;
367
+ let body = cache && cache.has(key) ? cache.get(key) : null;
368
+ if (body === null || body === undefined) {
369
+ let result;
370
+ try {
371
+ if (route === 'trace') {
372
+ result = computeTrace(state.tree, { id, direction, includeCode });
373
+ } else if (route === 'impact') {
374
+ result = computeImpact(state.tree, { id });
375
+ } else {
376
+ // AC-208-3: strict per-AC coverage, with the CLI's optional
377
+ // scope narrowing. testPointers are the resolved map the
378
+ // walker produced (fail-closed when missing per
379
+ // computeCoverage's doc).
380
+ result = computeCoverage(state.tree, {
381
+ strict: true,
382
+ scopeId: scope,
383
+ testPointers: state.testPointers,
384
+ });
385
+ }
386
+ } catch (err) {
387
+ fail(res, err);
388
+ return;
389
+ }
390
+ body = JSON.stringify(result);
391
+ if (cache) cache.set(key, body);
392
+ }
393
+
394
+ // AC-208-1 / -2 / -3: application/json with cache-control: no-store,
395
+ // computed from the current state and served relative to the mount.
396
+ res.writeHead(200, {
397
+ 'content-type': MIME.json,
398
+ 'content-length': String(Buffer.byteLength(body)),
399
+ 'cache-control': 'no-store',
400
+ });
401
+ res.end(body);
402
+ }
403
+
404
+ /**
405
+ * FBS-207 (TAC-4136 QueryRouteError): the three routes share one JSON
406
+ * error body `{error, id?, direction?, scope?}`, with request echo for
407
+ * only the fields that were present. 404 for unknown-id, 400 for the
408
+ * two usage errors.
409
+ *
410
+ * @param {import('node:http').ServerResponse} res
411
+ * @param {404 | 400} status
412
+ * @param {{error: 'unknown-id' | 'bad-direction' | 'bad-scope', id?: string | null, direction?: string, scope?: string}} payload
413
+ */
414
+ function sendQueryError(res, status, payload) {
415
+ const body = JSON.stringify(payload);
416
+ res.writeHead(status, {
417
+ 'content-type': MIME.json,
418
+ 'content-length': String(Buffer.byteLength(body)),
419
+ 'cache-control': 'no-store',
420
+ });
421
+ res.end(body);
422
+ }
@@ -12,6 +12,11 @@ import { parseArgs } from 'node:util';
12
12
  import { formatError, isRcfError } from '#core/errors';
13
13
 
14
14
  import { runVerification } from '../engine/index.js';
15
+ import {
16
+ DEFAULT_VERIFY_MODE,
17
+ VERIFY_MODES,
18
+ isVerifyMode,
19
+ } from '../engine/deterministic.js';
15
20
  import { isSemverString, PLAYWRIGHT_MCP_VERSION } from '../engine/launcher.js';
16
21
  import { serialiseReport } from '../report/index.js';
17
22
  import { gateTripped, FINDING_SEVERITIES } from '../verdict/index.js';
@@ -28,6 +33,10 @@ const OPTION_SPEC = {
28
33
  'provision-mode': { type: 'string' },
29
34
  persona: { type: 'string' },
30
35
  'playwright-mcp-version': { type: 'string' },
36
+ // Issue 330 (2026-10-08): the verify mode. agentScreenshotCritique is
37
+ // the parent-session default; deterministic is the model-free, in-
38
+ // process mode a dispatched worker can run (never spawns `claude`).
39
+ mode: { type: 'string' },
31
40
  help: { type: 'boolean' },
32
41
  };
33
42
 
@@ -59,6 +68,22 @@ Optional:
59
68
  fires and the report records the overridden pin
60
69
  as runStats.playwrightMcpVersion). Must be an
61
70
  exact semver X.Y.Z.
71
+ --mode <mode> Verify mode (default: agentScreenshotCritique):
72
+ agentScreenshotCritique parent-session mode;
73
+ spawns the agent CLI as a subagent with
74
+ Playwright MCP, drives the live app with a
75
+ model, and emits per-AC findings. Only run
76
+ from a parent session that may spawn the
77
+ agent CLI.
78
+ deterministic model-free, in-process mode a
79
+ dispatched worker can run; records a per-AC
80
+ skip ledger as blockedAcs[] with reason
81
+ 'critique-only' and stamps
82
+ runStats.counts = { verified, failed,
83
+ skipped, total }. The finalise gate refuses
84
+ promotion on a deterministic run that
85
+ skipped any AC (ADR-4112 verified
86
+ invariant).
62
87
  --help Print this help
63
88
 
64
89
  Exit codes:
@@ -146,6 +171,20 @@ export async function main(argv, deps = {}) {
146
171
  stderr.write(`Playwright MCP: pinned to @playwright/mcp@${PLAYWRIGHT_MCP_VERSION}\n`);
147
172
  }
148
173
 
174
+ // Issue 330: resolve --mode. Unknown value refuses exit 2 so a typo
175
+ // does not silently fall through to the default agent mode (a
176
+ // dispatched worker that typed --mode=determinstic would otherwise
177
+ // spawn `claude` and be killed by the harness).
178
+ const modeFlag = flags.mode;
179
+ if (modeFlag !== undefined && !isVerifyMode(modeFlag)) {
180
+ stderr.write(`[error] usage --mode must be one of ${VERIFY_MODES.join(' | ')} (got "${modeFlag}")\n`);
181
+ return 2;
182
+ }
183
+ const mode = modeFlag ?? DEFAULT_VERIFY_MODE;
184
+ if (mode === 'deterministic') {
185
+ stderr.write('Verify mode: deterministic (model-free, in-process; a dispatched worker can run this)\n');
186
+ }
187
+
149
188
  const result = await runVerification({
150
189
  repo: flags.repo,
151
190
  chainRef: flags.chain,
@@ -157,6 +196,7 @@ export async function main(argv, deps = {}) {
157
196
  persona: flags.persona,
158
197
  severityGate: gate,
159
198
  playwrightMcpVersion: overrideRaw,
199
+ mode,
160
200
  }, deps);
161
201
 
162
202
  if (isRcfError(result)) {
@@ -0,0 +1,150 @@
1
+ // Deterministic verify mode (issue 330, 2026-10-08). A dispatched worker
2
+ // cannot spawn `claude` as a subagent (an estate rule the surrounding
3
+ // harness enforces by killing any such child), so the default
4
+ // `agentScreenshotCritique` mode is a parent-session mode. This module
5
+ // is its sister: a model-free verify path that a dispatched worker can
6
+ // run end-to-end from inside a tool call.
7
+ //
8
+ // Scope today (minimal, honest, additive):
9
+ // - HTTP reachability probe against `--url` recorded on `runStats`
10
+ // as diagnostic context (NOT a per-AC verification);
11
+ // - every AC the chain carries is recorded on `blockedAcs[]` with
12
+ // reason `critique-only` so the aggregate verdict is BLOCKED and
13
+ // the finalise gate refuses promotion by construction (ADR-4112
14
+ // verified invariant). The report also carries
15
+ // `runStats.counts = { verified, failed, skipped, total }` so a
16
+ // report reader sees the mode did not actively verify anything.
17
+ //
18
+ // The catalog-driven per-AC probe seam is a later train car: when the
19
+ // chain's AC subschema gains structured probe fields (status, path,
20
+ // header, body substring, selector) OR a project ships
21
+ // `docs/runtime-verify-catalog.md` keyed by acId, this module will
22
+ // resolve those probes and surface pass/fail/skipped per AC with
23
+ // evidence. The partial fix today keeps that door open: `runStats.mode`
24
+ // and `runStats.counts` are stable across future extensions.
25
+ //
26
+ // Shared-parent-state note: this module uses `globalThis.fetch`
27
+ // (injectable via `deps.fetchImpl`) and never spawns a child
28
+ // process, so it cannot leak the parent's Playwright or MCP session.
29
+ // A DOM-probe extension (Playwright headless, in-process) can land in
30
+ // a later train car; it would use the already-declared optional peer
31
+ // dependency and never the MCP.
32
+
33
+ const DEFAULT_REACHABLE_TIMEOUT_MS = 10000;
34
+ const CRITIQUE_ONLY_REASON = 'critique-only: subjective visual judgement needed; deterministic mode cannot judge this AC (parent-session agentScreenshotCritique mode is required).';
35
+
36
+ /**
37
+ * One-shot HTTP probe. Returns status/ok/elapsedMs + a short bodyHead
38
+ * sample so a report reader can see what the deployed URL served, or
39
+ * an `error.kind` on a transport failure. Diagnostic context on
40
+ * `runStats` only, not a verification claim against any AC.
41
+ *
42
+ * @param {object} args
43
+ * @param {string} args.url
44
+ * @param {typeof fetch} [args.fetchImpl]
45
+ * @param {number} [args.timeoutMs]
46
+ * @returns {Promise<{ ok: boolean, status: number|null, elapsedMs: number, bodyHead?: string, error?: { kind: string, message: string } }>}
47
+ */
48
+ export async function probeHttpReachability({ url, fetchImpl, timeoutMs = DEFAULT_REACHABLE_TIMEOUT_MS }) {
49
+ const fetchFn = fetchImpl ?? globalThis.fetch;
50
+ if (typeof fetchFn !== 'function') {
51
+ return { ok: false, status: null, elapsedMs: 0, error: { kind: 'noFetch', message: 'no fetch implementation available (node >=24 ships globalThis.fetch; pass deps.fetchImpl otherwise)' } };
52
+ }
53
+ const controller = new AbortController();
54
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
55
+ const startedAt = Date.now();
56
+ try {
57
+ const res = await fetchFn(url, { method: 'GET', signal: controller.signal, redirect: 'follow' });
58
+ const elapsedMs = Date.now() - startedAt;
59
+ let bodyHead;
60
+ try {
61
+ const text = await res.text();
62
+ bodyHead = typeof text === 'string' ? text.slice(0, 256) : undefined;
63
+ } catch {
64
+ bodyHead = undefined;
65
+ }
66
+ return {
67
+ ok: typeof res.status === 'number' && res.status > 0 && res.status < 500,
68
+ status: typeof res.status === 'number' ? res.status : null,
69
+ elapsedMs,
70
+ ...(bodyHead !== undefined ? { bodyHead } : {}),
71
+ };
72
+ } catch (err) {
73
+ const elapsedMs = Date.now() - startedAt;
74
+ const kind = err?.name === 'AbortError' ? 'timeout' : 'network';
75
+ return { ok: false, status: null, elapsedMs, error: { kind, message: err?.message ?? String(err) } };
76
+ } finally {
77
+ clearTimeout(timer);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Run the deterministic verify pass. Pure in-process; no agent CLI,
83
+ * no MCP, no child processes. Returns the per-AC skip ledger and the
84
+ * diagnostic HTTP probe on `runStats`.
85
+ *
86
+ * Honest minimum (no chain-level probe field exists today): every
87
+ * testable AC lands on `blockedAcs[]` as critique-only, so the
88
+ * aggregate verdict is BLOCKED and the finalise gate refuses by
89
+ * construction. The caller (engine) MERGES these entries with the
90
+ * provisioning-side blockedAcs; the two are the same shape.
91
+ *
92
+ * @param {object} args
93
+ * @param {string} args.url
94
+ * @param {Array<object>} args.acs - flattened acceptance criteria from the chain
95
+ * @param {object} [args.deps]
96
+ * @param {typeof fetch} [args.deps.fetchImpl]
97
+ * @returns {Promise<{ findings: object[], blockedAcs: Array<{acId: string, reason: string, mode: string}>, runStats: object }>}
98
+ */
99
+ export async function runDeterministic({ url, acs = [], deps = {} }) {
100
+ const testable = Array.isArray(acs) ? acs.filter((a) => a && a.testable !== false) : [];
101
+ const startedAt = Date.now();
102
+ const probe = await probeHttpReachability({ url, fetchImpl: deps.fetchImpl });
103
+ probe.url = url;
104
+ const findings = [];
105
+ const blockedAcs = testable.map((ac) => ({
106
+ acId: ac.acId,
107
+ reason: CRITIQUE_ONLY_REASON,
108
+ mode: 'deterministic',
109
+ }));
110
+ const finishedAt = Date.now();
111
+ const counts = {
112
+ total: testable.length,
113
+ verified: 0,
114
+ failed: 0,
115
+ skipped: blockedAcs.length,
116
+ };
117
+ return {
118
+ findings,
119
+ blockedAcs,
120
+ runStats: {
121
+ mode: 'deterministic',
122
+ httpProbe: {
123
+ url,
124
+ ok: probe.ok,
125
+ status: probe.status,
126
+ elapsedMs: probe.elapsedMs,
127
+ ...(probe.bodyHead !== undefined ? { bodyHead: probe.bodyHead } : {}),
128
+ ...(probe.error !== undefined ? { error: probe.error } : {}),
129
+ },
130
+ counts,
131
+ startedAtMs: startedAt,
132
+ finishedAtMs: finishedAt,
133
+ },
134
+ };
135
+ }
136
+
137
+ /** Supported verify modes (public enum for CLI + engine). */
138
+ export const VERIFY_MODES = Object.freeze(['agentScreenshotCritique', 'deterministic']);
139
+ export const DEFAULT_VERIFY_MODE = 'agentScreenshotCritique';
140
+
141
+ /**
142
+ * True iff value is a known verify mode. The CLI uses this as its
143
+ * sole gate so a new mode added here is accepted everywhere.
144
+ *
145
+ * @param {unknown} value
146
+ * @returns {boolean}
147
+ */
148
+ export function isVerifyMode(value) {
149
+ return typeof value === 'string' && VERIFY_MODES.includes(value);
150
+ }
@@ -19,6 +19,11 @@ import { readChain as defaultReadChain } from '../chain/index.js';
19
19
  import { runProvisioning, cleanup as defaultCleanup } from '../provision/index.js';
20
20
  import { composeBrief } from './brief.js';
21
21
  import { PLAYWRIGHT_MCP_VERSION, playwrightMcpConfig, resolveLauncher } from './launcher.js';
22
+ import {
23
+ DEFAULT_VERIFY_MODE,
24
+ isVerifyMode,
25
+ runDeterministic,
26
+ } from './deterministic.js';
22
27
  import { aggregateVerdict, derivePerAcVerdicts, validateFinding } from '../verdict/index.js';
23
28
  import { buildReport } from '../report/index.js';
24
29
 
@@ -63,6 +68,10 @@ export async function runVerification(opts = {}, deps = {}) {
63
68
  const now = deps.now ?? (() => new Date().toISOString());
64
69
  const readChain = deps.readChain ?? defaultReadChain;
65
70
  const startedAt = now();
71
+ // Issue 330: verify mode selects the launcher below (step 6). Resolved
72
+ // early so every report (NOT-DEPLOYED, LAUNCH-FAILURE, normal) stamps
73
+ // runStats.mode and finalise can key its refusal line off it.
74
+ const mode = isVerifyMode(opts.mode) ? opts.mode : DEFAULT_VERIFY_MODE;
66
75
 
67
76
  // 1. Resolve + validate the runtime declaration (§4).
68
77
  const resolved = resolveProfile({ profile: opts.profile, url: opts.url, parityEnv: opts.parityEnv });
@@ -100,6 +109,7 @@ export async function runVerification(opts = {}, deps = {}) {
100
109
  verdict: 'NOT-DEPLOYED', verdictAuthority,
101
110
  findings: [], blockedAcs: [], provisioning: null,
102
111
  perAcVerdicts,
112
+ runStats: { mode },
103
113
  });
104
114
  return { report };
105
115
  }
@@ -139,11 +149,23 @@ export async function runVerification(opts = {}, deps = {}) {
139
149
  ? playwrightMcpConfig(playwrightMcpVersion)
140
150
  : undefined;
141
151
 
142
- // 6. Launch the isolated verifier agent (§7.3 isolation env, §9 fresh session).
152
+ // 6. Launch the isolated verifier agent (§7.3 isolation env, §9 fresh session),
153
+ // OR, in deterministic mode (issue 330), run the in-process model-free
154
+ // pass so a dispatched worker can finalise without spawning `claude`.
155
+ // Deterministic mode's blockedAcs merge into the provisioning-side list
156
+ // so the aggregate verdict is BLOCKED by construction while the honest
157
+ // counts (verified/failed/skipped) are stamped on `runStats` for the
158
+ // finalise refusal line (ADR-4112 verified invariant).
143
159
  let launchResult;
144
160
  try {
145
- const launchAgent = await resolveLauncher(deps);
146
- launchResult = await launchAgent({ brief, url, profile, mcpConfig: mcpConfigForPin });
161
+ if (mode === 'deterministic') {
162
+ const detResult = await runDeterministic({ url, acs: chain.acs, deps });
163
+ launchResult = { findings: detResult.findings, runStats: detResult.runStats };
164
+ for (const b of detResult.blockedAcs ?? []) blockedAcs.push(b);
165
+ } else {
166
+ const launchAgent = await resolveLauncher(deps);
167
+ launchResult = await launchAgent({ brief, url, profile, mcpConfig: mcpConfigForPin });
168
+ }
147
169
  } catch (err) {
148
170
  // A verifier agent that could not run — or whose output could not be
149
171
  // ingested — is NEVER a fabricated PASS (§9). But the report is still
@@ -159,7 +181,7 @@ export async function runVerification(opts = {}, deps = {}) {
159
181
  findings: [], blockedAcs, provisioning,
160
182
  launchFailure: { message: err.message, rawOutputPath: err.rawOutputPath ?? null },
161
183
  perAcVerdicts,
162
- runStats: { playwrightMcpVersion },
184
+ runStats: { playwrightMcpVersion, mode },
163
185
  });
164
186
  return { report };
165
187
  }
@@ -190,6 +212,7 @@ export async function runVerification(opts = {}, deps = {}) {
190
212
  };
191
213
  const runStatsForReport = {
192
214
  ...(launchResult?.runStats ?? {}),
215
+ mode,
193
216
  playwrightMcpVersion,
194
217
  evalCoverage,
195
218
  };
@@ -1,7 +1,8 @@
1
- // REQ renderer. Carries an optional per-REQ Mermaid subdiagram block when
2
- // the caller passes one (the html-page module decides whether to include it).
3
- // Child user stories are NOT rendered as inline links because the Phase 3.2
4
- // layout nests them as `<details>` under the REQ's own `<details>` wrapper.
1
+ // REQ renderer. FBS-208 (AC-209-5): the per-REQ Mermaid slice is retired on
2
+ // the user-facing surface; this renderer no longer emits a slice block even
3
+ // when a caller still passes `ctx.subdiagram`. Child user stories are NOT
4
+ // rendered as inline links because the Phase 3.2 layout nests them as
5
+ // `<details>` under the REQ's own `<details>` wrapper.
5
6
 
6
7
  import {
7
8
  anchorIdFor,
@@ -17,7 +18,9 @@ import {
17
18
  * @param {object} ctx
18
19
  * @param {string|undefined} ctx.raw
19
20
  * @param {import('#core/errors').RcfError[]} [ctx.errors]
20
- * @param {string|undefined} [ctx.subdiagram]
21
+ * @param {string|undefined} [ctx.subdiagram] - accepted for back-compat;
22
+ * FBS-208 retires rendering of the per-REQ Mermaid slice, so this value
23
+ * is ignored.
21
24
  * @param {string|undefined} [ctx.idPrefix] - prefix prepended to every emitted
22
25
  * id/anchor so the same REQ can be rendered under multiple Product Map
23
26
  * buckets without duplicating ids. Empty when rendered from the
@@ -32,9 +35,9 @@ export function renderReq(req, ctx) {
32
35
  const prefix = ctx.idPrefix ?? '';
33
36
  const anchor = `${prefix}${anchorIdFor(req.reqId ?? 'REQ')}`;
34
37
  const broken = ctx.errors?.length ? brokenBanner(ctx.errors) : '';
35
- const subdiagram = ctx.subdiagram
36
- ? `<section class="subdiagram"><h4>Slice diagram</h4><pre class="mermaid">${escapeHtml(ctx.subdiagram)}</pre></section>`
37
- : '';
38
+ // FBS-208 (AC-209-5): the Mermaid slice block is not emitted even when
39
+ // `ctx.subdiagram` is passed. The ctx key stays accepted so Product Map
40
+ // callers continue to compile.
38
41
  return `
39
42
  <article id="${anchor}" class="doc doc-req">
40
43
  <h3>${escapeHtml(req.reqId ?? 'REQ')} - ${escapeHtml(req.title ?? '')}</h3>
@@ -45,7 +48,6 @@ export function renderReq(req, ctx) {
45
48
  ${fieldPara('Priority', req.priority)}
46
49
  ${fieldPara('Rationale', req.rationale)}
47
50
  ${fieldList('Tags', req.tags)}
48
- ${subdiagram}
49
51
  ${ctx.suppressRawJson ? '' : rawJsonDisclosure(ctx.raw, req, req.reqId, prefix)}
50
52
  </article>`.trim();
51
53
  }
@@ -46,7 +46,10 @@ import {
46
46
  } from './doc-renderers/index.js';
47
47
  import { detailsWrap, escapeHtml } from './doc-renderers/helpers.js';
48
48
  import { computeQueue } from '../build/queue.js';
49
- import { allRequirementSubdiagrams } from './mermaid-diagram.js';
49
+ // FBS-208 (AC-209-5): the per-REQ Mermaid slice is retired on the
50
+ // user-facing surfaces. The generator in `./mermaid-diagram.js` is
51
+ // retained while AC-201-1 still binds its unit tests; it is no longer
52
+ // invoked from any renderer here.
50
53
  import { renderProductMapPanel } from './product-map.js';
51
54
  import { renderReadinessPanel } from './readiness.js';
52
55
  import { renderToastContainer } from './components/toast.js';
@@ -177,7 +180,7 @@ export function renderPage(model, opts) {
177
180
  * @returns {string}
178
181
  */
179
182
  export function renderContent(model) {
180
- const subdiagrams = allRequirementSubdiagrams(model);
183
+ // FBS-208 (AC-209-5): per-REQ Mermaid slice retired on the surface.
181
184
  const needsWorkIds = computeReqNeedsWorkIds(model.readiness);
182
185
 
183
186
  const prdSection = model.prd
@@ -191,13 +194,14 @@ export function renderContent(model) {
191
194
  : '<p><em>No PRD on disk.</em></p>';
192
195
 
193
196
  const prdSelector = renderPrdRequirementSelector(model);
194
- const requirementsPanel = renderRequirementsPanel(model, subdiagrams, needsWorkIds);
197
+ const requirementsPanel = renderRequirementsPanel(model, needsWorkIds);
195
198
  const architecturePanel = renderArchitecturePanel(model);
196
199
  const buildPanel = renderBuildPanel(model);
197
200
  const productMapPanel = renderProductMapPanel(model);
198
201
  const readinessPanel = renderReadinessPanel(model.readiness ?? null, {
199
202
  profile: model.profileText ?? null,
200
203
  freezeRecord: model.freezeRecord ?? null,
204
+ tree: model,
201
205
  });
202
206
 
203
207
  const errorBanner = renderErrorBanner(model.errors ?? []);
@@ -248,10 +252,10 @@ export function renderContent(model) {
248
252
  * is the one the current router already walks (decision 11, PR 1).
249
253
  *
250
254
  * @param {import('./tree-model.js').BuiltTreeModel} model
251
- * @param {Map<string, string>} subdiagrams - REQ id -> Mermaid source
255
+ * (FBS-208: the per-REQ Mermaid slice is retired; this param slot is gone)
252
256
  * @param {Set<string>} needsWorkIds - REQ ids still needing PO work
253
257
  */
254
- function renderRequirementsPanel(model, subdiagrams, needsWorkIds) {
258
+ function renderRequirementsPanel(model, needsWorkIds) {
255
259
  if (model.requirements.length === 0 && model.userStories.length === 0) {
256
260
  return renderEmptyState({
257
261
  title: 'No requirements on disk',
@@ -289,7 +293,7 @@ function renderRequirementsPanel(model, subdiagrams, needsWorkIds) {
289
293
  showExpandAll: true,
290
294
  });
291
295
 
292
- const reqRows = model.requirements.map((r) => renderRequirementRow(r, model, subdiagrams, needsWorkIds)).join('\n');
296
+ const reqRows = model.requirements.map((r) => renderRequirementRow(r, model, needsWorkIds)).join('\n');
293
297
 
294
298
  const orphanUs = model.userStories.filter((u) => !u.reqId || !model.requirements.some((r) => r.reqId === u.reqId));
295
299
  const orphanBlock = orphanUs.length > 0
@@ -313,7 +317,7 @@ ${reqRows}
313
317
  ${orphanBlock}`;
314
318
  }
315
319
 
316
- function renderRequirementRow(req, model, subdiagrams, needsWorkIds) {
320
+ function renderRequirementRow(req, model, needsWorkIds) {
317
321
  const stories = model.storiesByReqId.get(req.reqId) ?? [];
318
322
  const acCount = stories.reduce((n, u) => n + (u.acceptanceCriteria?.length ?? 0), 0);
319
323
  const area = normaliseFacet(req.domain);
@@ -348,10 +352,10 @@ function renderRequirementRow(req, model, subdiagrams, needsWorkIds) {
348
352
  ? `<p class="rcf-req-needswork"><strong>Needs work:</strong> ${escapeHtml(needsWorkReason)}</p>`
349
353
  : '';
350
354
 
351
- const subdiagram = subdiagrams.get(req.reqId);
352
- const sliceDetails = subdiagram
353
- ? `<details class="rcf-req-slice"><summary>Slice diagram <span class="muted small">(REQ, stories, criteria and the FBS that deliver them)</span></summary><div class="rcf-req-slice-body"><pre class="mermaid">${escapeHtml(subdiagram)}</pre></div></details>`
354
- : '';
355
+ // FBS-208 (AC-209-5): per-REQ Mermaid slice retired. The chain's own
356
+ // `requirementSubdiagram` generator stays in `./mermaid-diagram.js`
357
+ // for AC-201-1, but no user-facing surface renders it any more.
358
+ const sliceDetails = '';
355
359
 
356
360
  const rawJson = model.rawById.get(req.reqId) ?? JSON.stringify(req, null, 2);
357
361
  const rawDetails = `<details class="rcf-req-raw raw-json" data-doc-id="${escapeHtml(req.reqId)}::raw"><summary class="muted small">Raw JSON</summary><pre>${escapeHtml(rawJson)}</pre></details>`;
package/src/view/index.js CHANGED
@@ -163,7 +163,13 @@ export async function renderModelToPage({ projectRoot }) {
163
163
  // shapes:pathsResolve agrees with the CLI without parsing HTML. The
164
164
  // viewer HTML is still the operator-facing surface; this field is
165
165
  // additive and consumed by test code only.
166
- return { fullPageHtml, contentHtml, errors, tree, pmPartials, indexJson, readiness };
166
+ // FBS-207 (TAC-4136, AC-208-6): the walked `tree` and the resolved
167
+ // `testPointers` map are kept on the returned state so the server's
168
+ // query routes (/trace.json, /impact.json, /coverage.json) compute
169
+ // per request from the same tree the page was rendered from, and
170
+ // the server's queryCache invalidates on `state.version`. Additive;
171
+ // nothing else reads these fields on the view-layer path.
172
+ return { fullPageHtml, contentHtml, errors, tree, testPointers, pmPartials, indexJson, readiness };
167
173
  }
168
174
 
169
175
  /**
@@ -439,6 +439,10 @@
439
439
  host.innerHTML = payload.contentHtml;
440
440
  }
441
441
  currentVersion = payload.version;
442
+ // FBS-208 (AC-209-4): publish the latest tree version so the
443
+ // Trace matrix wiring can cache per state.version and detect a
444
+ // swap. Previous caches stay for the stale-banner readback.
445
+ try { win.__rcfTreeVersion = payload.version; } catch (e) { /* soft */ }
442
446
  // The Product Map panel keeps a client-side partial cache
443
447
  // (window.__rcfPmPartialCache is opaque to us). A tree-update
444
448
  // invalidates every partial: the next activatePmGroup on a lazy