rcf-lite 0.0.1 → 0.8.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 (349) hide show
  1. package/CHANGELOG.md +344 -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 +71 -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/admissibility/enforce.js +142 -0
  177. package/src/admissibility/index.js +8 -0
  178. package/src/admissibility/markers.js +104 -0
  179. package/src/admissibility/scope-lint.js +163 -0
  180. package/src/browser-verify/auth-smoke.js +109 -0
  181. package/src/browser-verify/index.js +29 -0
  182. package/src/browser-verify/invariants.js +336 -0
  183. package/src/browser-verify/manifest-writer.js +189 -0
  184. package/src/browser-verify/runner.js +171 -0
  185. package/src/build/bundle.js +198 -0
  186. package/src/build/formatters/json.js +18 -0
  187. package/src/build/formatters/markdown.js +406 -0
  188. package/src/build/index.js +14 -0
  189. package/src/build/mark.js +177 -0
  190. package/src/build/queue.js +285 -0
  191. package/src/cli/browser-verify.js +231 -0
  192. package/src/cli/build.js +584 -0
  193. package/src/cli/coverage.js +219 -0
  194. package/src/cli/create.js +358 -0
  195. package/src/cli/delete.js +127 -0
  196. package/src/cli/design.js +353 -0
  197. package/src/cli/doctor.js +548 -0
  198. package/src/cli/fbs.js +171 -0
  199. package/src/cli/finalise.js +377 -0
  200. package/src/cli/guidance.js +206 -0
  201. package/src/cli/help.js +156 -0
  202. package/src/cli/impact.js +119 -0
  203. package/src/cli/init.js +282 -0
  204. package/src/cli/intake.js +153 -0
  205. package/src/cli/link.js +128 -0
  206. package/src/cli/mcp.js +160 -0
  207. package/src/cli/preflight.js +220 -0
  208. package/src/cli/read.js +168 -0
  209. package/src/cli/req-baseline.js +269 -0
  210. package/src/cli/req-classify.js +135 -0
  211. package/src/cli/review.js +295 -0
  212. package/src/cli/test-suite.js +221 -0
  213. package/src/cli/trace.js +172 -0
  214. package/src/cli/ui-baseline.js +292 -0
  215. package/src/cli/ui-classify.js +108 -0
  216. package/src/cli/update.js +197 -0
  217. package/src/cli/validate.js +168 -0
  218. package/src/cli/view.js +452 -0
  219. package/src/core/baseline-catalog/data/auth.json +42 -0
  220. package/src/core/baseline-catalog/data/http-api.json +42 -0
  221. package/src/core/baseline-catalog/data/notifications.json +33 -0
  222. package/src/core/baseline-catalog/data/persistence.json +33 -0
  223. package/src/core/baseline-catalog/data/web-ui.json +60 -0
  224. package/src/core/baseline-catalog/index.js +121 -0
  225. package/src/core/errors/index.js +167 -0
  226. package/src/core/fixtures/register-canary/canary-prompt-01.json +18 -0
  227. package/src/core/fixtures/register-canary/canary-prompt-02.json +21 -0
  228. package/src/core/fixtures/register-canary/canary-prompt-03.json +17 -0
  229. package/src/core/isolation/index.js +60 -0
  230. package/src/core/mcp/framing.js +103 -0
  231. package/src/core/mcp/index.js +8 -0
  232. package/src/core/mcp/server.js +228 -0
  233. package/src/core/patterns/register-canary.js +209 -0
  234. package/src/core/patterns/req-shapes.js +158 -0
  235. package/src/core/patterns/services.js +358 -0
  236. package/src/core/patterns/ui-shapes.js +166 -0
  237. package/src/core/store/cn-resolve.js +134 -0
  238. package/src/core/store/derive-deps.js +93 -0
  239. package/src/core/store/ids.js +78 -0
  240. package/src/core/store/index.js +20 -0
  241. package/src/core/store/init.js +255 -0
  242. package/src/core/store/loader.js +214 -0
  243. package/src/core/store/tp-resolve.js +176 -0
  244. package/src/core/store/validator.js +191 -0
  245. package/src/core/store/walker.js +944 -0
  246. package/src/core/store/writer.js +1879 -0
  247. package/src/design/index.js +11 -0
  248. package/src/design/writer.js +271 -0
  249. package/src/finalise/detect.js +151 -0
  250. package/src/finalise/index.js +31 -0
  251. package/src/finalise/ingest.js +160 -0
  252. package/src/finalise/install.js +119 -0
  253. package/src/finalise/ship-without-verified.js +131 -0
  254. package/src/finalise/spawn.js +84 -0
  255. package/src/intake/fidelity.js +105 -0
  256. package/src/intake/index.js +6 -0
  257. package/src/intake/manifest-writer.js +100 -0
  258. package/src/intake/orchestrator.js +138 -0
  259. package/src/intake/validate.js +80 -0
  260. package/src/mcp/map-errors.js +131 -0
  261. package/src/mcp/prompts.js +49 -0
  262. package/src/mcp/resources.js +244 -0
  263. package/src/mcp/tools.js +1212 -0
  264. package/src/preflight/design-shapes.js +185 -0
  265. package/src/preflight/index.js +90 -0
  266. package/src/preflight/manifest-writer.js +264 -0
  267. package/src/preflight/scanner.js +206 -0
  268. package/src/preflight/secrets.js +134 -0
  269. package/src/preflight/session.js +246 -0
  270. package/src/query/attestation.js +285 -0
  271. package/src/query/coverage.js +308 -0
  272. package/src/query/formatters/json.js +21 -0
  273. package/src/query/formatters/mermaid.js +209 -0
  274. package/src/query/formatters/table.js +203 -0
  275. package/src/query/impact.js +173 -0
  276. package/src/query/index.js +13 -0
  277. package/src/query/refuse-on-admissibility.js +73 -0
  278. package/src/query/trace.js +345 -0
  279. package/src/register-canary/fixture-loader.js +87 -0
  280. package/src/register-canary/index.js +10 -0
  281. package/src/register-canary/record-writer.js +132 -0
  282. package/src/register-canary/runner.js +156 -0
  283. package/src/req-baseline/gate.js +86 -0
  284. package/src/req-baseline/index.js +27 -0
  285. package/src/req-baseline/open-candidates.js +143 -0
  286. package/src/req-baseline/opt-out.js +195 -0
  287. package/src/req-baseline/sweep.js +230 -0
  288. package/src/req-detection/classifier.js +181 -0
  289. package/src/req-detection/index.js +9 -0
  290. package/src/req-detection/persist.js +55 -0
  291. package/src/review/index.js +325 -0
  292. package/src/review/mutation.js +117 -0
  293. package/src/review/ui-baseline-drift.js +138 -0
  294. package/src/ruleset/index.js +140 -0
  295. package/src/ruleset/ruleset.json +146 -0
  296. package/src/server/index.js +178 -0
  297. package/src/server/routes.js +110 -0
  298. package/src/server/sse.js +118 -0
  299. package/src/setup/agent-setup.js +362 -0
  300. package/src/setup/identity-seed.js +104 -0
  301. package/src/setup/knowledge-seed.js +123 -0
  302. package/src/setup/managed-block.js +193 -0
  303. package/src/setup/managed-gitignore.js +166 -0
  304. package/src/setup/managed-markers.js +49 -0
  305. package/src/ui-baseline/defaults.js +119 -0
  306. package/src/ui-baseline/index.js +25 -0
  307. package/src/ui-baseline/manifest-writer.js +282 -0
  308. package/src/ui-baseline/session.js +178 -0
  309. package/src/ui-detection/classifier.js +192 -0
  310. package/src/verify/chain/index.js +221 -0
  311. package/src/verify/cli/cleanup.js +61 -0
  312. package/src/verify/cli/help.js +56 -0
  313. package/src/verify/cli/mcp.js +98 -0
  314. package/src/verify/cli/provision.js +71 -0
  315. package/src/verify/cli/report.js +71 -0
  316. package/src/verify/cli/run.js +155 -0
  317. package/src/verify/engine/brief.js +87 -0
  318. package/src/verify/engine/index.js +177 -0
  319. package/src/verify/engine/launcher.js +307 -0
  320. package/src/verify/mcp/tools.js +107 -0
  321. package/src/verify/profile/index.js +146 -0
  322. package/src/verify/provision/index.js +256 -0
  323. package/src/verify/report/index.js +139 -0
  324. package/src/verify/report/renderer.js +118 -0
  325. package/src/verify/verdict/index.js +313 -0
  326. package/src/view/doc-renderers/adr.js +44 -0
  327. package/src/view/doc-renderers/build-sequence.js +40 -0
  328. package/src/view/doc-renderers/fbs.js +128 -0
  329. package/src/view/doc-renderers/helpers.js +159 -0
  330. package/src/view/doc-renderers/index.js +12 -0
  331. package/src/view/doc-renderers/prd.js +45 -0
  332. package/src/view/doc-renderers/req.js +43 -0
  333. package/src/view/doc-renderers/tac.js +38 -0
  334. package/src/view/doc-renderers/tad.js +74 -0
  335. package/src/view/doc-renderers/test-suite.js +45 -0
  336. package/src/view/doc-renderers/user-story.js +63 -0
  337. package/src/view/html-page.js +462 -0
  338. package/src/view/index.js +63 -0
  339. package/src/view/live-client.js +338 -0
  340. package/src/view/mermaid-diagram.js +178 -0
  341. package/src/view/style.css +735 -0
  342. package/src/view/tree-model.js +152 -0
  343. package/src/view/vendored/mermaid.min.js +2607 -0
  344. package/src/view-supervisor/index.js +26 -0
  345. package/src/view-supervisor/logs.js +32 -0
  346. package/src/view-supervisor/manifest-writer.js +178 -0
  347. package/src/view-supervisor/persist-until.js +85 -0
  348. package/src/view-supervisor/supervisor.js +276 -0
  349. package/src/watch/index.js +152 -0
@@ -0,0 +1,548 @@
1
+ // `rcf doctor` subcommand handler (0.6.0 spec §2.2). Diagnoses
2
+ // init-hygiene drift across four checks (agent-instructions, gitignore,
3
+ // knowledge, identity) and, with --fix, applies the safe minimal
4
+ // repair. Never runs implicitly: no init hook, no post-install script,
5
+ // no validate sub-call fires --fix on the operator's behalf (§2.8).
6
+ //
7
+ // Exit codes:
8
+ // - 0 clean (every check enabled reports clean).
9
+ // - 3 drift (at least one check reports drift).
10
+ // - 2 usage error.
11
+ //
12
+ // --fix contract (§2.7): rewrites managed blocks WHOLESALE inside the
13
+ // markers; every byte outside the markers is preserved verbatim. Files
14
+ // with structurally broken markers (orphan / duplicate) are refused;
15
+ // the operator repairs by hand. Running --fix on a clean repo writes
16
+ // zero files (idempotent no-op).
17
+
18
+ import { readFile, stat, writeFile } from 'node:fs/promises';
19
+ import { join } from 'node:path';
20
+ import { parseArgs } from 'node:util';
21
+
22
+ import {
23
+ loadLegacyFragmentHashes,
24
+ loadManagedBlock,
25
+ loadManagedBlockHash,
26
+ managedBlockPath,
27
+ } from '../setup/agent-setup.js';
28
+ import {
29
+ MARKER_BEGIN,
30
+ MARKER_END,
31
+ LEGACY_MARKER_BEGIN,
32
+ LEGACY_MARKER_END,
33
+ } from '../setup/managed-markers.js';
34
+ import {
35
+ applyFix as applyManagedBlockFix,
36
+ classifyBlock,
37
+ hashInnerContent,
38
+ } from '../setup/managed-block.js';
39
+ import {
40
+ composeGitignoreBlock,
41
+ composeGitignoreInner,
42
+ computeGitignoreBlockHash,
43
+ extractGitignoreBlock,
44
+ GITIGNORE_MARKER_BEGIN,
45
+ GITIGNORE_MARKER_END,
46
+ managedGitignoreEntries,
47
+ } from '../setup/managed-gitignore.js';
48
+ import { identityProfilePath } from '../setup/identity-seed.js';
49
+ import { knowledgePaths } from '../setup/knowledge-seed.js';
50
+
51
+ const OPTION_SPEC = {
52
+ fix: { type: 'boolean' },
53
+ check: { type: 'string' },
54
+ json: { type: 'boolean' },
55
+ quiet: { type: 'boolean' },
56
+ force: { type: 'boolean' },
57
+ help: { type: 'boolean' },
58
+ };
59
+
60
+ const KNOWN_CHECKS = /** @type {const} */ (['agent-instructions', 'gitignore', 'knowledge', 'identity']);
61
+
62
+ export const HELP = `Usage: rcf doctor [--fix] [--check <check>[,check]] [--json] [--quiet] [--help]
63
+
64
+ Diagnose init-hygiene drift in the current project. Exits 0 clean, 3 when
65
+ any check reports drift, 2 on usage errors.
66
+
67
+ Options:
68
+ --fix Apply the minimal safe repair for every check
69
+ that reports drift. Rewrites managed blocks
70
+ wholesale; leaves operator content untouched.
71
+ Refuses to touch files with structurally broken
72
+ markers; those must be resolved by hand or by
73
+ removing the corrupted region.
74
+ --check <check>[,check] Run only the named checks. Default: all.
75
+ Values: agent-instructions, gitignore,
76
+ knowledge, identity.
77
+ --json Emit machine-readable envelope: { ok, drift[] }.
78
+ --quiet Only summary line + first 3 drift items.
79
+ --force Accept a legacy-markers --fix on hand-edited
80
+ content that a non-interactive run would
81
+ otherwise refuse. See rcf help doctor for the
82
+ hand-edited-legacy migration rules.
83
+ --help Print this help.
84
+ `;
85
+
86
+ /**
87
+ * @param {string[]} argv - argv slice after `doctor`
88
+ * @param {object} [deps]
89
+ * @returns {Promise<number>}
90
+ */
91
+ export async function main(argv, deps = {}) {
92
+ const stdout = deps.stdout ?? process.stdout;
93
+ const stderr = deps.stderr ?? process.stderr;
94
+ const cwd = deps.cwd ?? process.cwd();
95
+
96
+ let parsed;
97
+ try {
98
+ parsed = parseArgs({ args: argv, options: OPTION_SPEC, allowPositionals: true, strict: true });
99
+ } catch (err) {
100
+ stderr.write(`[error] usage ${err.message}\n`);
101
+ stderr.write(HELP);
102
+ return 2;
103
+ }
104
+ const flags = parsed.values;
105
+ if (flags.help) {
106
+ stdout.write(HELP);
107
+ return 0;
108
+ }
109
+ if (parsed.positionals.length > 0) {
110
+ stderr.write(`[error] usage doctor: unexpected argument '${parsed.positionals[0]}'\n`);
111
+ stderr.write(HELP);
112
+ return 2;
113
+ }
114
+
115
+ let enabled = [...KNOWN_CHECKS];
116
+ if (typeof flags.check === 'string' && flags.check.length > 0) {
117
+ const asked = flags.check.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
118
+ const bad = asked.filter((c) => !KNOWN_CHECKS.includes(/** @type {(typeof KNOWN_CHECKS)[number]} */ (c)));
119
+ if (bad.length > 0) {
120
+ stderr.write(`[error] usage doctor: unknown check '${bad[0]}'. Known checks: ${KNOWN_CHECKS.join(', ')}\n`);
121
+ return 2;
122
+ }
123
+ enabled = asked;
124
+ }
125
+
126
+ // Load canonical block + hash once; a missing shipped asset is a
127
+ // distinct error class so doctor never reports spurious clean.
128
+ const canonical = await loadManagedBlock();
129
+ if (typeof canonical !== 'string') {
130
+ stderr.write(`[error] ${canonical.kind} ${canonical.message}\n`);
131
+ return 1;
132
+ }
133
+ const canonicalHash = await loadManagedBlockHash();
134
+ if (typeof canonicalHash !== 'string') {
135
+ stderr.write(`[error] ${canonicalHash.kind} ${canonicalHash.message}\n`);
136
+ return 1;
137
+ }
138
+ // §7.3 fail-safe hand-edit detector: any legacy inner content whose
139
+ // hash is NOT in this whitelist is treated as hand-edited (warn on
140
+ // TTY, refuse without --force on non-TTY). A missing / malformed
141
+ // whitelist is a distinct error class so doctor never quietly falls
142
+ // back to a permissive heuristic.
143
+ const legacyFragmentHashes = await loadLegacyFragmentHashes();
144
+ if (!(legacyFragmentHashes instanceof Set)) {
145
+ stderr.write(`[error] ${legacyFragmentHashes.kind} ${legacyFragmentHashes.message}\n`);
146
+ return 1;
147
+ }
148
+
149
+ const ctx = {
150
+ projectRoot: cwd,
151
+ canonical,
152
+ canonicalHash,
153
+ legacyFragmentHashes,
154
+ fix: Boolean(flags.fix),
155
+ force: Boolean(flags.force),
156
+ // isTty is deps-injectable for tests; falls back to real stdout.
157
+ isTty: deps.isTty ?? Boolean(stdout.isTTY),
158
+ };
159
+
160
+ /** @type {Array<{check: string, item: string, file: string, message: string, refusedByFix: boolean}>} */
161
+ const drift = [];
162
+ /** @type {Array<{file: string, action: string}>} */
163
+ const writes = [];
164
+
165
+ for (const check of enabled) {
166
+ let result;
167
+ if (check === 'agent-instructions') result = await runAgentInstructionsCheck(ctx);
168
+ else if (check === 'gitignore') result = await runGitignoreCheck(ctx);
169
+ else if (check === 'knowledge') result = await runKnowledgeCheck(ctx);
170
+ else if (check === 'identity') result = await runIdentityCheck(ctx);
171
+ else continue;
172
+ for (const d of result.drift) drift.push({ check, ...d });
173
+ for (const w of result.writes) writes.push(w);
174
+ }
175
+
176
+ // §2.7 exit-code semantics: with --fix, a run that repaired every
177
+ // fixable drift item exits 0 even though drift WAS reported at scan
178
+ // time. Exit 3 only when at least one drift item remains unrepaired
179
+ // (either --fix was not passed, or the item was refused by --fix).
180
+ const refusedCount = drift.filter((d) => d.refusedByFix).length;
181
+ const unrepairedCount = ctx.fix ? refusedCount : drift.length;
182
+ const ok = unrepairedCount === 0;
183
+ const exitCode = ok ? 0 : 3;
184
+
185
+ if (flags.json) {
186
+ stdout.write(`${JSON.stringify({ ok, drift, writes }, null, 2)}\n`);
187
+ return exitCode;
188
+ }
189
+
190
+ writeHumanSummary({ stdout, ok, drift, writes, fixed: ctx.fix, quiet: Boolean(flags.quiet) });
191
+ return exitCode;
192
+ }
193
+
194
+ /** Render a human-readable summary. */
195
+ function writeHumanSummary({ stdout, ok, drift, writes, fixed, quiet }) {
196
+ const repaired = writes.length;
197
+ if (ok && repaired === 0) {
198
+ stdout.write('rcf doctor: clean.\n');
199
+ return;
200
+ }
201
+ if (ok && repaired > 0) {
202
+ // Every drift item was repaired successfully. Report the repair
203
+ // count so the operator sees what the run did.
204
+ stdout.write(`rcf doctor: ${repaired} item${repaired === 1 ? '' : 's'} repaired; clean.\n`);
205
+ for (const w of writes) stdout.write(` fixed: ${w.file} (${w.action}).\n`);
206
+ return;
207
+ }
208
+ const refused = drift.filter((d) => d.refusedByFix);
209
+ stdout.write(`rcf doctor: ${drift.length} drift item${drift.length === 1 ? '' : 's'}`);
210
+ if (fixed) stdout.write(` (${repaired} repaired, ${refused.length} refused)`);
211
+ stdout.write('.\n');
212
+ const limit = quiet ? 3 : drift.length;
213
+ for (const d of drift.slice(0, limit)) {
214
+ const tag = d.refusedByFix ? ' [refused]' : '';
215
+ stdout.write(` [${d.check}] ${d.item} ${d.file}${tag}\n`);
216
+ if (!quiet) stdout.write(` ${d.message}\n`);
217
+ }
218
+ if (limit < drift.length) {
219
+ stdout.write(` ... ${drift.length - limit} more (run without --quiet to see all).\n`);
220
+ }
221
+ if (fixed) {
222
+ for (const w of writes) stdout.write(` fixed: ${w.file} (${w.action}).\n`);
223
+ } else {
224
+ stdout.write('Run `rcf doctor --fix` to apply the safe repairs above.\n');
225
+ }
226
+ }
227
+
228
+ /* ------------------------------------------------------------------ */
229
+ /* Check: agent-instructions */
230
+ /* ------------------------------------------------------------------ */
231
+
232
+ async function runAgentInstructionsCheck(ctx) {
233
+ const drift = [];
234
+ const writes = [];
235
+ const blockOpts = {
236
+ markerBegin: MARKER_BEGIN,
237
+ markerEnd: MARKER_END,
238
+ legacyMarkerBegin: LEGACY_MARKER_BEGIN,
239
+ legacyMarkerEnd: LEGACY_MARKER_END,
240
+ };
241
+ for (const name of ['CLAUDE.md', 'AGENTS.md']) {
242
+ const path = join(ctx.projectRoot, name);
243
+ let text;
244
+ try {
245
+ text = await readFile(path, 'utf8');
246
+ } catch (err) {
247
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') continue;
248
+ throw err;
249
+ }
250
+ const state = classifyBlock(text, ctx.canonicalHash, blockOpts);
251
+ if (state === 'clean') continue;
252
+ const refusedByFix = state === 'orphan-marker' || state === 'duplicate-block';
253
+ const item = state;
254
+ const message = messageForState(state, path);
255
+ drift.push({ item, file: name, message, refusedByFix });
256
+ if (ctx.fix && !refusedByFix) {
257
+ if (state === 'legacy-markers' && !ctx.force) {
258
+ // Hand-edited-legacy migration warning (§7.3). Non-interactive
259
+ // runs REFUSE without --force to avoid destroying operator edits.
260
+ const legacyHasHandEdits = detectLegacyHandEdits(text, ctx.legacyFragmentHashes);
261
+ if (legacyHasHandEdits && !ctx.isTty) {
262
+ // Convert the drift into a refused item without repairing.
263
+ drift[drift.length - 1] = {
264
+ item: 'legacy-markers-hand-edited',
265
+ file: name,
266
+ message: `${name} contains a legacy managed block with hand edits that do not match the pre-0.6.0 canonical fragment. Re-run with --force to overwrite (edits inside markers will be lost), or copy the hand-edited lines OUTSIDE the markers first.`,
267
+ refusedByFix: true,
268
+ };
269
+ continue;
270
+ }
271
+ }
272
+ const applied = applyManagedBlockFix(text, ctx.canonical, blockOpts, ctx.canonicalHash);
273
+ if (applied && applied.action !== 'noop') {
274
+ await writeFile(path, applied.nextText, 'utf8');
275
+ writes.push({ file: name, action: applied.action });
276
+ }
277
+ }
278
+ }
279
+ return { drift, writes };
280
+ }
281
+
282
+ function messageForState(state, path) {
283
+ switch (state) {
284
+ case 'missing-block':
285
+ return `${path} has no managed block. \`rcf doctor --fix\` appends one at end of file.`;
286
+ case 'stale-hash':
287
+ return `${path} has a managed block whose text does not match the canonical hash. \`rcf doctor --fix\` rewrites it in place.`;
288
+ case 'legacy-markers':
289
+ return `${path} carries pre-0.6.0 markers. \`rcf doctor --fix\` migrates the block to the new markers and canonical text.`;
290
+ case 'orphan-marker':
291
+ return `orphan managed-block marker in ${path}. Repair by hand: pair the marker or remove the corrupted region, then re-run \`rcf doctor --fix\`.`;
292
+ case 'duplicate-block':
293
+ return `${path} contains more than one managed-block pair. Repair by hand: remove the stale pair, then re-run \`rcf doctor --fix\`.`;
294
+ default:
295
+ return `${path}: ${state}.`;
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Detect whether a legacy-markers file's inner content diverges from
301
+ * the pre-0.6.0 canonical fragment as shipped in 0.4.x/0.5.x
302
+ * (§7.3 fail-safe). Hashes the extracted legacy inner content (trimmed,
303
+ * matching `writeAgentInstructions`'s pre-0.6.0 write convention: the
304
+ * fragment landed between markers as `\n${fragment}\n`, and the shipped
305
+ * `fragment` was itself `.trim()`-normalised by `loadHarnessFragment`,
306
+ * so the trimmed inner content is exactly the fragment) and checks
307
+ * membership in the shipped whitelist.
308
+ *
309
+ * Fail-safe by default: any hash NOT in the whitelist is treated as
310
+ * hand-edited. Empty whitelist (edge case) means "every legacy block
311
+ * is potentially hand-edited", which is the safer stance. The
312
+ * heuristic-based v0.6.0 draft (`length < 500`, `startsWith('## RCF')`
313
+ * denylist) was fail-open by design — a hand-edit that kept the
314
+ * leading `## RCF`, stayed above the length floor, and did not add one
315
+ * of the three named headers slipped past silently. This replacement
316
+ * inverts the safety profile so unknown content requires explicit
317
+ * `--force` in non-interactive mode.
318
+ *
319
+ * @param {string} fileText
320
+ * @param {Set<string>} legacyFragmentHashes - whitelist loaded via loadLegacyFragmentHashes.
321
+ * @returns {boolean}
322
+ */
323
+ function detectLegacyHandEdits(fileText, legacyFragmentHashes) {
324
+ const inner = extractLegacyInner(fileText);
325
+ if (inner === null) return false;
326
+ const innerHash = hashInnerContent(inner);
327
+ return !legacyFragmentHashes.has(innerHash);
328
+ }
329
+
330
+ function extractLegacyInner(fileText) {
331
+ const beginAt = fileText.indexOf(LEGACY_MARKER_BEGIN);
332
+ if (beginAt < 0) return null;
333
+ const innerStart = beginAt + LEGACY_MARKER_BEGIN.length;
334
+ const endAt = fileText.indexOf(LEGACY_MARKER_END, innerStart);
335
+ if (endAt < 0) return null;
336
+ return fileText.slice(innerStart, endAt);
337
+ }
338
+
339
+ /* ------------------------------------------------------------------ */
340
+ /* Check: gitignore */
341
+ /* ------------------------------------------------------------------ */
342
+
343
+ async function runGitignoreCheck(ctx) {
344
+ const drift = [];
345
+ const writes = [];
346
+ const path = join(ctx.projectRoot, '.gitignore');
347
+ let text;
348
+ try {
349
+ text = await readFile(path, 'utf8');
350
+ } catch (err) {
351
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') {
352
+ drift.push({
353
+ item: 'missing-file',
354
+ file: '.gitignore',
355
+ message: 'no .gitignore in project root. `rcf doctor --fix` creates it with only the managed block.',
356
+ refusedByFix: false,
357
+ });
358
+ if (ctx.fix) {
359
+ await writeFile(path, composeGitignoreBlock(), 'utf8');
360
+ writes.push({ file: '.gitignore', action: 'created' });
361
+ }
362
+ return { drift, writes };
363
+ }
364
+ throw err;
365
+ }
366
+ const beginCount = countOccurrencesLocal(text, GITIGNORE_MARKER_BEGIN);
367
+ const endCount = countOccurrencesLocal(text, GITIGNORE_MARKER_END);
368
+ if (beginCount >= 2 && endCount >= 2) {
369
+ drift.push({
370
+ item: 'duplicate-block',
371
+ file: '.gitignore',
372
+ message: '.gitignore contains more than one managed-block pair. Repair by hand: remove the stale pair, then re-run.',
373
+ refusedByFix: true,
374
+ });
375
+ return { drift, writes };
376
+ }
377
+ if (beginCount !== endCount) {
378
+ drift.push({
379
+ item: 'orphan-marker',
380
+ file: '.gitignore',
381
+ message: 'orphan managed-block marker in .gitignore. Repair by hand: pair the marker or remove the corrupted region.',
382
+ refusedByFix: true,
383
+ });
384
+ return { drift, writes };
385
+ }
386
+ if (beginCount === 0) {
387
+ drift.push({
388
+ item: 'missing-block',
389
+ file: '.gitignore',
390
+ message: '.gitignore has no managed block. `rcf doctor --fix` appends one at end of file.',
391
+ refusedByFix: false,
392
+ });
393
+ if (ctx.fix) {
394
+ const composed = composeGitignoreBlock();
395
+ const sep = text.length === 0 ? '' : (text.endsWith('\n') ? '\n' : '\n\n');
396
+ const next = `${text}${sep}${composed}`;
397
+ await writeFile(path, next, 'utf8');
398
+ writes.push({ file: '.gitignore', action: 'appended' });
399
+ }
400
+ return { drift, writes };
401
+ }
402
+ const located = extractGitignoreBlock(text);
403
+ if (!located) {
404
+ // Shouldn't happen given the counts above, but guard defensively.
405
+ drift.push({
406
+ item: 'orphan-marker',
407
+ file: '.gitignore',
408
+ message: 'orphan managed-block marker in .gitignore.',
409
+ refusedByFix: true,
410
+ });
411
+ return { drift, writes };
412
+ }
413
+ const innerHash = hashInnerContent(located.innerText);
414
+ const expected = computeGitignoreBlockHash();
415
+ if (innerHash === expected) {
416
+ return { drift, writes };
417
+ }
418
+ drift.push({
419
+ item: 'stale-hash',
420
+ file: '.gitignore',
421
+ message: '.gitignore managed block does not match the current aggregator output. `rcf doctor --fix` rewrites it.',
422
+ refusedByFix: false,
423
+ });
424
+ if (ctx.fix) {
425
+ const composed = composeGitignoreBlock();
426
+ const next = text.slice(0, located.beginIndex) + composed + text.slice(located.endIndex);
427
+ await writeFile(path, next, 'utf8');
428
+ writes.push({ file: '.gitignore', action: 'replaced' });
429
+ }
430
+ return { drift, writes };
431
+ }
432
+
433
+ function countOccurrencesLocal(haystack, needle) {
434
+ if (needle.length === 0) return 0;
435
+ let count = 0;
436
+ let i = 0;
437
+ while (true) {
438
+ const at = haystack.indexOf(needle, i);
439
+ if (at < 0) return count;
440
+ count += 1;
441
+ i = at + needle.length;
442
+ }
443
+ }
444
+
445
+ /* ------------------------------------------------------------------ */
446
+ /* Check: knowledge */
447
+ /* ------------------------------------------------------------------ */
448
+
449
+ async function runKnowledgeCheck(ctx) {
450
+ const drift = [];
451
+ const paths = knowledgePaths(ctx.projectRoot);
452
+ const rootExists = await pathExists(paths.dir);
453
+ if (!rootExists) {
454
+ drift.push({
455
+ item: 'missing-directory',
456
+ file: 'rcf/knowledge/',
457
+ message: 'rcf/knowledge/ is missing (0.6.0 convention). Run `rcf init` to seed it, or create the directory by hand.',
458
+ refusedByFix: true,
459
+ });
460
+ return { drift, writes: [] };
461
+ }
462
+ const notesExists = await pathExists(join(paths.dir, 'notes'));
463
+ const docsExists = await pathExists(join(paths.dir, 'docs'));
464
+ if (!notesExists) {
465
+ drift.push({
466
+ item: 'missing-subdir',
467
+ file: 'rcf/knowledge/notes/',
468
+ message: 'rcf/knowledge/notes/ is missing. Run `rcf init` to re-seed, or create the directory by hand.',
469
+ refusedByFix: true,
470
+ });
471
+ }
472
+ if (!docsExists) {
473
+ drift.push({
474
+ item: 'missing-subdir',
475
+ file: 'rcf/knowledge/docs/',
476
+ message: 'rcf/knowledge/docs/ is missing. Run `rcf init` to re-seed, or create the directory by hand.',
477
+ refusedByFix: true,
478
+ });
479
+ }
480
+ return { drift, writes: [] };
481
+ }
482
+
483
+ async function pathExists(path) {
484
+ try {
485
+ await stat(path);
486
+ return true;
487
+ } catch (err) {
488
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return false;
489
+ throw err;
490
+ }
491
+ }
492
+
493
+ /* ------------------------------------------------------------------ */
494
+ /* Check: identity */
495
+ /* ------------------------------------------------------------------ */
496
+
497
+ async function runIdentityCheck(ctx) {
498
+ const drift = [];
499
+ const path = identityProfilePath(ctx.projectRoot);
500
+ const profileExists = await pathExists(path);
501
+ if (!profileExists) {
502
+ drift.push({
503
+ item: 'missing-file',
504
+ file: 'rcf/.identity/profile.md',
505
+ message: 'optional; profile.md is the recommended template but not required. Run `rcf init` to seed it, or create it by hand.',
506
+ refusedByFix: true,
507
+ });
508
+ }
509
+ const ignored = await isPathIgnored(ctx.projectRoot);
510
+ if (profileExists && !ignored) {
511
+ drift.push({
512
+ item: 'gitignore-mismatch',
513
+ file: 'rcf/.identity/',
514
+ message: '`rcf/.identity/` is not gitignored; the profile may be exposed. Check your `.gitignore`.',
515
+ refusedByFix: true,
516
+ });
517
+ }
518
+ return { drift, writes: [] };
519
+ }
520
+
521
+ /**
522
+ * Coarse effective-ignore check: does the project's .gitignore contain
523
+ * a line that matches `rcf/.identity/`? We look inside the managed
524
+ * block first (the expected home) and then scan any operator-owned
525
+ * lines. Real git semantics are more subtle (later-match wins,
526
+ * unignore patterns, .git/info/exclude); doctor's job here is to warn
527
+ * on the common case, and the AC-4.3 test uses `git check-ignore` for
528
+ * the definitive assertion. If any registered aggregator entry with
529
+ * path `rcf/.identity/` appears anywhere in the .gitignore text and
530
+ * there is no bare `!rcf/.identity/` unignore, we treat it as ignored.
531
+ */
532
+ async function isPathIgnored(projectRoot) {
533
+ try {
534
+ const text = await readFile(join(projectRoot, '.gitignore'), 'utf8');
535
+ const wantsIgnored = managedGitignoreEntries()
536
+ .some((e) => e.path === 'rcf/.identity/')
537
+ || false;
538
+ const lines = text.split('\n').map((l) => l.trim());
539
+ const ignored = lines.includes('rcf/.identity/');
540
+ const unignored = lines.includes('!rcf/.identity/') || lines.includes('!rcf/.identity/*');
541
+ return wantsIgnored && ignored && !unignored;
542
+ } catch {
543
+ return false;
544
+ }
545
+ }
546
+
547
+ // Silence unused-import warnings for values consumed only via names.
548
+ void composeGitignoreInner;