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
@@ -0,0 +1,70 @@
1
+ {
2
+ "createdAt": "2026-10-07T13:46:54.539Z",
3
+ "updatedAt": "2026-10-07T13:46:54.539Z",
4
+ "description": "User story under REQ-002 for the traceability view (discovery sections 4 rank 8, 9 and 12, section 6.5; FBS-208; TAC-4136, ADR-4140). The Readiness tab's Trace sub-tab renders a matrix for a pivot read from entity= in the hash: rows are the stories and criteria under the pivot, columns are suites, cases, build specs and components, cells are filled where the chain reaches and coloured by resolution from coverage. Every id in the readiness tables carries a Trace action and the lookup modal's readiness hits land on the same hash. The client caches per state.version in memory and re-renders after an SSE swap. The per-requirement Mermaid slice on the Requirements tab is retired in the same change; no Mermaid is user-facing on these surfaces.",
5
+ "asA": "a product owner showing traceability or an engineer checking a requirement's reach",
6
+ "iWant": "a matrix from the chosen requirement down to its tests, with the unresolved cells visible, reachable from any id on the page",
7
+ "soThat": "the viewer demonstrates the traceability the method sells without a terminal",
8
+ "tacIds": [
9
+ "TAC-4136-view-query-routes"
10
+ ],
11
+ "acceptanceCriteria": [
12
+ {
13
+ "id": "AC-209-1",
14
+ "description": "[happy] Given the Trace sub-tab and a pivot id in the hash, when the client fetches \"./trace.json\" for it, then a matrix renders with one TraceMatrixRow per story and criterion under the pivot and columns for suites, cases, build specs and components, each TraceMatrixCell.reached true where the forward trace reaches the column from the row's criterion.",
15
+ "testable": true,
16
+ "ownerRef": {
17
+ "tacId": "TAC-4136-view-query-routes",
18
+ "field": "interfaces[renderTraceMatrix]"
19
+ }
20
+ },
21
+ {
22
+ "id": "AC-209-2",
23
+ "description": "[happy] Given coverage data for the pivot's criteria, when a cell renders, then TraceMatrixCell.resolution is \"resolving\" on the --sv-success token, \"unresolved\" on the --sv-warning token or \"none\" on the --sv-muted token, with no new colour token introduced.",
24
+ "testable": true,
25
+ "ownerRef": {
26
+ "tacId": "TAC-4136-view-query-routes",
27
+ "field": "interfaces[TraceMatrixCell]"
28
+ }
29
+ },
30
+ {
31
+ "id": "AC-209-3",
32
+ "description": "[happy] Given any id rendered in the questions, blocking, thin-requirements or coverage tables, when the row renders, then it carries a \"Trace\" action linking to \"#tab=readiness&sub=trace&entity=<id>\", and a lookup modal hit on a readiness row navigates to the same hash preserving embed and theme.",
33
+ "testable": true,
34
+ "ownerRef": {
35
+ "tacId": "TAC-4136-view-query-routes",
36
+ "field": "interfaces[wireTraceMatrix]"
37
+ }
38
+ },
39
+ {
40
+ "id": "AC-209-4",
41
+ "description": "[edge] Given an SSE tree-update that swaps #rcf-live-content while the Trace sub-tab shows a matrix, when the swap lands, then the matrix is re-rendered from the client cache keyed by state.version and marked stale until the next fetch completes, never dropped.",
42
+ "testable": true
43
+ },
44
+ {
45
+ "id": "AC-209-5",
46
+ "description": "[must-not] Given the Requirements tab, when a requirement renders, then no Mermaid slice diagram is rendered for it (the per-requirement slice and its details element are gone) and no Mermaid is rendered anywhere on the Readiness tab.",
47
+ "testable": true
48
+ },
49
+ {
50
+ "id": "AC-209-6",
51
+ "description": "[failure] Given a pivot the route reports with found false, when the Trace sub-tab renders, then an empty state names the id and offers the lookup modal, and no partial matrix is drawn.",
52
+ "testable": true
53
+ },
54
+ {
55
+ "id": "AC-209-7",
56
+ "description": "[edge] Given a pivot whose forward trace yields more than 200 TraceMatrixRow entries, when the matrix renders, then a row filter by story id or criterion id is shown, the full TraceMatrixRow.cells set is retained in memory and nothing is truncated silently.",
57
+ "testable": true,
58
+ "ownerRef": {
59
+ "tacId": "TAC-4136-view-query-routes",
60
+ "field": "interfaces[TraceMatrixRow]"
61
+ }
62
+ }
63
+ ],
64
+ "usId": "US-209",
65
+ "prdId": "PRD-001",
66
+ "reqId": "REQ-002",
67
+ "version": "0.1.0",
68
+ "status": "draft",
69
+ "title": "the viewer renders a trace matrix for a selected node and offers Trace on every id, retiring the Mermaid slice"
70
+ }
@@ -40,8 +40,25 @@
40
40
  # `npm install rcf-lite`.
41
41
 
42
42
  feedVersion: 1
43
- latest: "0.32.4"
43
+ latest: "0.33.0"
44
44
  releases:
45
+ - version: "0.33.0"
46
+ date: "2026-10-09"
47
+ breaking: false
48
+ headlines:
49
+ - "rcf-lite 0.33.0 makes the viewer's Readiness tab the command page: questions, blocking items, a coverage summary, a stage grid with sub-tabs, delta counts and a trace matrix, backed by three new trace, impact and coverage JSON routes with a per-version memo on the viewer server. `rcf verify run` and `rcf build finalise` gain a deterministic mode so a dispatched worker that cannot spawn the agent CLI can still produce a report and refuse promotion honestly. The live viewer survives write-temp-then-rename saves (vim backupcopy=no, JetBrains safe-write) and never silently drops a change that lands mid-rewalk. The @stravica-ai/rcf-schemas 0.6.3 pin is unchanged."
50
+ - "For embedders: three new viewer routes (/trace.json, /impact.json, /coverage.json) must be added to the reverse-proxy allow-list for /rcf-viewer/* before the version pin bump. The embed contract and the rcf-view-theme postMessage are unchanged."
51
+ minAgentAction: null
52
+ notesUrl: "https://stravica.ai/docs/rcf/changelog/"
53
+
54
+ - version: "0.32.5"
55
+ date: "2026-10-07"
56
+ breaking: false
57
+ headlines:
58
+ - "rcf-lite 0.32.5 ships two chain-shape fixes surfaced by Dex on the discovery-viewer-command-page branch (reporter). The dogfood walker in test/rcf-tree.test.js now mirrors the sidecar-ledger carve-out from src/define/ledgers.js by importing the new DEFINE_RELATIVE_DIR constant and ignoring rcf/define/, so the first `pnpm rcf define ledger brief add` on a fresh tree no longer blows up three dogfood assertions (issue 321). A new guidance/discovery/ (and companion docs/discovery/) subdir gives project discovery artefacts a predictable home outside the three locked methodology inventories; a first-time contributor can now land a discovery brief without touching the methodology lock or the pack manifest (issue 322). The 0.32.1 through 0.32.4 fixes carry forward unchanged."
59
+ minAgentAction: null
60
+ notesUrl: "https://stravica.ai/docs/rcf/changelog/"
61
+
45
62
  - version: "0.32.4"
46
63
  date: "2026-10-07"
47
64
  breaking: false
@@ -30,11 +30,18 @@ import { resolve as resolvePath } from 'node:path';
30
30
  import process from 'node:process';
31
31
  import { parseArgs } from 'node:util';
32
32
 
33
+ import { writeFile } from 'node:fs/promises';
34
+
33
35
  import { isRcfError, rcfError, writeUnexpectedFailure } from '#core/errors';
34
36
  import { updateDocument, walkTree } from '#core/store';
35
37
 
36
38
  import { findProjectRoot } from '../view/index.js';
37
39
  import { kindOf } from '../query/index.js';
40
+ import {
41
+ DEFAULT_VERIFY_MODE,
42
+ VERIFY_MODES,
43
+ isVerifyMode,
44
+ } from '../verify/engine/deterministic.js';
38
45
  import {
39
46
  buildVerifyArgs,
40
47
  composeShipWithoutVerifiedRecord,
@@ -111,6 +118,12 @@ const OPTION_SPEC = {
111
118
  // deploy), the check is skipped and a warn line records that
112
119
  // finalise could not resolve the branch state.
113
120
  'allow-pre-merge': { type: 'boolean' },
121
+ // Issue 330 (2026-10-08): verify mode passed through to the fresh
122
+ // `rcf verify run` subprocess. Default is agentScreenshotCritique (the
123
+ // parent-session mode). A dispatched worker runs --mode deterministic
124
+ // because the harness refuses to let a worker spawn `claude` as a
125
+ // subagent, which agentScreenshotCritique requires.
126
+ mode: { type: 'string' },
114
127
  quiet: { type: 'boolean' },
115
128
  help: { type: 'boolean' },
116
129
  };
@@ -176,6 +189,18 @@ Options:
176
189
  dev-loop dry runs; the decision is logged
177
190
  on stdout. Absent a git checkout the check
178
191
  degrades to a warn line.
192
+ --mode <mode> Verify mode (default: agentScreenshotCritique):
193
+ agentScreenshotCritique parent-session mode
194
+ (spawns the agent CLI as a subagent); only
195
+ runs from a session that may invoke the
196
+ agent CLI.
197
+ deterministic model-free, in-process mode a
198
+ dispatched worker can run. Produces the
199
+ same report schema with a per-AC skip
200
+ ledger; the finalise gate refuses
201
+ promotion on a deterministic run that
202
+ skipped any AC (ADR-4112 verified
203
+ invariant).
179
204
  --quiet Suppress non-error confirmations
180
205
  --help Print this help
181
206
 
@@ -292,6 +317,14 @@ export async function main(argv, deps = {}) {
292
317
  if (typeof flags.provision === 'string' && flags.provision.startsWith('-')) {
293
318
  return usage('--provision takes a file path, not a flag or inline value (credentials are never accepted inline)');
294
319
  }
320
+ // Issue 330: resolve --mode. Unknown value refuses exit 2 so a typo
321
+ // ("--mode determinstic") does not silently fall through to the
322
+ // default agent mode a dispatched worker cannot run.
323
+ const modeFlag = flags.mode;
324
+ if (modeFlag !== undefined && !isVerifyMode(modeFlag)) {
325
+ return usage(`--mode must be one of ${VERIFY_MODES.join(' | ')} (got "${modeFlag}")`);
326
+ }
327
+ const mode = modeFlag ?? DEFAULT_VERIFY_MODE;
295
328
 
296
329
  const projectRoot = await findProjectRoot(cwd);
297
330
  if (!projectRoot) {
@@ -361,10 +394,48 @@ export async function main(argv, deps = {}) {
361
394
  provision: flags.provision,
362
395
  chain: flags.chain,
363
396
  persona: flags.persona,
397
+ mode,
364
398
  });
365
399
 
400
+ // Issue 330 partial-report preseed. Verify is a fresh subprocess and
401
+ // may fail to start (ENOENT, blocked by harness), be killed, or exit
402
+ // non-zero before it has had the chance to write its own report. In
403
+ // every one of those cases the ingest step below would otherwise read
404
+ // ENOENT and finalise would print "could not read the verify report"
405
+ // with no clue about what the mode or the reason was. Preseeding the
406
+ // report path with a `{ status: "aborted" }` stub guarantees ingest
407
+ // always has something to read; a successful verify run overwrites
408
+ // the stub with its real report in the same path.
409
+ const finaliseStartedAt = new Date().toISOString();
410
+ const partialStub = {
411
+ schemaVersion: '1',
412
+ verdict: 'LAUNCH-FAILURE',
413
+ verdictAuthority: null,
414
+ run: {
415
+ profile,
416
+ url: flags.url,
417
+ startedAt: finaliseStartedAt,
418
+ finishedAt: null,
419
+ runStats: { mode, status: 'pending', reason: 'verify not yet started' },
420
+ },
421
+ findings: [],
422
+ blockedAcs: [],
423
+ launchFailure: {
424
+ message: 'rcf verify did not write a report (process was killed, errored before write, or did not start)',
425
+ status: 'aborted',
426
+ reason: 'preseed',
427
+ },
428
+ };
429
+ const writer = deps.writeFile ?? writeFile;
430
+ try {
431
+ await writer(outPath, `${JSON.stringify(partialStub, null, 2)}\n`, 'utf8');
432
+ } catch (err) {
433
+ stderr.write(`[finalise] could not preseed partial report at ${outPath}: ${err.message}\n`);
434
+ return 1;
435
+ }
436
+
366
437
  if (!quiet) {
367
- stdout.write(`[finalise] launching rcf verify run (fresh subprocess) against ${flags.url} [profile=${profile}]...\n`);
438
+ stdout.write(`[finalise] launching rcf verify run (fresh subprocess) against ${flags.url} [profile=${profile}, mode=${mode}]...\n`);
368
439
  }
369
440
 
370
441
  let spawnResult;
@@ -398,7 +469,13 @@ export async function main(argv, deps = {}) {
398
469
  // §9 independence guarantee). An unreadable report on a pass is treated as a
399
470
  // HOLD, never a silent promotion (mirrors ingest's "unreadable is not a pass").
400
471
  const passLoaded = await (deps.loadReport ? deps.loadReport(outPath, deps) : loadReport(outPath, deps));
401
- const authority = passLoaded.ok ? passLoaded.report.verdictAuthority : undefined;
472
+ // Issue 330: a passing exit code with ONLY the preseed stub on disk
473
+ // means verify exited 0 without writing its own report; treat
474
+ // that as unreadable (never a silent promotion). Mirrors the
475
+ // "unreadable is not a pass" NIT-3 contract.
476
+ const passPreseedOnly = passLoaded.ok
477
+ && passLoaded.report?.launchFailure?.reason === 'preseed';
478
+ const authority = (passLoaded.ok && !passPreseedOnly) ? passLoaded.report.verdictAuthority : undefined;
402
479
  if (authority !== 'ship') {
403
480
  stderr.write(`[finalise] HOLD: rcf verify passed but this run carries '${authority ?? 'unknown'}' authority, `
404
481
  + `not 'ship'; ${fbsId} left '${currentStatus}'.\n`);
@@ -406,8 +483,32 @@ export async function main(argv, deps = {}) {
406
483
  + 'runtime is edge-identical to prod - carries ship authority and can promote to verified. '
407
484
  + `Report: ${outPath}\n`);
408
485
  if (!passLoaded.ok) stderr.write(`[finalise] (the verify report could not be read: ${passLoaded.reason})\n`);
486
+ else if (passPreseedOnly) stderr.write('[finalise] (the verify report could not be read: verify exited 0 without replacing the preseed stub)\n');
409
487
  return 4;
410
488
  }
489
+ // Issue 330 (2026-10-08) deterministic-mode refusal, keyed off the
490
+ // ADR-4112 verified invariant. A deterministic-mode run never
491
+ // actively exercises a critique-only AC (every testable AC is
492
+ // recorded on blockedAcs[] with reason `critique-only`). Promoting
493
+ // to `verified` off such a run would quietly contradict the ADR
494
+ // promise. Refuse with a clear counts line; the operator either
495
+ // re-runs the gate under agentScreenshotCritique from a parent
496
+ // session, or extends the chain with a per-AC probe shape the
497
+ // deterministic mode can judge.
498
+ if (passLoaded.ok) {
499
+ const runStats = passLoaded.report?.run?.runStats
500
+ ?? passLoaded.report?.runStats
501
+ ?? null;
502
+ if (runStats && runStats.mode === 'deterministic') {
503
+ const counts = runStats.counts ?? { verified: 0, failed: 0, skipped: 0, total: 0 };
504
+ if ((counts.skipped ?? 0) > 0 || (counts.verified ?? 0) < (counts.total ?? 0)) {
505
+ stderr.write(`[finalise] gate NOT promoted: deterministic mode verified ${counts.verified ?? 0} of ${counts.total ?? 0} ACs (skipped ${counts.skipped ?? 0} critique-only); critique mode required to promote ${fbsId} to 'verified'. ADR-4112 verified invariant.\n`);
506
+ stderr.write("Re-run from a parent session that may spawn the agent CLI ('rcf build finalise ... --mode agentScreenshotCritique'), or extend the chain so every AC carries a deterministic probe shape.\n");
507
+ stderr.write(`Report: ${outPath}\n`);
508
+ return 4;
509
+ }
510
+ }
511
+ }
411
512
  // 0.7.0 verification-integrity §5.2: the finalise gate reads the
412
513
  // verify report's per-AC verdicts and refuses to promote to
413
514
  // 'verified' on MOCK-ONLY-DECLARED / BLOCKED-BY-DECLARATION unless
@@ -583,9 +684,47 @@ export async function main(argv, deps = {}) {
583
684
  // Gate NOT passed. The FBS stays put; surface the findings from the report
584
685
  // (the §5.4 verify -> fix -> re-verify seam), then fail non-zero.
585
686
  stderr.write(`[finalise] gate NOT passed (rcf verify exit ${spawnResult.code}); ${fbsId} left '${currentStatus}'.\n`);
687
+ // Issue 330: if the subprocess exited without replacing the preseed
688
+ // partial-report stub, overwrite it with an `aborted` record naming
689
+ // the mode and the exit code so a reader sees why there is no
690
+ // verdict, not an ENOENT.
586
691
  const loaded = await (deps.loadReport ? deps.loadReport(outPath, deps) : loadReport(outPath, deps));
692
+ const finishedAtAborted = new Date().toISOString();
693
+ if (loaded.ok && loaded.report?.launchFailure?.reason === 'preseed') {
694
+ const abortedReport = {
695
+ ...loaded.report,
696
+ verdict: 'LAUNCH-FAILURE',
697
+ verdictAuthority: null,
698
+ run: {
699
+ ...(loaded.report.run ?? {}),
700
+ finishedAt: finishedAtAborted,
701
+ runStats: {
702
+ ...((loaded.report.run && loaded.report.run.runStats) || {}),
703
+ mode,
704
+ status: 'aborted',
705
+ reason: `rcf verify exited ${spawnResult.code} without writing a report`,
706
+ exitCode: spawnResult.code,
707
+ },
708
+ },
709
+ launchFailure: {
710
+ message: `rcf verify exited ${spawnResult.code} without writing a report (mode=${mode})`,
711
+ status: 'aborted',
712
+ reason: `rcf verify exited ${spawnResult.code} without writing a report`,
713
+ },
714
+ };
715
+ try {
716
+ await writer(outPath, `${JSON.stringify(abortedReport, null, 2)}\n`, 'utf8');
717
+ loaded.report = abortedReport;
718
+ } catch (err) {
719
+ stderr.write(`[finalise] could not overwrite preseed stub at ${outPath}: ${err.message}\n`);
720
+ }
721
+ }
587
722
  if (loaded.ok) {
588
723
  stderr.write(summariseReport(loaded.report));
724
+ const abortStats = loaded.report?.run?.runStats ?? loaded.report?.runStats ?? null;
725
+ if (abortStats && abortStats.status === 'aborted') {
726
+ stderr.write(`[finalise] mode=${abortStats.mode ?? mode}: ${abortStats.reason ?? 'verify produced no report'}\n`);
727
+ }
589
728
  stderr.write(`Full report (build-lite ingestible, chain-node-addressed): ${outPath}\n`);
590
729
  } else {
591
730
  stderr.write(`[finalise] could not read the verify report: ${loaded.reason}\n`);
package/src/cli/view.js CHANGED
@@ -272,6 +272,10 @@ export async function main(argv, deps = {}) {
272
272
  const stdout = deps.stdout ?? process.stdout;
273
273
  const stderr = deps.stderr ?? process.stderr;
274
274
  const onSignal = deps.onSignal ?? ((sig, handler) => process.on(sig, handler));
275
+ // Issue #333 item 2: tests inject a `startServerImpl` to observe the
276
+ // sinks the CLI wires without racing a real walker; the default is
277
+ // the real `startServer`.
278
+ const startServerImpl = deps.startServerImpl ?? startServer;
275
279
 
276
280
  // Track C+D §9: subverb routing. When the first argv token is
277
281
  // `start | status | stop | logs`, dispatch to the lifecycle handler
@@ -315,13 +319,21 @@ export async function main(argv, deps = {}) {
315
319
  }
316
320
 
317
321
  const logSink = opts.verbose ? (line) => stderr.write(`${line}\n`) : () => {};
322
+ // Issue #333 item 2: a host that spawns `rcf audit view` as a
323
+ // supervised child without --verbose still needs to see fatal watcher
324
+ // and walker exceptions, otherwise the viewer looks alive while
325
+ // live-updates have silently stopped. `logError` is unconditional;
326
+ // the chatty per-event lines keep flowing through `log` only when
327
+ // --verbose is on.
328
+ const logErrorSink = (line) => stderr.write(`${line}\n`);
318
329
 
319
330
  let server;
320
331
  try {
321
- server = await startServer({
332
+ server = await startServerImpl({
322
333
  projectRoot,
323
334
  port: resolvedPort,
324
335
  log: logSink,
336
+ logError: logErrorSink,
325
337
  testHost: resolveTestHost(opts.testHost, env),
326
338
  });
327
339
  } catch (err) {
@@ -55,7 +55,15 @@ export const LEDGER_NAMES = /** @type {const} */ (['brief', 'decisions', 'concer
55
55
 
56
56
  /** @typedef {(typeof LEDGER_NAMES)[number]} LedgerName */
57
57
 
58
- const RELATIVE_DIR = 'rcf/define';
58
+ /**
59
+ * Project-root-relative directory the ledgers (and the freeze record) live in.
60
+ * Exported so any walker that enumerates `rcf/` can mirror the carve-out
61
+ * without a second hard-coded copy (issue 321).
62
+ */
63
+ export const DEFINE_RELATIVE_DIR = 'rcf/define';
64
+
65
+ // Legacy alias kept until all internal references migrate.
66
+ const RELATIVE_DIR = DEFINE_RELATIVE_DIR;
59
67
 
60
68
  /** Per-ledger config: filename, array key on the body, entry schema. */
61
69
  const LEDGER_CONFIG = /** @type {const} */ ({
@@ -31,6 +31,7 @@ import { isolationEnv } from '#core/isolation';
31
31
  * @param {string} [opts.provision] - provisioning FILE path
32
32
  * @param {string} [opts.chain] - PRD/chain ref
33
33
  * @param {string} [opts.persona]
34
+ * @param {string} [opts.mode] - verify mode (issue 330): agentScreenshotCritique | deterministic
34
35
  * @returns {string[]}
35
36
  */
36
37
  export function buildVerifyArgs(opts) {
@@ -47,6 +48,7 @@ export function buildVerifyArgs(opts) {
47
48
  if (opts.parityEnv) args.push('--parity-env');
48
49
  if (opts.provision) args.push('--provision', opts.provision);
49
50
  if (opts.persona) args.push('--persona', opts.persona);
51
+ if (opts.mode) args.push('--mode', opts.mode);
50
52
  return args;
51
53
  }
52
54
 
@@ -4,7 +4,7 @@
4
4
  // (D11); EADDRINUSE is a hard failure (D10).
5
5
  //
6
6
  // Public surface:
7
- // startServer({ projectRoot, port, host, log, heartbeatMs, watchImpl })
7
+ // startServer({ projectRoot, port, host, log, logError, heartbeatMs, watchImpl, renderImpl })
8
8
  // -> Promise<{ url, port, close, hub, currentState }>
9
9
  // close() drains SSE with a `shutdown` event and releases the port. The
10
10
  // caller (bin/rcf-view.js) is responsible for the 2s force-exit budget
@@ -27,8 +27,16 @@ import { createSseHub } from './sse.js';
27
27
  * @property {string} [host='127.0.0.1']
28
28
  * @property {number} [heartbeatMs=30000]
29
29
  * @property {number} [debounceMs=50]
30
- * @property {(line: string) => void} [log] - stderr sink
30
+ * @property {(line: string) => void} [log] - verbose-gated chatty sink;
31
+ * defaults to a noop so a caller that does not want chatter can omit it.
32
+ * @property {(line: string) => void} [logError] - fatal-error sink that is
33
+ * always written to, independent of `log`. Issue #333 item 2: the CLI
34
+ * wires this to stderr unconditionally so a host that spawned
35
+ * `rcf audit view` without --verbose still sees a dead watcher or a
36
+ * failed walker. Defaults to `log` for back-compat.
31
37
  * @property {typeof defaultWatch} [watchImpl] - injectable watch primitive for tests
38
+ * @property {typeof renderModelToPage} [renderImpl] - injectable walker
39
+ * for tests; defaults to the real `renderModelToPage`.
32
40
  * @property {boolean} [testHost=false] - viewer UI refresh PR 9 (TAC-4134,
33
41
  * ADR-4136): when true, mount the wespa host fixture at /test-host.html
34
42
  * plus its embed-client script at /test-host.js. Off by default so
@@ -73,54 +81,96 @@ export async function startServer(args) {
73
81
  const heartbeatMs = typeof args.heartbeatMs === 'number' ? args.heartbeatMs : 30000;
74
82
  const debounceMs = typeof args.debounceMs === 'number' ? args.debounceMs : 50;
75
83
  const log = typeof args.log === 'function' ? args.log : () => {};
84
+ // Issue #333 item 2: fatal watcher/walker exceptions always surface to
85
+ // stderr regardless of --verbose; chatty per-event lines stay gated on
86
+ // `log`. Callers that pass only `log` keep the pre-fix behaviour (one
87
+ // sink for both chatty and fatal), which the back-compat test asserts.
88
+ const logError = typeof args.logError === 'function' ? args.logError : log;
76
89
  const watchImpl = typeof args.watchImpl === 'function' ? args.watchImpl : defaultWatch;
90
+ // Issue #333 item 1: tests inject a controllable render promise to
91
+ // observe the trailing-rewalk coalescing without racing a real walk.
92
+ const renderImpl = typeof args.renderImpl === 'function' ? args.renderImpl : renderModelToPage;
77
93
  const testHost = args.testHost === true;
78
94
 
79
- /** @type {{ version: number, fullPageHtml: string, contentHtml: string, errors: import('#core/errors').RcfError[] } | null} */
95
+ /** @type {{ version: number, fullPageHtml: string, contentHtml: string, errors: import('#core/errors').RcfError[], tree?: import('#core/store/walker.js').TreeModel, testPointers?: Map<string, import('#core/store/tp-resolve.js').TestPointerResolution> } | null} */
80
96
  let state = null;
81
97
  let version = 0;
82
98
  let rewalkInFlight = null;
99
+ // Issue #333 item 1: a change that arrives while a walk is in flight
100
+ // queues exactly one trailing walk so the post-in-flight on-disk state
101
+ // is picked up; any number of changes during the in-flight walk collapse
102
+ // onto that single trailing walk (never dropped, never re-run per call).
103
+ let rewalkTrailing = false;
83
104
  let closed = false;
84
105
 
85
106
  const sse = createSseHub({ heartbeatMs, log });
86
107
 
87
- async function rewalk() {
88
- if (closed) return;
108
+ // FBS-207 (TAC-4136 queryCache + AC-208-6): per-version memo for the
109
+ // three JSON query routes keyed by `${version}:${route}:${pivot}:${d
110
+ // irection|scope}`. Replaced wholesale on every rewalk so a new tree
111
+ // never serves stale compute. One compute per key per version; never
112
+ // persisted; never written under rcf/. Page-lifetime: a reconnected
113
+ // client sees a fresh cache.
114
+ /** @type {Map<string, string>} */
115
+ let queryCache = new Map();
116
+
117
+ async function runWalk() {
118
+ try {
119
+ const result = await renderImpl({ projectRoot });
120
+ if (closed) return;
121
+ version += 1;
122
+ state = {
123
+ version,
124
+ fullPageHtml: result.fullPageHtml,
125
+ contentHtml: result.contentHtml,
126
+ errors: result.errors,
127
+ pmPartials: result.pmPartials,
128
+ // Viewer UI refresh PR 7 (TAC-4132, ADR-4134): the ID lookup
129
+ // index is built once per rewalk and snapshotted on `state`
130
+ // so `GET /index.json` serves from memory and the SSE
131
+ // `tree-update` version lets the client invalidate its cache.
132
+ indexJson: result.indexJson,
133
+ // FBS-207 (TAC-4136, AC-208-6): the walked tree and resolved
134
+ // test-pointer map snapshotted on state so /trace.json,
135
+ // /impact.json and /coverage.json compute from the same tree
136
+ // the page was rendered from. `tree` was already produced by
137
+ // renderModelToPage; the server now carries it onto state.
138
+ tree: result.tree,
139
+ testPointers: result.testPointers,
140
+ };
141
+ // FBS-207 (AC-208-6): a new version drops every previous
142
+ // version's compute entries wholesale, as TAC-4136 specifies.
143
+ queryCache = new Map();
144
+ sse.broadcast('tree-update', { version, contentHtml: result.contentHtml });
145
+ if (result.errors && result.errors.length > 0) {
146
+ sse.broadcast('walker-error', { errors: result.errors });
147
+ }
148
+ } catch (err) {
149
+ // Fatal walker exception: always to stderr via `logError` so a
150
+ // host spawning `rcf audit view` without --verbose still sees it
151
+ // (issue #333 item 2).
152
+ logError(`[server] walker failed: ${/** @type {Error} */ (err).message}`);
153
+ sse.broadcast('walker-error', {
154
+ errors: [{ kind: 'ioFailure', message: /** @type {Error} */ (err).message }],
155
+ });
156
+ } finally {
157
+ const trailing = rewalkTrailing;
158
+ rewalkTrailing = false;
159
+ rewalkInFlight = trailing && !closed ? runWalk() : null;
160
+ }
161
+ }
162
+
163
+ function rewalk() {
164
+ if (closed) return Promise.resolve();
89
165
  if (rewalkInFlight) {
90
- // Coalesce concurrent walks. The trailing one will pick up the
91
- // final on-disk state; a middle one adds nothing.
92
- return rewalkInFlight;
166
+ // A walk is already running. Flag that a trailing walk is wanted
167
+ // and return a promise that resolves when the trailing walk (not
168
+ // the in-flight one) has completed. Any concurrent callers coalesce
169
+ // onto the same trailing walk - the final on-disk state wins.
170
+ rewalkTrailing = true;
171
+ return rewalkInFlight.then(() => rewalkInFlight ?? undefined);
93
172
  }
94
- rewalkInFlight = (async () => {
95
- try {
96
- const result = await renderModelToPage({ projectRoot });
97
- if (closed) return;
98
- version += 1;
99
- state = {
100
- version,
101
- fullPageHtml: result.fullPageHtml,
102
- contentHtml: result.contentHtml,
103
- errors: result.errors,
104
- pmPartials: result.pmPartials,
105
- // Viewer UI refresh PR 7 (TAC-4132, ADR-4134): the ID lookup
106
- // index is built once per rewalk and snapshotted on `state`
107
- // so `GET /index.json` serves from memory and the SSE
108
- // `tree-update` version lets the client invalidate its cache.
109
- indexJson: result.indexJson,
110
- };
111
- sse.broadcast('tree-update', { version, contentHtml: result.contentHtml });
112
- if (result.errors && result.errors.length > 0) {
113
- sse.broadcast('walker-error', { errors: result.errors });
114
- }
115
- } catch (err) {
116
- log(`[server] walker failed: ${/** @type {Error} */ (err).message}`);
117
- sse.broadcast('walker-error', {
118
- errors: [{ kind: 'ioFailure', message: /** @type {Error} */ (err).message }],
119
- });
120
- } finally {
121
- rewalkInFlight = null;
122
- }
123
- })();
173
+ rewalkInFlight = runWalk();
124
174
  return rewalkInFlight;
125
175
  }
126
176
 
@@ -149,6 +199,16 @@ export async function startServer(args) {
149
199
  const router = createRouter({
150
200
  currentState: () => state,
151
201
  sse,
202
+ // FBS-207 (TAC-4136): the three JSON query routes share one page-
203
+ // lifetime memo held on the server closure; the router reads and
204
+ // writes it through this handle. Replaced wholesale on each
205
+ // rewalk, above.
206
+ queryCache: {
207
+ get: (key) => queryCache.get(key),
208
+ set: (key, value) => { queryCache.set(key, value); },
209
+ has: (key) => queryCache.has(key),
210
+ size: () => queryCache.size,
211
+ },
152
212
  // Snapshotted assets take precedence; the legacy path props are
153
213
  // still passed so tests that mount the router directly with a
154
214
  // path (no asset load) keep working.
@@ -175,7 +235,7 @@ export async function startServer(args) {
175
235
  paths: [watchDir],
176
236
  onChange: () => { rewalk().catch(() => {}); },
177
237
  debounceMs,
178
- onError: (err) => { log(`[watch] ${err.message}`); },
238
+ onError: (err) => { logError(`[watch] ${err.message}`); },
179
239
  });
180
240
 
181
241
  // Bind. EADDRINUSE surfaces as a rejected Promise so the caller can
@@ -236,5 +296,10 @@ export async function startServer(args) {
236
296
  currentState: () => state,
237
297
  sse,
238
298
  rewalk,
299
+ // FBS-207 (TAC-4136, AC-208-6 test seam): read-only probe on the
300
+ // page-lifetime memo. Lets the AC-208-6 integration test prove the
301
+ // rewalk cache SWAP (not just a version-prefixed key change) by
302
+ // observing size before and after an explicit `rewalk()`.
303
+ queryCacheSize: () => queryCache.size,
239
304
  };
240
305
  }