rcf-lite 0.0.1 → 0.7.1

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 (342) hide show
  1. package/CHANGELOG.md +288 -0
  2. package/LICENSE +202 -0
  3. package/README.md +92 -4
  4. package/bin/rcf-verify.js +122 -0
  5. package/bin/rcf.js +174 -0
  6. package/bin/view-supervisor-child.mjs +14 -0
  7. package/fixtures/canary-manifest.json +103 -0
  8. package/guidance/README.md +25 -0
  9. package/guidance/build-cycle-playbook.md +492 -0
  10. package/guidance/build-cycle.md +40 -0
  11. package/guidance/document-model.md +45 -0
  12. package/guidance/elicitation-playbook.md +442 -0
  13. package/guidance/harness-template.md +246 -0
  14. package/guidance/managed/README.md +63 -0
  15. package/guidance/managed/agent-instructions-block.hash +1 -0
  16. package/guidance/managed/agent-instructions-block.md +220 -0
  17. package/guidance/managed/legacy-fragment-hashes.json +22 -0
  18. package/guidance/manifest.json +21 -0
  19. package/guidance/overview.md +48 -0
  20. package/guidance/persona-programme.md +44 -0
  21. package/package.json +69 -8
  22. package/rcf/adrs/adr-001.json +25 -0
  23. package/rcf/adrs/adr-002.json +25 -0
  24. package/rcf/adrs/adr-003.json +25 -0
  25. package/rcf/adrs/adr-004.json +20 -0
  26. package/rcf/adrs/adr-005.json +20 -0
  27. package/rcf/adrs/adr-006.json +25 -0
  28. package/rcf/adrs/adr-007.json +25 -0
  29. package/rcf/adrs/adr-008.json +25 -0
  30. package/rcf/adrs/adr-009.json +25 -0
  31. package/rcf/build-sequence.json +11 -0
  32. package/rcf/code-nodes/cn-001.json +14 -0
  33. package/rcf/code-nodes/cn-002.json +14 -0
  34. package/rcf/code-nodes/cn-003.json +15 -0
  35. package/rcf/code-nodes/cn-004.json +17 -0
  36. package/rcf/code-nodes/cn-005.json +19 -0
  37. package/rcf/code-nodes/cn-006.json +17 -0
  38. package/rcf/code-nodes/cn-007.json +16 -0
  39. package/rcf/code-nodes/cn-008.json +19 -0
  40. package/rcf/code-nodes/cn-009.json +16 -0
  41. package/rcf/code-nodes/cn-010.json +18 -0
  42. package/rcf/code-nodes/cn-011.json +18 -0
  43. package/rcf/code-nodes/cn-012.json +18 -0
  44. package/rcf/code-nodes/cn-013.json +19 -0
  45. package/rcf/code-nodes/cn-014.json +16 -0
  46. package/rcf/code-nodes/cn-015.json +16 -0
  47. package/rcf/code-nodes/cn-016.json +16 -0
  48. package/rcf/code-nodes/cn-017.json +16 -0
  49. package/rcf/code-nodes/cn-018.json +15 -0
  50. package/rcf/code-nodes/cn-019.json +16 -0
  51. package/rcf/code-nodes/cn-020.json +16 -0
  52. package/rcf/code-nodes/cn-021.json +16 -0
  53. package/rcf/code-nodes/cn-022.json +16 -0
  54. package/rcf/code-nodes/cn-023.json +16 -0
  55. package/rcf/code-nodes/cn-024.json +16 -0
  56. package/rcf/code-nodes/cn-025.json +16 -0
  57. package/rcf/code-nodes/cn-026.json +16 -0
  58. package/rcf/code-nodes/cn-027.json +16 -0
  59. package/rcf/code-nodes/cn-028.json +16 -0
  60. package/rcf/code-nodes/cn-029.json +19 -0
  61. package/rcf/code-nodes/cn-030.json +14 -0
  62. package/rcf/code-nodes/cn-031.json +14 -0
  63. package/rcf/code-nodes/cn-032.json +14 -0
  64. package/rcf/code-nodes/cn-033.json +14 -0
  65. package/rcf/code-nodes/cn-034.json +14 -0
  66. package/rcf/code-nodes/cn-035.json +14 -0
  67. package/rcf/code-nodes/cn-036.json +14 -0
  68. package/rcf/code-nodes/cn-037.json +14 -0
  69. package/rcf/code-nodes/cn-038.json +14 -0
  70. package/rcf/code-nodes/cn-039.json +14 -0
  71. package/rcf/code-nodes/cn-040.json +14 -0
  72. package/rcf/code-nodes/cn-041.json +14 -0
  73. package/rcf/code-nodes/cn-042.json +14 -0
  74. package/rcf/code-nodes/cn-043.json +14 -0
  75. package/rcf/code-nodes/cn-044.json +14 -0
  76. package/rcf/code-nodes/cn-045.json +14 -0
  77. package/rcf/code-nodes/cn-046.json +14 -0
  78. package/rcf/code-nodes/cn-047.json +14 -0
  79. package/rcf/code-nodes/cn-048.json +14 -0
  80. package/rcf/code-nodes/cn-049.json +14 -0
  81. package/rcf/code-nodes/cn-050.json +14 -0
  82. package/rcf/code-nodes/cn-051.json +14 -0
  83. package/rcf/code-nodes/cn-052.json +14 -0
  84. package/rcf/code-nodes/cn-053.json +14 -0
  85. package/rcf/code-nodes/cn-054.json +14 -0
  86. package/rcf/code-nodes/cn-055.json +14 -0
  87. package/rcf/code-nodes/cn-056.json +14 -0
  88. package/rcf/code-nodes/cn-057.json +14 -0
  89. package/rcf/fbs/fbs-001.json +49 -0
  90. package/rcf/fbs/fbs-002.json +42 -0
  91. package/rcf/fbs/fbs-003.json +37 -0
  92. package/rcf/fbs/fbs-004.json +39 -0
  93. package/rcf/fbs/fbs-005.json +38 -0
  94. package/rcf/fbs/fbs-006.json +48 -0
  95. package/rcf/fbs/fbs-007.json +39 -0
  96. package/rcf/fbs/fbs-008.json +40 -0
  97. package/rcf/fbs/fbs-009.json +36 -0
  98. package/rcf/fbs/fbs-010.json +41 -0
  99. package/rcf/fbs/fbs-011.json +36 -0
  100. package/rcf/fbs/fbs-012.json +46 -0
  101. package/rcf/fbs/fbs-013.json +42 -0
  102. package/rcf/fbs/fbs-014.json +49 -0
  103. package/rcf/fbs/fbs-015.json +32 -0
  104. package/rcf/manifest.json +17 -0
  105. package/rcf/prd.json +47 -0
  106. package/rcf/requirements/req-001.json +19 -0
  107. package/rcf/requirements/req-002.json +19 -0
  108. package/rcf/requirements/req-003.json +19 -0
  109. package/rcf/requirements/req-004.json +19 -0
  110. package/rcf/requirements/req-005.json +19 -0
  111. package/rcf/requirements/req-006.json +19 -0
  112. package/rcf/requirements/req-007.json +19 -0
  113. package/rcf/requirements/req-008.json +19 -0
  114. package/rcf/requirements/req-009.json +19 -0
  115. package/rcf/tacs/tac-001.json +45 -0
  116. package/rcf/tacs/tac-002.json +109 -0
  117. package/rcf/tacs/tac-003.json +40 -0
  118. package/rcf/tacs/tac-004.json +51 -0
  119. package/rcf/tacs/tac-005.json +52 -0
  120. package/rcf/tacs/tac-006.json +104 -0
  121. package/rcf/tacs/tac-007.json +38 -0
  122. package/rcf/tacs/tac-008.json +51 -0
  123. package/rcf/tad.json +51 -0
  124. package/rcf/test-suites/PENDING.md +23 -0
  125. package/rcf/test-suites/ts-001.json +38 -0
  126. package/rcf/test-suites/ts-002.json +38 -0
  127. package/rcf/test-suites/ts-003.json +43 -0
  128. package/rcf/test-suites/ts-004.json +44 -0
  129. package/rcf/test-suites/ts-005.json +30 -0
  130. package/rcf/test-suites/ts-006.json +36 -0
  131. package/rcf/test-suites/ts-007.json +43 -0
  132. package/rcf/test-suites/ts-008.json +37 -0
  133. package/rcf/test-suites/ts-009.json +38 -0
  134. package/rcf/test-suites/ts-010.json +38 -0
  135. package/rcf/test-suites/ts-011.json +44 -0
  136. package/rcf/test-suites/ts-012.json +36 -0
  137. package/rcf/test-suites/ts-013.json +38 -0
  138. package/rcf/test-suites/ts-014.json +38 -0
  139. package/rcf/test-suites/ts-015.json +38 -0
  140. package/rcf/test-suites/ts-016.json +37 -0
  141. package/rcf/test-suites/ts-017.json +38 -0
  142. package/rcf/test-suites/ts-018.json +38 -0
  143. package/rcf/test-suites/ts-019.json +37 -0
  144. package/rcf/test-suites/ts-020.json +46 -0
  145. package/rcf/test-suites/ts-021.json +46 -0
  146. package/rcf/test-suites/ts-022.json +46 -0
  147. package/rcf/test-suites/ts-023.json +46 -0
  148. package/rcf/test-suites/ts-024.json +46 -0
  149. package/rcf/test-suites/ts-025.json +52 -0
  150. package/rcf/user-stories/us-101.json +40 -0
  151. package/rcf/user-stories/us-102.json +40 -0
  152. package/rcf/user-stories/us-201.json +40 -0
  153. package/rcf/user-stories/us-202.json +40 -0
  154. package/rcf/user-stories/us-203.json +32 -0
  155. package/rcf/user-stories/us-301.json +40 -0
  156. package/rcf/user-stories/us-302.json +40 -0
  157. package/rcf/user-stories/us-303.json +40 -0
  158. package/rcf/user-stories/us-304.json +40 -0
  159. package/rcf/user-stories/us-401.json +40 -0
  160. package/rcf/user-stories/us-402.json +40 -0
  161. package/rcf/user-stories/us-403.json +40 -0
  162. package/rcf/user-stories/us-501.json +40 -0
  163. package/rcf/user-stories/us-502.json +40 -0
  164. package/rcf/user-stories/us-503.json +40 -0
  165. package/rcf/user-stories/us-601.json +40 -0
  166. package/rcf/user-stories/us-602.json +40 -0
  167. package/rcf/user-stories/us-701.json +40 -0
  168. package/rcf/user-stories/us-702.json +40 -0
  169. package/rcf/user-stories/us-801.json +49 -0
  170. package/rcf/user-stories/us-802.json +49 -0
  171. package/rcf/user-stories/us-803.json +49 -0
  172. package/rcf/user-stories/us-804.json +49 -0
  173. package/rcf/user-stories/us-805.json +49 -0
  174. package/rcf/user-stories/us-901.json +40 -0
  175. package/src/.gitkeep +0 -0
  176. package/src/browser-verify/auth-smoke.js +109 -0
  177. package/src/browser-verify/index.js +29 -0
  178. package/src/browser-verify/invariants.js +336 -0
  179. package/src/browser-verify/manifest-writer.js +189 -0
  180. package/src/browser-verify/runner.js +171 -0
  181. package/src/build/bundle.js +198 -0
  182. package/src/build/formatters/json.js +18 -0
  183. package/src/build/formatters/markdown.js +406 -0
  184. package/src/build/index.js +14 -0
  185. package/src/build/mark.js +177 -0
  186. package/src/build/queue.js +285 -0
  187. package/src/cli/browser-verify.js +231 -0
  188. package/src/cli/build.js +584 -0
  189. package/src/cli/coverage.js +219 -0
  190. package/src/cli/create.js +355 -0
  191. package/src/cli/delete.js +127 -0
  192. package/src/cli/design.js +353 -0
  193. package/src/cli/doctor.js +548 -0
  194. package/src/cli/fbs.js +171 -0
  195. package/src/cli/finalise.js +377 -0
  196. package/src/cli/guidance.js +206 -0
  197. package/src/cli/help.js +156 -0
  198. package/src/cli/impact.js +119 -0
  199. package/src/cli/init.js +282 -0
  200. package/src/cli/intake.js +153 -0
  201. package/src/cli/link.js +128 -0
  202. package/src/cli/mcp.js +160 -0
  203. package/src/cli/preflight.js +220 -0
  204. package/src/cli/read.js +162 -0
  205. package/src/cli/req-baseline.js +269 -0
  206. package/src/cli/req-classify.js +135 -0
  207. package/src/cli/review.js +295 -0
  208. package/src/cli/test-suite.js +216 -0
  209. package/src/cli/trace.js +172 -0
  210. package/src/cli/ui-baseline.js +292 -0
  211. package/src/cli/ui-classify.js +108 -0
  212. package/src/cli/update.js +197 -0
  213. package/src/cli/validate.js +168 -0
  214. package/src/cli/view.js +452 -0
  215. package/src/core/baseline-catalog/data/auth.json +42 -0
  216. package/src/core/baseline-catalog/data/http-api.json +42 -0
  217. package/src/core/baseline-catalog/data/notifications.json +33 -0
  218. package/src/core/baseline-catalog/data/persistence.json +33 -0
  219. package/src/core/baseline-catalog/data/web-ui.json +60 -0
  220. package/src/core/baseline-catalog/index.js +121 -0
  221. package/src/core/errors/index.js +167 -0
  222. package/src/core/fixtures/register-canary/canary-prompt-01.json +18 -0
  223. package/src/core/fixtures/register-canary/canary-prompt-02.json +21 -0
  224. package/src/core/fixtures/register-canary/canary-prompt-03.json +17 -0
  225. package/src/core/isolation/index.js +60 -0
  226. package/src/core/mcp/framing.js +103 -0
  227. package/src/core/mcp/index.js +8 -0
  228. package/src/core/mcp/server.js +228 -0
  229. package/src/core/patterns/register-canary.js +209 -0
  230. package/src/core/patterns/req-shapes.js +158 -0
  231. package/src/core/patterns/services.js +358 -0
  232. package/src/core/patterns/ui-shapes.js +166 -0
  233. package/src/core/store/cn-resolve.js +134 -0
  234. package/src/core/store/derive-deps.js +93 -0
  235. package/src/core/store/ids.js +78 -0
  236. package/src/core/store/index.js +20 -0
  237. package/src/core/store/init.js +255 -0
  238. package/src/core/store/loader.js +211 -0
  239. package/src/core/store/tp-resolve.js +176 -0
  240. package/src/core/store/validator.js +191 -0
  241. package/src/core/store/walker.js +898 -0
  242. package/src/core/store/writer.js +1849 -0
  243. package/src/design/index.js +11 -0
  244. package/src/design/writer.js +271 -0
  245. package/src/finalise/detect.js +129 -0
  246. package/src/finalise/index.js +17 -0
  247. package/src/finalise/ingest.js +119 -0
  248. package/src/finalise/install.js +119 -0
  249. package/src/finalise/ship-without-verified.js +131 -0
  250. package/src/finalise/spawn.js +84 -0
  251. package/src/intake/fidelity.js +105 -0
  252. package/src/intake/index.js +6 -0
  253. package/src/intake/manifest-writer.js +100 -0
  254. package/src/intake/orchestrator.js +138 -0
  255. package/src/intake/validate.js +80 -0
  256. package/src/mcp/map-errors.js +131 -0
  257. package/src/mcp/prompts.js +49 -0
  258. package/src/mcp/resources.js +244 -0
  259. package/src/mcp/tools.js +1204 -0
  260. package/src/preflight/design-shapes.js +185 -0
  261. package/src/preflight/index.js +90 -0
  262. package/src/preflight/manifest-writer.js +264 -0
  263. package/src/preflight/scanner.js +206 -0
  264. package/src/preflight/secrets.js +134 -0
  265. package/src/preflight/session.js +246 -0
  266. package/src/query/attestation.js +285 -0
  267. package/src/query/coverage.js +308 -0
  268. package/src/query/formatters/json.js +21 -0
  269. package/src/query/formatters/mermaid.js +209 -0
  270. package/src/query/formatters/table.js +203 -0
  271. package/src/query/impact.js +173 -0
  272. package/src/query/index.js +9 -0
  273. package/src/query/trace.js +345 -0
  274. package/src/register-canary/fixture-loader.js +87 -0
  275. package/src/register-canary/index.js +10 -0
  276. package/src/register-canary/record-writer.js +132 -0
  277. package/src/register-canary/runner.js +156 -0
  278. package/src/req-baseline/gate.js +86 -0
  279. package/src/req-baseline/index.js +27 -0
  280. package/src/req-baseline/open-candidates.js +143 -0
  281. package/src/req-baseline/opt-out.js +195 -0
  282. package/src/req-baseline/sweep.js +230 -0
  283. package/src/req-detection/classifier.js +181 -0
  284. package/src/req-detection/index.js +9 -0
  285. package/src/req-detection/persist.js +55 -0
  286. package/src/review/index.js +325 -0
  287. package/src/review/mutation.js +117 -0
  288. package/src/review/ui-baseline-drift.js +138 -0
  289. package/src/server/index.js +178 -0
  290. package/src/server/routes.js +110 -0
  291. package/src/server/sse.js +118 -0
  292. package/src/setup/agent-setup.js +362 -0
  293. package/src/setup/identity-seed.js +104 -0
  294. package/src/setup/knowledge-seed.js +123 -0
  295. package/src/setup/managed-block.js +193 -0
  296. package/src/setup/managed-gitignore.js +166 -0
  297. package/src/setup/managed-markers.js +49 -0
  298. package/src/ui-baseline/defaults.js +119 -0
  299. package/src/ui-baseline/index.js +25 -0
  300. package/src/ui-baseline/manifest-writer.js +282 -0
  301. package/src/ui-baseline/session.js +178 -0
  302. package/src/ui-detection/classifier.js +192 -0
  303. package/src/verify/chain/index.js +190 -0
  304. package/src/verify/cli/cleanup.js +61 -0
  305. package/src/verify/cli/help.js +56 -0
  306. package/src/verify/cli/mcp.js +98 -0
  307. package/src/verify/cli/provision.js +71 -0
  308. package/src/verify/cli/report.js +71 -0
  309. package/src/verify/cli/run.js +155 -0
  310. package/src/verify/engine/brief.js +87 -0
  311. package/src/verify/engine/index.js +177 -0
  312. package/src/verify/engine/launcher.js +307 -0
  313. package/src/verify/mcp/tools.js +107 -0
  314. package/src/verify/profile/index.js +146 -0
  315. package/src/verify/provision/index.js +256 -0
  316. package/src/verify/report/index.js +139 -0
  317. package/src/verify/report/renderer.js +118 -0
  318. package/src/verify/verdict/index.js +246 -0
  319. package/src/view/doc-renderers/adr.js +44 -0
  320. package/src/view/doc-renderers/build-sequence.js +40 -0
  321. package/src/view/doc-renderers/fbs.js +128 -0
  322. package/src/view/doc-renderers/helpers.js +159 -0
  323. package/src/view/doc-renderers/index.js +12 -0
  324. package/src/view/doc-renderers/prd.js +45 -0
  325. package/src/view/doc-renderers/req.js +43 -0
  326. package/src/view/doc-renderers/tac.js +38 -0
  327. package/src/view/doc-renderers/tad.js +74 -0
  328. package/src/view/doc-renderers/test-suite.js +45 -0
  329. package/src/view/doc-renderers/user-story.js +63 -0
  330. package/src/view/html-page.js +462 -0
  331. package/src/view/index.js +63 -0
  332. package/src/view/live-client.js +338 -0
  333. package/src/view/mermaid-diagram.js +178 -0
  334. package/src/view/style.css +735 -0
  335. package/src/view/tree-model.js +152 -0
  336. package/src/view/vendored/mermaid.min.js +2607 -0
  337. package/src/view-supervisor/index.js +26 -0
  338. package/src/view-supervisor/logs.js +32 -0
  339. package/src/view-supervisor/manifest-writer.js +178 -0
  340. package/src/view-supervisor/persist-until.js +85 -0
  341. package/src/view-supervisor/supervisor.js +276 -0
  342. package/src/watch/index.js +152 -0
@@ -0,0 +1,584 @@
1
+ // `rcf build` subcommand handler (Phase 6 §D1). One verb, four modes:
2
+ //
3
+ // rcf build queue overview
4
+ // rcf build <fbs-id> spec bundle for one FBS item
5
+ // rcf build --next bundle for the next actionable item
6
+ // rcf build <fbs-id> --mark <status> record a lifecycle transition
7
+ //
8
+ // Positional is an FBS id ONLY: the FBS is the queue unit (one US's
9
+ // ACs can span multiple FBS items, so US addressing is ambiguous by
10
+ // construction). US ids exit 2 with a pointer at `rcf trace`.
11
+ //
12
+ // Deterministic-only boundary (§D13): this verb assembles what the
13
+ // tree says. It does not score bundle quality, detect under-specified
14
+ // FBS items, or generate spec prose - that non-deterministic judgement
15
+ // belongs to the Phase 7+ prompting + MCP resources surface.
16
+
17
+ import { writeFile } from 'node:fs/promises';
18
+ import { parseArgs } from 'node:util';
19
+
20
+ import { formatErrors, isRcfError, rcfError, writeUnexpectedFailure } from '#core/errors';
21
+ import { updateDocument, walkTree } from '#core/store';
22
+ import { findProjectRoot } from '../view/index.js';
23
+ import { kindOf } from '../query/index.js';
24
+ import { scanUnbackedServices } from '../query/attestation.js';
25
+ import {
26
+ assembleBundle,
27
+ checkCodeNodeGate,
28
+ checkContrastBeforePaletteGate,
29
+ checkDesignGate,
30
+ computeQueue,
31
+ formatJson,
32
+ formatMarkdown,
33
+ hasNoCodeNodesDeclaration,
34
+ planMark,
35
+ selectNext,
36
+ } from '../build/index.js';
37
+ import { classifyFbs } from '../ui-detection/classifier.js';
38
+ import { firstBaselineDisagreement } from '../design/index.js';
39
+ import { writeBrowserVerificationAck } from '../browser-verify/index.js';
40
+ // Track C+D §5.4 Stage-1 refusal gate.
41
+ import { fbsRefusalForOpenSweep } from '../req-baseline/gate.js';
42
+
43
+ const OPTION_SPEC = {
44
+ next: { type: 'boolean' },
45
+ mark: { type: 'string' },
46
+ format: { type: 'string' },
47
+ out: { type: 'string' },
48
+ strict: { type: 'boolean' },
49
+ quiet: { type: 'boolean' },
50
+ help: { type: 'boolean' },
51
+ // Phase 10 (X2 CodeNode bridge, D17): declare a build spec as genuinely
52
+ // producing no traceable code (docs-only, config-only), exempting it
53
+ // from the mark-complete CN gate. Combines only with `--mark complete`.
54
+ 'no-code-nodes': { type: 'boolean' },
55
+ // Track B (ui-design-gate-0.7.0-spec §8.6): ship-without-verified
56
+ // escape hatch on --mark complete for a browserVerification record
57
+ // whose verdict is `block`. Records the operator's reason on the
58
+ // browserVerification record.
59
+ 'accept-block': { type: 'boolean' },
60
+ reason: { type: 'string' },
61
+ };
62
+
63
+ export const HELP = `Usage: rcf build [fbs-id] [options]
64
+
65
+ Assemble FBS spec bundles and drive the build queue (the SDD adapter).
66
+ Four modes:
67
+
68
+ rcf build Queue overview (the FBS queue as a table,
69
+ with parallel-safe tier groups)
70
+ rcf build <fbs-id> Spec bundle for one FBS item
71
+ rcf build --next Bundle for the next actionable item
72
+ rcf build <fbs-id> --mark <status> Record a lifecycle transition
73
+
74
+ The positional is an FBS id only. For 'which FBS items implement this
75
+ story', use: rcf trace <us-id> --forward --format json
76
+
77
+ Lifecycle (forward-only): notStarted -> inProgress -> complete -> verified.
78
+ The --mark ladder caps at 'complete': --mark verified is refused (exit 4)
79
+ because 'verified' is written only by the finalise gate (rcf finalise) after
80
+ an independent verify run. Backward transitions are also refused (exit 4);
81
+ the deliberate-correction / manual-override escape hatch is:
82
+ rcf update <fbs-id> --set executionStatus=<status>
83
+
84
+ Bundle assembly is mechanical and deterministic: it projects what the
85
+ tree says. It does NOT judge whether the FBS is well-specified or the
86
+ bundle sufficient - that belongs to a later prompting + MCP phase.
87
+
88
+ Options:
89
+ --next Select the next actionable FBS item (lowest
90
+ buildOrder, notStarted, all dependencies
91
+ satisfied) and emit its bundle
92
+ --mark <status> Record a lifecycle transition (combines only
93
+ with the positional and --quiet)
94
+ --format <format> md (default) | json (queue + bundle modes)
95
+ --out <path> Write the bundle to a file (bundle modes)
96
+ --strict Refuse (exit 4) a bundle for a blocked item;
97
+ no effect with --next (it never selects
98
+ blocked items)
99
+ --no-code-nodes With --mark complete: declare this FBS as
100
+ genuinely producing no traceable code
101
+ (docs-only, config-only), recorded on the FBS
102
+ and exempting it from the mark-complete CN gate
103
+ --accept-block With --mark complete on a uiBearing FBS: accept
104
+ a browserVerification block (or a missing record)
105
+ and ship the FBS at complete. Requires --reason
106
+ "..." (>= 20 chars). Records the reason on the
107
+ browserVerification.operatorShipDespiteBlockReason
108
+ field. Track B ship-without-verified (spec §8.6).
109
+ --reason "..." Reason string for --accept-block.
110
+ --quiet Suppress non-error confirmations
111
+ --help Print this help
112
+
113
+ Mark-complete CN gate (D17): marking an FBS complete refuses (exit 3,
114
+ missingCodeNodes) when any of its ACs carries no Code Node. Author CN
115
+ coverage first, or pass --no-code-nodes for a genuinely no-code spec.
116
+ `;
117
+
118
+ const VALID_FORMATS = new Set(['md', 'json']);
119
+
120
+ /**
121
+ * @param {string[]} argv - argv slice after `build`
122
+ * @param {object} [deps]
123
+ * @returns {Promise<number>}
124
+ */
125
+ export async function main(argv, deps = {}) {
126
+ const stdout = deps.stdout ?? process.stdout;
127
+ const stderr = deps.stderr ?? process.stderr;
128
+ const cwd = deps.cwd ?? process.cwd();
129
+
130
+ let parsed;
131
+ try {
132
+ parsed = parseArgs({ args: argv, options: OPTION_SPEC, allowPositionals: true, strict: true });
133
+ } catch (err) {
134
+ stderr.write(`[error] usage ${err.message}\n`);
135
+ stderr.write(HELP);
136
+ return 2;
137
+ }
138
+ const flags = parsed.values;
139
+ const positionals = parsed.positionals;
140
+ if (flags.help) { stdout.write(HELP); return 0; }
141
+
142
+ // Positional discipline (§D1): at most one, no globs.
143
+ if (positionals.length > 1) {
144
+ stderr.write('[error] usage build: multiple positional ids are not supported\n');
145
+ return 2;
146
+ }
147
+ const positional = positionals[0] ?? null;
148
+ if (positional && (positional.includes('*') || positional.includes('?'))) {
149
+ stderr.write('[error] usage build: wildcard / glob positional not supported\n');
150
+ return 2;
151
+ }
152
+
153
+ // Mode detection + flag-conflict rules (§D1 / §D9).
154
+ const usage = (message) => {
155
+ stderr.write(`[error] usage build: ${message}\n`);
156
+ return 2;
157
+ };
158
+ if (flags.next && positional) return usage('--next takes no positional');
159
+ if (flags.mark !== undefined) {
160
+ if (!positional) return usage('--mark requires an <fbs-id> positional');
161
+ if (flags.next) return usage('--mark cannot combine with --next');
162
+ if (flags.format !== undefined) return usage('--mark cannot combine with --format');
163
+ if (flags.out !== undefined) return usage('--out is invalid in mark mode');
164
+ if (flags.strict) return usage('--mark cannot combine with --strict');
165
+ }
166
+ // Phase 10 (X2 CodeNode bridge, D17): --no-code-nodes only makes sense
167
+ // declaring a spec complete with no traceable code.
168
+ if (flags['no-code-nodes'] && flags.mark !== 'complete') {
169
+ return usage('--no-code-nodes only combines with --mark complete');
170
+ }
171
+ // Track B (§8.6): --accept-block only makes sense with --mark complete
172
+ // and requires --reason "...".
173
+ if (flags['accept-block'] && flags.mark !== 'complete') {
174
+ return usage('--accept-block only combines with --mark complete');
175
+ }
176
+ if (flags['accept-block'] && (typeof flags.reason !== 'string' || flags.reason.length < 20)) {
177
+ return usage('--accept-block requires --reason "..." with at least 20 characters');
178
+ }
179
+ const mode = flags.mark !== undefined
180
+ ? 'mark'
181
+ : flags.next
182
+ ? 'next'
183
+ : positional
184
+ ? 'bundle'
185
+ : 'queue';
186
+ if (mode === 'queue') {
187
+ if (flags.out !== undefined) return usage('--out is invalid in queue mode');
188
+ if (flags.strict) return usage('--strict applies to bundle modes only');
189
+ }
190
+ const format = flags.format ?? 'md';
191
+ if (!VALID_FORMATS.has(format)) {
192
+ return usage(`unknown --format ${format} (expected md | json)`);
193
+ }
194
+
195
+ const projectRoot = await findProjectRoot(cwd);
196
+ if (!projectRoot) {
197
+ stderr.write('[error] usage no project root found (no rcf/manifest.json in this directory or any ancestor). Run `npx rcf init` to create and wire a project.\n');
198
+ return 2;
199
+ }
200
+ // Single walkTree call; walker errors block every mode, including
201
+ // --mark - no status write lands on a tree that fails validation (§D6).
202
+ const { tree, errors } = await walkTree({ projectRoot });
203
+ if (errors.length > 0) {
204
+ stderr.write(`${formatErrors(errors, { verbose: false, strict: false })}\n`);
205
+ return 3;
206
+ }
207
+
208
+ const io = { stdout, stderr, quiet: Boolean(flags.quiet), out: flags.out ?? null };
209
+
210
+ if (mode === 'mark') {
211
+ return await runMark({
212
+ tree, projectRoot, fbsId: positional, status: flags.mark, io,
213
+ noCodeNodes: Boolean(flags['no-code-nodes']),
214
+ acceptBlock: Boolean(flags['accept-block']),
215
+ acceptReason: typeof flags.reason === 'string' ? flags.reason : null,
216
+ });
217
+ }
218
+ if (mode === 'queue') {
219
+ const queue = computeQueue(tree);
220
+ const output = format === 'json' ? formatJson(queue, 'queue') : formatMarkdown(queue, 'queue');
221
+ stdout.write(output);
222
+ return 0;
223
+ }
224
+ if (mode === 'next') {
225
+ return await emitNext({ tree, format, io });
226
+ }
227
+ return await emitBundle({
228
+ tree, fbsId: positional, format, strict: Boolean(flags.strict), io,
229
+ });
230
+ }
231
+
232
+ /**
233
+ * Classify a positional id (§D1): FBS ids proceed; US ids exit 2 with
234
+ * the trace pointer; other ids exit 2.
235
+ */
236
+ function classifyPositional(tree, id) {
237
+ const kind = kindOf(tree, id);
238
+ if (kind === 'fbs') return null;
239
+ if (kind === 'userStory') {
240
+ return rcfError({
241
+ kind: 'usage',
242
+ message: `build: ${id} is a user story, not an FBS id; the FBS is the queue unit. `
243
+ + `To list the FBS items linked to this story: rcf trace ${id} --forward --format json`,
244
+ documentId: id,
245
+ });
246
+ }
247
+ if (kind) {
248
+ return rcfError({
249
+ kind: 'usage',
250
+ message: `build: ${id} is a ${kind} id; rcf build addresses FBS items only`,
251
+ documentId: id,
252
+ });
253
+ }
254
+ return rcfError({ kind: 'usage', message: `build: id ${id} not found`, documentId: id });
255
+ }
256
+
257
+ async function emitBundle({ tree, fbsId, format, strict, io }) {
258
+ const classification = classifyPositional(tree, fbsId);
259
+ if (classification) {
260
+ io.stderr.write(`[error] usage ${classification.message}\n`);
261
+ return 2;
262
+ }
263
+ // Track C+D §5.4: single-FBS bundle emission also refuses when the
264
+ // bound US has open baseline sweep candidates. Same shape as --next
265
+ // so a caller can rely on one refusal semantics regardless of entry.
266
+ const openSweep = fbsRefusalForOpenSweep(tree, fbsId);
267
+ if (openSweep) {
268
+ io.stderr.write(`[error] ${openSweep.message}\n`);
269
+ return 4;
270
+ }
271
+ // Track B (spec §4.4): classifier signal alongside the single-FBS
272
+ // bundle. Same transparency rule as --next.
273
+ emitClassifierSignal(io, tree, fbsId);
274
+ const bundle = assembleBundle(tree, { fbsId });
275
+ // Blocked-dependency gate (§D12): warn by default (the BLOCKED block
276
+ // in section 2), refuse under --strict.
277
+ if (strict && bundle.blockedBy.length > 0) {
278
+ const blocking = bundle.dependencies
279
+ .filter((d) => bundle.blockedBy.includes(d.fbsId))
280
+ .map((d) => `${d.fbsId} (${d.executionStatus ?? 'unknown'})`)
281
+ .join(', ');
282
+ io.stderr.write(`[error] refused build: ${fbsId} is blocked by ${blocking}\n`);
283
+ return 4;
284
+ }
285
+ const output = format === 'json' ? formatJson(bundle, 'bundle') : formatMarkdown(bundle, 'bundle');
286
+ return await emitToSink(output, io);
287
+ }
288
+
289
+ async function emitNext({ tree, format, io }) {
290
+ const queue = computeQueue(tree);
291
+ const next = selectNext(queue);
292
+ if (next) {
293
+ // Track C+D §5.4: Stage-1 (Define) refuses to enter Build for any
294
+ // FBS binding an AC on a US that still has open baseline sweep
295
+ // candidates. Fires before any classifier signal so the operator
296
+ // sees the refusal ahead of noise.
297
+ const openSweep = fbsRefusalForOpenSweep(tree, next.fbsId);
298
+ if (openSweep) {
299
+ io.stderr.write(`[error] ${openSweep.message}\n`);
300
+ return 4;
301
+ }
302
+ // Spec section 4.2 / review N-1: warn when the selected FBS names
303
+ // dependsOnServices that no preFlightConfig record covers, so the
304
+ // operator sees the same signal the elicitation and build-cycle
305
+ // playbooks already advertise for `rcf build --next`. Warn-only
306
+ // (not exit-4) matches the spec's Stage 1 warn-only ruling; the
307
+ // hard refuse lives at Stage 4 via `coverage --strict`.
308
+ const unbacked = scanUnbackedServices(tree, next.fbsId);
309
+ if (unbacked.length > 0) {
310
+ const names = unbacked.map((u) => u.serviceId).join(', ');
311
+ io.stderr.write(`[warn] build --next: ${next.fbsId} touches services not covered by any preFlightConfig (${names}); run 'rcf preflight' before Stage 4.\n`);
312
+ }
313
+ // Track B (spec §4.4): always print the classifier verdict + signals
314
+ // for the selected FBS. Transparent-by-default so a `notUi` verdict
315
+ // on prose that visibly contains UI keywords is still visible to the
316
+ // operator; missing the transparency would re-enable the silent
317
+ // shortcut pattern the cold-run "Skip RCF" wobble called out.
318
+ emitClassifierSignal(io, tree, next.fbsId);
319
+ const bundle = assembleBundle(tree, { fbsId: next.fbsId });
320
+ const output = format === 'json' ? formatJson(bundle, 'next') : formatMarkdown(bundle, 'next');
321
+ return await emitToSink(output, io);
322
+ }
323
+ // Nothing actionable is a valid answer, exit 0 (§D2 / OQ-P6-2): the
324
+ // envelope distinguishes "done" (queueEmpty) from "stuck".
325
+ const envelope = {
326
+ queueEmpty: queue.totals.notStarted === 0 && queue.totals.inProgress === 0,
327
+ totals: queue.totals,
328
+ blocked: queue.items.filter((i) => i.state === 'blocked').map((i) => i.fbsId),
329
+ inProgress: queue.items.filter((i) => i.state === 'inProgress').map((i) => i.fbsId),
330
+ };
331
+ const output = format === 'json' ? formatJson(envelope, 'next') : formatMarkdown(envelope, 'next');
332
+ return await emitToSink(output, io);
333
+ }
334
+
335
+ /**
336
+ * Default sink is stdout (pipe-friendly for the harness loop); `--out`
337
+ * writes to a file instead - parent directory must exist, plain
338
+ * overwrite, single writeFile (§D4). Write failures exit 1.
339
+ */
340
+ async function emitToSink(output, io) {
341
+ if (!io.out) {
342
+ io.stdout.write(output);
343
+ return 0;
344
+ }
345
+ try {
346
+ await writeFile(io.out, output, 'utf8');
347
+ } catch (err) {
348
+ writeUnexpectedFailure(
349
+ rcfError({ kind: 'ioFailure', message: `build: --out write failed: ${err.message}`, stack: err.stack }),
350
+ io.stderr,
351
+ );
352
+ return 1;
353
+ }
354
+ if (!io.quiet) io.stdout.write(`bundle written to ${io.out}\n`);
355
+ return 0;
356
+ }
357
+
358
+ /**
359
+ * Mark mode (§D5): plan via the pure transition table, execute via the
360
+ * Phase 4 `updateDocument` path (single executionStatus set; the
361
+ * writer schema-validates and bumps updatedAt). Output is a fixed
362
+ * one-line confirmation - exit codes carry the outcome (OQ-P6-4).
363
+ */
364
+ async function runMark({ tree, projectRoot, fbsId, status, io, noCodeNodes = false, acceptBlock = false, acceptReason = null }) {
365
+ const plan = planMark(tree, { fbsId, status });
366
+ if (isRcfError(plan)) {
367
+ io.stderr.write(`[error] usage ${plan.message}\n`);
368
+ return 2;
369
+ }
370
+ if (plan.refused) {
371
+ io.stderr.write(`[error] refused ${plan.message}\n`);
372
+ return 4;
373
+ }
374
+ if (plan.noOp) {
375
+ // Idempotent no-op: a retried harness step does not fail the loop.
376
+ if (!io.quiet) io.stdout.write(`${plan.fbsId} already ${plan.to}\n`);
377
+ return 0;
378
+ }
379
+
380
+ const sets = [{ path: 'executionStatus', value: plan.to }];
381
+
382
+ // Track B (§5.2): soft warning on --mark inProgress for uiBearing
383
+ // FBS that has no designStage authored yet. Warn-only per spec.
384
+ if (plan.to === 'inProgress') {
385
+ const fbs = tree.byId.get(plan.fbsId);
386
+ if (fbs?.uiBearing === true && !fbs?.designStage) {
387
+ io.stderr.write(`[warn] build --mark inProgress: ${plan.fbsId} is uiBearing but has no designStage yet. Consider running 'rcf design ${plan.fbsId}' before Stage 2 build begins. Not a refusal; the hard gate fires at --mark complete.\n`);
388
+ }
389
+ }
390
+
391
+ // Phase 10 (X2 CodeNode bridge, D17, operator ruling): the mark-complete
392
+ // CN gate. Refuses (exit 3, structured missingCodeNodes) when any AC of
393
+ // the completed spec carries no Code Node, unless the FBS already
394
+ // carries the no-code-nodes declaration or this invocation supplies it.
395
+ if (plan.to === 'complete') {
396
+ const fbs = tree.byId.get(plan.fbsId);
397
+
398
+ // Track B (§5.5): the Design gate. Refuses --mark complete when
399
+ // the FBS is uiBearing=true and designStageComplete is not true.
400
+ // Fires before the CN gate so operators do not have to fix CN
401
+ // coverage on an FBS that also needs a design pass.
402
+ const designGate = checkDesignGate(fbs);
403
+ if (!designGate.ok) {
404
+ io.stderr.write(`[error] refused build: ${plan.fbsId} is uiBearing but designStageComplete is not true on the FBS record.\n`
405
+ + ' Complete the Design substage first:\n'
406
+ + ` rcf design ${plan.fbsId} (dispatches the Design worker)\n`
407
+ + ` rcf design ${plan.fbsId} --mark-complete (hand-authored equivalent, once all three artefacts are present)\n`);
408
+ return 4;
409
+ }
410
+
411
+ // Track B (§7 mandate 10, §6.2): contrast-before-palette gate.
412
+ // The boolean is the primary attestation (§12 O-5); git history
413
+ // corroboration is deferred to a future spec release (§7 mandate
414
+ // 10 permits this when history is inconclusive, and a fresh clone
415
+ // is the default posture for the sub-process runner). The pure
416
+ // gate refuses on the boolean alone.
417
+ const cbpGate = checkContrastBeforePaletteGate(fbs);
418
+ if (!cbpGate.ok) {
419
+ io.stderr.write(`[error] refused ${cbpGate.message}\n`);
420
+ return 4;
421
+ }
422
+
423
+ // Track B (§6.2): belt-and-braces baseline-vs-designStage
424
+ // disagreement refusal, in case the design --mark-complete path
425
+ // was bypassed (e.g. rcf update wrote the disagreement after
426
+ // design --mark-complete succeeded).
427
+ if (fbs?.uiBearing === true) {
428
+ const disagreement = firstBaselineDisagreement(tree, fbs);
429
+ if (disagreement) {
430
+ io.stderr.write(`[error] refused build: ${plan.fbsId} designStage.${disagreement.designStagePath} = ${JSON.stringify(disagreement.designValue)} conflicts with uiBaseline.defaults.${disagreement.path} = ${JSON.stringify(disagreement.baselineValue)} and there is no operatorOptOuts entry.\n`
431
+ + ' Options:\n'
432
+ + ` 1) Change designStage.${disagreement.designStagePath} to match the baseline.\n`
433
+ + ` 2) rcf ui-baseline opt-out --field ${disagreement.path} --reason "..." (project-wide override).\n`
434
+ + ` 3) rcf update ${plan.fbsId} --set designStage.${disagreement.designStagePath}=... (per-FBS override; records ack).\n`);
435
+ return 4;
436
+ }
437
+ }
438
+
439
+ // Track B (§8.5, §8.6): browser-verification verdict gate.
440
+ // Reads the latest browserVerification record for the FBS and
441
+ // gates by verdict:
442
+ // pass -> permitted
443
+ // warn -> permitted only when operatorAckAt is populated
444
+ // block -> refused unless --accept-block --reason "..."
445
+ // Absent record -> refused (finalise still refuses via the verify
446
+ // `BROWSER-VERIFICATION-MISSING` verdict class, which lives in
447
+ // the verify car; the build-side refusal here catches the same
448
+ // defect earlier so the operator does not push the FBS to
449
+ // complete without any evidence).
450
+ if (fbs?.uiBearing === true) {
451
+ const bvRecord = latestBrowserVerification(tree.manifest, plan.fbsId);
452
+ if (!bvRecord) {
453
+ // Track B review B-1 (2026-07-31): refuse cleanly when no
454
+ // browserVerification record exists, even with --accept-block.
455
+ // Previously acceptBlock let execution fall through past the
456
+ // ack-writer (which is guarded on bvRecord being truthy),
457
+ // marking the FBS complete with only an ephemeral stderr line
458
+ // as evidence of the operator's reason. That is the exact
459
+ // "durable record or it did not happen" defect Track A's B-1
460
+ // closed for --ship-without-verified. Composing a synthetic
461
+ // browserVerification record here would invent evidence for a
462
+ // gate the operator never actually ran; the honest posture is
463
+ // to make the operator run the gate (the stub driver produces
464
+ // a warn record, which then legitimately anchors the ack) or
465
+ // ratify a real record. Two-command dance: acknowledged and
466
+ // preferred.
467
+ io.stderr.write(`[error] refused build: ${plan.fbsId} is uiBearing but no browserVerification record exists on the manifest.\n`
468
+ + ` Run: rcf browser-verify ${plan.fbsId}\n`
469
+ + ` Then, if the resulting record is block or warn and you have decided to ship anyway, rerun with --accept-block --reason "..." on that record.\n`
470
+ + ' (--accept-block requires an existing browserVerification record; it acknowledges a real verdict rather than skipping the gate.)\n');
471
+ return 4;
472
+ } else if (bvRecord.verdict === 'block' && !acceptBlock) {
473
+ io.stderr.write(`[error] refused build: ${plan.fbsId} browserVerification ${bvRecord.id} verdict is 'block'.\n`
474
+ + ' Re-run browser-verify after fixing the failures, or acknowledge with:\n'
475
+ + ` rcf build ${plan.fbsId} --mark complete --accept-block --reason "..."\n`);
476
+ return 4;
477
+ } else if (bvRecord.verdict === 'warn' && !bvRecord.operatorAckAt) {
478
+ io.stderr.write(`[error] refused build: ${plan.fbsId} browserVerification ${bvRecord.id} verdict is 'warn' but operatorAckAt is not set.\n`
479
+ + ' Ack the warn verdict with:\n'
480
+ + ` rcf browser-verify ${plan.fbsId} --ack\n`);
481
+ return 4;
482
+ }
483
+ // Ship-without-verified: record the operator's reason on the bv
484
+ // record when they used --accept-block. Only fires on `block`
485
+ // because the earlier `warn` guard already returned 4 when the
486
+ // record was warn-without-ack; the sanctioned warn clear is
487
+ // `rcf browser-verify <id> --ack`, not `--accept-block`.
488
+ if (acceptBlock && bvRecord && bvRecord.verdict === 'block') {
489
+ const ackResult = await writeBrowserVerificationAck({
490
+ projectRoot, tree, fbsId: plan.fbsId,
491
+ operatorAckAt: true,
492
+ operatorShipDespiteBlockReason: acceptReason,
493
+ });
494
+ if (ackResult && 'kind' in ackResult && 'message' in ackResult) {
495
+ if (ackResult.kind === 'ioFailure') { writeUnexpectedFailure(ackResult, io.stderr); return 1; }
496
+ io.stderr.write(`[error] ${ackResult.kind} ${ackResult.message}\n`);
497
+ return 3;
498
+ }
499
+ }
500
+ }
501
+
502
+ const alreadyDeclared = hasNoCodeNodesDeclaration(fbs);
503
+ if (noCodeNodes && !alreadyDeclared) {
504
+ sets.push({ path: 'noCodeNodes', value: true });
505
+ } else if (!alreadyDeclared) {
506
+ const gate = checkCodeNodeGate(tree, fbs);
507
+ if (!gate.ok) {
508
+ const err = rcfError({
509
+ kind: 'missingCodeNodes',
510
+ message: `build --mark complete: refused - ${plan.fbsId} has AC(s) with no Code Node: ${gate.missingAcIds.join(', ')}. `
511
+ + 'Author CN coverage for these ACs, or pass --no-code-nodes for a genuinely no-code (docs-only, config-only) spec.',
512
+ documentId: plan.fbsId,
513
+ field: 'acIds',
514
+ rule: 'missingCodeNodes',
515
+ });
516
+ io.stderr.write(`[error] ${err.kind} ${err.message}\n`);
517
+ return 3;
518
+ }
519
+ }
520
+ }
521
+
522
+ const result = await updateDocument({
523
+ projectRoot,
524
+ tree,
525
+ id: plan.fbsId,
526
+ sets,
527
+ options: {},
528
+ });
529
+ if (isRcfError(result)) {
530
+ if (result.kind === 'ioFailure') {
531
+ writeUnexpectedFailure(result, io.stderr);
532
+ return 1;
533
+ }
534
+ io.stderr.write(`[error] ${result.kind} ${result.message}\n`);
535
+ if (result.kind === 'usage') return 2;
536
+ if (result.kind === 'validation' || result.kind === 'brokenReference') return 3;
537
+ return 1;
538
+ }
539
+ if (!io.quiet) io.stdout.write(`marked ${plan.fbsId} ${plan.from} -> ${plan.to}\n`);
540
+ return 0;
541
+ }
542
+
543
+ /**
544
+ * Print the UI-bearing classifier verdict for an FBS on the bundle
545
+ * runbook (Track B, ui-design-gate-0.7.0-spec §4.4). Emits nothing when
546
+ * the FBS document is missing (defensive; the caller has already
547
+ * classified). Not enough to gate on its own; the operator ratifies
548
+ * via `rcf update <fbs-id> --set uiBearing=true|false`.
549
+ *
550
+ * @param {object} io
551
+ * @param {object} tree
552
+ * @param {string} fbsId
553
+ */
554
+ function emitClassifierSignal(io, tree, fbsId) {
555
+ const fbs = tree.byId.get(fbsId);
556
+ if (!fbs) return;
557
+ const block = classifyFbs(tree, fbsId);
558
+ if (!block) return;
559
+ const signalCount = Array.isArray(block.signals) ? block.signals.length : 0;
560
+ const suffix = fbs.uiBearing === true ? ' [ratified: uiBearing=true]'
561
+ : fbs.uiBearing === false ? ' [ratified: uiBearing=false]'
562
+ : ' [not yet ratified]';
563
+ io.stderr.write(`[info] build: ui-classifier verdict=${block.verdict} reason=${block.reason} (${signalCount} signal(s))${suffix}\n`);
564
+ if (block.verdict === 'ui' && fbs.uiBearing !== true) {
565
+ io.stderr.write(` Classified as UI-bearing. Ratify with: rcf update ${fbsId} --set uiBearing=true\n`);
566
+ io.stderr.write(` Override with: rcf update ${fbsId} --set uiBearing=false (if the classifier is wrong).\n`);
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Latest browserVerification record for an FBS. Returns null when
572
+ * none exists.
573
+ *
574
+ * @param {object|null} manifest
575
+ * @param {string} fbsId
576
+ * @returns {object|null}
577
+ */
578
+ function latestBrowserVerification(manifest, fbsId) {
579
+ const records = Array.isArray(manifest?.browserVerification) ? manifest.browserVerification : [];
580
+ for (let i = records.length - 1; i >= 0; i -= 1) {
581
+ if (records[i]?.fbsId === fbsId) return records[i];
582
+ }
583
+ return null;
584
+ }