rcf-lite 0.28.4 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/CHANGELOG.md +107 -1
  2. package/bin/rcf.js +4 -0
  3. package/fixtures/canary-manifest.json +9 -9
  4. package/guidance/define-draft-shapes.md +89 -0
  5. package/guidance/define-intake.md +97 -0
  6. package/guidance/define-intent.md +107 -0
  7. package/guidance/discover-prototype.md +67 -0
  8. package/guidance/discovery-prototypes.md +69 -0
  9. package/guidance/harness-template.md +34 -0
  10. package/guidance/managed/agent-instructions-block.hash +1 -1
  11. package/guidance/managed/agent-instructions-block.md +34 -0
  12. package/guidance/manifest.json +9 -2
  13. package/package.json +1 -1
  14. package/rcf/adrs/adr-4124-define-freeze-verb.json +30 -0
  15. package/rcf/adrs/adr-4126-persona-tagged-blockers-two-levels.json +40 -0
  16. package/rcf/adrs/adr-4127-readiness-tab-order-and-register.json +40 -0
  17. package/rcf/adrs/adr-4129-questions-harness-driven-loop.json +25 -0
  18. package/rcf/adrs/adr-4130-intake-harness-split-tool-scanned.json +20 -0
  19. package/rcf/adrs/adr-4132-prototypes-in-discovery-as-evidence.json +20 -0
  20. package/rcf/adrs/adr-4133-dag-dependency-depth-layout.json +40 -0
  21. package/rcf/adrs/adr-4134-index-json-route.json +35 -0
  22. package/rcf/adrs/adr-4135-readiness-po-register-and-adapter.json +35 -0
  23. package/rcf/adrs/adr-4136-embedding-host-fixture.json +35 -0
  24. package/rcf/code-nodes/cn-725.json +18 -2
  25. package/rcf/code-nodes/cn-726.json +10 -2
  26. package/rcf/code-nodes/cn-727.json +13 -4
  27. package/rcf/code-nodes/cn-728.json +18 -2
  28. package/rcf/code-nodes/cn-729.json +10 -2
  29. package/rcf/code-nodes/cn-730.json +13 -3
  30. package/rcf/code-nodes/cn-731.json +20 -0
  31. package/rcf/code-nodes/cn-732.json +14 -0
  32. package/rcf/code-nodes/cn-733.json +14 -0
  33. package/rcf/code-nodes/cn-734.json +14 -0
  34. package/rcf/code-nodes/cn-735.json +20 -0
  35. package/rcf/code-nodes/cn-736.json +14 -0
  36. package/rcf/code-nodes/cn-737.json +14 -0
  37. package/rcf/code-nodes/cn-738.json +14 -0
  38. package/rcf/code-nodes/cn-739.json +18 -0
  39. package/rcf/code-nodes/cn-740.json +19 -0
  40. package/rcf/code-nodes/cn-741.json +16 -0
  41. package/rcf/code-nodes/cn-742.json +16 -0
  42. package/rcf/code-nodes/cn-743.json +15 -0
  43. package/rcf/code-nodes/cn-744.json +22 -0
  44. package/rcf/code-nodes/cn-745.json +16 -0
  45. package/rcf/code-nodes/cn-746.json +14 -0
  46. package/rcf/code-nodes/cn-747.json +18 -0
  47. package/rcf/code-nodes/cn-748.json +16 -0
  48. package/rcf/code-nodes/cn-749.json +16 -0
  49. package/rcf/code-nodes/cn-750.json +15 -0
  50. package/rcf/code-nodes/cn-751.json +18 -0
  51. package/rcf/code-nodes/cn-752.json +14 -0
  52. package/rcf/fbs/fbs-196.json +13 -5
  53. package/rcf/fbs/fbs-198.json +42 -0
  54. package/rcf/fbs/fbs-199.json +59 -0
  55. package/rcf/fbs/fbs-200.json +46 -0
  56. package/rcf/fbs/fbs-201.json +49 -0
  57. package/rcf/fbs/fbs-202.json +110 -0
  58. package/rcf/fbs/fbs-203.json +100 -0
  59. package/rcf/requirements/req-002.json +2 -2
  60. package/rcf/requirements/req-173.json +2 -2
  61. package/rcf/requirements/req-174.json +10 -4
  62. package/rcf/requirements/req-175.json +10 -4
  63. package/rcf/requirements/req-176.json +25 -0
  64. package/rcf/requirements/req-180.json +31 -0
  65. package/rcf/requirements/req-181.json +31 -0
  66. package/rcf/requirements/req-182.json +25 -0
  67. package/rcf/requirements/req-186.json +25 -0
  68. package/rcf/requirements/req-187.json +31 -0
  69. package/rcf/requirements/req-188.json +25 -0
  70. package/rcf/tacs/tac-4121-define-ledgers.json +15 -3
  71. package/rcf/tacs/tac-4122-define-gates.json +40 -4
  72. package/rcf/tacs/tac-4123-define-readiness.json +39 -3
  73. package/rcf/tacs/tac-4124-define-freeze.json +28 -0
  74. package/rcf/tacs/tac-4127-readiness-view.json +49 -0
  75. package/rcf/tacs/tac-4128-define-guidance.json +53 -0
  76. package/rcf/tacs/tac-4129-define-questions.json +45 -0
  77. package/rcf/tacs/tac-4130-define-intake-brief.json +35 -0
  78. package/rcf/tacs/tac-4131-build-dag-view.json +39 -0
  79. package/rcf/tacs/tac-4132-id-lookup.json +42 -0
  80. package/rcf/tacs/tac-4133-readiness-po-layer.json +37 -0
  81. package/rcf/tacs/tac-4134-wespa-host-fixture.json +54 -0
  82. package/rcf/test-suites/ts-016.json +3 -3
  83. package/rcf/test-suites/ts-215.json +148 -4
  84. package/rcf/test-suites/ts-216.json +19 -3
  85. package/rcf/test-suites/ts-218.json +78 -0
  86. package/rcf/test-suites/ts-219.json +70 -0
  87. package/rcf/test-suites/ts-220.json +78 -0
  88. package/rcf/test-suites/ts-221.json +78 -0
  89. package/rcf/test-suites/ts-222.json +100 -0
  90. package/rcf/test-suites/ts-223.json +94 -0
  91. package/rcf/test-suites/ts-224.json +70 -0
  92. package/rcf/test-suites/ts-225.json +78 -0
  93. package/rcf/test-suites/ts-226.json +158 -0
  94. package/rcf/test-suites/ts-227.json +38 -0
  95. package/rcf/test-suites/ts-228.json +86 -0
  96. package/rcf/test-suites/ts-229.json +83 -0
  97. package/rcf/user-stories/us-17302.json +50 -0
  98. package/rcf/user-stories/us-17401.json +14 -2
  99. package/rcf/user-stories/us-17402.json +55 -0
  100. package/rcf/user-stories/us-17403.json +60 -0
  101. package/rcf/user-stories/us-17502.json +60 -0
  102. package/rcf/user-stories/us-17503.json +65 -0
  103. package/rcf/user-stories/us-17601.json +58 -0
  104. package/rcf/user-stories/us-18001.json +55 -0
  105. package/rcf/user-stories/us-18002.json +35 -0
  106. package/rcf/user-stories/us-18101.json +35 -0
  107. package/rcf/user-stories/us-18202.json +40 -0
  108. package/rcf/user-stories/us-18601.json +55 -0
  109. package/rcf/user-stories/us-18602.json +50 -0
  110. package/rcf/user-stories/us-18603.json +45 -0
  111. package/rcf/user-stories/us-18701.json +50 -0
  112. package/rcf/user-stories/us-18702.json +50 -0
  113. package/rcf/user-stories/us-18801.json +45 -0
  114. package/rcf/user-stories/us-204.json +59 -0
  115. package/rcf/user-stories/us-205.json +64 -0
  116. package/rcf/user-stories/us-206.json +54 -0
  117. package/releases/releases.yaml +11 -1
  118. package/src/cli/freeze.js +730 -0
  119. package/src/cli/help.js +12 -0
  120. package/src/cli/ledger.js +228 -12
  121. package/src/cli/questions.js +288 -0
  122. package/src/cli/readiness.js +267 -49
  123. package/src/cli/view.js +29 -0
  124. package/src/define/freeze-record.js +30 -15
  125. package/src/define/ledgers.js +210 -11
  126. package/src/intake/index.js +1 -1
  127. package/src/intake/orchestrator.js +117 -0
  128. package/src/mcp/tools.js +354 -0
  129. package/src/query/delta.js +6 -0
  130. package/src/query/gates.js +314 -39
  131. package/src/query/index.js +16 -2
  132. package/src/query/questions.js +438 -0
  133. package/src/query/readiness.js +248 -7
  134. package/src/server/index.js +24 -2
  135. package/src/server/routes.js +68 -1
  136. package/src/setup/identity-seed.js +17 -0
  137. package/src/view/build-dag.js +563 -0
  138. package/src/view/components/badge.js +41 -0
  139. package/src/view/components/diff.js +72 -0
  140. package/src/view/components/doc-row.js +62 -0
  141. package/src/view/components/empty-state.js +35 -0
  142. package/src/view/components/entity-selector.js +137 -0
  143. package/src/view/components/filter-bar.js +97 -0
  144. package/src/view/components/findings-list.js +49 -0
  145. package/src/view/components/index.js +12 -0
  146. package/src/view/components/pill.js +46 -0
  147. package/src/view/components/sub-tab-strip.js +43 -0
  148. package/src/view/components/toast.js +25 -0
  149. package/src/view/components-fixture.js +265 -0
  150. package/src/view/doc-renderers/architecture.js +304 -0
  151. package/src/view/doc-renderers/build.js +160 -0
  152. package/src/view/doc-renderers/index.js +2 -0
  153. package/src/view/doc-renderers/prd.js +35 -6
  154. package/src/view/html-page.js +700 -602
  155. package/src/view/index.js +96 -3
  156. package/src/view/live-client.js +39 -6
  157. package/src/view/needs-work.js +79 -0
  158. package/src/view/page-init.js +2193 -0
  159. package/src/view/readiness/phrasebook.js +115 -0
  160. package/src/view/readiness/po-layer.js +288 -0
  161. package/src/view/readiness/question-adapter.js +329 -0
  162. package/src/view/readiness/stage-legend.js +71 -0
  163. package/src/view/readiness.js +505 -0
  164. package/src/view/style.css +2017 -382
  165. package/src/view/test-host/embed-client.js +121 -0
  166. package/src/view/test-host/fixture.js +183 -0
  167. package/src/view/test-host/lints.js +220 -0
  168. package/src/view/view-index.js +323 -0
@@ -49,6 +49,24 @@ import {
49
49
  * @property {number[]} briefSince
50
50
  * @property {import('./impact.js').ImpactNode[]} impacted
51
51
  * @property {string[]} impactedFbs
52
+ * @property {Record<string, string>} currentDocHashes
53
+ * Live per-document hashes (ledgers keyed as `ledger:<name>`).
54
+ * Threaded from `computeDelta` so the Readiness tab can render a
55
+ * document-level hash diff for each changed / added id (spec §5
56
+ * block 6; the frozen body is not persisted in the freeze record
57
+ * in 0.29.0, so the diff is hash-only for now).
58
+ */
59
+
60
+ /**
61
+ * @typedef {object} CheckResult
62
+ * @property {string} name
63
+ * @property {boolean} ok
64
+ * @property {'delta'|'tree'} over
65
+ * @property {number} pass
66
+ * @property {number} total
67
+ * @property {Array<{ id: string, why: string }>} failing
68
+ * @property {'productOwner'|'engineer'} persona
69
+ * @property {string} question
52
70
  */
53
71
 
54
72
  /**
@@ -56,10 +74,47 @@ import {
56
74
  * @property {string} stage
57
75
  * @property {string} gate
58
76
  * @property {'passed'|'failing'|'acknowledged'|'notApplicable'} state
59
- * @property {Array<{ name: string, ok: boolean, over: 'delta'|'tree', pass: number, total: number, failing: Array<{ id: string, why: string }> }>} checks
77
+ * @property {CheckResult[]} checks
60
78
  * @property {string} [reason]
61
79
  */
62
80
 
81
+ /**
82
+ * @typedef {object} Blocker
83
+ * @property {string} stage
84
+ * @property {string} gate
85
+ * @property {string} check
86
+ * @property {'productOwner'|'engineer'} persona
87
+ * @property {'delta'|'tree'} over
88
+ * @property {number} failingCount
89
+ * @property {string[]} ids
90
+ * @property {string} question
91
+ */
92
+
93
+ /**
94
+ * @typedef {object} LevelVerdict
95
+ * @property {boolean} ok
96
+ * @property {Blocker[]} blockedBy
97
+ * @property {NextAction | null} nextAction
98
+ */
99
+
100
+ /**
101
+ * @typedef {object} ReadinessLevels
102
+ * @property {LevelVerdict} intentComplete
103
+ * @property {LevelVerdict} readyToBuild
104
+ */
105
+
106
+ /**
107
+ * @typedef {object} PersonaGroup
108
+ * @property {Blocker[]} blockers
109
+ * @property {NextAction | null} nextAction
110
+ */
111
+
112
+ /**
113
+ * @typedef {object} ReadinessPersonas
114
+ * @property {PersonaGroup} productOwner
115
+ * @property {PersonaGroup} engineer
116
+ */
117
+
63
118
  /**
64
119
  * @typedef {object} NextAction
65
120
  * @property {string} stage
@@ -77,6 +132,8 @@ import {
77
132
  * @property {{ tree: import('./coverage.js').CoverageResult, delta: import('./coverage.js').CoverageResult[] }} coverage
78
133
  * @property {Array<Record<string, unknown>>} decisions
79
134
  * @property {boolean} freezeable
135
+ * @property {ReadinessLevels} levels
136
+ * @property {ReadinessPersonas} personas
80
137
  */
81
138
 
82
139
  /**
@@ -198,6 +255,14 @@ export function computeReadiness(tree, args = {}) {
198
255
  const freezeable = stages.every((s) => s.state === 'passed' || s.state === 'acknowledged' || s.state === 'notApplicable');
199
256
  const nextAction = freezeable ? null : deriveNextAction(stages);
200
257
 
258
+ // 7b. Fold `levels` and `personas` from stages[].checks[] alone
259
+ // (ADR-4126, proposal section 2.1). Nothing is written. L1
260
+ // intent-complete is "every productOwner check ok across every
261
+ // stage"; L2 ready-to-build equals freezeable with blockers over
262
+ // failing stages only.
263
+ const levels = deriveLevels(stages, freezeable, nextAction);
264
+ const personas = derivePersonas(stages, levels);
265
+
201
266
  // 8. Build the tree summary line inputs.
202
267
  const queue = computeQueue(tree);
203
268
  const fbsTotal = (tree.fbsItems ?? []).length;
@@ -219,12 +284,15 @@ export function computeReadiness(tree, args = {}) {
219
284
  briefSince: delta.briefSince,
220
285
  impacted: impactedNodes,
221
286
  impactedFbs,
287
+ currentDocHashes: delta.currentDocHashes ?? {},
222
288
  },
223
289
  stages,
224
290
  nextAction,
225
291
  coverage: { tree: coverageTree, delta: coverageDelta },
226
292
  decisions: openDecisions,
227
293
  freezeable,
294
+ levels,
295
+ personas,
228
296
  };
229
297
  }
230
298
 
@@ -266,33 +334,206 @@ function collectDeltaReqAncestors(tree, pivotIds) {
266
334
 
267
335
  /**
268
336
  * Pick the first failing stage in D1..D8 order and the first failing
269
- * check inside it, then compose a `NextAction`.
337
+ * check inside it, then compose a `NextAction`. With no `persona`
338
+ * passed, behaves exactly as 0.28.4 did (first failing stage, first
339
+ * failing check, largest failing list). With a `persona` ('productOwner'
340
+ * | 'engineer'), picks the first failing stage that holds a failing
341
+ * check of that persona, then the largest failing-list check among
342
+ * them; the command carries `--level intent` on the productOwner
343
+ * variant and no `--level` flag otherwise (proposal section 2.1).
344
+ *
345
+ * The productOwner next action is defined over every stage holding a
346
+ * failing PO check (not just stages with state `failing`), because
347
+ * `levels.intentComplete.blockedBy` ignores stage state. In practice
348
+ * PO checks live on blocking stages (D1, D2, D4, D7) so any failing
349
+ * PO check also makes its stage `failing`, but the compute does not
350
+ * rely on that coincidence.
270
351
  *
271
352
  * @param {StageResult[]} stages
353
+ * @param {'productOwner' | 'engineer'} [persona]
272
354
  * @returns {NextAction | null}
273
355
  */
274
- export function deriveNextAction(stages) {
356
+ export function deriveNextAction(stages, persona) {
275
357
  const byStage = new Map(stages.map((s) => [s.stage, s]));
276
358
  for (const stageId of STAGE_ORDER) {
277
359
  const s = byStage.get(stageId);
278
- if (!s || s.state !== 'failing') continue;
360
+ if (!s) continue;
361
+ // Without a persona filter: today's rule (stage must be
362
+ // `failing`; this skips `acknowledged` warn-with-ack stages).
363
+ // With a persona filter: scan any stage that holds a failing
364
+ // check of that persona. PO checks only live on blocking stages
365
+ // so this coincides with "stage failing" in practice, but it
366
+ // keeps the compute honest if that ever changes.
367
+ if (!persona && s.state !== 'failing') continue;
368
+ const failing = s.checks.filter((c) => !c.ok && (persona ? c.persona === persona : true));
369
+ if (failing.length === 0) continue;
279
370
  // First failing check; tie-break by the largest failing list so
280
371
  // the operator sees the most impactful gap first.
281
- const failing = s.checks.filter((c) => !c.ok);
282
- if (failing.length === 0) continue;
283
372
  const check = failing.reduce((a, b) => (b.failing.length > a.failing.length ? b : a), failing[0]);
284
373
  const ids = [...new Set(check.failing.map((f) => f.id))].slice(0, 20);
285
374
  const shortName = STAGE_SHORT_NAMES[stageId] ?? stageId;
375
+ const command = persona === 'productOwner'
376
+ ? `rcf define readiness --level intent --check ${shortName}`
377
+ : `rcf define readiness --check ${shortName}`;
286
378
  return {
287
379
  stage: stageId,
288
380
  check: check.name,
289
381
  ids,
290
- command: `rcf define readiness --check ${shortName}`,
382
+ command,
291
383
  };
292
384
  }
293
385
  return null;
294
386
  }
295
387
 
388
+ /**
389
+ * Build a `Blocker` row from a stage + check pair. `ids` are
390
+ * deduplicated and capped at 20 to match the display convention
391
+ * `nextAction.ids` uses (proposal section 2.1).
392
+ *
393
+ * @param {StageResult} stage
394
+ * @param {CheckResult} check
395
+ * @returns {Blocker}
396
+ */
397
+ function makeBlocker(stage, check) {
398
+ return {
399
+ stage: stage.stage,
400
+ gate: stage.gate,
401
+ check: check.name,
402
+ persona: check.persona,
403
+ over: check.over,
404
+ failingCount: check.failing.length,
405
+ ids: [...new Set(check.failing.map((f) => f.id))].slice(0, 20),
406
+ question: check.question,
407
+ };
408
+ }
409
+
410
+ /**
411
+ * Fold the two levels from `stages[].checks[]` (ADR-4126, proposal
412
+ * section 2.1). L1 ignores stage state and lists every failing PO
413
+ * check; L2 lists every failing check in a stage whose state is
414
+ * `failing` (checks inside an `acknowledged` stage are not blockers).
415
+ *
416
+ * @param {StageResult[]} stages
417
+ * @param {boolean} freezeable
418
+ * @param {NextAction | null} nextAction
419
+ * @returns {ReadinessLevels}
420
+ */
421
+ export function deriveLevels(stages, freezeable, nextAction) {
422
+ /** @type {Blocker[]} */
423
+ const poBlockers = [];
424
+ /** @type {Blocker[]} */
425
+ const buildBlockers = [];
426
+ for (const stage of stages) {
427
+ for (const check of stage.checks) {
428
+ if (check.ok) continue;
429
+ if (check.persona === 'productOwner') {
430
+ poBlockers.push(makeBlocker(stage, check));
431
+ }
432
+ if (stage.state === 'failing') {
433
+ buildBlockers.push(makeBlocker(stage, check));
434
+ }
435
+ }
436
+ }
437
+ const intentOk = poBlockers.length === 0;
438
+ return {
439
+ intentComplete: {
440
+ ok: intentOk,
441
+ blockedBy: poBlockers,
442
+ nextAction: intentOk ? null : deriveNextAction(stages, 'productOwner'),
443
+ },
444
+ readyToBuild: {
445
+ ok: freezeable,
446
+ blockedBy: buildBlockers,
447
+ nextAction,
448
+ },
449
+ };
450
+ }
451
+
452
+ /**
453
+ * Fold the `personas` block (ADR-4126, proposal section 3.3).
454
+ * `productOwner.blockers` deep-equals `levels.intentComplete.blockedBy`;
455
+ * `engineer.blockers` is the failing engineer checks in failing stages
456
+ * (the L2 blockedBy minus the PO entries).
457
+ *
458
+ * @param {StageResult[]} stages
459
+ * @param {ReadinessLevels} levels
460
+ * @returns {ReadinessPersonas}
461
+ */
462
+ export function derivePersonas(stages, levels) {
463
+ const engineerBlockers = levels.readyToBuild.blockedBy.filter((b) => b.persona === 'engineer');
464
+ return {
465
+ productOwner: {
466
+ blockers: levels.intentComplete.blockedBy,
467
+ nextAction: levels.intentComplete.nextAction,
468
+ },
469
+ engineer: {
470
+ blockers: engineerBlockers,
471
+ nextAction: deriveNextAction(stages, 'engineer'),
472
+ },
473
+ };
474
+ }
475
+
476
+ /**
477
+ * Validate a `--level` flag value against the closed set
478
+ * `intent | build`. Returns the value, `null` when the flag was
479
+ * absent, or throws a usage error whose message is formatted exactly
480
+ * like the `--check` usage line so the CLI can forward it verbatim.
481
+ *
482
+ * @param {string | undefined} raw
483
+ * @returns {'intent' | 'build' | null}
484
+ */
485
+ export function parseLevelFlag(raw) {
486
+ if (raw === undefined || raw === null) return null;
487
+ if (raw === 'intent' || raw === 'build') return raw;
488
+ throw new Error(`unknown --level ${raw} (expected intent | build)`);
489
+ }
490
+
491
+ /**
492
+ * Validate a `--persona` flag value against the closed set
493
+ * `productOwner | engineer`. Returns the value, `null` when the flag
494
+ * was absent, or throws a usage error with the same shape.
495
+ *
496
+ * @param {string | undefined} raw
497
+ * @returns {'productOwner' | 'engineer' | null}
498
+ */
499
+ export function parsePersonaFlag(raw) {
500
+ if (raw === undefined || raw === null) return null;
501
+ if (raw === 'productOwner' || raw === 'engineer') return raw;
502
+ throw new Error(`unknown --persona ${raw} (expected productOwner | engineer)`);
503
+ }
504
+
505
+ /**
506
+ * Format the two verdict lines shared by the CLI text report and
507
+ * the Readiness tab (proposal section 2.3). The strings are built
508
+ * from `levels` alone so the two surfaces cannot disagree.
509
+ *
510
+ * @param {ReadinessResult} result
511
+ * @returns {{ intentComplete: string, readyToBuild: string }}
512
+ */
513
+ export function formatVerdictLines(result) {
514
+ const l = result.levels;
515
+ const intentLine = l.intentComplete.ok
516
+ ? 'Intent-complete: yes. Every product-owner question is answered.'
517
+ : (() => {
518
+ const n = l.intentComplete.blockedBy.length;
519
+ const detail = l.intentComplete.blockedBy
520
+ .map((b) => `${b.stage}/${b.check}`)
521
+ .join(', ');
522
+ return `Intent-complete: no; ${n} question${n === 1 ? '' : 's'} for the product owner (${detail}).`;
523
+ })();
524
+ const buildLine = l.readyToBuild.ok
525
+ ? `Ready-to-build: yes. Freezeable at ${shortHash(result.tree.currentTreeHash)}.`
526
+ : (() => {
527
+ const blockers = l.readyToBuild.blockedBy;
528
+ const stages = [...new Set(blockers.map((b) => b.stage))].sort();
529
+ const total = blockers.length;
530
+ const p = blockers.filter((b) => b.persona === 'productOwner').length;
531
+ const e = blockers.filter((b) => b.persona === 'engineer').length;
532
+ return `Ready-to-build: no; blocked on ${stages.join(', ')} (${total} check${total === 1 ? '' : 's'}: ${p} product owner, ${e} engineer).`;
533
+ })();
534
+ return { intentComplete: intentLine, readyToBuild: buildLine };
535
+ }
536
+
296
537
  /**
297
538
  * Short 8-character prefix of a `sha256:<hex>` string, or '(none)' when
298
539
  * absent. Kept exported so text formatters share one convention.
@@ -14,7 +14,7 @@ import { createServer } from 'node:http';
14
14
  import { readFile } from 'node:fs/promises';
15
15
  import { join } from 'node:path';
16
16
 
17
- import { LIVE_CLIENT_PATH, STYLE_CSS_PATH, VENDORED_MERMAID_PATH, renderModelToPage } from '../view/index.js';
17
+ import { LIVE_CLIENT_PATH, PAGE_INIT_PATH, STYLE_CSS_PATH, TEST_HOST_CLIENT_PATH, VENDORED_MERMAID_PATH, renderModelToPage } from '../view/index.js';
18
18
  import { watch as defaultWatch } from '../watch/index.js';
19
19
  import { createRouter } from './routes.js';
20
20
  import { createScopeHandler } from './scope-endpoint.js';
@@ -29,6 +29,10 @@ import { createSseHub } from './sse.js';
29
29
  * @property {number} [debounceMs=50]
30
30
  * @property {(line: string) => void} [log] - stderr sink
31
31
  * @property {typeof defaultWatch} [watchImpl] - injectable watch primitive for tests
32
+ * @property {boolean} [testHost=false] - viewer UI refresh PR 9 (TAC-4134,
33
+ * ADR-4136): when true, mount the wespa host fixture at /test-host.html
34
+ * plus its embed-client script at /test-host.js. Off by default so
35
+ * wespa's proxy allow-list is unaffected.
32
36
  */
33
37
 
34
38
  /**
@@ -70,6 +74,7 @@ export async function startServer(args) {
70
74
  const debounceMs = typeof args.debounceMs === 'number' ? args.debounceMs : 50;
71
75
  const log = typeof args.log === 'function' ? args.log : () => {};
72
76
  const watchImpl = typeof args.watchImpl === 'function' ? args.watchImpl : defaultWatch;
77
+ const testHost = args.testHost === true;
73
78
 
74
79
  /** @type {{ version: number, fullPageHtml: string, contentHtml: string, errors: import('#core/errors').RcfError[] } | null} */
75
80
  let state = null;
@@ -97,6 +102,11 @@ export async function startServer(args) {
97
102
  contentHtml: result.contentHtml,
98
103
  errors: result.errors,
99
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,
100
110
  };
101
111
  sse.broadcast('tree-update', { version, contentHtml: result.contentHtml });
102
112
  if (result.errors && result.errors.length > 0) {
@@ -122,10 +132,17 @@ export async function startServer(args) {
122
132
  // so a checkout branch switch during the server's lifetime cannot
123
133
  // change what gets served. The tree walker is the only path that
124
134
  // re-reads disk on rcf/ change; every other asset is fixed at boot.
125
- const [styleAsset, mermaidAsset, liveClientAsset] = await Promise.all([
135
+ const [styleAsset, mermaidAsset, liveClientAsset, pageInitAsset, testHostClientAsset] = await Promise.all([
126
136
  loadStaticAsset(STYLE_CSS_PATH, 'text/css; charset=utf-8'),
127
137
  loadStaticAsset(VENDORED_MERMAID_PATH, 'application/javascript; charset=utf-8'),
128
138
  loadStaticAsset(LIVE_CLIENT_PATH, 'application/javascript; charset=utf-8'),
139
+ loadStaticAsset(PAGE_INIT_PATH, 'application/javascript; charset=utf-8'),
140
+ // Viewer UI refresh PR 9 (TAC-4134): the wespa host fixture client
141
+ // only loads when testHost is on. Keeping the read behind the gate
142
+ // means a startup under default options never touches the file.
143
+ testHost
144
+ ? loadStaticAsset(TEST_HOST_CLIENT_PATH, 'application/javascript; charset=utf-8')
145
+ : Promise.resolve(null),
129
146
  ]);
130
147
 
131
148
  const scopeHandler = createScopeHandler({ projectRoot });
@@ -138,10 +155,15 @@ export async function startServer(args) {
138
155
  styleAsset,
139
156
  mermaidAsset,
140
157
  liveClientAsset,
158
+ pageInitAsset,
141
159
  stylePath: STYLE_CSS_PATH,
142
160
  mermaidPath: VENDORED_MERMAID_PATH,
143
161
  liveClientPath: LIVE_CLIENT_PATH,
162
+ pageInitPath: PAGE_INIT_PATH,
144
163
  scope: scopeHandler,
164
+ testHost,
165
+ testHostClientAsset,
166
+ testHostClientPath: testHost ? TEST_HOST_CLIENT_PATH : undefined,
145
167
  });
146
168
 
147
169
  const server = createServer(router);
@@ -4,7 +4,28 @@
4
4
  // GET /style.css -> shipped stylesheet
5
5
  // GET /mermaid.min.js-> vendored mermaid runtime
6
6
  // GET /live-client.js-> phase 3.8 live client script
7
+ // GET /page-init.js -> viewer UI refresh PR 1 shell / router script
8
+ // (Dex / wespa 2026-10-02 #263; release-noted so
9
+ // the proxy's route allow-list is extended).
7
10
  // GET /scope.json -> deep-link scope resolver (w-2026-08-30-dave-020)
11
+ // GET /index.json -> viewer UI refresh PR 7 ID lookup index built
12
+ // from BuiltTreeModel on every rewalk; `{ rows }`
13
+ // with one row per document id plus one per AC
14
+ // (ADR-4134; release-noted).
15
+ // GET /_fixtures/components -> viewer UI refresh PR 2 component
16
+ // fixture page (release-noted). Static page, no
17
+ // live-walk coupling, so it is always available
18
+ // even before the first rewalk completes.
19
+ // GET /test-host.html -> viewer UI refresh PR 9 wespa host fixture
20
+ // page (TAC-4134, ADR-4136). Only served when
21
+ // `deps.testHost === true`; 404 otherwise so
22
+ // wespa's reverse-proxy allow-list is unaffected.
23
+ // Mimics the wespa shell (56px header, resizable
24
+ // sidebar) and iframes the viewer at `./?embed=1
25
+ // &theme=light`. Test-suite proof only; not a
26
+ // shipped UI.
27
+ // GET /test-host.js -> viewer UI refresh PR 9 wespa host fixture
28
+ // client script. Same testHost gate as the HTML.
8
29
  // Everything else -> 404 text/plain.
9
30
  //
10
31
  // No CORS headers, no cache headers on static assets beyond what the
@@ -12,6 +33,9 @@
12
33
 
13
34
  import { readFile, stat } from 'node:fs/promises';
14
35
 
36
+ import { renderComponentsFixturePage } from '../view/components-fixture.js';
37
+ import { renderTestHostPage } from '../view/test-host/fixture.js';
38
+
15
39
  // Issue #248 (0.28.3): the shipped static assets (style.css,
16
40
  // mermaid.min.js, live-client.js) MUST be resolved once at server
17
41
  // startup, not read from disk on every HTTP request. If the checkout
@@ -32,12 +56,13 @@ const MIME = {
32
56
  html: 'text/html; charset=utf-8',
33
57
  css: 'text/css; charset=utf-8',
34
58
  js: 'application/javascript; charset=utf-8',
59
+ json: 'application/json; charset=utf-8',
35
60
  txt: 'text/plain; charset=utf-8',
36
61
  };
37
62
 
38
63
  /**
39
64
  * @typedef {object} RouterDeps
40
- * @property {() => { fullPageHtml: string, contentHtml: string, version: number } | null} currentState
65
+ * @property {() => { fullPageHtml: string, contentHtml: string, version: number, indexJson?: string } | null} currentState
41
66
  * @property {ReturnType<typeof import('./sse.js').createSseHub>} sse
42
67
  * @property {string} stylePath
43
68
  * @property {string} mermaidPath
@@ -95,6 +120,10 @@ export function createRouter(deps) {
95
120
  serveAsset(res, deps.liveClientAsset, deps.liveClientPath, MIME.js).catch((err) => fail(res, err));
96
121
  return;
97
122
  }
123
+ if (path === '/page-init.js') {
124
+ serveAsset(res, deps.pageInitAsset, deps.pageInitPath, MIME.js).catch((err) => fail(res, err));
125
+ return;
126
+ }
98
127
  if (path.startsWith('/product-map/')) {
99
128
  const state = deps.currentState();
100
129
  if (!state || !state.pmPartials) {
@@ -113,6 +142,44 @@ export function createRouter(deps) {
113
142
  res.end(html);
114
143
  return;
115
144
  }
145
+ if (path === '/_fixtures/components') {
146
+ res.writeHead(200, { 'content-type': MIME.html, 'cache-control': 'no-store' });
147
+ res.end(renderComponentsFixturePage());
148
+ return;
149
+ }
150
+ if (path === '/test-host.html' && deps.testHost) {
151
+ // Viewer UI refresh PR 9 (TAC-4134, ADR-4136): the wespa host
152
+ // fixture is only mounted when the server was started with
153
+ // testHost: true. No X-Frame-Options, no CSP; the viewer's
154
+ // framing lint asserts the same on GET /.
155
+ res.writeHead(200, { 'content-type': MIME.html, 'cache-control': 'no-store' });
156
+ res.end(renderTestHostPage());
157
+ return;
158
+ }
159
+ if (path === '/test-host.js' && deps.testHost) {
160
+ serveAsset(res, deps.testHostClientAsset, deps.testHostClientPath, MIME.js).catch((err) => fail(res, err));
161
+ return;
162
+ }
163
+ if (path === '/index.json') {
164
+ // Viewer UI refresh PR 7 (TAC-4132, ADR-4134): the ID lookup
165
+ // index is built once per rewalk and lives on `state.indexJson`
166
+ // as a pre-stringified payload, so this handler never touches
167
+ // disk and content-length matches the exact bytes served.
168
+ const state = deps.currentState();
169
+ if (!state || typeof state.indexJson !== 'string') {
170
+ res.writeHead(503, { 'content-type': MIME.txt });
171
+ res.end('view server initialising\n');
172
+ return;
173
+ }
174
+ const body = state.indexJson;
175
+ res.writeHead(200, {
176
+ 'content-type': MIME.json,
177
+ 'content-length': String(Buffer.byteLength(body)),
178
+ 'cache-control': 'no-store',
179
+ });
180
+ res.end(body);
181
+ return;
182
+ }
116
183
  if (path === '/scope.json') {
117
184
  if (typeof deps.scope !== 'function') {
118
185
  res.writeHead(404, { 'content-type': MIME.txt });
@@ -75,6 +75,23 @@ escalation.)_
75
75
 
76
76
  unstated
77
77
 
78
+ ## Surface
79
+
80
+ _(how the agent frames its review-ready output when a slice closes.
81
+ One of the three values below on a single line; the seeded default is
82
+ \`prDiff\` and a hand-edit is one word away.)_
83
+
84
+ - \`viewer\`: the slice is reviewed in a rendered viewer surface (an
85
+ HTML preview, a Readiness tab, a dashboard). The agent lands on a
86
+ URL the operator opens.
87
+ - \`runningApp\`: the slice is reviewed by running the app and
88
+ observing behaviour. The agent lands on a one-line reproduction
89
+ recipe.
90
+ - \`prDiff\`: the slice is reviewed as a pull-request diff on the
91
+ shared repo. The agent lands on a PR link with the chain IDs listed.
92
+
93
+ prDiff
94
+
78
95
  ## Project-scoped notes
79
96
 
80
97
  _(anything specific to this project that would be useful for the agent