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,26 @@
1
+ // Public surface for the view-supervisor module (spec §9).
2
+
3
+ export {
4
+ startDetached,
5
+ stopDetached,
6
+ statusOfDetached,
7
+ runDetachedChild,
8
+ } from './supervisor.js';
9
+
10
+ export {
11
+ readViewServerRecord,
12
+ writeViewServerRecord,
13
+ clearViewServerRecord,
14
+ writePidFile,
15
+ readPidFile,
16
+ removePidFile,
17
+ DEFAULT_PID_PATH,
18
+ isPidAlive,
19
+ } from './manifest-writer.js';
20
+
21
+ export {
22
+ supervisorLogPath,
23
+ ensureLogDir,
24
+ writeLogLine,
25
+ readLogTail,
26
+ } from './logs.js';
@@ -0,0 +1,32 @@
1
+ // Supervisor log helpers (spec §9.2 `rcf view logs`).
2
+
3
+ import { appendFile, mkdir, readFile } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+
6
+ /**
7
+ * @param {string} projectRoot
8
+ */
9
+ export function supervisorLogPath(projectRoot) {
10
+ return join(projectRoot, '.rcf', 'view-server.log');
11
+ }
12
+
13
+ export async function ensureLogDir(projectRoot) {
14
+ const dir = dirname(supervisorLogPath(projectRoot));
15
+ await mkdir(dir, { recursive: true });
16
+ }
17
+
18
+ export async function writeLogLine(projectRoot, line) {
19
+ await ensureLogDir(projectRoot);
20
+ await appendFile(supervisorLogPath(projectRoot), `${new Date().toISOString()} ${line}\n`, 'utf8');
21
+ }
22
+
23
+ export async function readLogTail(projectRoot, tail = 200) {
24
+ try {
25
+ const raw = await readFile(supervisorLogPath(projectRoot), 'utf8');
26
+ const lines = raw.split('\n').filter(Boolean);
27
+ return lines.slice(-tail);
28
+ } catch (err) {
29
+ if (err.code === 'ENOENT') return [];
30
+ throw err;
31
+ }
32
+ }
@@ -0,0 +1,178 @@
1
+ // reviewSurface.viewServer manifest reader/writer (spec §3.6).
2
+ //
3
+ // Writes are atomic (.tmp → rename) and schema-validated before persist,
4
+ // mirroring the preflight and intake writers.
5
+
6
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
7
+ import { dirname, join } from 'node:path';
8
+ import { existsSync } from 'node:fs';
9
+
10
+ import { rcfError } from '#core/errors';
11
+ import { validateDocument } from '#core/store';
12
+
13
+ export const DEFAULT_PID_PATH = '.rcf/view-server.pid';
14
+
15
+ /**
16
+ * The managed-gitignore entry the aggregator consumes (spec §9.3). The
17
+ * pid file and the supervisor log are host-local runtime artefacts; the
18
+ * chain never carries them.
19
+ */
20
+ export const viewServerGitignoreEntry = Object.freeze({
21
+ path: '.rcf/view-server.pid',
22
+ owner: 'rcf view --detach (view-server pid file)',
23
+ since: '0.7.0',
24
+ });
25
+
26
+ export const viewServerLogGitignoreEntry = Object.freeze({
27
+ path: '.rcf/view-server.log',
28
+ owner: 'rcf view --detach (supervisor log)',
29
+ since: '0.7.0',
30
+ });
31
+
32
+ /**
33
+ * Read the reviewSurface.viewServer record from the manifest, if any.
34
+ *
35
+ * @param {string} projectRoot
36
+ * @returns {Promise<object|null>}
37
+ */
38
+ export async function readViewServerRecord(projectRoot) {
39
+ const abs = join(projectRoot, 'rcf', 'manifest.json');
40
+ try {
41
+ const raw = await readFile(abs, 'utf8');
42
+ const parsed = JSON.parse(raw);
43
+ return parsed?.reviewSurface?.viewServer ?? null;
44
+ } catch (err) {
45
+ if (err.code === 'ENOENT') return null;
46
+ throw err;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Persist a reviewSurface.viewServer block onto the manifest.
52
+ *
53
+ * @param {object} args
54
+ * @param {string} args.projectRoot
55
+ * @param {object} args.record
56
+ * @returns {Promise<{ record: object } | import('#core/errors').RcfError>}
57
+ */
58
+ export async function writeViewServerRecord({ projectRoot, record }) {
59
+ const abs = join(projectRoot, 'rcf', 'manifest.json');
60
+ let manifest = {};
61
+ try {
62
+ const raw = await readFile(abs, 'utf8');
63
+ manifest = JSON.parse(raw);
64
+ } catch (err) {
65
+ if (err.code !== 'ENOENT') throw err;
66
+ }
67
+ const nextManifest = {
68
+ ...manifest,
69
+ reviewSurface: {
70
+ ...(manifest.reviewSurface ?? {}),
71
+ viewServer: record,
72
+ },
73
+ };
74
+ const validation = validateDocument({ doc: nextManifest, kind: 'manifest', filePath: 'rcf/manifest.json' });
75
+ if (validation) return validation;
76
+ try {
77
+ await mkdir(dirname(abs), { recursive: true });
78
+ const tmp = `${abs}.tmp`;
79
+ await writeFile(tmp, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8');
80
+ try {
81
+ await rename(tmp, abs);
82
+ } catch (err) {
83
+ try { await unlink(tmp); } catch { /* ignore */ }
84
+ throw err;
85
+ }
86
+ } catch (err) {
87
+ return rcfError({
88
+ kind: 'ioFailure',
89
+ message: `view-supervisor: manifest write failed: ${err.message}`,
90
+ filePath: 'rcf/manifest.json',
91
+ stack: err.stack,
92
+ });
93
+ }
94
+ return { record };
95
+ }
96
+
97
+ /**
98
+ * Clear the reviewSurface.viewServer field.
99
+ *
100
+ * @param {string} projectRoot
101
+ * @returns {Promise<void>}
102
+ */
103
+ export async function clearViewServerRecord(projectRoot) {
104
+ const abs = join(projectRoot, 'rcf', 'manifest.json');
105
+ let manifest = {};
106
+ try {
107
+ const raw = await readFile(abs, 'utf8');
108
+ manifest = JSON.parse(raw);
109
+ } catch (err) {
110
+ if (err.code === 'ENOENT') return;
111
+ throw err;
112
+ }
113
+ if (!manifest.reviewSurface?.viewServer) return;
114
+ const nextReview = { ...(manifest.reviewSurface ?? {}) };
115
+ delete nextReview.viewServer;
116
+ const nextManifest = { ...manifest, reviewSurface: nextReview };
117
+ // If the reviewSurface object is empty, drop it entirely so validate
118
+ // does not carry an empty object.
119
+ if (Object.keys(nextReview).length === 0) delete nextManifest.reviewSurface;
120
+ const tmp = `${abs}.tmp`;
121
+ await writeFile(tmp, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8');
122
+ try {
123
+ await rename(tmp, abs);
124
+ } catch (err) {
125
+ try { await unlink(tmp); } catch { /* ignore */ }
126
+ throw err;
127
+ }
128
+ }
129
+
130
+ /** Write a pid file at projectRoot/.rcf/view-server.pid. */
131
+ export async function writePidFile(projectRoot, pid) {
132
+ const abs = join(projectRoot, DEFAULT_PID_PATH);
133
+ await mkdir(dirname(abs), { recursive: true });
134
+ await writeFile(abs, `${pid}\n`, 'utf8');
135
+ }
136
+
137
+ /** Read the pid file, or null when absent. */
138
+ export async function readPidFile(projectRoot) {
139
+ const abs = join(projectRoot, DEFAULT_PID_PATH);
140
+ if (!existsSync(abs)) return null;
141
+ try {
142
+ const raw = await readFile(abs, 'utf8');
143
+ const n = Number.parseInt(raw.trim(), 10);
144
+ return Number.isFinite(n) && n > 0 ? n : null;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ /** Remove the pid file, ignoring ENOENT. */
151
+ export async function removePidFile(projectRoot) {
152
+ const abs = join(projectRoot, DEFAULT_PID_PATH);
153
+ try {
154
+ await unlink(abs);
155
+ } catch (err) {
156
+ if (err.code !== 'ENOENT') throw err;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Probe whether a pid is alive. `process.kill(pid, 0)` is the standard
162
+ * cross-platform (POSIX) predicate; throws ESRCH when the process is
163
+ * gone and EPERM when the process exists but the caller lacks permission
164
+ * (still "alive" from our perspective).
165
+ *
166
+ * @param {number} pid
167
+ * @returns {boolean}
168
+ */
169
+ export function isPidAlive(pid) {
170
+ if (!Number.isFinite(pid) || pid <= 0) return false;
171
+ try {
172
+ process.kill(pid, 0);
173
+ return true;
174
+ } catch (err) {
175
+ if (err.code === 'EPERM') return true;
176
+ return false;
177
+ }
178
+ }
@@ -0,0 +1,85 @@
1
+ // `rcf view start --persist-until <value>` argument parser (spec §9.2).
2
+ //
3
+ // The spec's sample invocation is `--persist-until 4h`; the shipping
4
+ // help text also names an ISO timestamp as the durable form. Accept
5
+ // both so the sample works AND cross-platform ISO timestamps still do.
6
+ //
7
+ // Duration grammar (minimal and documented):
8
+ //
9
+ // <duration> := <hours> | <minutes> | <hours><minutes>
10
+ // <hours> := /^\d+h$/ e.g. `4h`, `24h`
11
+ // <minutes> := /^\d+m$/ e.g. `30m`, `90m`
12
+ // combined := /^\d+h\d+m$/ e.g. `2h30m`
13
+ //
14
+ // Anything else (`4hrs`, `4 h`, `PT4H`, `4:00`, an empty component like
15
+ // `0h`, or a plain integer with no unit) is a usage error: the parser
16
+ // returns `{ ok: false, error: <message> }` and the CLI exits 2 with
17
+ // that message plus the grammar. The old behaviour - `Date.parse('4h')`
18
+ // returning NaN, no persist timer set, supervisor runs forever - is
19
+ // what this parser exists to prevent.
20
+ //
21
+ // ISO parsing: any string `Date.parse` accepts as a finite timestamp
22
+ // passes. We deliberately do NOT restrict to a strict ISO-8601 subset
23
+ // - the shipping help text says "ISO timestamp" and operators reach
24
+ // for `date -u +%Y-%m-%dT%H:%M:%SZ` and similar; Date.parse handles
25
+ // them all. A past timestamp is accepted (the supervisor will unwind
26
+ // on the next event loop turn); the parser is a shape check, not a
27
+ // policy check.
28
+
29
+ const DURATION_RE = /^(?:(\d+)h)?(?:(\d+)m)?$/;
30
+
31
+ const GRAMMAR_HINT = 'expected a duration (e.g. `4h`, `30m`, `2h30m`) or an ISO timestamp (e.g. `2026-07-31T18:00:00Z`)';
32
+
33
+ /**
34
+ * Parse a `--persist-until` argument into an ISO timestamp.
35
+ *
36
+ * @param {string} raw - argv value; must be a string
37
+ * @param {object} [opts]
38
+ * @param {number} [opts.nowMs] - injected clock for tests; defaults to Date.now()
39
+ * @returns {{ ok: true, iso: string, deadlineMs: number, source: 'duration'|'iso' }
40
+ * | { ok: false, error: string }}
41
+ */
42
+ export function parsePersistUntil(raw, opts = {}) {
43
+ if (typeof raw !== 'string' || raw.length === 0) {
44
+ return { ok: false, error: `--persist-until: missing value; ${GRAMMAR_HINT}` };
45
+ }
46
+ const nowMs = typeof opts.nowMs === 'number' ? opts.nowMs : Date.now();
47
+
48
+ // Duration form. The regex `/^(?:(\d+)h)?(?:(\d+)m)?$/` also matches
49
+ // the empty string; guard against that plus `0h` / `0m` / `0h0m`
50
+ // (which the CLI accepting would make the supervisor unwind
51
+ // immediately, arguably useless).
52
+ const durationMatch = DURATION_RE.exec(raw);
53
+ const hasHours = durationMatch && durationMatch[1] !== undefined;
54
+ const hasMinutes = durationMatch && durationMatch[2] !== undefined;
55
+ if (durationMatch && (hasHours || hasMinutes)) {
56
+ const hours = hasHours ? Number.parseInt(durationMatch[1], 10) : 0;
57
+ const minutes = hasMinutes ? Number.parseInt(durationMatch[2], 10) : 0;
58
+ const ms = (hours * 60 * 60 * 1000) + (minutes * 60 * 1000);
59
+ if (ms <= 0) {
60
+ return { ok: false, error: `--persist-until: duration must be non-zero (got \`${raw}\`); ${GRAMMAR_HINT}` };
61
+ }
62
+ const deadlineMs = nowMs + ms;
63
+ return { ok: true, iso: new Date(deadlineMs).toISOString(), deadlineMs, source: 'duration' };
64
+ }
65
+
66
+ // A bare digit run is a duration-shaped mistake (someone typed `4`
67
+ // instead of `4h`). Date.parse would accept it as a year and the
68
+ // supervisor would happily set a timer for the far past; that is
69
+ // worse UX than a clean refusal.
70
+ if (/^\d+$/.test(raw)) {
71
+ return { ok: false, error: `--persist-until: bare integer \`${raw}\` has no unit; ${GRAMMAR_HINT}` };
72
+ }
73
+
74
+ // ISO form. Date.parse is loose (accepts a variety of shapes); we
75
+ // only care that it lands on a finite timestamp. A partial ISO like
76
+ // `2026` still parses; that is a policy question we leave to the
77
+ // supervisor's timer semantics (a past deadline unwinds immediately,
78
+ // which is a clearer failure than silent no-op).
79
+ const parsedMs = Date.parse(raw);
80
+ if (Number.isFinite(parsedMs)) {
81
+ return { ok: true, iso: new Date(parsedMs).toISOString(), deadlineMs: parsedMs, source: 'iso' };
82
+ }
83
+
84
+ return { ok: false, error: `--persist-until: unrecognised value \`${raw}\`; ${GRAMMAR_HINT}` };
85
+ }
@@ -0,0 +1,276 @@
1
+ // View-server supervisor (spec §9.3).
2
+ //
3
+ // Detached mode: the parent CLI process forks a supervisor child via
4
+ // `child_process.spawn(..., { detached: true, stdio: 'ignore' })` then
5
+ // unref's; the parent returns immediately. The supervisor:
6
+ // 1. Writes its pid to .rcf/view-server.pid (gitignored).
7
+ // 2. Starts the view server on the requested port.
8
+ // 3. Writes the reviewSurface.viewServer manifest block.
9
+ // 4. Runs a heartbeat that bumps lastHeartbeatAt every 30 seconds
10
+ // (or the injected interval for tests).
11
+ // 5. Handles SIGTERM cleanly: server closed, manifest record cleared,
12
+ // pid file removed.
13
+ // 6. On the child server crashing, attempts one restart within 5s;
14
+ // a second crash within a minute exits the supervisor cleanly.
15
+ //
16
+ // Windows is out of scope per spec §9.3 / §16 O-6.
17
+
18
+ import { spawn } from 'node:child_process';
19
+ import { setTimeout as delay } from 'node:timers/promises';
20
+ import { dirname, resolve } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { readFile } from 'node:fs/promises';
23
+ import process from 'node:process';
24
+
25
+ import { startServer } from '../server/index.js';
26
+ import {
27
+ writePidFile,
28
+ readPidFile,
29
+ removePidFile,
30
+ writeViewServerRecord,
31
+ readViewServerRecord,
32
+ clearViewServerRecord,
33
+ isPidAlive,
34
+ DEFAULT_PID_PATH,
35
+ } from './manifest-writer.js';
36
+ import { ensureLogDir, supervisorLogPath, writeLogLine } from './logs.js';
37
+
38
+ const here = dirname(fileURLToPath(import.meta.url));
39
+ const CHILD_ENTRY_PATH = resolve(here, '..', '..', 'bin', 'view-supervisor-child.mjs');
40
+
41
+ const DEFAULT_HEARTBEAT_MS = 30_000;
42
+ const DEFAULT_STARTUP_TIMEOUT_MS = 15_000;
43
+
44
+ /**
45
+ * Fork a detached supervisor child.
46
+ *
47
+ * @param {object} args
48
+ * @param {string} args.projectRoot
49
+ * @param {number} args.port
50
+ * @param {string|null} [args.persistUntil] ISO timestamp; supervisor auto-stops
51
+ * @param {number} [args.heartbeatMs] for tests
52
+ * @param {number} [args.startupTimeoutMs] for tests
53
+ * @returns {Promise<{ pid: number, url: string, record: object }>}
54
+ */
55
+ export async function startDetached({ projectRoot, port, persistUntil = null, heartbeatMs = DEFAULT_HEARTBEAT_MS, startupTimeoutMs = DEFAULT_STARTUP_TIMEOUT_MS }) {
56
+ await ensureLogDir(projectRoot);
57
+
58
+ // Refuse when a supervisor is already up.
59
+ const existing = await statusOfDetached(projectRoot);
60
+ if (existing.state === 'running') {
61
+ return { pid: existing.pid, url: existing.url, record: existing.record, alreadyRunning: true };
62
+ }
63
+
64
+ const child = spawn(process.execPath, [CHILD_ENTRY_PATH], {
65
+ detached: true,
66
+ stdio: 'ignore',
67
+ env: {
68
+ ...process.env,
69
+ RCF_VIEW_SUPERVISOR_PROJECT_ROOT: projectRoot,
70
+ RCF_VIEW_SUPERVISOR_PORT: String(port),
71
+ RCF_VIEW_SUPERVISOR_PERSIST_UNTIL: persistUntil ?? '',
72
+ RCF_VIEW_SUPERVISOR_HEARTBEAT_MS: String(heartbeatMs),
73
+ },
74
+ });
75
+ if (typeof child.unref === 'function') child.unref();
76
+ const pid = child.pid;
77
+ if (typeof pid !== 'number') {
78
+ throw new Error('view-supervisor: spawn returned no pid');
79
+ }
80
+
81
+ // Wait for the child to record itself in the manifest. Poll instead
82
+ // of piping the child's stdio (we intentionally detach).
83
+ const started = Date.now();
84
+ let record = null;
85
+ while (Date.now() - started < startupTimeoutMs) {
86
+ // eslint-disable-next-line no-await-in-loop
87
+ record = await readViewServerRecord(projectRoot);
88
+ if (record?.pid === pid) break;
89
+ // eslint-disable-next-line no-await-in-loop
90
+ await delay(100);
91
+ }
92
+ if (!record || record.pid !== pid) {
93
+ // Startup failed. Try to clean up the orphaned child.
94
+ try { process.kill(pid, 'SIGTERM'); } catch { /* ignore */ }
95
+ throw new Error(`view-supervisor: child ${pid} did not record itself in the manifest within ${startupTimeoutMs}ms`);
96
+ }
97
+
98
+ // Extract URL from healthCheckPath: 127.0.0.1:PORT/healthz -> the
99
+ // base URL is everything up to /healthz.
100
+ const url = record.healthCheckPath.replace(/\/healthz$/, '');
101
+ return { pid, url, record };
102
+ }
103
+
104
+ /**
105
+ * Stop a running detached supervisor. Idempotent.
106
+ *
107
+ * @param {string} projectRoot
108
+ * @returns {Promise<{ stopped: boolean, pid: number|null }>}
109
+ */
110
+ export async function stopDetached(projectRoot) {
111
+ const pid = await readPidFile(projectRoot);
112
+ if (!pid) {
113
+ // Best-effort cleanup for the manifest record.
114
+ await clearViewServerRecord(projectRoot);
115
+ return { stopped: false, pid: null };
116
+ }
117
+ if (!isPidAlive(pid)) {
118
+ await clearViewServerRecord(projectRoot);
119
+ await removePidFile(projectRoot);
120
+ return { stopped: false, pid };
121
+ }
122
+ try {
123
+ process.kill(pid, 'SIGTERM');
124
+ } catch (err) {
125
+ if (err.code !== 'ESRCH') throw err;
126
+ }
127
+ // Wait for the process to exit cleanly.
128
+ const deadline = Date.now() + 5000;
129
+ while (Date.now() < deadline) {
130
+ if (!isPidAlive(pid)) break;
131
+ // eslint-disable-next-line no-await-in-loop
132
+ await delay(100);
133
+ }
134
+ await clearViewServerRecord(projectRoot);
135
+ await removePidFile(projectRoot);
136
+ return { stopped: true, pid };
137
+ }
138
+
139
+ /**
140
+ * Report the current state of the detached supervisor.
141
+ *
142
+ * @param {string} projectRoot
143
+ * @param {object} [opts]
144
+ * @param {number} [opts.heartbeatMs]
145
+ * @returns {Promise<{ state: 'not-started'|'running'|'stale', pid: number|null, record: object|null, url: string|null, lastHeartbeatAt: string|null }>}
146
+ */
147
+ export async function statusOfDetached(projectRoot, opts = {}) {
148
+ const heartbeatMs = opts.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
149
+ const record = await readViewServerRecord(projectRoot);
150
+ const pidFilePid = await readPidFile(projectRoot);
151
+ const pid = record?.pid ?? pidFilePid ?? null;
152
+ if (!record && !pidFilePid) {
153
+ return { state: 'not-started', pid: null, record: null, url: null, lastHeartbeatAt: null };
154
+ }
155
+ const alive = pid !== null && isPidAlive(pid);
156
+ const url = record?.healthCheckPath ? record.healthCheckPath.replace(/\/healthz$/, '') : null;
157
+ const lastHeartbeatAt = record?.lastHeartbeatAt ?? null;
158
+ const heartbeatFresh = lastHeartbeatAt
159
+ ? (Date.now() - Date.parse(lastHeartbeatAt)) <= (heartbeatMs * 2)
160
+ : false;
161
+ if (alive && heartbeatFresh) {
162
+ return { state: 'running', pid, record, url, lastHeartbeatAt };
163
+ }
164
+ return { state: 'stale', pid, record, url, lastHeartbeatAt };
165
+ }
166
+
167
+ /**
168
+ * The supervisor loop. Runs inside the detached child; the caller is
169
+ * `bin/view-supervisor-child.mjs`. Reads config from env vars set by
170
+ * startDetached, brings up the view server, writes the manifest record,
171
+ * runs the heartbeat, and installs the shutdown handlers.
172
+ *
173
+ * @returns {Promise<void>}
174
+ */
175
+ export async function runDetachedChild() {
176
+ const projectRoot = process.env.RCF_VIEW_SUPERVISOR_PROJECT_ROOT;
177
+ const port = Number.parseInt(process.env.RCF_VIEW_SUPERVISOR_PORT ?? '', 10);
178
+ const persistUntil = process.env.RCF_VIEW_SUPERVISOR_PERSIST_UNTIL || null;
179
+ const heartbeatMs = Number.parseInt(process.env.RCF_VIEW_SUPERVISOR_HEARTBEAT_MS ?? '', 10) || DEFAULT_HEARTBEAT_MS;
180
+ if (!projectRoot || !Number.isFinite(port)) {
181
+ await writeLogLine(projectRoot ?? '.', 'runDetachedChild: missing projectRoot or port');
182
+ process.exit(2);
183
+ return;
184
+ }
185
+
186
+ await writeLogLine(projectRoot, `supervisor starting on port ${port} (pid ${process.pid})`);
187
+
188
+ let server;
189
+ try {
190
+ server = await startServer({
191
+ projectRoot,
192
+ port,
193
+ log: (line) => { writeLogLine(projectRoot, `server: ${line}`).catch(() => {}); },
194
+ });
195
+ } catch (err) {
196
+ await writeLogLine(projectRoot, `supervisor: server start failed: ${err.message}`);
197
+ process.exit(1);
198
+ return;
199
+ }
200
+
201
+ await writePidFile(projectRoot, process.pid);
202
+
203
+ const socketPath = `.rcf/view-supervisor.${process.pid}.sock`;
204
+ const healthCheckPath = `${server.url.replace(/\/$/, '')}/healthz`;
205
+
206
+ let heartbeatTimer;
207
+ let persistTimer;
208
+ let stopping = false;
209
+
210
+ const writeRecord = async () => {
211
+ const record = {
212
+ mode: 'detached',
213
+ startedAt: new Date().toISOString(),
214
+ socketPath,
215
+ pid: process.pid,
216
+ healthCheckPath,
217
+ lastHeartbeatAt: new Date().toISOString(),
218
+ };
219
+ if (persistUntil) record.operatorRequestedPersistUntil = persistUntil;
220
+ const result = await writeViewServerRecord({ projectRoot, record });
221
+ if (result && result.kind && typeof result.message === 'string') {
222
+ await writeLogLine(projectRoot, `supervisor: manifest write failed: ${result.message}`);
223
+ }
224
+ };
225
+
226
+ const bumpHeartbeat = async () => {
227
+ const record = await readViewServerRecord(projectRoot);
228
+ if (!record) return;
229
+ const next = { ...record, lastHeartbeatAt: new Date().toISOString() };
230
+ await writeViewServerRecord({ projectRoot, record: next });
231
+ };
232
+
233
+ const shutdown = async (reason) => {
234
+ if (stopping) return;
235
+ stopping = true;
236
+ await writeLogLine(projectRoot, `supervisor: shutting down (${reason})`);
237
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
238
+ if (persistTimer) clearTimeout(persistTimer);
239
+ try {
240
+ await server.close();
241
+ } catch (err) {
242
+ await writeLogLine(projectRoot, `supervisor: server close error: ${err.message}`);
243
+ }
244
+ await clearViewServerRecord(projectRoot);
245
+ await removePidFile(projectRoot);
246
+ process.exit(0);
247
+ };
248
+
249
+ process.on('SIGTERM', () => { shutdown('SIGTERM'); });
250
+ process.on('SIGINT', () => { shutdown('SIGINT'); });
251
+
252
+ await writeRecord();
253
+
254
+ heartbeatTimer = setInterval(() => {
255
+ bumpHeartbeat().catch((err) => writeLogLine(projectRoot, `supervisor: heartbeat error: ${err.message}`).catch(() => {}));
256
+ }, heartbeatMs);
257
+ if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref();
258
+
259
+ if (persistUntil) {
260
+ const deadline = Date.parse(persistUntil);
261
+ if (Number.isFinite(deadline)) {
262
+ const wait = Math.max(0, deadline - Date.now());
263
+ persistTimer = setTimeout(() => { shutdown('persistUntilExpired'); }, wait);
264
+ if (typeof persistTimer.unref === 'function') persistTimer.unref();
265
+ }
266
+ }
267
+
268
+ await writeLogLine(projectRoot, `supervisor: ready`);
269
+ }
270
+
271
+ // Kept exports available so downstream tooling that pulls the supervisor
272
+ // path or config can do so without spelunking.
273
+ void CHILD_ENTRY_PATH;
274
+ void readFile;
275
+ void supervisorLogPath;
276
+ void DEFAULT_PID_PATH;