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
package/src/view/index.js CHANGED
@@ -6,17 +6,33 @@
6
6
 
7
7
  import { dirname, join, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
- import { stat } from 'node:fs/promises';
9
+ import { readFile, stat } from 'node:fs/promises';
10
10
 
11
- import { walkTree } from '#core/store';
11
+ import { resolveTestPointers, walkTree } from '#core/store';
12
+ import { computeReadiness } from '../query/readiness.js';
13
+ import { computeQuestions } from '../query/questions.js';
14
+ import { loadFreezeRecord } from '../define/freeze-record.js';
15
+ import { loadAllLedgers } from '../define/ledgers.js';
12
16
  import { renderContent, renderPage } from './html-page.js';
13
17
  import { renderProductMapGrouping } from './product-map.js';
14
18
  import { buildTreeModel } from './tree-model.js';
19
+ import { serialiseViewIndex } from './view-index.js';
15
20
 
16
21
  const here = dirname(fileURLToPath(import.meta.url));
17
22
  export const VENDORED_MERMAID_PATH = resolve(here, 'vendored', 'mermaid.min.js');
18
23
  export const STYLE_CSS_PATH = resolve(here, 'style.css');
19
24
  export const LIVE_CLIENT_PATH = resolve(here, 'live-client.js');
25
+ // Viewer UI refresh PR 1 (Dex / wespa 2026-10-02 #263): the former
26
+ // inline <script> block from html-page.js now lives at this path and
27
+ // is served over the new /page-init.js route. Release notes name it so
28
+ // wespa's proxy allow-list is extended before any version pin bump.
29
+ export const PAGE_INIT_PATH = resolve(here, 'page-init.js');
30
+ // Viewer UI refresh PR 9 (TAC-4134, ADR-4136): wespa host fixture
31
+ // client script. Served over /test-host.js only when the server is
32
+ // started with testHost: true (CLI --test-host or RCF_VIEW_TEST_HOST=1).
33
+ // Off the default route surface so wespa's proxy allow-list is
34
+ // unaffected.
35
+ export const TEST_HOST_CLIENT_PATH = resolve(here, 'test-host', 'embed-client.js');
20
36
 
21
37
  /**
22
38
  * Walk up from `start` looking for an ancestor directory containing a
@@ -57,7 +73,62 @@ export async function findProjectRoot(start) {
57
73
  */
58
74
  export async function renderModelToPage({ projectRoot }) {
59
75
  const { tree, errors } = await walkTree({ projectRoot });
76
+
77
+ // Load the readiness inputs once per rewalk; the viewer is pure
78
+ // (never recomputes readiness). Failures load as `null` so the tab
79
+ // still renders when a tree is partial.
80
+ const [freezeRecord, ledgers, testPointers, profileText] = await Promise.all([
81
+ loadFreezeRecord({ projectRoot }).catch(() => null),
82
+ loadAllLedgers({ projectRoot }).catch(() => ({})),
83
+ resolveTestPointers({ projectRoot, tree }).catch(() => undefined),
84
+ readProfileText(projectRoot),
85
+ ]);
86
+
87
+ let readiness = null;
88
+ try {
89
+ readiness = computeReadiness(tree, {
90
+ freeze: freezeRecord,
91
+ ledgers,
92
+ profile: undefined,
93
+ profileText,
94
+ testPointers,
95
+ validateErrors: errors,
96
+ });
97
+ } catch (err) {
98
+ // A malformed tree should not block the viewer; the Readiness tab
99
+ // renders its "could not be computed" placeholder in that case.
100
+ readiness = null;
101
+ }
102
+
103
+ // Viewer UI refresh PR 8 (TAC-4133, ADR-4135, AC-18002-1): fold the
104
+ // DEFINE step 3 PR 2 `computeQuestions` output onto the readiness
105
+ // object before the view reads it, so the PO layer's question
106
+ // adapter feature-detects the real shape (`questions[]`, `groups[]`,
107
+ // `optional[]`, `engineer.blockers`) rather than falling back to the
108
+ // blockers path. Pure fold; `computeQuestions` is a composer over
109
+ // the same readiness object and writes nothing under rcf/.
110
+ if (readiness) {
111
+ try {
112
+ const q = computeQuestions(readiness, {
113
+ tree,
114
+ ledgers,
115
+ profileText,
116
+ persona: 'productOwner',
117
+ });
118
+ readiness.questions = q.questions;
119
+ readiness.questionGroups = q.groups;
120
+ readiness.questionOptional = q.optional;
121
+ readiness.questionEngineer = q.engineer ?? null;
122
+ } catch {
123
+ // Composition failure never blocks the viewer; the adapter falls
124
+ // back to the blockers path when `readiness.questions` is absent.
125
+ }
126
+ }
127
+
60
128
  const model = buildTreeModel({ tree, errors });
129
+ model.readiness = readiness;
130
+ model.profileText = profileText;
131
+ model.freezeRecord = freezeRecord;
61
132
  const fullPageHtml = renderPage(model);
62
133
  const contentHtml = renderContent(model);
63
134
  // Pre-rendered partials for the /product-map/<group> endpoint
@@ -72,5 +143,27 @@ export async function renderModelToPage({ projectRoot }) {
72
143
  capability: renderProductMapGrouping(model, 'capability'),
73
144
  blueprint: renderProductMapGrouping(model, 'blueprint'),
74
145
  };
75
- return { fullPageHtml, contentHtml, errors, tree, pmPartials };
146
+ // Viewer UI refresh PR 7 (TAC-4132, ADR-4134): the ID lookup index
147
+ // is built on every rewalk from BuiltTreeModel and served from the
148
+ // new `./index.json` route (release-noted so wespa's proxy allow-
149
+ // list is extended before the version pin bump). One row per doc id
150
+ // plus one row per AC under each US.
151
+ const indexJson = serialiseViewIndex(model);
152
+ return { fullPageHtml, contentHtml, errors, tree, pmPartials, indexJson };
153
+ }
154
+
155
+ /**
156
+ * Read `rcf/.identity/profile.md` text or return null when the file
157
+ * is absent (the Readiness tab orders persona groups as PO-first in
158
+ * the absent case).
159
+ *
160
+ * @param {string} projectRoot
161
+ * @returns {Promise<string | null>}
162
+ */
163
+ async function readProfileText(projectRoot) {
164
+ try {
165
+ return await readFile(join(projectRoot, 'rcf', '.identity', 'profile.md'), 'utf8');
166
+ } catch (err) {
167
+ return null;
168
+ }
76
169
  }
@@ -185,7 +185,10 @@
185
185
  function fetchScope(slug, fetchImpl) {
186
186
  var f = typeof fetchImpl === 'function' ? fetchImpl : (typeof fetch === 'function' ? fetch : null);
187
187
  if (!f) return Promise.reject(new Error('no fetch available'));
188
- var url = '/scope.json?blueprint=' + encodeURIComponent(slug);
188
+ // Dex / wespa 2026-10-02 (#263, PR 1): asset references are relative
189
+ // so the viewer works under the proxy mount (/rcf-viewer/) without
190
+ // path rewrites.
191
+ var url = './scope.json?blueprint=' + encodeURIComponent(slug);
189
192
  return Promise.resolve(f(url)).then(function (resp) {
190
193
  if (!resp) throw new Error('scope: no response');
191
194
  var okFlag = resp.ok !== undefined ? resp.ok : (resp.status >= 200 && resp.status < 300);
@@ -410,7 +413,9 @@
410
413
  setState('disconnected');
411
414
  return;
412
415
  }
413
- var source = new EventSourceCtor('/events');
416
+ // Dex / wespa 2026-10-02 (#263, PR 1): relative so the stream
417
+ // works under the proxy mount without a rewrite rule.
418
+ var source = new EventSourceCtor('./events');
414
419
 
415
420
  source.onopen = function () {
416
421
  lastEventAt = Date.now();
@@ -440,6 +445,11 @@
440
445
  // grouping refetches instead of using the stale cache. See
441
446
  // hydrateLazyGroup in the inline script.
442
447
  win.__rcfPmDirty = true;
448
+ // Viewer UI refresh PR 7 (TAC-4132, ADR-4134): the ID lookup
449
+ // index is a snapshot of the tree. A tree-update invalidates
450
+ // whichever rows the client last fetched; wireLookup refetches
451
+ // on the next modal open instead of staying stale.
452
+ win.__rcfLookupDirty = true;
443
453
  // Tab buttons live in the header (outside the swap wrapper) so
444
454
  // their click handlers survive. But the swap serves every panel
445
455
  // with the same `hidden` state Phase 3.6 renders (Overview
@@ -535,8 +545,25 @@
535
545
  };
536
546
  }
537
547
 
548
+ // Connection pill: the shell footer renders #rcf-conn-pill (viewer UI
549
+ // refresh PR 1 follow-up: pill moved from a floating top-right dot
550
+ // into the footer). If the pill is missing (older shell, embedded
551
+ // snippet, no-shell contexts), fall back to injecting a floating dot
552
+ // so the live-status signal is never silently dropped.
553
+ function pillLabelFor(state) {
554
+ if (state === 'connected') return 'Live updates on';
555
+ if (state === 'reconnecting') return 'Reconnecting…';
556
+ return 'Disconnected';
557
+ }
558
+
559
+ function pillTitleFor(state) {
560
+ if (state === 'connected') return 'Connected - live updates on';
561
+ if (state === 'reconnecting') return 'Reconnecting to view server...';
562
+ return 'Disconnected from view server';
563
+ }
564
+
538
565
  function injectConnectionDot(doc) {
539
- if (doc.getElementById('rcf-conn-dot')) return;
566
+ if (doc.getElementById('rcf-conn-pill') || doc.getElementById('rcf-conn-dot')) return;
540
567
  var style = doc.createElement('style');
541
568
  style.textContent = [
542
569
  '#rcf-conn-dot {',
@@ -560,12 +587,18 @@
560
587
  }
561
588
 
562
589
  function updateDot(doc, state) {
590
+ var pill = doc.getElementById('rcf-conn-pill');
591
+ if (pill) {
592
+ pill.className = 'live ' + state;
593
+ pill.title = pillTitleFor(state);
594
+ var label = pill.querySelector('.label');
595
+ if (label) label.textContent = pillLabelFor(state);
596
+ return;
597
+ }
563
598
  var dot = doc.getElementById('rcf-conn-dot');
564
599
  if (!dot) return;
565
600
  dot.className = state;
566
- if (state === 'connected') dot.title = 'Connected - live updates on';
567
- else if (state === 'reconnecting') dot.title = 'Reconnecting to view server...';
568
- else dot.title = 'Disconnected from view server';
601
+ dot.title = pillTitleFor(state);
569
602
  }
570
603
 
571
604
  // ---- module exports (for node:vm tests) --------------------------------
@@ -0,0 +1,79 @@
1
+ // Viewer UI refresh PR 3 (decision 3, decision 14): compute the set of
2
+ // REQ ids that still "need work" from the readiness result. The source
3
+ // is the readiness result the server already carries on `model.readiness`
4
+ // (built once per rewalk by `computeReadiness`), so we never re-run gates
5
+ // in the client and never recompute them from the tree.
6
+ //
7
+ // The design doc section 8 names the three PO-persona checks that call
8
+ // out specific REQ ids: `skeleton:reqIntent` (requirement needs a plain
9
+ // description), `stories:reqHasUs` (who uses this requirement, what do
10
+ // they do), and `skeleton:resolvedBy` (brief kinds that could become a
11
+ // REQ). The first two name REQ ids directly on the blocker's `ids`
12
+ // array. `skeleton:resolvedBy` names brief statement ids (SK-x), not
13
+ // REQ ids, so it is informational for the Requirements tab and only
14
+ // gets picked up when the suggestion names a REQ. Any REQ id that ends
15
+ // up in any of the above blocker lists is in the Needs-work set.
16
+ //
17
+ // Decision 14: Requirements is the engineer view at all times. The
18
+ // Needs-work chip uses engineer-plain language (no PO phrasebook text);
19
+ // the data behind it is still the PO blockers so a product-owner and
20
+ // an engineer looking at the same tree see the same "still needs work"
21
+ // requirements.
22
+
23
+ const NEEDS_WORK_CHECKS = new Set([
24
+ 'skeleton:reqIntent',
25
+ 'stories:reqHasUs',
26
+ 'skeleton:resolvedBy',
27
+ ]);
28
+
29
+ /**
30
+ * @param {import('../query/readiness.js').ReadinessResult | null | undefined} readiness
31
+ * @returns {Set<string>} REQ ids that still need work
32
+ */
33
+ export function computeReqNeedsWorkIds(readiness) {
34
+ /** @type {Set<string>} */
35
+ const out = new Set();
36
+ if (!readiness) return out;
37
+ const level = readiness.levels?.intentComplete;
38
+ if (!level || !Array.isArray(level.blockedBy)) return out;
39
+ for (const blocker of level.blockedBy) {
40
+ if (!blocker || !NEEDS_WORK_CHECKS.has(blocker.check)) continue;
41
+ const ids = Array.isArray(blocker.ids) ? blocker.ids : [];
42
+ for (const id of ids) {
43
+ if (typeof id === 'string' && id.startsWith('REQ-')) out.add(id);
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * Return a short, engineer-plain reason for a REQ being in the needs-
51
+ * work set. Decision 14 keeps PO phrasebook text out of the Requirements
52
+ * tab, so we map the check name to a terse engineer-facing label rather
53
+ * than reproducing CHECK_QUESTION. Returns null when the REQ is not in
54
+ * any needs-work check.
55
+ *
56
+ * @param {import('../query/readiness.js').ReadinessResult | null | undefined} readiness
57
+ * @param {string} reqId
58
+ * @returns {string | null}
59
+ */
60
+ export function needsWorkReasonFor(readiness, reqId) {
61
+ if (!readiness) return null;
62
+ const level = readiness.levels?.intentComplete;
63
+ if (!level || !Array.isArray(level.blockedBy)) return null;
64
+ const reasons = [];
65
+ for (const blocker of level.blockedBy) {
66
+ if (!blocker || !NEEDS_WORK_CHECKS.has(blocker.check)) continue;
67
+ const ids = Array.isArray(blocker.ids) ? blocker.ids : [];
68
+ if (!ids.includes(reqId)) continue;
69
+ reasons.push(REASON_BY_CHECK[blocker.check] ?? blocker.check);
70
+ }
71
+ if (reasons.length === 0) return null;
72
+ return reasons.join('; ');
73
+ }
74
+
75
+ const REASON_BY_CHECK = {
76
+ 'skeleton:reqIntent': 'missing plain description or domain',
77
+ 'stories:reqHasUs': 'no user story yet',
78
+ 'skeleton:resolvedBy': 'resolved-by target still open',
79
+ };