geneseed 2.0.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 (337) hide show
  1. package/CHANGELOG.md +938 -0
  2. package/DESIGN.md +1423 -0
  3. package/LICENSE +21 -0
  4. package/QUICKSTART.md +48 -0
  5. package/README.md +336 -0
  6. package/SETUP.md +1027 -0
  7. package/SHIPPED.md +70 -0
  8. package/adapters/bob/README.md +100 -0
  9. package/adapters/claude-code/README.md +101 -0
  10. package/adapters/claude-code/settings.json +47 -0
  11. package/adapters/claude-code/skills/geneseed/SKILL.md +72 -0
  12. package/adapters/copilot/README.md +76 -0
  13. package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +434 -0
  14. package/adapters/opencode/HOW-OPENCODE-LOADS.md +158 -0
  15. package/adapters/opencode/README.md +582 -0
  16. package/adapters/opencode/agents/orchestrator.md +25 -0
  17. package/adapters/opencode/opencode.json +44 -0
  18. package/adapters/opencode/plugins/geneseed-activity.js +430 -0
  19. package/adapters/opencode/plugins/geneseed-context.js +876 -0
  20. package/adapters/opencode/plugins/geneseed-guard.js +290 -0
  21. package/adapters/opencode/plugins/geneseed-learn.js +396 -0
  22. package/adapters/opencode/plugins/geneseed-notify.js +142 -0
  23. package/adapters/opencode/plugins/geneseed-ponytail.js +148 -0
  24. package/adapters/opencode/plugins/geneseed-workflow.js +183 -0
  25. package/adapters/opencode/workflows/_runtime.js +277 -0
  26. package/adapters/opencode/workflows/council.js +64 -0
  27. package/adapters/opencode/workflows/dispatch.js +89 -0
  28. package/adapters/opencode/workflows/research-plan-implement.js +43 -0
  29. package/adapters/opencode/workflows/review.js +57 -0
  30. package/bin/geneseed-cli.mjs +412 -0
  31. package/bin/geneseed-hook.mjs +129 -0
  32. package/bin/geneseed.mjs +1086 -0
  33. package/docs/declined.md +149 -0
  34. package/docs/limits.md +270 -0
  35. package/docs/opencode-plugin-setup.md +64 -0
  36. package/docs/token-footprint.md +68 -0
  37. package/docs/web/_groups.json +45 -0
  38. package/docs/web/about.md +6 -0
  39. package/docs/web/adapters-claude-code.md +8 -0
  40. package/docs/web/adapters-opencode-loads.md +8 -0
  41. package/docs/web/adapters-opencode-spec.md +8 -0
  42. package/docs/web/adapters-opencode.md +8 -0
  43. package/docs/web/agents.md +8 -0
  44. package/docs/web/authoring.md +11 -0
  45. package/docs/web/autostart.md +9 -0
  46. package/docs/web/claude-hooks.md +36 -0
  47. package/docs/web/cli.md +6 -0
  48. package/docs/web/collaboration.md +27 -0
  49. package/docs/web/configure-wiki.md +9 -0
  50. package/docs/web/design.md +7 -0
  51. package/docs/web/env-knobs.md +9 -0
  52. package/docs/web/exclusions.md +26 -0
  53. package/docs/web/first-session.md +28 -0
  54. package/docs/web/footprint.md +31 -0
  55. package/docs/web/foreman-mode.md +33 -0
  56. package/docs/web/glossary.md +6 -0
  57. package/docs/web/harnesses.md +28 -0
  58. package/docs/web/headless.md +9 -0
  59. package/docs/web/ingest-docs.md +9 -0
  60. package/docs/web/install-paths.md +9 -0
  61. package/docs/web/install-quick.md +35 -0
  62. package/docs/web/lsp-overview.md +37 -0
  63. package/docs/web/mcp-claude-code.md +10 -0
  64. package/docs/web/mcp-filesystem.md +9 -0
  65. package/docs/web/mcp-gitlab.md +9 -0
  66. package/docs/web/mcp-markitdown.md +9 -0
  67. package/docs/web/mcp-overview.md +34 -0
  68. package/docs/web/mcp-trouble.md +9 -0
  69. package/docs/web/mcp-verify.md +9 -0
  70. package/docs/web/memory.md +7 -0
  71. package/docs/web/migrate.md +39 -0
  72. package/docs/web/model.md +20 -0
  73. package/docs/web/notebook.md +7 -0
  74. package/docs/web/pipelines.md +24 -0
  75. package/docs/web/plugin-activity.md +23 -0
  76. package/docs/web/plugin-context.md +26 -0
  77. package/docs/web/plugin-guard.md +24 -0
  78. package/docs/web/plugin-learn.md +27 -0
  79. package/docs/web/plugin-notify.md +24 -0
  80. package/docs/web/plugin-ponytail.md +23 -0
  81. package/docs/web/plugin-setup.md +7 -0
  82. package/docs/web/plugin-workflow.md +24 -0
  83. package/docs/web/plugins.md +17 -0
  84. package/docs/web/project-context.md +9 -0
  85. package/docs/web/rules.md +8 -0
  86. package/docs/web/run-anywhere.md +9 -0
  87. package/docs/web/self-improve.md +8 -0
  88. package/docs/web/setup-choices.md +39 -0
  89. package/docs/web/skills.md +8 -0
  90. package/docs/web/themes-author.md +8 -0
  91. package/docs/web/themes.md +9 -0
  92. package/docs/web/token-footprint.md +7 -0
  93. package/docs/web/trouble.md +38 -0
  94. package/docs/web/upgrade.md +9 -0
  95. package/docs/web/verify.md +30 -0
  96. package/docs/web/wiki.md +7 -0
  97. package/docs/web/worktree.md +10 -0
  98. package/docs/web-ui.md +144 -0
  99. package/docs/wiki.md +43 -0
  100. package/geneseed +39 -0
  101. package/geneseed.cmd +34 -0
  102. package/harness.config.json +6 -0
  103. package/js/anim.mjs +326 -0
  104. package/js/catalog.mjs +112 -0
  105. package/js/checkout.mjs +88 -0
  106. package/js/cli-table.json +1034 -0
  107. package/js/cli.mjs +498 -0
  108. package/js/diff.mjs +384 -0
  109. package/js/doctor.mjs +1364 -0
  110. package/js/emit.mjs +1373 -0
  111. package/js/excludes.mjs +271 -0
  112. package/js/generate.mjs +379 -0
  113. package/js/hooks.mjs +1026 -0
  114. package/js/hosts.mjs +253 -0
  115. package/js/installs.mjs +378 -0
  116. package/js/inventory.mjs +175 -0
  117. package/js/lib/fs.mjs +913 -0
  118. package/js/lib/proc.mjs +28 -0
  119. package/js/lib/udiff.mjs +275 -0
  120. package/js/link.mjs +286 -0
  121. package/js/mcp.mjs +335 -0
  122. package/js/memory.mjs +108 -0
  123. package/js/menu.mjs +118 -0
  124. package/js/migrate.mjs +251 -0
  125. package/js/native.mjs +426 -0
  126. package/js/opencode.mjs +325 -0
  127. package/js/registry.mjs +110 -0
  128. package/js/render.mjs +282 -0
  129. package/js/settings.mjs +924 -0
  130. package/js/setup.mjs +492 -0
  131. package/js/status.mjs +281 -0
  132. package/js/themes.mjs +195 -0
  133. package/js/tui.mjs +444 -0
  134. package/js/uninstall.mjs +1137 -0
  135. package/js/update.mjs +910 -0
  136. package/js/web/actions.mjs +868 -0
  137. package/js/web/activity.mjs +284 -0
  138. package/js/web/api.mjs +873 -0
  139. package/js/web/docs.mjs +531 -0
  140. package/js/web/graph.mjs +0 -0
  141. package/js/web/jobs.mjs +480 -0
  142. package/js/web/server.mjs +1135 -0
  143. package/package.json +54 -0
  144. package/registry.json +474 -0
  145. package/src/AGENT.md.tmpl +390 -0
  146. package/src/agents/_template.md +76 -0
  147. package/src/agents/advocate.md +36 -0
  148. package/src/agents/architect.md +55 -0
  149. package/src/agents/developer.md +66 -0
  150. package/src/agents/docs.md +54 -0
  151. package/src/agents/empiricist.md +39 -0
  152. package/src/agents/explorer.md +54 -0
  153. package/src/agents/framer.md +36 -0
  154. package/src/agents/historian.md +51 -0
  155. package/src/agents/operator.md +37 -0
  156. package/src/agents/pragmatist.md +37 -0
  157. package/src/agents/reviewer.md +52 -0
  158. package/src/agents/security.md +57 -0
  159. package/src/agents/skeptic.md +48 -0
  160. package/src/agents/steward.md +36 -0
  161. package/src/agents/tester.md +54 -0
  162. package/src/agents/user-advocate.md +36 -0
  163. package/src/agents/visionary.md +36 -0
  164. package/src/laws/universal.md +387 -0
  165. package/src/memory/README.md +109 -0
  166. package/src/memory/gitignore +6 -0
  167. package/src/modes/README.md +26 -0
  168. package/src/modes/direct.md +2 -0
  169. package/src/modes/foreman.md +19 -0
  170. package/src/notebook/README.md +39 -0
  171. package/src/notebook/gitignore +7 -0
  172. package/src/postures/README.md +31 -0
  173. package/src/postures/artisan.md +14 -0
  174. package/src/postures/assistant.md +12 -0
  175. package/src/postures/expert.md +11 -0
  176. package/src/postures/mentor.md +11 -0
  177. package/src/postures/peer.md +12 -0
  178. package/src/skills/_template.md +47 -0
  179. package/src/skills/brainstorm.md +28 -0
  180. package/src/skills/clarify.md +28 -0
  181. package/src/skills/codebase-design.md +44 -0
  182. package/src/skills/commit.md +48 -0
  183. package/src/skills/council.md +34 -0
  184. package/src/skills/crash-course.md +28 -0
  185. package/src/skills/daydream/.claude-plugin/plugin.json +9 -0
  186. package/src/skills/daydream/SKILL.md +74 -0
  187. package/src/skills/daydream/VENDOR.md +25 -0
  188. package/src/skills/daydream/critic-prompt.md +38 -0
  189. package/src/skills/daydream/instructions.md +422 -0
  190. package/src/skills/daydream/synthesizer-prompt.md +41 -0
  191. package/src/skills/debug.md +37 -0
  192. package/src/skills/decode.md +28 -0
  193. package/src/skills/develop.md +29 -0
  194. package/src/skills/document-project.md +79 -0
  195. package/src/skills/domain-modeling.md +38 -0
  196. package/src/skills/drill.md +28 -0
  197. package/src/skills/feynman.md +26 -0
  198. package/src/skills/forge-mcp.md +48 -0
  199. package/src/skills/fresh-eyes.md +29 -0
  200. package/src/skills/frontend-design.md +58 -0
  201. package/src/skills/gap-detector.md +27 -0
  202. package/src/skills/geneseed-code-review.md +33 -0
  203. package/src/skills/geneseed.md +29 -0
  204. package/src/skills/git-archaeology.md +47 -0
  205. package/src/skills/git-rescue.md +68 -0
  206. package/src/skills/handoff.md +29 -0
  207. package/src/skills/herdr.md +164 -0
  208. package/src/skills/ingest.md +49 -0
  209. package/src/skills/learning-path.md +26 -0
  210. package/src/skills/migrate.md +45 -0
  211. package/src/skills/opencode-theme.md +52 -0
  212. package/src/skills/parallel-agents.md +28 -0
  213. package/src/skills/pipeline.md +52 -0
  214. package/src/skills/plan.md +45 -0
  215. package/src/skills/ponytail.md +29 -0
  216. package/src/skills/profile.md +58 -0
  217. package/src/skills/prose.md +42 -0
  218. package/src/skills/prototype.md +44 -0
  219. package/src/skills/react-view-transitions/AGENTS.md +955 -0
  220. package/src/skills/react-view-transitions/README.md +42 -0
  221. package/src/skills/react-view-transitions/SKILL.md +320 -0
  222. package/src/skills/react-view-transitions/VENDOR.md +16 -0
  223. package/src/skills/react-view-transitions/metadata.json +12 -0
  224. package/src/skills/react-view-transitions/references/css-recipes.md +242 -0
  225. package/src/skills/react-view-transitions/references/implementation.md +182 -0
  226. package/src/skills/react-view-transitions/references/nextjs.md +176 -0
  227. package/src/skills/react-view-transitions/references/patterns.md +262 -0
  228. package/src/skills/refactor.md +32 -0
  229. package/src/skills/release.md +43 -0
  230. package/src/skills/repo-map.md +36 -0
  231. package/src/skills/research.md +42 -0
  232. package/src/skills/review-response.md +36 -0
  233. package/src/skills/roast-me.md +28 -0
  234. package/src/skills/rule.md +100 -0
  235. package/src/skills/ship.md +49 -0
  236. package/src/skills/tdd.md +31 -0
  237. package/src/skills/tickets.md +44 -0
  238. package/src/skills/token-report/SKILL.md +76 -0
  239. package/src/skills/token-report/VENDOR.md +23 -0
  240. package/src/skills/token-report/scripts/token_report.mjs +1301 -0
  241. package/src/skills/wayfinder.md +49 -0
  242. package/src/skills/wiki.md +68 -0
  243. package/src/skills/workflow.md +38 -0
  244. package/themes/README.md +69 -0
  245. package/themes/_TEMPLATE.json +147 -0
  246. package/themes/biker.json +147 -0
  247. package/themes/commentator.json +147 -0
  248. package/themes/cyberpunk.json +147 -0
  249. package/themes/gamer.json +147 -0
  250. package/themes/imperial.json +147 -0
  251. package/themes/joker.json +147 -0
  252. package/themes/marvin.json +147 -0
  253. package/themes/mean.json +147 -0
  254. package/themes/military.json +147 -0
  255. package/themes/neutral.json +147 -0
  256. package/themes/opencode/README.md +58 -0
  257. package/themes/opencode/catppuccin.json +27 -0
  258. package/themes/opencode/dracula.json +27 -0
  259. package/themes/opencode/everforest.json +27 -0
  260. package/themes/opencode/gruvbox.json +27 -0
  261. package/themes/opencode/kanagawa.json +27 -0
  262. package/themes/opencode/neon.json +27 -0
  263. package/themes/opencode/nord.json +27 -0
  264. package/themes/opencode/rosepine.json +27 -0
  265. package/themes/opencode/tokyonight.json +27 -0
  266. package/themes/pirate.json +147 -0
  267. package/themes/sports.json +147 -0
  268. package/themes/verstappen.json +147 -0
  269. package/themes/wizard.json +147 -0
  270. package/web/dist/assets/About-DX1-VTBP.js +1 -0
  271. package/web/dist/assets/Activity-Dp9NaXOn.js +1 -0
  272. package/web/dist/assets/ActivityDetail-HpOBtrXS.js +1 -0
  273. package/web/dist/assets/Diff-Ct4G3Se8.js +5 -0
  274. package/web/dist/assets/Doctor-DGjkZFuA.js +1 -0
  275. package/web/dist/assets/Graph-b8o-S6kv.js +1 -0
  276. package/web/dist/assets/Harnesses-gr3-aHBF.js +1 -0
  277. package/web/dist/assets/Laws-DTwF5tTC.js +1 -0
  278. package/web/dist/assets/Library-BEZscyaQ.js +1 -0
  279. package/web/dist/assets/Markdown-k_LSLBGq.js +1 -0
  280. package/web/dist/assets/Profile-DydukTxP.js +1 -0
  281. package/web/dist/assets/Rules-CIRG4Ldt.js +1 -0
  282. package/web/dist/assets/Skills-Buoi1IBr.js +1 -0
  283. package/web/dist/assets/StatusBadge-CE_AJttl.js +1 -0
  284. package/web/dist/assets/Themes-CI4o3gJC.js +2 -0
  285. package/web/dist/assets/activity-BCW7e6Cy.js +1 -0
  286. package/web/dist/assets/audiowide-latin-400-normal-6GFCX7ni.woff2 +0 -0
  287. package/web/dist/assets/geist-mono-latin-400-normal-DKaoCDn5.woff2 +0 -0
  288. package/web/dist/assets/geist-mono-latin-500-normal-C3sF8Y1B.woff2 +0 -0
  289. package/web/dist/assets/geist-mono-latin-600-normal-BHzjB6_C.woff2 +0 -0
  290. package/web/dist/assets/hanken-grotesk-latin-400-italic-Bserb8X5.woff2 +0 -0
  291. package/web/dist/assets/hanken-grotesk-latin-400-normal-BG6hkEXj.woff2 +0 -0
  292. package/web/dist/assets/hanken-grotesk-latin-500-italic-BGfwwhek.woff2 +0 -0
  293. package/web/dist/assets/hanken-grotesk-latin-500-normal-DrDcrrxK.woff2 +0 -0
  294. package/web/dist/assets/hanken-grotesk-latin-600-normal-CIXX6EOa.woff2 +0 -0
  295. package/web/dist/assets/hanken-grotesk-latin-700-normal-CeQ8H3UY.woff2 +0 -0
  296. package/web/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  297. package/web/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  298. package/web/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  299. package/web/dist/assets/ibm-plex-serif-latin-400-normal-BIGslYFI.woff2 +0 -0
  300. package/web/dist/assets/ibm-plex-serif-latin-500-normal-REKAfBVG.woff2 +0 -0
  301. package/web/dist/assets/ibm-plex-serif-latin-600-normal-DAxitmSu.woff2 +0 -0
  302. package/web/dist/assets/index-B1PIx7RM.js +1 -0
  303. package/web/dist/assets/index-BnTQUSUz.js +12 -0
  304. package/web/dist/assets/index-Ck2iCdaC.js +2 -0
  305. package/web/dist/assets/index-DUCsDv7z.css +1 -0
  306. package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  307. package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  308. package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  309. package/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  310. package/web/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  311. package/web/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  312. package/web/dist/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
  313. package/web/dist/assets/marked.esm-JKvyS0Pp.js +46 -0
  314. package/web/dist/assets/oswald-latin-400-normal-BAFo8sP8.woff2 +0 -0
  315. package/web/dist/assets/oswald-latin-500-normal-B-eyT0Dw.woff2 +0 -0
  316. package/web/dist/assets/oswald-latin-600-normal-plFmSr5g.woff2 +0 -0
  317. package/web/dist/assets/oswald-latin-700-normal-vy6hmKSr.woff2 +0 -0
  318. package/web/dist/assets/poppins-latin-400-normal-cpxAROuN.woff2 +0 -0
  319. package/web/dist/assets/poppins-latin-500-normal-C8OXljZJ.woff2 +0 -0
  320. package/web/dist/assets/poppins-latin-600-normal-zEkxB9Mr.woff2 +0 -0
  321. package/web/dist/assets/poppins-latin-700-normal-Qrb0O0WB.woff2 +0 -0
  322. package/web/dist/assets/react-DOpiTn5v.js +32 -0
  323. package/web/dist/assets/roman-DkYN0LX9.js +1 -0
  324. package/web/dist/assets/space-grotesk-latin-400-normal-CJ-V5oYT.woff2 +0 -0
  325. package/web/dist/assets/space-grotesk-latin-500-normal-lFbtlQH6.woff2 +0 -0
  326. package/web/dist/assets/space-grotesk-latin-600-normal-DjKNqYRj.woff2 +0 -0
  327. package/web/dist/assets/space-grotesk-latin-700-normal-RjhwGPKo.woff2 +0 -0
  328. package/web/dist/assets/space-mono-latin-400-italic-YylcN9Ay.woff2 +0 -0
  329. package/web/dist/assets/space-mono-latin-400-normal-Rg4St2Dn.woff2 +0 -0
  330. package/web/dist/assets/space-mono-latin-700-italic-vNvENeTh.woff2 +0 -0
  331. package/web/dist/assets/space-mono-latin-700-normal-mWgeinG7.woff2 +0 -0
  332. package/web/dist/icon-maskable.svg +10 -0
  333. package/web/dist/icon.svg +8 -0
  334. package/web/dist/index.html +21 -0
  335. package/web/dist/manifest.webmanifest +14 -0
  336. package/web/dist/sw.js +40 -0
  337. package/web/src/pages/Laws.jsx +230 -0
package/js/doctor.mjs ADDED
@@ -0,0 +1,1364 @@
1
+ /**
2
+ * `_harness_build.cmd_doctor` — validate the build.
3
+ *
4
+ * Sixteen `_*_problems` checks over one theme (or every theme) — the newest is P10c's `cli`
5
+ * — and `[doctor] ok — …` when all sixteen find nothing. That last sentence is why this
6
+ * module's gate looks the way it does, and it is worth stating here rather than only in the
7
+ * spec: **delete any single check
8
+ * and a clean run is byte-identical**, so a cross-implementation comparison cannot tell this
9
+ * file from a stub that prints the OK line. `tests/harness_golden.py` therefore plants ONE
10
+ * FAULT PER CHECK, in a private copy of the checkout — see `_copy_checkout` there for why a
11
+ * verb that reads `ROOT` needed a fixture kind that did not exist until this phase.
12
+ *
13
+ * WHY THIS IS THE VERB THAT SPAWNS. `bin/geneseed-cli.mjs` was under a blanket transitive
14
+ * `child_process` ban, and its own docblock predicted the day a verb that genuinely spawns
15
+ * would land: this is it. `_authoring_problems` runs `node --check` over the OpenCode
16
+ * plugins, and there is no in-process equivalent — `vm.Script` compiles as a SCRIPT and every
17
+ * plugin is ESM (`import { promises as fs } from "node:fs"`), which `node --check` accepts
18
+ * through module-syntax detection and `vm.Script` rejects with a SyntaxError of its own. So
19
+ * the ban becomes an ALLOW-LIST of exactly the shape the hook entry already carries for
20
+ * `$GENESEED_LLM`: one binding, one call site, and a test that names the argv. What the ban
21
+ * was actually protecting — that the port never shells back to Python or to the generator —
22
+ * is now asserted directly instead of as a side effect.
23
+ *
24
+ * WHAT IT DOES NOT REACH, MEASURED RATHER THAN ASSUMED. `_doctor_collect` takes `on_progress`
25
+ * and `groups`; `cmd_doctor` passes neither, and they are the TUI's and the web's. The naive
26
+ * closure walk counts ~140 lines of curses drawing behind the first and a structured-view
27
+ * accumulator behind the second. `_ran` is reproduced here anyway, because it is four lines
28
+ * and dropping it would make P6's port of `_doctor_collect` a rewrite rather than a
29
+ * parameter; the progress callback is not, because nothing in this entry can supply one.
30
+ */
31
+ import { existsSync, readFileSync, readdirSync, mkdtempSync, rmSync, statSync } from 'node:fs';
32
+ import { spawnSync } from 'node:child_process';
33
+ import os from 'node:os';
34
+ import path from 'node:path';
35
+
36
+ import { CONFIG, PLUGIN_SRC, ROOT, SRC, THEMES, WORKFLOW_SRC, makeCfg } from './checkout.mjs';
37
+ import {
38
+ buildInto, main as driverMain, emitGlobalInto, emitProjectInto,
39
+ } from '../bin/geneseed.mjs';
40
+ import { stripCapabilityLinks } from './emit.mjs';
41
+ import { hostCatalogsNatively, pyResolve } from './hosts.mjs';
42
+ import { EMIT_HOST_SCOPE, footprintOfDir, installedDefaults, themeFiles } from './installs.mjs';
43
+ import {
44
+ ENTITY_STATUSES, LAW_CLASS, LAW_CLASSES, SKILL_CLASS,
45
+ } from './inventory.mjs';
46
+ import {
47
+ VENDORED_SKILL_DIRS, descBlockProblem, firstBlockquote, isVendoredPath, validateIsVendored,
48
+ } from './native.mjs';
49
+ import { PALETTE_ROLES, colorThemeFiles } from './opencode.mjs';
50
+ import { STRUCTURE, renderAll } from './render.mjs';
51
+ import { SHIM_ARGV, hookShimPath } from './settings.mjs';
52
+ import { pySplitLines } from './lib/udiff.mjs';
53
+ import { NO_WINDOW } from './lib/proc.mjs';
54
+ import {
55
+ comparePaths, normcase, parseJson, pyPrint, pyPrintErr, pyRepr, pyStr, pyStripSpace, pyWhich,
56
+ readText, withDiscardableStderr,
57
+ } from './lib/fs.mjs';
58
+
59
+ // --------------------------------------------------------------------------------------
60
+ // _harness_core's scanning primitives
61
+ // --------------------------------------------------------------------------------------
62
+
63
+ /** `_harness_core.TOKEN_RE` / `LINK_RE` / `ABS_LINK_RE`. `g` where `findall` is used. */
64
+ const TOKEN_RE = /\{\{[A-Z_]+\}\}/g;
65
+ const LINK_RE = /\]\((?!https?:\/\/|#)([^)]+)\)/g;
66
+ const ABS_LINK_RE = /^([A-Za-z]:[\\/]|\/|~)/;
67
+ const FENCE_RE = /```[\s\S]*?```/g;
68
+ const INLINE_CODE_RE = /`[^`]*`/g;
69
+ const COMMENT_RE = /<!--[\s\S]*?-->/g;
70
+
71
+ /** `_harness_core.strip_code` — fenced blocks, inline code and HTML comments, in that order. */
72
+ function stripCode(text) {
73
+ return text.replace(FENCE_RE, '').replace(COMMENT_RE, '').replace(INLINE_CODE_RE, '');
74
+ }
75
+
76
+ /**
77
+ * `_harness_core._within` — `child.relative_to(parent)` without the exception.
78
+ *
79
+ * Segment-wise and through `normcase`, because that is what `PurePath` comparison does: a
80
+ * bundle at `C:\Temp\X` contains `c:\temp\x\a.md` on Windows and does not on Linux. A
81
+ * `startsWith` on the raw strings would additionally call `/tmp/bundle2` a child of
82
+ * `/tmp/bundle`, which is the classic version of this bug.
83
+ */
84
+ function within(child, parent) {
85
+ const c = normcase(child).split(/[\\/]/);
86
+ const p = normcase(parent).split(/[\\/]/);
87
+ return p.length <= c.length && p.every((seg, i) => c[i] === seg);
88
+ }
89
+
90
+ const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
91
+ const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
92
+
93
+ /** `sorted(d.glob(pat))`, or `[]` for a directory that is not there. */
94
+ function globSorted(dir, filter) {
95
+ let names;
96
+ try { names = readdirSync(dir); } catch { return []; }
97
+ return names.filter(filter).map((n) => path.join(dir, n)).sort(comparePaths);
98
+ }
99
+
100
+ /** `Path.rglob('*')` — every entry under `dir`, sorted as `sorted()` sorts paths. */
101
+ function rglob(dir) {
102
+ const out = [];
103
+ const walk = (d) => {
104
+ let entries;
105
+ try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
106
+ for (const e of entries.map((x) => x.name).sort(comparePaths)) {
107
+ const p = path.join(d, e);
108
+ out.push(p);
109
+ if (isDir(p)) walk(p);
110
+ }
111
+ };
112
+ walk(dir);
113
+ return out.sort(comparePaths);
114
+ }
115
+
116
+ // `withDiscardableStderr` was defined here while `renderedProblems` was its only caller.
117
+ // `js/hooks.mjs` became the second and third when the hook started CATCHING `expanduser`'s
118
+ // refusal, so it moved beside `pyPrintErr` in `js/lib/fs.mjs` — the writes it intercepts —
119
+ // and its docblock carries the "not for silencing noise" warning with it.
120
+
121
+ /** `Path.stem` — the basename with its last suffix removed. */
122
+ const stemOf = (p) => path.basename(p, path.extname(p));
123
+
124
+ /** `sorted(set)` over strings, the ordering every problem list is emitted in. */
125
+ const sortedUnique = (xs) => [...new Set(xs)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
126
+ /** `sorted(probs)` — SORTED, not deduped, which is what `_ran` fills a group with. */
127
+ const sortedProblems = (xs) => [...xs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
128
+
129
+ /**
130
+ * `k in d` for a Python dict — OWN keys only.
131
+ *
132
+ * Every membership test in this file reads a key that came out of a JSON document or a source
133
+ * filename, and a bare `k in obj` would answer true for `constructor`, `toString` and the rest
134
+ * of `Object.prototype`. A theme defining a `toString` voice token, or a skill file named
135
+ * `valueOf.md`, would then be reported as missing by the reference and not by this port — a
136
+ * divergence no cell can reach and no reviewer would look for.
137
+ */
138
+ const has = (obj, k) => Object.prototype.hasOwnProperty.call(obj, k);
139
+
140
+ // --------------------------------------------------------------------------------------
141
+ // the checks
142
+ // --------------------------------------------------------------------------------------
143
+
144
+ /**
145
+ * `_harness_build._link_problems` — dead links AND non-hermetic ones.
146
+ *
147
+ * Hermeticity is what lets a bundle be copied or subtree-split into any repo; a link that
148
+ * escapes `out` breaks it silently. The two are separate messages because they have separate
149
+ * causes, and a target that merely does not exist is the DEAD one — the absolute check runs
150
+ * first and returns, so `~/x.md` never reaches the filesystem at all.
151
+ */
152
+ export function linkProblems(md, text, out, rel) {
153
+ const problems = [];
154
+ for (const m of stripCode(text).matchAll(LINK_RE)) {
155
+ const link = m[1];
156
+ const raw = link.split('#')[0].trim();
157
+ if (!raw) continue;
158
+ if (ABS_LINK_RE.test(raw)) {
159
+ problems.push(`non-hermetic absolute link '${link}' in ${rel}`);
160
+ continue;
161
+ }
162
+ // `(md.parent / raw).resolve()` — `pyResolve`, not `path.resolve`, because Python's
163
+ // canonicalises the symlinks in the part that exists and this comparison is against an
164
+ // `out` that went through the same call.
165
+ const target = pyResolve(path.join(path.dirname(md), raw));
166
+ if (!existsSync(target)) problems.push(`dead link '${link}' in ${rel}`);
167
+ else if (!within(target, out)) {
168
+ problems.push(`non-hermetic link '${link}' escapes the bundle in ${rel}`);
169
+ }
170
+ }
171
+ return problems;
172
+ }
173
+
174
+ /**
175
+ * `_harness_build._check_build` — scan one rendered tree for unresolved tokens and bad links.
176
+ *
177
+ * `isVendored` decides which relative shape counts as a vendored folder, and the two callers
178
+ * pass different ones ON PURPOSE: a `files`/opencode-global bundle nests skills at
179
+ * `skills/<name>/…` and a per-repo native layer one level deeper at `.claude/skills/<name>/…`.
180
+ * Vendored folders are verbatim upstream docs carrying their own license, and their internal
181
+ * cross-links point at the upstream project's own files, so they are exempt.
182
+ *
183
+ * `rel` is `str(Path)`, i.e. the platform's separator, because that is what the Python prints
184
+ * — every emit-scan cell in the acceptance matrix names a backslash on Windows.
185
+ */
186
+ export function checkBuild(themeName, out, isVendored = isVendoredPath) {
187
+ const outAbs = pyResolve(out);
188
+ const problems = [];
189
+ for (const md of rglob(outAbs)) {
190
+ if (!md.endsWith('.md') || !isFile(md)) continue;
191
+ const rel = path.relative(outAbs, md);
192
+ if (isVendored(rel)) continue;
193
+ const text = readText(md);
194
+ // `set(TOKEN_RE.findall(text))` — Python iterates the set in ITS order, and the whole
195
+ // list is sorted by `_doctor_collect` before printing, so only uniqueness travels.
196
+ for (const tok of new Set(text.match(TOKEN_RE) ?? [])) {
197
+ problems.push(`[${themeName}] unresolved token ${tok} in ${rel}`);
198
+ }
199
+ for (const p of linkProblems(md, text, outAbs, rel)) problems.push(`[${themeName}] ${p}`);
200
+ }
201
+ return problems;
202
+ }
203
+
204
+ /**
205
+ * `_harness_build._theme_parity_problems` — every theme defines the same VOICE keys.
206
+ *
207
+ * A token present in one theme map and missing from another renders as a raw `{{TOKEN}}`
208
+ * only in the files that use it and only under that theme, so a plain build can miss it
209
+ * entirely. The maps are compared directly, in every mode, whatever `--theme` scoped the rest
210
+ * of the run to.
211
+ */
212
+ export function themeParityProblems() {
213
+ const themes = new Map();
214
+ for (const p of themeFiles()) {
215
+ let doc;
216
+ try { doc = JSON.parse(readText(p)); } catch (e) {
217
+ return [`[themes] ${path.basename(p)} unreadable: ${e.message}`];
218
+ }
219
+ themes.set(stemOf(p), doc);
220
+ }
221
+ if (themes.size < 2) return [];
222
+ const allKeys = new Set();
223
+ for (const t of themes.values()) for (const k of Object.keys(t)) allKeys.add(k);
224
+ const problems = [];
225
+ for (const [name, t] of themes) {
226
+ for (const k of [...allKeys].filter((x) => !has(t, x)).sort()) {
227
+ problems.push(`[themes] '${name}' missing key {${k}} (defined in another theme)`);
228
+ }
229
+ }
230
+ return problems;
231
+ }
232
+
233
+ /** `_harness_build._HEX_RE`. Anchored at BOTH ends, so `#12345` and `#1122334` both fail. */
234
+ const HEX_RE = /^#[0-9a-fA-F]{6}$/;
235
+
236
+ /**
237
+ * `_harness_build._color_theme_problems` — every curated colour theme carries the full
238
+ * palette, in 6-digit hex. Voice-theme parity, for colours.
239
+ *
240
+ * TWO OF ITS THREE ARMS ARE UNREACHABLE FROM ANY CELL, and it is doctor's own ordering that
241
+ * makes them so: the opencode-global emit reads the same files (`writeColorThemes` ->
242
+ * `palette[role]`) and runs BEFORE this check, so a palette that is missing a role, or
243
+ * missing entirely, kills the emit rather than reaching here. The consumer is stricter than
244
+ * the gate. Only the third arm — a value present and not `#rrggbb` — passes the emit through
245
+ * to be reported.
246
+ */
247
+ export function colorThemeProblems() {
248
+ const problems = [];
249
+ for (const p of colorThemeFiles(makeCfg())) {
250
+ let spec;
251
+ try { spec = JSON.parse(readText(p)); } catch (e) {
252
+ problems.push(`[colors] ${path.basename(p)} unreadable: ${e.message}`);
253
+ continue;
254
+ }
255
+ const pal = spec.palette;
256
+ if (pal === null || typeof pal !== 'object' || Array.isArray(pal)) {
257
+ problems.push(`[colors] ${path.basename(p)} has no 'palette' object`);
258
+ continue;
259
+ }
260
+ for (const role of [...PALETTE_ROLES].filter((r) => !has(pal, r)).sort()) {
261
+ problems.push(`[colors] '${stemOf(p)}' palette missing role '${role}'`);
262
+ }
263
+ for (const [role, val] of Object.entries(pal)) {
264
+ if (!(typeof val === 'string' && HEX_RE.test(val))) {
265
+ problems.push(`[colors] '${stemOf(p)}' role '${role}' is not #rrggbb hex: ${pyRepr(val)}`);
266
+ }
267
+ }
268
+ }
269
+ return problems;
270
+ }
271
+
272
+ /**
273
+ * `_harness_build._rendered_problems` — a committed bundle must match a fresh render of
274
+ * `src/` for its OWN recorded theme, emit and footprint.
275
+ *
276
+ * Doctor's temp builds never touch the committed bundle, so drift there is invisible to
277
+ * every other check. All three values are read back off the bundle's own markers rather than
278
+ * assumed, and each for the same reason: assuming one reports every file as drifted the
279
+ * moment that default moves, which is a diagnosis about this function rather than about the
280
+ * bundle. The emit marker is the one that took longest to learn — a host that catalogues
281
+ * capabilities to the model itself gets AGENT.md's tables collapsed to a pointer, and the
282
+ * OpenCode emits additionally strip the per-row spec links, so rendering the portable shape
283
+ * and comparing it against an OpenCode bundle reports AGENT.md stale on EVERY run with no
284
+ * rebuild able to clear it.
285
+ *
286
+ * Host-state files (context.json, MEMORY.md, the markers) are created once and never
287
+ * rendered, so they are not in the render set and are correctly ignored. Notebook files
288
+ * except its `.gitignore` are seed-once and agent-owned after the first build, so they are
289
+ * compared only for EXISTENCE.
290
+ */
291
+ export function renderedProblems(bundle) {
292
+ if (!isDir(bundle)) return [];
293
+ const cfg = makeCfg();
294
+ const marker = path.join(bundle, '.geneseed-theme');
295
+ let themeName;
296
+ if (existsSync(marker)) themeName = readText(marker).trim();
297
+ else if (existsSync(CONFIG)) themeName = parseJson(readText(CONFIG))?.theme ?? 'neutral';
298
+ else themeName = 'neutral';
299
+ let emit;
300
+ try { emit = readText(path.join(bundle, '.geneseed-emit')).trim(); } catch { emit = ''; }
301
+ const host = (EMIT_HOST_SCOPE.get(emit) ?? ['', ''])[0];
302
+ let items;
303
+ try {
304
+ // THE FIRST CALLER IN THE PORT THAT CATCHES A REFUSAL, and that is why the stderr is
305
+ // buffered. `sys.exit(msg)` attaches the message to the EXCEPTION and the interpreter
306
+ // prints it on the way out, so a Python caller that catches SystemExit sees no output at
307
+ // all; this port's convention is to write at the raise site (`loadTheme`, and its
308
+ // docblock says why), which is identical for every caller that lets the throw propagate
309
+ // and wrong for exactly this one. Buffered and replayed on success, so a WARN the render
310
+ // legitimately emits still reaches the user, and discarded with the refusal it belongs to.
311
+ ({ items } = withDiscardableStderr(() => renderAll(cfg, themeName, {
312
+ footprint: footprintOfDir(bundle), nativeCatalog: hostCatalogsNatively(host),
313
+ })));
314
+ } catch (e) {
315
+ // `except SystemExit` — `effectiveTheme` refuses an unknown theme, and the driver's
316
+ // marker for a deliberate refusal is the `exitCode` this port already uses everywhere.
317
+ if (e && e.exitCode !== undefined) {
318
+ return [`[rendered] cannot render theme '${themeName}' for ${path.basename(bundle)}/`];
319
+ }
320
+ throw e;
321
+ }
322
+ const problems = [];
323
+ const nbDirname = STRUCTURE.DIR_NOTEBOOK ?? 'notebook';
324
+ for (const { rel: outRel, text: rendered, src } of items) {
325
+ const dest = path.join(bundle, outRel);
326
+ const parts = outRel.split('/');
327
+ const name = path.basename(bundle);
328
+ if (!existsSync(dest)) {
329
+ problems.push(`[rendered] ${name}/${outRel} missing — rebuild the bundle`);
330
+ } else if (parts[0] === nbDirname && parts[parts.length - 1] !== '.gitignore') {
331
+ continue; // seed-once, agent-owned: a rewrite is not drift
332
+ } else if (rendered !== null) {
333
+ // The OpenCode emits de-link AGENT.md's per-row table entries after rendering, so the
334
+ // bundle on disk legitimately differs from `renderAll`'s output for this one file.
335
+ const text = host === 'opencode' && outRel === 'AGENT.md'
336
+ ? stripCapabilityLinks(cfg, rendered) : rendered;
337
+ if (readText(dest) !== text) {
338
+ problems.push(`[rendered] ${name}/${outRel} stale (differs from a fresh render) `
339
+ + '— rebuild');
340
+ }
341
+ } else if (!readFileSync(dest).equals(readFileSync(src))) {
342
+ problems.push(`[rendered] ${name}/${outRel} stale — rebuild`);
343
+ }
344
+ }
345
+ return problems;
346
+ }
347
+
348
+ /** `_harness_build._src_stems` — spec stems under `src/<folder>`, minus `_`-scaffolds. */
349
+ function srcStems(folder) {
350
+ const d = path.join(SRC, folder);
351
+ if (!isDir(d)) return new Set();
352
+ return new Set(globSorted(d, (n) => n.endsWith('.md') && !n.startsWith('_')).map(stemOf));
353
+ }
354
+
355
+ /**
356
+ * `_harness_build._registry_keys` — every entity the registry must describe.
357
+ *
358
+ * The flat agent and skill specs plus the vendored skill FOLDERS, which have no flat spec and
359
+ * are still shipped capabilities. Keyed `<folder>/<stem>`, the shape doctor already prints.
360
+ */
361
+ function registryKeys() {
362
+ const keys = new Set();
363
+ for (const s of srcStems('agents')) keys.add(`agents/${s}`);
364
+ for (const s of srcStems('skills')) keys.add(`skills/${s}`);
365
+ for (const d of VENDORED_SKILL_DIRS) keys.add(`skills/${d}`);
366
+ return keys;
367
+ }
368
+
369
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
370
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
371
+
372
+ // `ENTITY_STATUSES`, `LAW_CLASSES`, `LAW_CLASS` and `SKILL_CLASS` moved to
373
+ // `js/inventory.mjs` in P6c — see the import above. They crossed HERE in P5g because
374
+ // doctor's authoring gates are what validate them, and they belong where Python keeps
375
+ // them (`_harness_tui`) now that the catalog reads them too. The gates below are
376
+ // unchanged, which is what licensed the move: a copy would have stopped being the
377
+ // value under test the first time one of them was edited.
378
+
379
+ /**
380
+ * `_harness_build._registry_problems` — `registry.json` describes exactly what `src/`
381
+ * provides, with well-formed fields.
382
+ *
383
+ * Two-way, like every anti-drift gate here: a new spec with no row and a row whose spec is
384
+ * gone are both errors. Without the second half a shipped skill would show no lifecycle
385
+ * status in the TUI and web catalogs while doctor stayed green. `last_verified` may be empty
386
+ * — nothing writes it yet — but when set it must be a date.
387
+ */
388
+ export function registryProblems() {
389
+ let doc;
390
+ const file = path.join(ROOT, 'registry.json');
391
+ let raw;
392
+ try { raw = readText(file); } catch (e) {
393
+ return [`[authoring] registry.json unreadable: ${e.message}`];
394
+ }
395
+ try { doc = JSON.parse(raw); } catch (e) {
396
+ return [`[authoring] registry.json is not valid JSON: ${e.message}`];
397
+ }
398
+ const entities = doc && typeof doc === 'object' && !Array.isArray(doc) ? doc.entities : null;
399
+ if (entities === null || typeof entities !== 'object' || Array.isArray(entities)) {
400
+ return ["[authoring] registry.json has no 'entities' object"];
401
+ }
402
+ const expected = registryKeys();
403
+ const present = new Set(Object.keys(entities));
404
+ const problems = [...expected].filter((k) => !present.has(k)).sort()
405
+ .map((key) => `[authoring] ${key} has no row in registry.json`);
406
+ problems.push(...[...present].filter((k) => !expected.has(k)).sort()
407
+ .map((key) => `[authoring] registry.json lists '${key}' but no such entity exists`));
408
+ for (const key of [...expected].filter((k) => present.has(k)).sort()) {
409
+ const row = entities[key];
410
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) {
411
+ problems.push(`[authoring] registry.json['${key}'] is not an object`);
412
+ continue;
413
+ }
414
+ // `row.get(...)` is None for an absent key, and `!r` renders that as `None`; a bare JS
415
+ // lookup is `undefined`, which `pyRepr` would not recognise as one.
416
+ if (!ENTITY_STATUSES.includes(row.status)) {
417
+ problems.push(`[authoring] registry.json['${key}'].status ${pyRepr(row.status ?? null)} `
418
+ + `is not one of ${pyRepr(ENTITY_STATUSES)}`);
419
+ }
420
+ if (!SEMVER_RE.test(pyStr(row.version ?? ''))) {
421
+ problems.push(`[authoring] registry.json['${key}'].version `
422
+ + `${pyRepr(row.version ?? null)} is not a semver (N.N.N)`);
423
+ }
424
+ if (!pyStr(row.owner ?? '').trim()) {
425
+ problems.push(`[authoring] registry.json['${key}'] has no owner`);
426
+ }
427
+ for (const field of ['added', 'last_verified']) {
428
+ const value = pyStr(row[field] ?? '');
429
+ if (value && !ISO_DATE_RE.test(value)) {
430
+ problems.push(`[authoring] registry.json['${key}'].${field} ${pyRepr(value)} is `
431
+ + 'not an ISO date (YYYY-MM-DD) or empty');
432
+ }
433
+ }
434
+ }
435
+ return problems;
436
+ }
437
+
438
+ /**
439
+ * `_harness_build._SECRET_PATTERNS` — credential shapes, by the prefix each issuer stamps,
440
+ * plus the generic assignment. Deliberately narrow: a gate that cries wolf gets disabled.
441
+ *
442
+ * `(?i)` becomes the `i` flag on the last one only, which is where the Python puts it.
443
+ */
444
+ const SECRET_PATTERNS = [
445
+ ['Anthropic API key', /sk-ant-[A-Za-z0-9_-]{20,}/],
446
+ ['OpenAI-style API key', /\bsk-[A-Za-z0-9]{32,}/],
447
+ ['GitHub token', /\bgh[pousr]_[A-Za-z0-9]{36}\b/],
448
+ ['AWS access key id', /\bAKIA[0-9A-Z]{16}\b/],
449
+ ['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
450
+ ['private key block', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
451
+ ['assigned credential',
452
+ /\b(password|passwd|secret|api[_-]?key|access[_-]?token)\s*[:=]\s*["'][^"'\s]{8,}["']/i],
453
+ ];
454
+
455
+ /**
456
+ * `_harness_build._secret_problems` — no credential may ship.
457
+ *
458
+ * Sweeps the four trees `sourceFingerprint()` hashes, which is exactly the material that ends
459
+ * up inside a user's bundle. Reports file:line and the KIND only — echoing the matched text
460
+ * would republish the secret into every CI log.
461
+ */
462
+ export function secretProblems() {
463
+ const problems = [];
464
+ for (const root of [SRC, THEMES, PLUGIN_SRC, WORKFLOW_SRC]) {
465
+ if (!isDir(root)) continue;
466
+ for (const p of rglob(root)) {
467
+ if (!isFile(p) || p.split(path.sep).includes('__pycache__')) continue;
468
+ // `except (OSError, UnicodeDecodeError): continue` — binary or unreadable, nothing to
469
+ // scan. `readText` would substitute U+FFFD where Python RAISES, so the strict decode is
470
+ // the check rather than a catch, and the newline folding `Path.read_text` does has to
471
+ // be applied by hand afterwards for the LINE NUMBERS to agree.
472
+ let raw;
473
+ try { raw = readFileSync(p); } catch { continue; }
474
+ let s;
475
+ try { s = new TextDecoder('utf-8', { fatal: true }).decode(raw); } catch { continue; }
476
+ s = s.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
477
+ // `path.relative` returns a `..`-prefixed path where `relative_to` RAISES; the fallback
478
+ // is the Python's, for a tree outside the repo (a test tmpdir).
479
+ const r = path.relative(ROOT, p);
480
+ const rel = (r.startsWith('..') || path.isAbsolute(r) ? p : r)
481
+ .split(path.sep).join('/');
482
+ // `str.splitlines()`, not `split('\n')`: it also breaks on VT, FF, NEL and the two
483
+ // Unicode separators, and the line NUMBER is printed.
484
+ pySplitLines(s).forEach((line, i) => {
485
+ for (const [label, pattern] of SECRET_PATTERNS) {
486
+ if (pattern.test(line)) {
487
+ problems.push(`[authoring] possible ${label} in ${rel}:${i + 1} — a `
488
+ + 'credential must never be committed');
489
+ }
490
+ }
491
+ });
492
+ }
493
+ }
494
+ return problems;
495
+ }
496
+
497
+ /**
498
+ * `_harness_build._shim_problems` — the hook shim exists and points at something real.
499
+ *
500
+ * This gate exists because the shim DISARMED an accidental safety net. When the emitted hook
501
+ * command still carried the checkout path, moving the checkout made that command
502
+ * non-canonical and the next emit's prune-and-rewire repaired every install by itself. Now
503
+ * the config is invariant under a move and the stale path lives in the shim body, which no
504
+ * other gate reads (the build scan only walks `*.md`). Without this a moved checkout leaves
505
+ * every gate silently dead: the hooks still fire, the shim still runs, and the interpreter
506
+ * reports "no such file" into a channel nobody reads.
507
+ *
508
+ * An ABSENT shim is not a problem: a source checkout that has never emitted has no reason to
509
+ * own one.
510
+ */
511
+ export function shimProblems() {
512
+ const p = hookShimPath();
513
+ if (!isFile(p)) return [];
514
+ let body;
515
+ try { body = readText(p); } catch (e) {
516
+ return [`[shim] ${p} exists but cannot be read (${e.message}) — hooks may be dead`];
517
+ }
518
+ // The body is machine-generated and quotes the two baked paths — plus, on POSIX, the argv
519
+ // placeholder `"$@"`, which is not a path. `SHIM_ARGV` is the single owner of that pair.
520
+ const quoted = [...body.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
521
+ return quoted.filter((q) => !SHIM_ARGV.has(q) && !existsSync(q)).map(
522
+ (m) => `[shim] ${p} pointed at ${m}, which does not exist — every hook in every `
523
+ + "install was dead (the checkout most likely moved). This run's own "
524
+ + 'emit has refreshed it; no further action needed.',
525
+ );
526
+ }
527
+
528
+ const HEX40_RE = /^[0-9a-f]{40}$/;
529
+ /** Refs that move under the pin — re-copying months later changes the shipped skill. */
530
+ const MOVING_REFS = new Set(['main', 'master', 'develop', 'dev', 'head', 'latest', 'trunk']);
531
+
532
+ /**
533
+ * `_harness_build._vendor_pin_problems` — every vendored skill folder records where it came
534
+ * from, under which license, and at WHICH immutable commit.
535
+ *
536
+ * First-party folders that merely ride the vendored mechanism for their multi-file layout
537
+ * declare that instead (`**Upstream:** this …`) and are exempt from the pin: there is no
538
+ * upstream to drift against.
539
+ */
540
+ export function vendorPinProblems() {
541
+ const problems = [];
542
+ for (const name of VENDORED_SKILL_DIRS) {
543
+ const folder = path.join(SRC, 'skills', name);
544
+ if (!isDir(folder)) {
545
+ problems.push(`[authoring] VENDORED_SKILL_DIRS lists '${name}' but `
546
+ + `src/skills/${name}/ does not exist`);
547
+ continue;
548
+ }
549
+ let text;
550
+ try { text = readText(path.join(folder, 'VENDOR.md')); } catch {
551
+ problems.push(`[authoring] skills/${name}/ has no VENDOR.md — its upstream, `
552
+ + 'pinned commit and license are unrecorded');
553
+ continue;
554
+ }
555
+ if (!/\*\*License:\*\*/.test(text)) {
556
+ problems.push(`[authoring] skills/${name}/VENDOR.md records no '**License:**'`);
557
+ }
558
+ const upstream = /\*\*Upstream:\*\*\s*(\S+)/.exec(text);
559
+ if (!upstream) {
560
+ problems.push(`[authoring] skills/${name}/VENDOR.md declares no '**Upstream:**'`);
561
+ continue;
562
+ }
563
+ if (upstream[1].toLowerCase().startsWith('this')) continue; // first-party
564
+ const pin = /\*\*Commit:\*\*\s*(\S+)/.exec(text);
565
+ if (!pin) {
566
+ problems.push(`[authoring] skills/${name}/VENDOR.md has no '**Commit:**' pin`);
567
+ } else if (MOVING_REFS.has(pin[1].toLowerCase())) {
568
+ problems.push(`[authoring] skills/${name}/VENDOR.md pins '${pin[1]}', a `
569
+ + 'moving branch — record the commit sha instead');
570
+ } else if (!HEX40_RE.test(pin[1].toLowerCase())) {
571
+ problems.push(`[authoring] skills/${name}/VENDOR.md pin '${pin[1]}' is not `
572
+ + 'a 40-character commit sha');
573
+ }
574
+ }
575
+ return problems;
576
+ }
577
+
578
+ const ROMAN_VALUES = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 };
579
+
580
+ /**
581
+ * `_harness_build._roman_to_int`. Law headings number in Roman and the web ledger keys its
582
+ * per-rule copy by the Arabic equivalent, so the gate has to bridge the two. 0 on an
583
+ * unparseable numeral, which surfaces as a gate problem rather than a crash.
584
+ */
585
+ export function romanToInt(num) {
586
+ let total = 0;
587
+ let prev = 0;
588
+ for (const ch of [...num.toUpperCase()].reverse()) {
589
+ const v = ROMAN_VALUES[ch];
590
+ if (v === undefined) return 0;
591
+ total += v < prev ? -v : v;
592
+ prev = Math.max(prev, v);
593
+ }
594
+ return total;
595
+ }
596
+
597
+ /**
598
+ * `_harness_build._LAW_META_ROW` — `<arabic>: ['<class>', '<principle>'],` in the web page.
599
+ *
600
+ * Either quote style, because a principle carrying an apostrophe is double-quoted. Every
601
+ * `\s*` spans newlines and the trailing comma before `]` is optional, so a row a prettier has
602
+ * reflowed across several lines still matches — a gate that only understood the one-line form
603
+ * silently lost it and left the law's Principle unguarded.
604
+ *
605
+ * `(?m)` is the `m` flag; JS has no inline form. The `.` inside the captures must NOT match a
606
+ * newline (Python has no `re.S` here), which is JS's default.
607
+ */
608
+ const LAW_META_ROW = /^[^\S\n]*(\d+):\s*\[\s*(['"])(.*?)\2\s*,\s*(['"])(.*?)\4\s*,?\s*\]\s*,?[^\S\n]*$/gm;
609
+
610
+ /**
611
+ * `_harness_build._law_meta_problems` — the web Laws ledger's per-rule copy stays complete.
612
+ *
613
+ * `LAW_META` holds each law's one-line Principle, copy that exists nowhere else in the tree,
614
+ * and a law with no row falls back to `['craft', '']`: the rule renders with a blank
615
+ * description and the wrong class chip, while everything upstream is fine. That is exactly
616
+ * how two laws shipped description-less.
617
+ */
618
+ export function lawMetaProblems(lawNums, lawClass, lawClasses) {
619
+ const page = path.join(ROOT, 'web', 'src', 'pages', 'Laws.jsx');
620
+ let text;
621
+ try { text = readText(page); } catch (e) {
622
+ return [`[authoring] web/src/pages/Laws.jsx unreadable: ${e.message}`];
623
+ }
624
+ const block = /^const LAW_META = \{$([\s\S]*?)^\}$/m.exec(text);
625
+ if (!block) {
626
+ return ['[authoring] LAW_META literal not found in web/src/pages/Laws.jsx — the web '
627
+ + 'Laws ledger\'s Principle column would have no gate'];
628
+ }
629
+ const meta = new Map();
630
+ for (const m of block[1].matchAll(LAW_META_ROW)) {
631
+ meta.set(Number(m[1]), [m[3], m[5]]);
632
+ }
633
+ const problems = [];
634
+ const seen = new Set();
635
+ for (const roman of lawNums) {
636
+ const n = romanToInt(roman);
637
+ seen.add(n);
638
+ if (!meta.has(n)) {
639
+ problems.push(`[authoring] laws/universal.md rule ${roman} (${n}) has no row in `
640
+ + 'LAW_META (web/src/pages/Laws.jsx) — the web Laws ledger would '
641
+ + 'render it with a blank Principle');
642
+ continue;
643
+ }
644
+ const [klass, principle] = meta.get(n);
645
+ if (!principle.trim()) {
646
+ problems.push(`[authoring] LAW_META[${n}] has an empty principle line — rule `
647
+ + `${roman} would render with a blank description`);
648
+ }
649
+ if (!lawClasses.includes(klass)) {
650
+ problems.push(`[authoring] LAW_META[${n}] class '${klass}' is not a known class `
651
+ + `${pyRepr(lawClasses)}`);
652
+ } else if (lawClass[roman] && klass !== lawClass[roman]) {
653
+ problems.push(`[authoring] LAW_META[${n}] classes rule ${roman} as '${klass}' but `
654
+ + `LAW_CLASS says '${lawClass[roman]}'`);
655
+ }
656
+ }
657
+ for (const n of [...meta.keys()].filter((k) => !seen.has(k)).sort((a, b) => a - b)) {
658
+ problems.push(`[authoring] LAW_META lists rule ${n} but laws/universal.md has no such rule`);
659
+ }
660
+ return problems;
661
+ }
662
+
663
+ /**
664
+ * `_harness_build._prose_mirror_problems` — the human-readable count mirrors, the ones the
665
+ * badge regex never sees.
666
+ *
667
+ * The README "What you get" table and the web onboarding copy each restate the law / agent /
668
+ * skill counts in prose, and the README enumerates the skills by name. Nothing renders these,
669
+ * so they drift silently — they already had. Pure over its inputs, so the corpus can feed it
670
+ * crafted drift without touching the tree.
671
+ */
672
+ export function proseMirrorProblems(readme, web, counts, skillStems, shipped = '') {
673
+ const problems = [];
674
+ const { laws, agents, skills, plugins } = counts;
675
+
676
+ for (const m of readme.matchAll(/(\d+) universal laws/g)) {
677
+ if (Number(m[1]) !== laws) {
678
+ problems.push(`[authoring] README prose says '${m[1]} universal laws' but src has ${laws}`);
679
+ }
680
+ }
681
+ // The README plugin count. It had drifted — the overview sentence said six while seven
682
+ // shipped — because the project's other plugin count ({N_PLUGINS} in the web docs) reads
683
+ // a DEPLOYED plugins dir that a markdown file in this repository never has. `plugins` is
684
+ // optional in `counts` (Python's `.get`, undefined here) because the corpus predates it.
685
+ if (plugins !== undefined) {
686
+ for (const m of readme.matchAll(/(\d+) \*{0,2}plugins/g)) {
687
+ if (Number(m[1]) !== plugins) {
688
+ problems.push(`[authoring] README prose says '${m[1]} plugins' but `
689
+ + `adapters/opencode/plugins has ${plugins}`);
690
+ }
691
+ }
692
+ }
693
+ for (const [label, want] of [['Agents', agents], ['Skills', skills]]) {
694
+ const m = new RegExp(`${label}\\*\\*\\s*\\((\\d+)\\)`).exec(readme);
695
+ if (m && Number(m[1]) !== want) {
696
+ problems.push(`[authoring] README '${label} (${m[1]})' but src has ${want}`);
697
+ }
698
+ }
699
+ // The README Skills row must enumerate EXACTLY the source skills — a dropped name is
700
+ // invisible to the (N) count alone, which is the off-by-one this gate forbids.
701
+ const row = readme.split('\n').find((ln) => ln.includes('🛠') && ln.includes('Skills')) ?? '';
702
+ if (row.includes('workflows:')) {
703
+ const listed = new Set(row.split('workflows:').slice(1).join('workflows:').split('·')
704
+ .map((seg) => seg.replace(/[^a-z0-9-]/g, '')));
705
+ listed.delete('');
706
+ for (const missing of [...skillStems].filter((s) => !listed.has(s)).sort()) {
707
+ problems.push(`[authoring] README skills list omits '${missing}'`);
708
+ }
709
+ for (const orphan of [...listed].filter((s) => !skillStems.has(s)).sort()) {
710
+ problems.push(`[authoring] README skills list names '${orphan}' but no `
711
+ + `skills/${orphan}.md exists`);
712
+ }
713
+ }
714
+
715
+ for (const m of web.matchAll(/(\d+) universal (?:laws|Rules)/g)) {
716
+ if (Number(m[1]) !== laws) {
717
+ problems.push(`[authoring] _web_core prose says '${m[1]} universal laws/Rules' but `
718
+ + `src has ${laws}`);
719
+ }
720
+ }
721
+ for (const m of web.matchAll(/(\d+) capability specialists/g)) {
722
+ if (Number(m[1]) !== agents) {
723
+ problems.push(`[authoring] _web_core prose says '${m[1]} capability specialists' but `
724
+ + `src has ${agents}`);
725
+ }
726
+ }
727
+ // N is a curated-subset size, not the total, so it is gated against the wikilinks it
728
+ // introduces rather than against the skill count. `[\s\S]` is Python's `re.S` dot.
729
+ const m = /(\d+) repeatable workflows the agent can invoke by name([\s\S]*?)playbook under/
730
+ .exec(web);
731
+ if (m) {
732
+ const listedN = (m[2].match(/\[\[[^\]]+\]\]/g) ?? []).length;
733
+ if (Number(m[1]) !== listedN) {
734
+ problems.push(`[authoring] _web_core says '${m[1]} repeatable workflows' `
735
+ + `but its wikilink list has ${listedN}`);
736
+ }
737
+ }
738
+
739
+ const s = /(\d+) laws, (\d+) agents, (\d+) skills/.exec(shipped);
740
+ if (s) {
741
+ for (const [got, want, label] of [[s[1], laws, 'laws'], [s[2], agents, 'agents'],
742
+ [s[3], skills, 'skills']]) {
743
+ if (Number(got) !== want) {
744
+ problems.push(`[authoring] SHIPPED.md says '${got} ${label}' but src has ${want}`);
745
+ }
746
+ }
747
+ }
748
+ return problems;
749
+ }
750
+
751
+ /** `_build_render._TMPL_SPEC_RE`, as the count gate spells it. */
752
+ const TMPL_SPEC_RE = /\{\{DIR_(AGENTS|SKILLS)\}\}\/([A-Za-z0-9_-]+)\.md/g;
753
+ const LAW_HEADING_RE = /^### \{\{LAW\}\} ([IVXLCDM]+)\b/gm;
754
+
755
+ /**
756
+ * `_harness_build._count_table_problems` — the hand-authored AGENT.md tables and the README
757
+ * badges, against `src/`.
758
+ *
759
+ * The tables must list EXACTLY the spec files (no dead row, no orphaned spec) and each
760
+ * `agents`/`skills`/`laws`/`themes` badge must equal the real count. This is the
761
+ * authoring-time guarantee that lets tables, badges and prose stay hand-written without
762
+ * silently drifting from the source tree.
763
+ */
764
+ export function countTableProblems() {
765
+ const problems = [];
766
+ let ttext;
767
+ try { ttext = readText(path.join(SRC, 'AGENT.md.tmpl')); } catch (e) {
768
+ return [`[authoring] AGENT.md.tmpl unreadable: ${e.message}`];
769
+ }
770
+
771
+ const linked = { agents: new Set(), skills: new Set() };
772
+ for (const m of ttext.matchAll(TMPL_SPEC_RE)) {
773
+ if (m[2] !== '_template') linked[m[1] === 'AGENTS' ? 'agents' : 'skills'].add(m[2]);
774
+ }
775
+ for (const folder of ['agents', 'skills']) {
776
+ const files = srcStems(folder);
777
+ for (const missing of [...linked[folder]].filter((x) => !files.has(x)).sort()) {
778
+ problems.push(`[authoring] AGENT.md links ${folder}/${missing}.md but no such spec exists`);
779
+ }
780
+ for (const orphan of [...files].filter((x) => !linked[folder].has(x)).sort()) {
781
+ problems.push(`[authoring] ${folder}/${orphan}.md exists but the AGENT.md table omits it`);
782
+ }
783
+ }
784
+
785
+ const skillFiles = srcStems('skills');
786
+ for (const missing of [...skillFiles].filter((s) => !has(SKILL_CLASS, s)).sort()) {
787
+ problems.push(`[authoring] skills/${missing}.md has no category in SKILL_CLASS`);
788
+ }
789
+ for (const stale of Object.keys(SKILL_CLASS).filter((s) => !skillFiles.has(s)).sort()) {
790
+ problems.push(`[authoring] SKILL_CLASS lists '${stale}' but no skills/${stale}.md exists`);
791
+ }
792
+
793
+ const lawsMd = path.join(SRC, 'laws', 'universal.md');
794
+ const lawNums = isFile(lawsMd)
795
+ ? [...readText(lawsMd).matchAll(LAW_HEADING_RE)].map((m) => m[1]) : [];
796
+ for (const num of lawNums) {
797
+ if (!has(LAW_CLASS, num)) {
798
+ problems.push(`[authoring] laws/universal.md rule ${num} has no class in LAW_CLASS`);
799
+ }
800
+ }
801
+ // `sorted(LAW_CLASS.items())` — by the Roman numeral as a STRING, which is what Python
802
+ // sorts here; the messages are re-sorted by `doctorCollect` anyway, so only the set travels.
803
+ for (const num of Object.keys(LAW_CLASS).sort()) {
804
+ if (!LAW_CLASSES.includes(LAW_CLASS[num])) {
805
+ problems.push(`[authoring] LAW_CLASS['${num}'] = '${LAW_CLASS[num]}' is not a known `
806
+ + `class ${pyRepr(LAW_CLASSES)}`);
807
+ }
808
+ }
809
+ problems.push(...lawMetaProblems(lawNums, LAW_CLASS, LAW_CLASSES));
810
+
811
+ const counts = {
812
+ agents: srcStems('agents').size,
813
+ skills: srcStems('skills').size,
814
+ laws: lawNums.length,
815
+ themes: themeFiles().length,
816
+ // Last on purpose: the badge loop below walks this object in insertion order and the
817
+ // message sequence is compared byte for byte against the Python implementation.
818
+ plugins: existsSync(PLUGIN_SRC)
819
+ ? readdirSync(PLUGIN_SRC).filter((f) => f.startsWith('geneseed-') && f.endsWith('.js')).length
820
+ : 0,
821
+ };
822
+ let readme;
823
+ try { readme = readText(path.join(ROOT, 'README.md')); } catch { return problems; }
824
+ for (const [key, n] of Object.entries(counts)) {
825
+ const m = new RegExp(`badge/${key}-(\\d+)`).exec(readme);
826
+ if (m && Number(m[1]) !== n) {
827
+ problems.push(`[authoring] README ${key} badge says ${m[1]} but src has ${n}`);
828
+ }
829
+ }
830
+
831
+ // WHERE THE ONBOARDING COPY LIVES, AND WHY THIS READ MOVED. It used to open the one module
832
+ // that held the web console's onboarding prose. That prose has since moved into
833
+ // `docs/web/*.md`, and the counts in it render from `{N_LAWS}` / `{N_AGENTS}` / `{N_SKILLS}`
834
+ // — so the read was still succeeding against a file the sentences had left, all three arms
835
+ // below scored zero, and the check looked healthy while gating nothing. A templated count
836
+ // cannot drift; what these arms still catch is a maintainer typing the NUMBER into a page
837
+ // instead of the token, which is the drift that reaches a reader.
838
+ //
839
+ // MEASURED BEFORE AND AFTER: zero problems either way on the shipped tree, so no recorded
840
+ // byte moves. `web` stays fail-soft — a missing docs tree is not an authoring fault.
841
+ let web = '';
842
+ try {
843
+ web = globSorted(path.join(ROOT, 'docs', 'web'), (n) => n.endsWith('.md'))
844
+ .map(readText).join('\n');
845
+ } catch { web = ''; }
846
+ let shipped;
847
+ try { shipped = readText(path.join(ROOT, 'SHIPPED.md')); } catch { shipped = ''; }
848
+ problems.push(...proseMirrorProblems(readme, web, counts, skillFiles, shipped));
849
+ return problems;
850
+ }
851
+
852
+ /** `_harness_core.LEARN_PROMPT_HEAD`'s extraction regex — one owner, two readers. */
853
+ const LEARN_PROMPT_RE = /const LEARN_PROMPT_HEAD = `([\s\S]*?)`/;
854
+
855
+ /**
856
+ * `_harness_core._load_learn_prompt_head` — the distil instructions, read out of the plugin.
857
+ *
858
+ * The single source of truth is the OpenCode plugin, the artifact that ships to the primary
859
+ * runtime, so the CLI extracts it at load time rather than carrying a copy. Reproduced here
860
+ * rather than imported from `js/hooks.mjs`, which this entry may not reach: `learn` spawns the
861
+ * model CLI, and the allow-list below names ONE call site.
862
+ *
863
+ * The fallback matters as much as the extraction. It is what makes the drift arm of
864
+ * `_authoring_problems` unreachable — both the loaded copy and the checked literal come from
865
+ * one file through one regex, so a reference can never disagree with itself. A port that
866
+ * hardcoded the prompt WOULD disagree, which is what
867
+ * `doctor/the-loaded-copy-follows-the-plugin-rather-than-a-constant` exists to catch.
868
+ */
869
+ function loadLearnPromptHead() {
870
+ try {
871
+ const m = LEARN_PROMPT_RE.exec(readText(path.join(PLUGIN_SRC, 'geneseed-learn.js')));
872
+ if (m) return m[1];
873
+ } catch { /* OSError — fall through */ }
874
+ return 'Distil at most one durable, reusable memory from the notes below. '
875
+ + 'When in doubt, output exactly: NOTHING.';
876
+ }
877
+
878
+ /**
879
+ * `_harness_build._authoring_problems` — author-time gates on the source specs and plugins.
880
+ *
881
+ * Every agent/skill spec must carry a one-line `>` purpose blockquote as the FIRST content
882
+ * block after its title, or its `description:` on every host either renders empty or silently
883
+ * picks up the WRONG line; the learn-prompt literal must stay extractable; and, if node is on
884
+ * PATH, the plugins must pass `node --check`. Then three source-wide gates: the lifecycle
885
+ * registry describes exactly what `src/` provides, no credential ships, and every vendored
886
+ * folder carries an immutable upstream pin.
887
+ */
888
+ export function authoringProblems() {
889
+ const problems = [];
890
+ for (const folder of ['agents', 'skills']) {
891
+ const d = path.join(SRC, folder);
892
+ if (!isDir(d)) continue;
893
+ for (const spec of globSorted(d, (n) => n.endsWith('.md') && !n.startsWith('_'))) {
894
+ let text;
895
+ try { text = readText(spec); } catch (e) {
896
+ problems.push(`[authoring] ${folder}/${path.basename(spec)} unreadable: ${e.message}`);
897
+ continue;
898
+ }
899
+ if (!firstBlockquote(text)) {
900
+ problems.push(`[authoring] ${folder}/${path.basename(spec)} has no '>' purpose line `
901
+ + '(its OpenCode description would render empty)');
902
+ continue;
903
+ }
904
+ const reason = descBlockProblem(text);
905
+ if (reason) {
906
+ problems.push(`[authoring] ${folder}/${path.basename(spec)}: ${reason} — `
907
+ + 'desc_of() would silently extract the wrong description');
908
+ }
909
+ }
910
+ }
911
+ const plugin = path.join(PLUGIN_SRC, 'geneseed-learn.js');
912
+ let m = null;
913
+ try { m = LEARN_PROMPT_RE.exec(readText(plugin)); } catch { m = null; }
914
+ if (!m) {
915
+ // "the harness", not `harness.py`: both implementations load this literal, and the file
916
+ // the old wording named is the one this migration deletes. Moved on both sides at once —
917
+ // these two strings are byte-compared by the live doctor comparison.
918
+ problems.push('[authoring] LEARN_PROMPT_HEAD literal not found in '
919
+ + 'geneseed-learn.js — the harness would fall back (single source broken)');
920
+ } else if (m[1] !== loadLearnPromptHead()) {
921
+ problems.push('[authoring] LEARN_PROMPT_HEAD drifted between geneseed-learn.js '
922
+ + "and the harness's loaded copy");
923
+ }
924
+ const node = pyWhich('node');
925
+ if (node) {
926
+ for (const js of globSorted(PLUGIN_SRC, (n) => n.endsWith('.js'))) {
927
+ // THE ONE SPAWN. `node --check` and nothing else — see the module header, and
928
+ // `test_the_cli_spawns_only_a_node_syntax_check`, which names this argv.
929
+ // `NO_WINDOW` because the reference's `run()` folds `CREATE_NO_WINDOW` into every
930
+ // CAPTURING spawn and this is one — and because this loop is the burst a user sees:
931
+ // one console window per plugin, every time the web daemon runs the doctor.
932
+ const r = spawnSync(node, ['--check', js], { encoding: 'utf-8', ...NO_WINDOW });
933
+ if (r.status !== 0) {
934
+ // `(stderr.strip().splitlines() or ["syntax error"])[-1]` — node's LAST line, which
935
+ // is its version banner rather than the SyntaxError. Faithful, and surprising enough
936
+ // that the cell asserts the prefix and never the version.
937
+ const lines = (r.stderr ?? '').trim().split('\n').filter((x) => x !== '');
938
+ const tail = lines.length ? lines[lines.length - 1] : 'syntax error';
939
+ problems.push(`[authoring] node --check failed for ${path.basename(js)}: ${tail}`);
940
+ }
941
+ }
942
+ }
943
+ problems.push(...registryProblems());
944
+ problems.push(...secretProblems());
945
+ problems.push(...vendorPinProblems());
946
+ problems.push(...countTableProblems());
947
+ return problems;
948
+ }
949
+
950
+ /**
951
+ * `_harness_build._themes_to_check` — which themes doctor validates.
952
+ *
953
+ * An explicit `--theme` wins. Otherwise, unless `--all` forces the maintainer sweep, scope to
954
+ * the theme THIS host installed so a one-theme user is not buried under the same problem
955
+ * echoed across all fourteen. Falls back to the full sweep when nothing is installed (a fresh
956
+ * clone) or the detected theme is unknown, so a maintainer in a clean checkout still gets
957
+ * full coverage. Pure, and gated as such: `--all` is a 14-theme sweep no cell can afford.
958
+ */
959
+ export function themesToCheck(theme, allThemes, detected, available) {
960
+ if (theme) return [theme];
961
+ if (!allThemes && detected && available.includes(detected)) return [detected];
962
+ return [...available].sort();
963
+ }
964
+
965
+ /** `tempfile.TemporaryDirectory()` — created, handed to `fn`, removed whatever happens. */
966
+ function withTempDir(fn, prefix = 'geneseed-doctor-') {
967
+ const tmp = mkdtempSync(path.join(os.tmpdir(), prefix));
968
+ try { return fn(tmp); } finally { rmSync(tmp, { recursive: true, force: true }); }
969
+ }
970
+
971
+ /**
972
+ * `contextlib.redirect_stdout(io.StringIO())` — swallow an emit's log, and ONLY stdout.
973
+ *
974
+ * The Python redirects stdout and leaves stderr alone, so a WARN from the emit still reaches
975
+ * the user. Reproducing that exactly is the point: swallowing both would hide
976
+ * `warnBobGlobalOverProject` and swallowing neither would put ~200 lines of emit summary into
977
+ * doctor's output. `withPyNewlines` inside `emitGlobalInto` wraps whatever `write` is
978
+ * installed at call time, so it translates into this buffer and the bytes never leave.
979
+ */
980
+ function swallowStdout(fn) {
981
+ const real = process.stdout.write.bind(process.stdout);
982
+ process.stdout.write = () => true;
983
+ try { return fn(); } finally { process.stdout.write = real; }
984
+ }
985
+
986
+ /**
987
+ * `_harness_build._global_emit_problems` — validate the opencode-global emit, in BOTH
988
+ * footprints.
989
+ *
990
+ * The RECOMMENDED install, and otherwise a doctor blind spot: the files build and ./Harness
991
+ * were checked and the global layout never was. The two footprints produce genuinely
992
+ * different AGENT.md bodies — lean swaps the inlined laws for a digest plus a pointer and
993
+ * ships a standalone laws file the full emit does not write — so checking one leaves the
994
+ * other's links and tokens unvalidated, and lean being the default made that the shape most
995
+ * installs actually run.
996
+ */
997
+ export function globalEmitProblems(themeName) {
998
+ const problems = [];
999
+ for (const footprint of ['lean', 'full']) {
1000
+ withTempDir((tmp) => {
1001
+ const cfgDir = path.join(tmp, 'cfg');
1002
+ try {
1003
+ swallowStdout(() => emitGlobalInto('opencode', {
1004
+ theme: themeName, out: path.join(tmp, 'bundle'), cfgDir, footprint,
1005
+ }));
1006
+ } catch (e) {
1007
+ if (e && e.exitCode !== undefined) {
1008
+ problems.push(`[${themeName} global/${footprint}] build failed`);
1009
+ return;
1010
+ }
1011
+ throw e;
1012
+ }
1013
+ problems.push(...checkBuild(`${themeName} global/${footprint}`, cfgDir));
1014
+ });
1015
+ }
1016
+ return problems;
1017
+ }
1018
+
1019
+ /**
1020
+ * `_harness_build._claude_bob_emit_problems` — validate the claude/bob/copilot PER-REPO
1021
+ * emits, which were never checked before.
1022
+ *
1023
+ * That is exactly why the CLAUDE.md/AGENTS.md skill-table dead links shipped unnoticed: the
1024
+ * emits that render straight into a repo were outside doctor's sweep. Scanned with
1025
+ * `validateIsVendored` rather than `isVendoredPath`, because the native per-repo layer nests
1026
+ * skills one level deeper than a bundle does.
1027
+ */
1028
+ export function claudeBobEmitProblems(themeName) {
1029
+ const problems = [];
1030
+ for (const label of ['claude', 'bob', 'copilot']) {
1031
+ withTempDir((tmp) => {
1032
+ const root = path.join(tmp, 'root');
1033
+ try {
1034
+ swallowStdout(() => emitProjectInto(label, {
1035
+ theme: themeName, out: path.join(root, 'bundle'), root,
1036
+ }));
1037
+ } catch (e) {
1038
+ if (e && e.exitCode !== undefined) {
1039
+ problems.push(`[${themeName} ${label}] build failed`);
1040
+ return;
1041
+ }
1042
+ throw e;
1043
+ }
1044
+ problems.push(...checkBuild(`${themeName} ${label}`, root, validateIsVendored));
1045
+ });
1046
+ }
1047
+ return problems;
1048
+ }
1049
+
1050
+ /**
1051
+ * `_harness_build._doctor_collect` — run every check; return `[themes, problems]`.
1052
+ *
1053
+ * TWO PARAMETERS OF THE PYTHON ARE NOT HERE, and both are absences on purpose. `on_progress`
1054
+ * is the TUI's — it drags ~140 lines of curses drawing behind it and no caller this binary
1055
+ * has can supply one. `groups` is the WEB's, the structured `{check, label, problems}` view
1056
+ * the Doctor page renders, and `_web_actions.py` / `_web_core.py` are its only callers; both
1057
+ * stay Python until P6. Four lines of accumulator with no caller and no cell is unreachable
1058
+ * code that is not part of an asserted partition, which is this port's own criterion for
1059
+ * deleting rather than keeping. P6 adds it with the caller that needs it.
1060
+ *
1061
+ * THE PER-THEME BUILD IS THE DRIVER, IN-PROCESS. The Python shells to `build.py` and reads a
1062
+ * return code; here that is `driverMain`, the route P5e established and the only one the
1063
+ * `child_process` allow-list leaves. `cwd=ROOT` does not travel in-process and does not have
1064
+ * to: `--out` is an absolute path, which is the one input `resolveOut` would have read the
1065
+ * working directory for.
1066
+ */
1067
+ export function doctorCollect({
1068
+ theme = null, allThemes = false, bundle = null, noBundle = false, groups = null,
1069
+ } = {}) {
1070
+ const available = themeFiles().map(stemOf);
1071
+ if (!available.length) return [[], ['[doctor] no themes found']];
1072
+
1073
+ // `_ran` — the label is the one place each check is NAMED, and since P6b a caller can
1074
+ // pass an array to collect them. `/api/doctor` renders one card per entry, which is the
1075
+ // only reason the structured view exists; `cmd_doctor` passes nothing and gets the flat
1076
+ // list, so the return contract is unchanged either way. `on_progress` is still P7's.
1077
+ const ran = (check, label, probs) => {
1078
+ if (groups !== null) groups.push({ check, label, problems: sortedProblems(probs) });
1079
+ return probs;
1080
+ };
1081
+
1082
+ // Only probe the deployed install when we actually need it (no theme / not --all).
1083
+ const detected = (theme || allThemes) ? null : (installedDefaults().theme ?? null);
1084
+ const themes = themesToCheck(theme, allThemes, detected, available);
1085
+ // Sampled HERE, before the emit loop below — every emit rewrites the hook shim, so a check
1086
+ // placed after them could only ever observe the freshly repaired file and would be dead
1087
+ // code that always reports clean. Reported in its usual slot further down.
1088
+ const shimProbs = shimProblems();
1089
+ let problems = [];
1090
+ withTempDir((tmp) => {
1091
+ for (const themeName of themes) {
1092
+ const out = path.join(tmp, themeName);
1093
+ // capture_output=True: BOTH streams, unlike the emits below, which redirect stdout
1094
+ // only. A failing build's stderr is not doctor's output either — the return code is.
1095
+ const rc = captureBoth(() => driverMain(['--theme', themeName, '--out', out]));
1096
+ if (rc !== 0) {
1097
+ problems = problems.concat(ran('build', `Build scan (${themeName})`,
1098
+ [`[${themeName}] build failed`]));
1099
+ continue;
1100
+ }
1101
+ problems = problems.concat(ran('build', `Build scan (${themeName})`,
1102
+ checkBuild(themeName, out)));
1103
+ problems = problems.concat(ran('global', `Global install (${themeName})`,
1104
+ globalEmitProblems(themeName)));
1105
+ problems = problems.concat(ran('claude_bob',
1106
+ `Claude/Bob/Copilot per-repo emit (${themeName})`, claudeBobEmitProblems(themeName)));
1107
+ }
1108
+ });
1109
+ problems = problems.concat(ran('parity', 'Theme parity', themeParityProblems()));
1110
+ problems = problems.concat(ran('colors', 'Colour themes', colorThemeProblems()));
1111
+ problems = problems.concat(ran('authoring', 'Authoring gates', authoringProblems()));
1112
+ problems = problems.concat(ran('shim', 'Hook shim', shimProbs));
1113
+ // P10c's `cli` check is GONE, and the reason is not that it stopped mattering. It hashed
1114
+ // `rituals/harness.py` and compared that against a digest baked into `cli.json`, to catch a
1115
+ // parser edited without regenerating the table. P2 made the table the OWNED document
1116
+ // (`js/cli-table.json`), so there is no generator to fall behind and no second file to hash
1117
+ // — the digest was a claim about a file this migration deletes. What replaces it is
1118
+ // `tests/test_cli_reference.py`'s argparse-vs-table equality, for as long as the parser is
1119
+ // still here to walk.
1120
+ if (!noBundle) {
1121
+ // `Path(bundle).expanduser().resolve()`, which `pyResolve` already IS — the default is
1122
+ // deliberately NOT resolved, because the Python's `ROOT / "Harness"` is not either and
1123
+ // the resolved spelling is what the messages print.
1124
+ const b = bundle ? pyResolve(bundle) : path.join(ROOT, 'Harness');
1125
+ problems = problems.concat(ran('bundle', 'Committed bundle drift', renderedProblems(b)));
1126
+ }
1127
+ return [themes, sortedUnique(problems)];
1128
+ }
1129
+
1130
+ /** `run(..., capture_output=True)` — both streams into the void, the return code out. */
1131
+ function captureBoth(fn) {
1132
+ const out = process.stdout.write.bind(process.stdout);
1133
+ const err = process.stderr.write.bind(process.stderr);
1134
+ process.stdout.write = () => true;
1135
+ process.stderr.write = () => true;
1136
+ try { return fn(); } finally { process.stdout.write = out; process.stderr.write = err; }
1137
+ }
1138
+
1139
+ /**
1140
+ * `_harness_build.cmd_doctor` — the CLI face.
1141
+ *
1142
+ * With `--theme`, one theme. With none it scopes to the INSTALLED theme, so a one-theme
1143
+ * install is not buried under the same issue repeated across every theme; `--all` is the full
1144
+ * maintainer sweep. The cross-theme parity check runs in every mode.
1145
+ */
1146
+ export function cmdDoctor(args) {
1147
+ const [themes, problems] = doctorCollect({
1148
+ theme: args.theme, allThemes: Boolean(args.all), bundle: args.bundle,
1149
+ noBundle: Boolean(args.noBundle),
1150
+ });
1151
+ if (!themes.length) {
1152
+ pyPrint(`${problems.length ? problems[0] : '[doctor] no themes found'}\n`);
1153
+ return 1;
1154
+ }
1155
+ const scoped = !args.theme && !args.all && themes.length === 1;
1156
+ const note = scoped
1157
+ ? ` (scoped to installed theme '${themes[0]}'; run with --all to sweep every theme)` : '';
1158
+ if (problems.length) {
1159
+ pyPrint(`[doctor] ${problems.length} problem(s) across ${themes.length} theme(s):\n`);
1160
+ for (const p of problems) pyPrint(` - ${p}\n`);
1161
+ if (problems.some((p) => p.includes('dead link'))) {
1162
+ pyPrint(' tip: dead links to skills mean your source is incomplete — run '
1163
+ + '`./geneseed update` (or re-sync src/), then re-check.\n');
1164
+ }
1165
+ if (problems.some((p) => p.startsWith('[themes]') && p.includes('missing key'))) {
1166
+ // `./geneseed build --sync-themes`, and the spelling is a one-shot decision frozen in
1167
+ // two fixtures, so it is argued here. The FRONT DOOR, not an interpreter and a file:
1168
+ // `build` forwards its extra arguments to the generator, so the tip names a command that
1169
+ // works today AND after the Python is deleted — which naming the interpreter and the
1170
+ // generator's file, as the old tip did, would not. The `./` prefix matches the sibling tip
1171
+ // five lines up (`./geneseed update`) and is honest about the audience: this problem is
1172
+ // only reachable while authoring themes in a checkout, which is where `./geneseed` is.
1173
+ pyPrint(' tip: a theme is missing a key another theme defines — run '
1174
+ + '`./geneseed build --sync-themes` to fill it from _TEMPLATE.json, '
1175
+ + 'then restyle the added key(s) and re-check.\n');
1176
+ }
1177
+ if (note) pyPrint(`${note}\n`);
1178
+ return 1;
1179
+ }
1180
+ pyPrint('[doctor] ok — ' + `${themes.length} theme(s) clean: no unresolved tokens, no dead `
1181
+ + 'links, nothing escapes the bundle; themes in parity; specs carry purpose '
1182
+ + 'lines; rendered bundle in sync\n');
1183
+ if (note) pyPrint(`${note}\n`);
1184
+ return 0;
1185
+ }
1186
+
1187
+ // --------------------------------------------------------------------------------------
1188
+ // validate — the generator's `--validate-only`, on this binary because it runs the doctor
1189
+ // --------------------------------------------------------------------------------------
1190
+
1191
+ /**
1192
+ * `build._validate_sandbox_problems` — the unresolved-token / dead-link / non-hermetic-link
1193
+ * scan over an already-rendered sandbox tree.
1194
+ *
1195
+ * A NEAR-TWIN OF `checkBuild` ABOVE, AND DELIBERATELY NOT IT, because the reference is two
1196
+ * functions and they differ in three observable ways: the token list is `sorted(set(...))`
1197
+ * here and bare `set(...)` there, the message carries no `[theme]` prefix (the caller adds
1198
+ * `[emit]` instead), and an unreadable file is SKIPPED rather than raised. Python duplicates
1199
+ * the loop because `build.py` cannot import the harness tree; here the two live in one file
1200
+ * and the duplication is eight lines — every primitive underneath (`rglob`, `stripCode`,
1201
+ * `linkProblems`, `readText`) is the shared one, which is the half that could actually drift.
1202
+ */
1203
+ export function validateSandboxProblems(sandbox) {
1204
+ const out = pyResolve(sandbox);
1205
+ const problems = [];
1206
+ for (const md of rglob(out)) {
1207
+ if (!md.endsWith('.md') || !isFile(md)) continue;
1208
+ const rel = path.relative(out, md);
1209
+ if (validateIsVendored(rel)) continue;
1210
+ let text;
1211
+ // `except (OSError, UnicodeDecodeError): continue` — binary or unreadable, nothing here.
1212
+ try { text = readText(md); } catch { continue; }
1213
+ for (const tok of sortedUnique(text.match(TOKEN_RE) ?? [])) {
1214
+ problems.push(`unresolved token ${tok} in ${rel}`);
1215
+ }
1216
+ problems.push(...linkProblems(md, text, out, rel));
1217
+ }
1218
+ return problems;
1219
+ }
1220
+
1221
+ /**
1222
+ * `subprocess.run(..., capture_output=True, text=True)` around an IN-PROCESS call.
1223
+ *
1224
+ * The reference shells to `harness.py doctor`; this calls `cmdDoctor` directly, and the
1225
+ * difference has to be undone in one place: a child's `capture_output` hands the parent
1226
+ * UNIVERSAL-NEWLINE text, so `r.stdout` holds `\n` on Windows even though the child wrote
1227
+ * `\r\n`. `pyPrint` inside `cmdDoctor` writes `\r\n`. Without the fold, the `.strip()` below
1228
+ * would leave every interior line CRLF and the re-print would double the translation.
1229
+ */
1230
+ function captureStreams(fn) {
1231
+ const chunks = { out: [], err: [] };
1232
+ const realOut = process.stdout.write.bind(process.stdout);
1233
+ const realErr = process.stderr.write.bind(process.stderr);
1234
+ process.stdout.write = (c) => { chunks.out.push(String(c)); return true; };
1235
+ process.stderr.write = (c) => { chunks.err.push(String(c)); return true; };
1236
+ let code;
1237
+ try {
1238
+ code = fn();
1239
+ } finally {
1240
+ process.stdout.write = realOut;
1241
+ process.stderr.write = realErr;
1242
+ }
1243
+ const decode = (xs) => xs.join('').replaceAll('\r\n', '\n').replaceAll('\r', '\n');
1244
+ return { code, out: decode(chunks.out), err: decode(chunks.err) };
1245
+ }
1246
+
1247
+ /**
1248
+ * `build._validate_only` — render + emit the requested target into a throwaway sandbox, run
1249
+ * every validation a real build would gate on, print what WOULD have been written, and return
1250
+ * the exit code. Nothing under the sandbox survives, and no marker, manifest or registry row
1251
+ * is ever written for real.
1252
+ *
1253
+ * WHY IT IS HERE AND NOT ON THE GENERATOR DRIVER, which is where its flag lives. The
1254
+ * source-tree half of the check IS the doctor, and this module starts a process (`node --check`
1255
+ * over the OpenCode plugins). `bin/geneseed.mjs` is under a transitive ban on reaching any
1256
+ * module that can, gated twice — `tests/test_node_cli_parity.py` greps the driver's source and
1257
+ * `tests/test_hook_cli_parity.py` walks its relative imports. Siting the tool on the CLI
1258
+ * binary, which already carries the doctor, crosses it with neither gate amended and without
1259
+ * the dynamic `import()` that would evade the walk.
1260
+ *
1261
+ * THE MARKER GUARANTEE COMES FREE, and that is why `emitProjectInto`/`emitGlobalInto`/
1262
+ * `buildInto` are used rather than `driverMain(['--emit', …])`: the driver writes
1263
+ * `.geneseed-emit`/`.geneseed-footprint` and records an install-registry row AFTER the
1264
+ * dispatch, so routing through it would register a temp directory that is deleted a
1265
+ * millisecond later. The reference calls the emit functions directly for the same reason.
1266
+ *
1267
+ * THE EMIT'S OWN STDOUT IS NOT SWALLOWED. `globalEmitProblems` above wraps its call in
1268
+ * `swallowStdout` and this deliberately does not: `_validate_only` has no `redirect_stdout`,
1269
+ * so the ~200 lines of emit summary ARE part of what a dry run shows the operator.
1270
+ *
1271
+ * NO CELL CAN REACH THIS. `golden.py`'s `_argv` never emitted `--validate-only`, so the flag
1272
+ * was ungated across implementations for the whole port; the gate is the corpus in
1273
+ * `tests/test_maintainer_tools_parity.py`, which runs the reference and this side over the
1274
+ * same flags and compares stdout, stderr and the exit code.
1275
+ */
1276
+ export function cmdValidate(args) {
1277
+ const problems = [];
1278
+ const scan = withTempDir((tmpRaw) => {
1279
+ // `.resolve()` mirrors `_check_build`'s: on Windows a temp dir can come back in 8.3
1280
+ // short form (`RUNNER~1`) while every link TARGET below is resolved long-form, and
1281
+ // `within` then rejects every relative link rather than only the escaping ones.
1282
+ const tmp = pyResolve(tmpRaw);
1283
+ // With a distinct `--root` the per-repo emits split their output — the bundle under
1284
+ // `out`, the native layer under `root` — so the sandbox bundle nests INSIDE the sandbox
1285
+ // root and the scan covers both layers.
1286
+ const root = args.root ? path.join(tmp, 'root') : path.join(tmp, 'out');
1287
+ const sandbox = args.root ? path.join(root, 'bundle') : root;
1288
+ const cfgDir = path.join(tmp, 'cfg');
1289
+ const emit = args.emit;
1290
+ const opts = { theme: args.theme, footprint: args.footprint };
1291
+ let scanDirs;
1292
+ try {
1293
+ if (emit.endsWith('-global')) {
1294
+ emitGlobalInto(emit.slice(0, -'-global'.length), { ...opts, out: sandbox, cfgDir });
1295
+ scanDirs = [cfgDir];
1296
+ } else if (emit === 'files') {
1297
+ buildInto({ ...opts, out: sandbox });
1298
+ scanDirs = [sandbox];
1299
+ } else {
1300
+ emitProjectInto(emit, { ...opts, out: sandbox, root });
1301
+ scanDirs = [root];
1302
+ }
1303
+ } catch (e) {
1304
+ // `except SystemExit as e` — a DELIBERATE refusal from the render (an unknown theme,
1305
+ // an incomplete source), interpolated as `{e}`.
1306
+ //
1307
+ // THE EXIT CODE AND NOT THE MESSAGE, measured rather than assumed: the reference's
1308
+ // render half is ALREADY this port. `_build_render.build` calls `_build_core.run_node`,
1309
+ // which ends `raise SystemExit(res.get("exit", 1))` — an INTEGER — so every refusal
1310
+ // that crosses the seam arrives here as `SystemExit(1)` and `str(e)` is `'1'`, not the
1311
+ // sentence `load_theme` wrote on stderr on the way out. A port that printed `e.message`
1312
+ // says `unknown theme 'x'` where the reference says `1`. The corpus in
1313
+ // `tests/test_maintainer_tools_parity.py` is what found it; nothing else could.
1314
+ if (e && e.exitCode !== undefined) {
1315
+ pyPrint(`[validate-only] render/emit FAILED for theme '${args.theme}' `
1316
+ + `emit '${emit}': ${e.exitCode}\n`);
1317
+ return null;
1318
+ }
1319
+ throw e;
1320
+ }
1321
+
1322
+ const written = scanDirs.filter(isDir).flatMap((d) => rglob(d)).filter(isFile)
1323
+ .sort(comparePaths);
1324
+ pyPrint(`[validate-only] theme=${args.theme} emit=${emit} `
1325
+ + `footprint=${args.footprint}\n`);
1326
+ pyPrint(`[validate-only] would write ${written.length} file(s) under ${args.out}`
1327
+ + (args.root ? ` (root ${args.root})` : '')
1328
+ + ' — nothing was actually written (sandboxed).\n');
1329
+ if (args.verbose) {
1330
+ let base = scanDirs[0];
1331
+ for (const p of written) {
1332
+ for (const d of scanDirs) {
1333
+ if (within(p, d)) { base = d; break; }
1334
+ }
1335
+ pyPrint(` would write: ${path.relative(base, p)}\n`);
1336
+ }
1337
+ }
1338
+ for (const d of scanDirs) {
1339
+ for (const p of validateSandboxProblems(d)) problems.push(`[${emit}] ${p}`);
1340
+ }
1341
+ return scanDirs;
1342
+ }, 'geneseed-validate-');
1343
+ if (scan === null) return 1;
1344
+
1345
+ // The source-tree-wide checks (theme parity, authoring gates, AGENT.md table parity,
1346
+ // colour themes) do not depend on --out/--root/--emit at all and already live fully tested
1347
+ // in the doctor, so they run through it rather than being re-derived. The reference SHELLS
1348
+ // to `harness.py doctor --theme T --no-bundle`; one process is what this port has.
1349
+ const doctor = captureStreams(() => cmdDoctor({ theme: args.theme, noBundle: true }));
1350
+ if (pyStripSpace(doctor.out)) pyPrint(`${pyStripSpace(doctor.out)}\n`);
1351
+ if (pyStripSpace(doctor.err)) pyPrintErr(`${pyStripSpace(doctor.err)}\n`);
1352
+ if (doctor.code !== 0) {
1353
+ problems.push(`[doctor] source-tree validation failed for theme '${args.theme}' `
1354
+ + '(see output above)');
1355
+ }
1356
+
1357
+ if (problems.length) {
1358
+ pyPrint(`[validate-only] ${problems.length} problem(s):\n`);
1359
+ for (const p of problems) pyPrint(` - ${p}\n`);
1360
+ return 1;
1361
+ }
1362
+ pyPrint('[validate-only] ok — would render and emit cleanly, no problems found.\n');
1363
+ return 0;
1364
+ }