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
@@ -0,0 +1,1086 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The Node driver — `build.py`'s `main()`, as a second CLI beside it.
4
+ *
5
+ * P4 adds a driver; it does not replace one. `build.py` is ALSO the `import build` facade
6
+ * that 19 `rituals/` modules read 55 distinct names from, and ~11 sites spawn
7
+ * `[sys.executable, build.py, ...]`. None of those flip here: the Python CLI has to
8
+ * survive this phase intact, and every gate that drives it keeps driving it.
9
+ *
10
+ * WHAT THIS FILE ORIGINATES, AND WHY THAT IS THE PHASE'S WHOLE POINT.
11
+ * Every phase since P2d has asked one question at the process boundary: which values does
12
+ * the CHILD resolve, and which does the parent decide and send? P3c's answer for `cfgDir`
13
+ * was "the child must never resolve it — a child that did would render 135 files into the
14
+ * developer's real ~/.config/opencode". P4 inverts that rule rather than repeating it:
15
+ * this file IS the parent now, so the values a child must never resolve are exactly the
16
+ * values this file must originate. `ROOT` and the seven paths under it come from this
17
+ * script's own location, not from an inherited `cfg`.
18
+ *
19
+ * TWO KEYS ARE DELIBERATELY ABSENT FROM `cfg`, and their absence is load-bearing.
20
+ * `js_cfg()` (_build_core.py:199) always sends `structure` and `capabilityLinkRe`, because
21
+ * the Python originals are module-level names that TESTS MUTATE — `_OWNED` membership
22
+ * asked one level out. This driver has no Python module to mutate, so it sends neither and
23
+ * `js/render.mjs:215`'s `cfg.structure ?? STRUCTURE` and `js/emit.mjs:680`'s
24
+ * `cfg.capabilityLinkRe ? ... : <default>` take their right-hand branches for the first
25
+ * time. Those two fallbacks have been dead code since they were written — always
26
+ * overridden by the driver that always supplies them. P4 is what makes them live.
27
+ *
28
+ * THE FOOTPRINT DEFAULT IS THE FLAG'S, NOT THE FUNCTION'S. `--footprint` defaults to
29
+ * `lean` (build.py:354); every `emit_*`/`build` SIGNATURE defaults to `full`. A driver
30
+ * that reproduced the signature default would emit a different harness in every cell while
31
+ * every gate that calls the functions directly stayed green.
32
+ */
33
+ import {
34
+ existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync,
35
+ statSync, unlinkSync,
36
+ } from 'node:fs';
37
+ import path from 'node:path';
38
+ import { fileURLToPath } from 'node:url';
39
+ import {
40
+ build, emitOpencodeRender, emitOpencodeGlobalRender, emitClaudeRender, phaseLog,
41
+ } from '../js/emit.mjs';
42
+ import { settingsIntegrityCheck } from '../js/settings.mjs';
43
+ import { writeText, parseJson, jsonDumpsIndent, withPyNewlines } from '../js/lib/fs.mjs';
44
+ // P5c moved these out of this file: `bin/geneseed-cli.mjs` needs the same four resolvers to
45
+ // find a global install, and a resolver that decides WHERE a driver writes is the last thing
46
+ // that should exist twice. golden.py's 259 cells are what made the move safe to attempt.
47
+ import {
48
+ GLOBAL_MANIFEST, pyResolve, opencodeConfigDir, claudeConfigDir, bobConfigDir,
49
+ copilotConfigDir,
50
+ } from '../js/hosts.mjs';
51
+
52
+ // P5d moved these out of this file for the reason P5c moved the host resolvers: `harness
53
+ // status` renders to count, so `bin/geneseed-cli.mjs` needs the same checkout paths and the
54
+ // same cfg. golden.py's 259 cells all build one, which is what made the move safe.
55
+ import { ROOT, CONFIG, THEMES, discoverNames, makeCfg } from '../js/checkout.mjs';
56
+
57
+ // P5f moved these, for the same arithmetic a third time: `harness rebuild-all` reads the
58
+ // registry to find every install it must re-emit, and a CLI verb may not reach into a driver
59
+ // for a reader. See js/registry.mjs.
60
+ import { registryRecord, registryRoots } from '../js/registry.mjs';
61
+
62
+ // P2. `--sync-themes` crossed into its own module rather than into this file: it is 90 lines
63
+ // of textual surgery over committed files with a corpus of its own, and it is the half of the
64
+ // maintainer pair that needs nothing this driver is banned from having.
65
+ import { syncThemes } from '../js/themes.mjs';
66
+
67
+ /** The nine `--emit` choices, in build.py:337-338's order. */
68
+ const EMITS = ['files', 'opencode', 'opencode-global', 'claude', 'claude-global',
69
+ 'bob', 'bob-global', 'copilot', 'copilot-global'];
70
+
71
+ /**
72
+ * The emits this driver can run end to end — all nine, as of P4e.
73
+ *
74
+ * The set is kept (rather than deleted along with the refusal branch) because it is the
75
+ * thing `test_the_node_driver_classifies_every_emit` reads to assert the PARTITION: every
76
+ * `--emit` choice is either here or refused with exit 3, so a tenth emit added to `EMITS`
77
+ * without crossing is a failure rather than an oversight. It is also what
78
+ * `test_every_relocation_var_moves_its_global_target` cross-checks its host table against.
79
+ */
80
+ const PORTED = new Set(EMITS);
81
+
82
+ /** `_build_emit.PRIMARY_AGENT_SRC`. */
83
+ const PRIMARY_AGENT_SRC = path.join(ROOT, 'adapters', 'opencode', 'agents', 'orchestrator.md');
84
+ /**
85
+ * `_build_global.HOSTS[host]['native_catalog']` — does the host catalogue skills and agents
86
+ * to the model itself? A DECISION the driver takes and sends, never something the render
87
+ * half re-derives: `emit_opencode` passes `host_catalogs_natively('opencode')`, which is
88
+ * `true`, where the plain `files` bundle passes `false`. Getting this wrong collapses (or
89
+ * fails to collapse) AGENT.md's capability tables in every cell.
90
+ */
91
+ const NATIVE_CATALOG = { opencode: true, claude: true, bob: false, copilot: false };
92
+
93
+ /** `_build_render.resolve_out` — absolute, or relative to the CURRENT WORKING DIRECTORY
94
+ * (not to ROOT), so the harness renders straight into any repository.
95
+ *
96
+ * EXPORTED IN P5i because `js/setup.mjs` became its second caller: `_setup_summary_lines`
97
+ * names the AGENT.md a bundle emit just wrote, and it names it by the same resolution the
98
+ * emit used. Copying the one line would be a second owner of the rule that a bare `Harness`
99
+ * follows the invocation and not the checkout — which is what
100
+ * `build/the-bundle-follows-cwd-not-the-checkout` exists to gate.
101
+ *
102
+ * `pyResolve` AND NOT `path.resolve`, and the difference is the whole reason `pyResolve`
103
+ * exists: the reference ends in `.resolve()`, which canonicalises — 8.3 short names expanded
104
+ * on Windows, symlinks followed on POSIX, the filesystem's own casing — where `path.resolve`
105
+ * only normalises `.`/`..`. Nine `pyResolve` call sites had the rule and this one did not.
106
+ * It is invisible on a machine whose paths are already canonical, which is every machine
107
+ * this ran on until GitHub's Windows runner handed it a `C:\\Users\\RUNNER~1\\...` and the
108
+ * two CLIs printed the same directory under two different names — a 70-byte difference in a
109
+ * line compared BYTE for byte by `test_the_two_entry_points_agree_on_stdout_BYTES`. The
110
+ * argument to `pyResolve` is made absolute first so its `expanduser` cannot fire: the
111
+ * reference does not expand `~` here, and a bare `~` is a legal directory name. */
112
+ export function resolveOut(raw) {
113
+ return pyResolve(path.resolve(process.cwd(), raw));
114
+ }
115
+
116
+ /**
117
+ * build.py:307-321 — the three defaults that come from `harness.config.json`.
118
+ *
119
+ * The corrupt-file branch is reproduced including its imprecision: the warning says only
120
+ * "using theme 'neutral'", but the branch also resets posture and mode. Matching the
121
+ * Python text matters more than fixing it here, because the two CLIs' stderr is compared
122
+ * byte-for-byte by `tests/golden.py` (:316).
123
+ */
124
+ function configDefaults() {
125
+ const d = { theme: 'neutral', posture: 'peer', mode: 'direct' };
126
+ if (!existsSync(CONFIG)) return d;
127
+ try {
128
+ const data = JSON.parse(readFileSync(CONFIG, 'utf8'));
129
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
130
+ if (data.theme !== undefined) d.theme = data.theme;
131
+ if (data.posture !== undefined) d.posture = data.posture;
132
+ if (data.mode !== undefined) d.mode = data.mode;
133
+ }
134
+ } catch {
135
+ process.stderr.write(`[geneseed] WARN: ${path.basename(CONFIG)} is unreadable — `
136
+ + "using theme 'neutral'.\n");
137
+ }
138
+ return d;
139
+ }
140
+
141
+ /**
142
+ * The parser's tables, at MODULE scope so `usage` reads the same two objects the loop below
143
+ * dispatches on. They were local to `parseArgs` until `-h` arrived; a help text listing its
144
+ * own private copy of the flags is a second source that drifts silently the first time a
145
+ * flag is added to one and not the other, and nothing downstream would say so.
146
+ */
147
+ const VALUED = {
148
+ '--theme': 'theme', '--posture': 'posture', '--mode': 'mode',
149
+ '--out': 'out', '--target': 'out', '--emit': 'emit',
150
+ '--footprint': 'footprint', '--root': 'root',
151
+ };
152
+ const FLAGS = {
153
+ '--sync-themes': 'syncThemes', '--validate-only': 'validateOnly',
154
+ '-v': 'verbose', '--verbose': 'verbose',
155
+ };
156
+
157
+ /**
158
+ * The four flags `choice` validates, as ONE table for the same anti-drift reason.
159
+ *
160
+ * A function and not a constant because two of the four read the checkout: `discoverNames`
161
+ * scans `postures/` and `modes/`, so the answer depends on the source tree this driver is
162
+ * standing in and cannot be frozen at import.
163
+ */
164
+ function choicesFor() {
165
+ return {
166
+ '--emit': EMITS,
167
+ '--footprint': ['lean', 'full'],
168
+ '--posture': discoverNames('postures', 'peer'),
169
+ '--mode': discoverNames('modes', 'direct'),
170
+ };
171
+ }
172
+
173
+ /**
174
+ * `-h/--help` — the flag argparse handed the reference for free and this hand-rolled parser
175
+ * did not, so the two entry points disagreed: the reference printed usage and exited 0 while
176
+ * `geneseed-build --help` died with `unrecognized arguments: --help` and exit 2.
177
+ *
178
+ * NO CELL COULD SEE IT. `golden.py`'s `_argv` builds every cell out of the render flags and
179
+ * never emits `--help`, so the byte comparison only ever ran inputs both implementations
180
+ * were built for — a flag missing from one side is invisible to a gate that never passes it.
181
+ * `test_the_help_text_names_every_flag_the_reference_takes` is the gate, and it reads
182
+ * `build.py`'s `add_argument` calls rather than this file, so it fails on drift from EITHER
183
+ * side rather than agreeing with whichever one it was copied from.
184
+ *
185
+ * The text is deliberately NOT argparse's byte-for-byte. Reproducing its wrapping would put
186
+ * a hand-written copy of the reference's output in a file no gate compares, which is the
187
+ * drift this whole docblock is about; the surface here is the npx one, in the same class as
188
+ * the `--sync-themes` and `--validate-only` refusals that already answer differently.
189
+ */
190
+ function usage() {
191
+ const choices = choicesFor();
192
+ const metavar = (flag) => (choices[flag]
193
+ ? `{${choices[flag].join(',')}}`
194
+ : VALUED[flag].toUpperCase());
195
+ const valued = Object.keys(VALUED);
196
+ const bare = Object.keys(FLAGS);
197
+ return [
198
+ `usage: geneseed-build [-h] ${[...valued.map((f) => `[${f} ${metavar(f)}]`),
199
+ ...bare.map((f) => `[${f}]`)].join(' ')}`,
200
+ '',
201
+ 'Render the Geneseed harness for a theme.',
202
+ '',
203
+ 'options:',
204
+ ' -h, --help',
205
+ ...valued.map((f) => ` ${f} ${metavar(f)}`),
206
+ ...bare.map((f) => ` ${f}`),
207
+ '',
208
+ ].join('\n');
209
+ }
210
+
211
+ /** argparse's `--flag value` and `--flag=value`, plus the `--target` alias for `--out`. */
212
+ function parseArgs(argv, defaults) {
213
+ const args = {
214
+ theme: defaults.theme, posture: defaults.posture, mode: defaults.mode,
215
+ out: 'Harness', emit: 'files', footprint: 'lean', root: null,
216
+ syncThemes: false, validateOnly: false, verbose: false,
217
+ };
218
+ for (let i = 0; i < argv.length; i += 1) {
219
+ const tok = argv[i];
220
+ const eq = tok.indexOf('=');
221
+ const name = eq > 0 ? tok.slice(0, eq) : tok;
222
+ if (tok === '-h' || tok === '--help') {
223
+ process.stdout.write(usage());
224
+ // The same marker `die` throws, with a SUCCESS code: help is not a refusal, and
225
+ // `main`'s catch is what turns either into this process's exit status. A bare
226
+ // `process.exit(0)` here would be the P5f bug in reverse — it takes `rebuild-all`'s
227
+ // loop with it, and skips the stdout flush the text was just written to.
228
+ const e = new Error('help');
229
+ e.exitCode = 0;
230
+ throw e;
231
+ }
232
+ if (VALUED[name]) {
233
+ const val = eq > 0 ? tok.slice(eq + 1) : argv[i += 1];
234
+ if (val === undefined) die(2, `argument ${name}: expected one argument`);
235
+ args[VALUED[name]] = val;
236
+ } else if (FLAGS[tok]) {
237
+ args[FLAGS[tok]] = true;
238
+ } else {
239
+ die(2, `unrecognized arguments: ${tok}`);
240
+ }
241
+ }
242
+ const choices = choicesFor();
243
+ for (const flag of Object.keys(choices)) choice(flag, args[VALUED[flag]], choices[flag]);
244
+ return args;
245
+ }
246
+
247
+ /**
248
+ * The generator's OWN flag surface, for the one consumer that is not this driver.
249
+ *
250
+ * `geneseed validate` takes `--theme/--emit/--out/--root/--footprint/-v` — the reference's
251
+ * `--validate-only` reads exactly the flags `build.py`'s parser already produced, so the port
252
+ * hands the CLI this parser rather than a second one beside it. That is not tidiness: a
253
+ * hand-rolled copy would have its own `--target` alias, its own `choices` lists and its own
254
+ * `-h`, and `test_the_help_text_names_every_flag_the_reference_takes` gates only this one.
255
+ *
256
+ * `withPyNewlines` because the refusal and `-h` paths WRITE: called from `bin/geneseed.mjs`
257
+ * they sit inside `main`'s funnel, and called from the CLI binary they would not.
258
+ */
259
+ export function parseDriverArgs(argv) {
260
+ return withPyNewlines(() => parseArgs(argv, configDefaults()));
261
+ }
262
+
263
+ function choice(flag, value, allowed) {
264
+ if (!allowed.includes(value)) {
265
+ die(2, `argument ${flag}: invalid choice: '${value}' `
266
+ + `(choose from ${allowed.map((c) => `'${c}'`).join(', ')})`);
267
+ }
268
+ }
269
+
270
+ /**
271
+ * argparse's error exit — a THROW since P5f, where it used to be `process.exit(code)`.
272
+ *
273
+ * `harness rebuild-all` re-emits every active install and its whole contract is "continue
274
+ * past a failure so one broken install never blocks the rest". The Python gets that for free:
275
+ * each rebuild is a SUBPROCESS, and a child that exits 2 hands back a return code. Here the
276
+ * driver is a module in the same process, so `process.exit` would take the loop, the CLI and
277
+ * every remaining install with it — the one place where importing rather than spawning is not
278
+ * transparent, and it turns a per-install failure into a total one.
279
+ *
280
+ * The marker is `exitCode`, the SAME one `assertSourceComplete` and `effectiveTheme` already
281
+ * use, and the first draft of this used a second name so that only `die` would be converted
282
+ * — on the argument that an incomplete source should keep its stack. That draft was wrong,
283
+ * and `rebuild-all/one-broken-install-does-not-stop-the-rest` is what said so: a
284
+ * `.geneseed-theme` naming a theme that does not exist makes `effectiveTheme` refuse, and
285
+ * with only `die` converted the refusal propagated out of the loop and the remaining
286
+ * installs were never rebuilt. `main` is standing in for a PROCESS, and a process boundary
287
+ * turns every deliberate refusal into an exit code — the narrower rule was a distinction
288
+ * with no principle behind it.
289
+ *
290
+ * One behaviour improves as a side effect, and it is worth naming rather than discovering
291
+ * later: `process.exit` terminates without flushing a pending stdout write, so a refusal on
292
+ * a slow pipe could lose output it had already produced. Returning through `main` lets Node
293
+ * drain normally.
294
+ */
295
+ function die(code, msg) {
296
+ process.stderr.write(`geneseed: error: ${msg}\n`);
297
+ const e = new Error(msg);
298
+ e.exitCode = code;
299
+ throw e;
300
+ }
301
+
302
+ /**
303
+ * `_build_settings._hook_runner_entry()`'s two values — this driver's answer, which since
304
+ * P5b is NOT Python's.
305
+ *
306
+ * The Python original returns `sys.executable` and `<checkout>/rituals/harness.py`: the
307
+ * interpreter running `build.py`, and the harness it will hand `%*` to. This driver returns
308
+ * `process.execPath` and `<checkout>/bin/geneseed-hook.mjs`, because the four verbs the
309
+ * emitted hooks invoke — context, git-gate, rule-gate, learn — are now Node, and an install
310
+ * this driver emits therefore needs no Python at all for its hooks.
311
+ *
312
+ * WHAT THIS RETIRED, AND WHY IT IS A DELETION. Until this phase the function was
313
+ * `hookOptsOrDie`: it scanned PATH for an interpreter and refused the four Claude-shaped
314
+ * emits with exit 4 when it found none, because writing a shim that names a nonexistent
315
+ * interpreter silently disables every hook in the install. Both halves are gone. `runner` is
316
+ * `process.execPath` — the node already running this file, which by construction exists —
317
+ * so there is nothing left to discover and no way for discovery to fail. P4e kept the
318
+ * unreachable exit-3 branch because `test_the_node_driver_classifies_every_emit` asserts a
319
+ * partition it belongs to; nothing asserts a partition over this one, so keeping it would
320
+ * be keeping code no test can reach for no stated reason.
321
+ *
322
+ * THE SHIM IS MACHINE-WIDE, AND THAT IS THE DECISION THIS PHASE ACTUALLY TOOK.
323
+ * `hookShimPath()` is `$GENESEED_HOME`-or-`~/.geneseed` + `bin/geneseed-hook[.cmd]`, with no
324
+ * per-install component: every emit of every install on the machine rewrites the same file,
325
+ * and every install's hooks execute it. While both drivers baked Python that was invisible,
326
+ * because last-writer-wins wrote the same thing. It is observable now — a machine whose last
327
+ * emit ran through this file has EVERY install's hooks running under Node, including
328
+ * installs the Python driver wrote, and the reverse.
329
+ *
330
+ * That is correct exactly while the two entry points answer the same verbs the same way, so
331
+ * the gate that used to be a formality is now load-bearing:
332
+ * `test_the_entry_carries_exactly_the_verbs_the_emitter_wires` reads the emitter's wiring
333
+ * and `bin/geneseed-hook.mjs`'s VERBS table and requires them EQUAL — a wired verb the entry
334
+ * lacks is a dead hook on every install on the machine, not just this one. The alternative
335
+ * considered and rejected was a per-driver shim path: the path is baked into every already
336
+ * emitted hook command, so changing it makes every existing install's hooks stale until
337
+ * re-emit, which is P10's migration arriving five phases early.
338
+ *
339
+ * EXPORTED SINCE P6i, because the emitter is no longer its only caller.
340
+ * `js/uninstall.mjs`'s `remergeClaudeHooks` re-merges the canonical hooks when a disabled
341
+ * Claude install is turned back on, and `mergeClaudeSettings` refuses to guess the pair —
342
+ * correctly. There is exactly one right answer on this side and it is this function, so the
343
+ * second caller imports it rather than restating two lines that must never drift: a
344
+ * reactivate whose shim path differed from the emitter's would wire hooks pointing at a file
345
+ * the emitter never writes.
346
+ */
347
+ export function hookRunnerEntry() {
348
+ return { runner: process.execPath, entry: path.join(ROOT, 'bin', 'geneseed-hook.mjs') };
349
+ }
350
+
351
+ /**
352
+ * `_build_global._preamble_exclude` — the `claudeMdExcludes` entry a PROJECT install writes
353
+ * to suppress the GLOBAL preamble of the same host, or null for a host that gets none.
354
+ *
355
+ * `_PREAMBLE_CONFIG_DIR` has exactly one key, so only a `CLAUDE.md` carrier resolves to a
356
+ * value: Bob's and Copilot's `AGENTS.md`, and Copilot's `copilot-instructions.md`, get null.
357
+ *
358
+ * Computed HERE and not in the render child, and that is the inverted boundary rule doing
359
+ * real work rather than being restated. P3b's note on the Python original: it resolves
360
+ * through `_build_core._claude_config_dir`, an `_OWNED` name precisely because the suite
361
+ * redirects it at a sandbox, and a redirect that stops at a subprocess half-works in
362
+ * silence. The child must therefore never derive it — but this file is the PARENT, so
363
+ * deriving it is exactly this file's job, and `js/emit.mjs` receives the answer.
364
+ *
365
+ * POSIX spelling, per the original: `claudeMdExcludes` entries are glob patterns, where a
366
+ * backslash is an escape, so the Windows-native form risks never matching.
367
+ */
368
+ function preambleExclude(claudeMd) {
369
+ if (path.basename(claudeMd) !== 'CLAUDE.md') return null;
370
+ return pyResolve(path.join(claudeConfigDir(), 'CLAUDE.md')).split(path.sep).join('/');
371
+ }
372
+
373
+ /**
374
+ * `_build_global._warn_bob_global_over_project` — informational, never auto-removes.
375
+ *
376
+ * Same shape as `warnCopilotGlobalOverProject` and a DIFFERENT message, because the two
377
+ * hosts differ in what they can promise: Copilot simply stacks both preambles, while Bob's
378
+ * workspace rules may or may not shadow the global copy depending on precedence nothing can
379
+ * verify at emit time — so this text hedges where Copilot's asserts.
380
+ */
381
+ function warnBobGlobalOverProject() {
382
+ const survivors = projectSurvivors('bob');
383
+ if (!survivors.length) return;
384
+ process.stderr.write(
385
+ `[geneseed] WARN: ${survivors.length} project Bob install(s) already exist — `
386
+ + 'emitting GLOBAL now means BOTH may auto-load together in those repos '
387
+ + "(doubled context) unless Bob's workspace rules truly shadow the global "
388
+ + "one there. Review and remove what you don't want:\n");
389
+ for (const root of survivors) {
390
+ process.stderr.write(` - ${root} -> harness uninstall --target "${root}"\n`);
391
+ }
392
+ }
393
+
394
+ /**
395
+ * `_build_render._rel_under` — POSIX path of `out` relative to `root`, or '' when they are
396
+ * the same directory OR when `out` is not under `root` at all.
397
+ *
398
+ * `Path.relative_to` RAISES for a non-descendant; `path.relative` happily walks up with
399
+ * `..`. Reproducing the raise is the whole content of this function: a `..` prefix here
400
+ * would be written into `opencode.json`'s instruction path.
401
+ */
402
+ function relUnder(out, root) {
403
+ const rel = path.relative(root, out);
404
+ if (rel === '' || rel === '.') return '';
405
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return '';
406
+ return rel.split(path.sep).join('/');
407
+ }
408
+
409
+ /**
410
+ * `_build_global._write_manifest_atomic` — temp + rename, so a torn manifest can never make
411
+ * the next emit treat every owned file as the user's own.
412
+ *
413
+ * `writeText`, not `writeFileSync`: Python writes this through `Path.write_text`, so the
414
+ * whole document is CRLF on Windows.
415
+ *
416
+ * `jsonDumpsIndent`, not `JSON.stringify`: `json.dumps` defaults to `ensure_ascii=True` and
417
+ * escapes every non-ASCII character, where `JSON.stringify` emits it raw. This was
418
+ * `JSON.stringify` for a whole phase and nothing caught it, because the only manifest being
419
+ * written was the per-repo OpenCode one and its `_comment` is pure ASCII. The
420
+ * `opencode-global` comment contains an em dash, and that is the character that finally
421
+ * made the difference observable — a helper that is wrong everywhere but tellable in only
422
+ * one place.
423
+ */
424
+ function writeManifestAtomic(file, data) {
425
+ const tmp = `${file}.tmp`;
426
+ writeText(tmp, `${jsonDumpsIndent(data)}\n`);
427
+ renameSync(tmp, file);
428
+ }
429
+
430
+ function isFile(p) {
431
+ try { return statSync(p).isFile(); } catch { return false; }
432
+ }
433
+
434
+ /**
435
+ * Write-before-delete: remove only what this layer owned before and no longer produces.
436
+ *
437
+ * Runs AFTER the whole current set is on disk, so a live file is never momentarily absent
438
+ * part-way through an emit. A pre-existing file that was never in the manifest is the
439
+ * user's and is not reachable from here at all.
440
+ */
441
+ function pruneOwned(oc, oldOwned, owned) {
442
+ const now = new Set(owned);
443
+ const failed = [];
444
+ for (const rel of [...new Set(oldOwned)].filter((r) => !now.has(r)).sort()) {
445
+ const victim = path.join(oc, rel);
446
+ try {
447
+ if (isFile(victim)) {
448
+ unlinkSync(victim);
449
+ const parent = path.dirname(victim);
450
+ if (parent !== oc && readdirSync(parent).length === 0) rmdirSync(parent);
451
+ }
452
+ } catch (e) {
453
+ // Deliberately divergent and deliberately unreachable in the gates: `str(OSError)`
454
+ // ("[Errno 13] Permission denied: ...") and a Node error message ("EACCES: ...") can
455
+ // never agree, and no cell drives a real filesystem failure through this text.
456
+ failed.push(`${rel} (${e.message})`);
457
+ }
458
+ }
459
+ if (failed.length) {
460
+ process.stderr.write('[geneseed] WARN: could not remove stale owned file(s): '
461
+ + `${failed.join(', ')}\n`);
462
+ }
463
+ }
464
+
465
+ function isDir(p) {
466
+ try { return statSync(p).isDirectory(); } catch { return false; }
467
+ }
468
+
469
+ /**
470
+ * `_build_global._project_survivors` — registered per-repo PROJECT installs of `emitName`.
471
+ *
472
+ * Each candidate root's own `.geneseed-emit` marker is read and compared to the literal
473
+ * emit name; an unreadable marker is skipped, never raised.
474
+ */
475
+ function projectSurvivors(emitName) {
476
+ const out = [];
477
+ for (const root of registryRoots()) {
478
+ let marker;
479
+ try {
480
+ marker = readFileSync(path.join(root, '.geneseed-emit'), 'utf8').trim();
481
+ } catch { continue; }
482
+ if (marker === emitName) out.push(root);
483
+ }
484
+ return out;
485
+ }
486
+
487
+ /**
488
+ * `_build_global._warn_copilot_global_over_project` — purely informational.
489
+ *
490
+ * Copilot documents no exclude or shadow mechanism, so a global preamble and a project
491
+ * one simply stack. UNREACHABLE from every golden cell: each emits into a fresh sandbox
492
+ * whose registry is empty, so `projectSurvivors` is always `[]` and this never prints.
493
+ * Reaching it needs a project emit registered BEFORE a global one in the same sandbox.
494
+ */
495
+ function warnCopilotGlobalOverProject() {
496
+ const survivors = projectSurvivors('copilot');
497
+ if (!survivors.length) return;
498
+ process.stderr.write(
499
+ `[geneseed] WARN: ${survivors.length} project Copilot install(s) already exist — `
500
+ + 'emitting GLOBAL now means BOTH preambles load together in those repos '
501
+ + '(doubled context): Copilot stacks ~/.copilot/copilot-instructions.md on top '
502
+ + "of a repo's own AGENTS.md. Review and remove what you don't want:\n");
503
+ for (const root of survivors) {
504
+ process.stderr.write(` - ${root} -> harness uninstall --target "${root}"\n`);
505
+ }
506
+ }
507
+
508
+ /**
509
+ * `_build_emit.emit_opencode` — the driver body, which is what this phase actually ports.
510
+ *
511
+ * RENDER and WIRE already ran in Node before P4; what lived only in Python was the stage
512
+ * either side of them: reading the previous manifest (PRE), pruning what this layer no
513
+ * longer owns, writing the manifest atomically, and the summary line. All five stages now
514
+ * run in one process, in the order `test_emit_phase_order.py` pins for the Python twin:
515
+ * RENDER -> WIRE -> PRUNE -> MANIFEST -> VERIFY (opencode has no VERIFY; it writes no
516
+ * settings file of its own).
517
+ */
518
+ function emitOpencode(cfg, args, out) {
519
+ const root = args.root ? resolveOut(args.root) : out;
520
+ const oc = path.join(root, '.opencode');
521
+ const manifestPath = path.join(oc, GLOBAL_MANIFEST);
522
+
523
+ // PRE. A missing manifest reads as "owned nothing before", which is what makes the first
524
+ // re-emit after an upgrade treat already-existing files as the user's (claim-on-create)
525
+ // rather than deleting them. The prune set is then empty by construction.
526
+ const manifestExisted = existsSync(manifestPath);
527
+ let oldOwned = [];
528
+ if (manifestExisted) {
529
+ try {
530
+ const m = parseJson(readFileSync(manifestPath, 'utf8'));
531
+ oldOwned = (m && m.owned) || [];
532
+ } catch { oldOwned = []; }
533
+ }
534
+
535
+ const agentPathRel = relUnder(out, root);
536
+ const agentPath = agentPathRel ? `${agentPathRel}/AGENT.md` : 'AGENT.md';
537
+
538
+ // RENDER + WIRE, one call. `primaryAgentSrc` lives in `_build_emit` and only this job
539
+ // needs it, so the caller adds it rather than `makeCfg` reaching across.
540
+ const rendered = emitOpencodeRender(
541
+ { ...cfg, primaryAgentSrc: PRIMARY_AGENT_SRC },
542
+ {
543
+ theme: args.theme, out, root, footprint: args.footprint,
544
+ nativeCatalog: NATIVE_CATALOG.opencode, oldOwned, manifestExisted, agentPath,
545
+ });
546
+ const { owned, stats, cfgName } = rendered;
547
+
548
+ phaseLog('PRUNE');
549
+ pruneOwned(oc, oldOwned, owned);
550
+
551
+ phaseLog('MANIFEST');
552
+ writeManifestAtomic(manifestPath, {
553
+ _comment: 'Files owned by Geneseed\'s per-repo OpenCode emit (--emit opencode). '
554
+ + 'Do not edit; removed on re-emit. A pre-existing file not in this '
555
+ + 'list is yours and is never touched.',
556
+ owned: [...owned].sort(),
557
+ scope: 'project',
558
+ });
559
+
560
+ const extras = [...(stats.primary ? ['primary agent'] : []),
561
+ ...(stats.nCommands ? [`${stats.nCommands} command(s)`] : [])];
562
+ const extra = extras.length ? ` + ${extras.join(', ')}` : '';
563
+ process.stdout.write(`[geneseed] opencode layer: ${stats.nAgents} subagents, `
564
+ + `${stats.nSkills} skills, ${stats.nPlugins} plugin(s), `
565
+ + `${stats.nWorkflows} workflow file(s), ${cfgName} (instructions: ${agentPath})`
566
+ + `${extra}\n`);
567
+ }
568
+
569
+ /**
570
+ * `_build_global.emit_opencode_global` — the driver body for the "everything global, zero
571
+ * per-repo" deployment.
572
+ *
573
+ * Shaped like `emitOpencode` and differing in four measured ways, each of which is a value
574
+ * this driver decides rather than the child re-deriving it:
575
+ * - the target is the RESOLVED config dir, not `--out`;
576
+ * - `out` is passed through unchanged and is the LEGACY BUNDLE a memory store is migrated
577
+ * from — not the target, not the config dir's parent, not derivable from anything the
578
+ * child holds;
579
+ * - `agentPath` is ABSOLUTE here (`<cfg>/AGENT.md` as POSIX), where the per-repo emit
580
+ * sends a path relative to the project root;
581
+ * - the manifest carries no `scope` and no `managed` claim set, because the one file this
582
+ * emit wires is never unwired by any teardown — so there is nothing to record and no
583
+ * VERIFY stage to re-read it.
584
+ */
585
+ function emitOpencodeGlobal(cfg, args, out) {
586
+ const cfgDir = args.cfgDir ?? opencodeConfigDir();
587
+ const manifestPath = path.join(cfgDir, GLOBAL_MANIFEST);
588
+
589
+ let oldOwned = [];
590
+ if (existsSync(manifestPath)) {
591
+ try {
592
+ const m = parseJson(readFileSync(manifestPath, 'utf8'));
593
+ oldOwned = (m && m.owned) || [];
594
+ } catch { oldOwned = []; }
595
+ }
596
+
597
+ const agentPath = path.join(cfgDir, 'AGENT.md').split(path.sep).join('/');
598
+
599
+ const rendered = emitOpencodeGlobalRender(
600
+ { ...cfg, primaryAgentSrc: PRIMARY_AGENT_SRC },
601
+ {
602
+ theme: args.theme, cfgDir, out: out === null ? null : out,
603
+ footprint: args.footprint, nativeCatalog: NATIVE_CATALOG.opencode,
604
+ oldOwned, agentPath,
605
+ });
606
+ const { owned, stats, memStatus, nbStatus, cfgName } = rendered;
607
+
608
+ phaseLog('PRUNE');
609
+ pruneOwned(cfgDir, oldOwned, owned);
610
+
611
+ phaseLog('MANIFEST');
612
+ writeManifestAtomic(manifestPath, {
613
+ _comment: 'Files owned by Geneseed\'s --emit opencode-global. '
614
+ + 'Do not edit; removed on re-emit. The memory and notebook '
615
+ + 'stores are NOT listed — they are never deleted.',
616
+ owned: [...owned].sort(),
617
+ });
618
+
619
+ const extras = [...(stats.primary ? ['primary agent'] : []),
620
+ ...(stats.nCommands ? [`${stats.nCommands} command(s)`] : [])];
621
+ const extra = extras.length ? ` + ${extras.join(', ')}` : '';
622
+ process.stdout.write(`[geneseed] opencode-global -> ${cfgDir}: ${stats.nAgents} subagents, `
623
+ + `${stats.nSkills} skills, ${stats.nPlugins} plugin(s), `
624
+ + `${stats.nWorkflows} workflow file(s), AGENT.md, ${memStatus}, ${nbStatus}, `
625
+ + `${cfgName} (no context.json)${extra}. `
626
+ + 'The learn plugin now finds <cfg>/memory automatically; set GENESEED_HARNESS only to '
627
+ + 'override.\n');
628
+ return cfgDir;
629
+ }
630
+
631
+ /**
632
+ * `_build_global._emit_claude_core` — the shared engine behind six emits.
633
+ *
634
+ * Only the two Copilot ones run through it so far, and that is a deliberate split rather
635
+ * than an arbitrary stopping point: `js/emit.mjs:1166` gates the entire settings/hook path
636
+ * behind `if (!isCopilot)`, so a Copilot emit never reaches `hookPrefix` and therefore never
637
+ * needs `hookOpts` — the pair of machine-absolute values (`sys.executable` and the harness
638
+ * entry point) that a Node driver has no way to produce. Copilot crossing first isolates
639
+ * this driver body from that question entirely.
640
+ *
641
+ * `hookOpts` is OMITTED for a host that does not wire hooks, rather than passed as `{}` or
642
+ * `null`. If a future change ever routed a Copilot emit into the hook path, `hookPrefix()`
643
+ * would receive `undefined`, hit its own default and throw the error it was built to throw
644
+ * — where `null` would raise an unrelated TypeError and `{}` would bake the literal string
645
+ * `"undefined"` into the shim and silently kill every hook in the install. Fail loud, on
646
+ * purpose. The four hook-writing hosts pass a real pair from `hookRunnerEntry()`, which
647
+ * cannot fail to produce one — `process.execPath` is the node already running.
648
+ */
649
+ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpts }) {
650
+ const manifestPath = path.join(cfgDir, GLOBAL_MANIFEST);
651
+ let oldOwned = [];
652
+ let oldManaged = {};
653
+ if (existsSync(manifestPath)) {
654
+ try {
655
+ const data = parseJson(readFileSync(manifestPath, 'utf8'));
656
+ oldOwned = data.owned || [];
657
+ oldManaged = (data.managed && typeof data.managed === 'object'
658
+ && !Array.isArray(data.managed)) ? data.managed : {};
659
+ } catch { oldOwned = []; oldManaged = {}; }
660
+ }
661
+ const isCopilot = host === 'copilot';
662
+
663
+ // RENDER + WIRE in one child's worth of work. `preambleExclude` is null for every host
664
+ // but Claude — `_PREAMBLE_CONFIG_DIR` has exactly one key, `CLAUDE.md` — so neither
665
+ // Copilot carrier filename (copilot-instructions.md, AGENTS.md) resolves to one, and
666
+ // neither does Bob's. Spelling it as a call rather than a literal is what makes that a
667
+ // measured `null` instead of an assumed one.
668
+ const rendered = emitClaudeRender(cfg, {
669
+ theme: args.theme, cfgDir, claudeMd, scope, host,
670
+ out: out === null ? null : out,
671
+ footprint: args.footprint, nativeCatalog: NATIVE_CATALOG[host] ?? false,
672
+ oldOwned, oldManaged, preambleExclude: preambleExclude(claudeMd),
673
+ ...(hookOpts ? { hookOpts } : {}),
674
+ });
675
+ const { owned, stats, memStatus, nbStatus, managed } = rendered;
676
+
677
+ phaseLog('PRUNE');
678
+ pruneOwned(cfgDir, oldOwned, owned);
679
+
680
+ phaseLog('MANIFEST');
681
+ writeManifestAtomic(manifestPath, {
682
+ _comment: "Files owned by Geneseed's Claude emit. Do not edit; removed on "
683
+ + 're-emit. The memory and notebook stores are NOT listed — never '
684
+ + 'deleted. `managed` records the CLAUDE.md block + settings.json '
685
+ + 'hooks so uninstall removes exactly those.',
686
+ owned: [...owned].sort(),
687
+ managed,
688
+ scope,
689
+ });
690
+
691
+ // VERIFY — re-read the settings file just written and match it against the claims just
692
+ // recorded, so a merge that silently did not stick (a commented file, a mid-flight
693
+ // external edit, a bug in the merge) is loud now instead of surfacing as hooks that
694
+ // quietly never fire. Skipped for Copilot, which writes no settings file at all
695
+ // (`_emit_claude_core:827`) — the other half of why Copilot could cross first.
696
+ //
697
+ // On the Python driver this stage runs in Python AFTER a Node child did the wiring, which
698
+ // makes it a live cross-implementation check on every build. Here both halves are Node, so
699
+ // that particular property is gone and only the self-check remains — worth stating,
700
+ // because it is a real reduction in what a Claude emit proves about itself, and the thing
701
+ // that replaces it is the acceptance matrix rather than anything at runtime.
702
+ //
703
+ // SILENT ON SUCCESS, which is its own coverage hazard: a clean emit prints nothing, so
704
+ // deleting this call is byte-identical in all 259 cells. `test_verify_reports_an_orphaned
705
+ // _geneseed_hook` plants the fault that makes it speak.
706
+ if (!isCopilot) {
707
+ phaseLog('VERIFY');
708
+ settingsIntegrityCheck(
709
+ path.join(cfgDir, managed.settings_file || 'settings.json'), managed, 'present');
710
+ }
711
+ return {
712
+ nAgents: stats.nAgents,
713
+ nSkills: stats.nSkills,
714
+ nHooks: (managed.settings_hooks || []).length,
715
+ memStatus,
716
+ nbStatus,
717
+ };
718
+ }
719
+
720
+ /** `_build_global.emit_copilot` — per-repo: AGENTS.md at the repo root + a `.github/` layer. */
721
+ function emitCopilot(cfg, args, out) {
722
+ const root = args.root ? resolveOut(args.root) : out;
723
+ const cfgDir = path.join(root, '.github');
724
+ const r = emitClaudeCore(cfg, args, {
725
+ cfgDir, claudeMd: path.join(root, 'AGENTS.md'), scope: 'project', host: 'copilot', out,
726
+ });
727
+ process.stdout.write(`[geneseed] copilot (folder) -> ${root}: AGENTS.md + .github/ `
728
+ + `(${r.nAgents} agents (.agent.md), ${r.nSkills} skills), ${r.memStatus}, `
729
+ + `${r.nbStatus}. No settings.json/hooks (Copilot has none).\n`);
730
+ return cfgDir;
731
+ }
732
+
733
+ /** `_build_global.emit_copilot_global` — into Copilot's personal config dir (~/.copilot). */
734
+ function emitCopilotGlobal(cfg, args, out) {
735
+ const cfgDir = args.cfgDir ?? copilotConfigDir();
736
+ warnCopilotGlobalOverProject();
737
+ const r = emitClaudeCore(cfg, args, {
738
+ cfgDir, claudeMd: path.join(cfgDir, 'copilot-instructions.md'),
739
+ scope: 'global', host: 'copilot', out,
740
+ });
741
+ process.stdout.write(`[geneseed] copilot-global -> ${cfgDir}: ${r.nAgents} agents (.agent.md), `
742
+ + `${r.nSkills} skills, copilot-instructions.md, ${r.memStatus}, ${r.nbStatus}. `
743
+ + 'No settings.json/hooks (Copilot has no hook mechanism — memory rides the '
744
+ + "preamble's instructions); MCP servers go in mcp-config.json.\n");
745
+ return cfgDir;
746
+ }
747
+
748
+ /**
749
+ * `_build_global.emit_claude_global` — into Claude Code's global config dir (~/.claude).
750
+ *
751
+ * `hookRunnerEntry()` is still called BEFORE the engine, though it can no longer refuse:
752
+ * the shape stayed after P5b deleted the refusal so that a future value which CAN fail is
753
+ * decided while nothing has been written, rather than behind a half-rendered config dir.
754
+ */
755
+ function emitClaudeGlobal(cfg, args, out) {
756
+ const cfgDir = args.cfgDir ?? claudeConfigDir();
757
+ const hookOpts = hookRunnerEntry();
758
+ const r = emitClaudeCore(cfg, args, {
759
+ cfgDir, claudeMd: path.join(cfgDir, 'CLAUDE.md'), scope: 'global', host: 'claude', out,
760
+ hookOpts,
761
+ });
762
+ // "Hooks call the harness by absolute path", not `harness.py` — and this one was already
763
+ // WRONG on this side rather than merely about to be: P5b made this driver bake `<node>
764
+ // bin/geneseed-hook.mjs` into the hooks it writes, so the sentence named a file its own
765
+ // emit does not use. The two drivers bake different entries and printed the same claim;
766
+ // the neutral wording is true of both, and stays true when only one is left. It is frozen
767
+ // in the `claude-global` cells' recorded stdout, which is why it moves here rather than in
768
+ // the deletion phase, which may not move a recorded byte.
769
+ process.stdout.write(`[geneseed] claude-global -> ${cfgDir}: ${r.nAgents} subagents, `
770
+ + `${r.nSkills} skills, CLAUDE.md, ${r.nHooks} hook group(s), settings.json, `
771
+ + `${r.memStatus}, ${r.nbStatus}. No plugins/workflows/themes (no Claude analogue); `
772
+ + '~/.claude/plugins is never touched. Hooks call the harness by absolute path; set '
773
+ + 'GENESEED_HARNESS only to relocate memory.\n');
774
+ return cfgDir;
775
+ }
776
+
777
+ /** `_build_global.emit_claude` — per-repo: CLAUDE.md at the root + a `.claude/` layer. */
778
+ function emitClaude(cfg, args, out) {
779
+ const root = args.root ? resolveOut(args.root) : out;
780
+ const hookOpts = hookRunnerEntry();
781
+ const r = emitClaudeCore(cfg, args, {
782
+ cfgDir: path.join(root, '.claude'), claudeMd: path.join(root, 'CLAUDE.md'),
783
+ scope: 'project', host: 'claude', out, hookOpts,
784
+ });
785
+ process.stdout.write(`[geneseed] claude (folder) -> ${root}: CLAUDE.md + .claude/ `
786
+ + `(${r.nAgents} subagents, ${r.nSkills} skills, ${r.nHooks} hook group(s), `
787
+ + `settings.json), ${r.memStatus}, ${r.nbStatus}.\n`);
788
+ }
789
+
790
+ /**
791
+ * `_build_global.emit_bob_global` — into Bob's global config dir (~/.bob).
792
+ *
793
+ * The warning fires BEFORE the emit, matching the Python order: it is about a state this
794
+ * emit is at the point of creating, so printing it afterwards would describe the situation
795
+ * as though the operator had already chosen it.
796
+ */
797
+ function emitBobGlobal(cfg, args, out) {
798
+ const cfgDir = args.cfgDir ?? bobConfigDir();
799
+ const hookOpts = hookRunnerEntry();
800
+ warnBobGlobalOverProject();
801
+ const r = emitClaudeCore(cfg, args, {
802
+ cfgDir, claudeMd: path.join(cfgDir, 'AGENTS.md'), scope: 'global', host: 'bob', out,
803
+ hookOpts,
804
+ });
805
+ process.stdout.write(`[geneseed] bob-global -> ${cfgDir}: ${r.nAgents} subagents, `
806
+ + `${r.nSkills} skills, rules/geneseed.md (Bob's always-injected channel; a global `
807
+ + 'AGENTS.md is not auto-loaded, so none is written), '
808
+ + `${r.nHooks} hook group(s), settings.json, ${r.memStatus}, ${r.nbStatus}.\n`);
809
+ return cfgDir;
810
+ }
811
+
812
+ /** `_build_global.emit_bob` — per-repo: AGENTS.md at the root + a `.bob/` layer. */
813
+ function emitBob(cfg, args, out) {
814
+ const root = args.root ? resolveOut(args.root) : out;
815
+ const hookOpts = hookRunnerEntry();
816
+ const r = emitClaudeCore(cfg, args, {
817
+ cfgDir: path.join(root, '.bob'), claudeMd: path.join(root, 'AGENTS.md'),
818
+ scope: 'project', host: 'bob', out, hookOpts,
819
+ });
820
+ process.stdout.write(`[geneseed] bob (folder) -> ${root}: AGENTS.md + .bob/ `
821
+ + `(${r.nAgents} subagents, ${r.nSkills} skills, rules/geneseed.md shadow stub, `
822
+ + `${r.nHooks} hook group(s), settings.json), ${r.memStatus}, ${r.nbStatus}.\n`);
823
+ }
824
+
825
+ /**
826
+ * build.py:437-466 — the POST stage, which writes markers and records the install and
827
+ * wires NOTHING. `test_the_post_emit_stage_wires_nothing` classifies that stage on the
828
+ * Python side; this is the same stage on this side, and it stays after the dispatch for
829
+ * the same reason.
830
+ *
831
+ * `writeText`, never `writeFileSync`: Python's `Path.write_text` opens in text mode with
832
+ * `newline=None` and translates the trailing `\n` to CRLF on Windows. A marker written
833
+ * with a bare LF differs from Python's in every cell on this platform.
834
+ */
835
+ function writeMarkers(markerDir, emit, footprint) {
836
+ try {
837
+ mkdirSync(markerDir, { recursive: true });
838
+ writeText(path.join(markerDir, '.geneseed-emit'), `${emit}\n`);
839
+ // Written for EVERY emit (build.py:443, unconditional), unlike the theme marker:
840
+ // the claude/bob/copilot PROJECT installs never call build(), so this is their only
841
+ // footprint record. Read by harness._footprint_of_dir, which defaults to 'full'.
842
+ writeText(path.join(markerDir, '.geneseed-footprint'), `${footprint}\n`);
843
+ } catch { /* best-effort, exactly as build.py:444-445 — a marker hiccup never fails a build */ }
844
+ }
845
+
846
+ /**
847
+ * `_build_global.HOSTS[host]['emit_global']` — the one column a CALLER outside the dispatch
848
+ * needs, and the reason it is a named export rather than a fifth branch in `main`.
849
+ *
850
+ * `harness diff` renders an 'expected' copy of a deployed install into a temp dir and
851
+ * compares it file by file. The Python spells that `HOSTS[host]["emit_global"](theme,
852
+ * out=..., cfg=<tmp>, footprint=...)`, where `cfg` overrides the target the emit would
853
+ * otherwise resolve. This is that call, and the override is threaded as `args.cfgDir` through
854
+ * the four `emit*Global` bodies rather than added to `parseArgs`: `build.py` has no
855
+ * `--cfg-dir` flag, and a flag on one driver and not the other is a divergence
856
+ * `tests/golden.py` compares 259 times and could never see, because it drives both from the
857
+ * same argv.
858
+ *
859
+ * WHICH SIDE OF THE BOUNDARY THIS SITS ON. P3c's rule was that a render CHILD must never
860
+ * resolve a config dir, because a child that did would write 135 files into the developer's
861
+ * real `~/.config/opencode`; P4a inverted it for the driver, which is the parent and must
862
+ * ORIGINATE what the child may not resolve. `diff` is a third position: it is a parent that
863
+ * needs the target to be somewhere other than where the resolver would put it, so it sends
864
+ * the value and the resolver is skipped entirely. An emit whose `cfgDir` came from anywhere
865
+ * but this argument would overwrite the live install `diff` is only reading.
866
+ */
867
+ const GLOBAL_EMITS = {
868
+ opencode: emitOpencodeGlobal,
869
+ claude: emitClaudeGlobal,
870
+ bob: emitBobGlobal,
871
+ copilot: emitCopilotGlobal,
872
+ };
873
+
874
+ /**
875
+ * The PER-REPO half of the same idea, and `doctor` is its only caller.
876
+ *
877
+ * `_claude_bob_emit_problems` renders `emit_claude`, `emit_bob` and `emit_copilot` into
878
+ * throwaway sandboxes and scans each — the three emits that write CLAUDE.md/AGENTS.md
879
+ * straight into a repo, and the ones outside doctor's sweep when the skill-table dead links
880
+ * shipped.
881
+ *
882
+ * IT CALLS THE EMIT, NOT `main`, AND THAT IS THE WHOLE POINT OF THE EXPORT. Routing this
883
+ * through `driverMain(['--emit', 'claude', …])` would look like less code and would run two
884
+ * stages the Python's direct call never reaches: `writeMarkers` drops `.geneseed-emit` and
885
+ * `.geneseed-footprint` into the sandbox, and `registryRecord` writes a row into the USER'S
886
+ * install registry for a temp directory that is deleted a millisecond later. A validation
887
+ * pass must not register an install.
888
+ *
889
+ * `footprint: 'full'` is the Python signature default that `_claude_bob_emit_problems`'s
890
+ * three-positional call leaves in place, inherited here rather than re-decided.
891
+ */
892
+ const PROJECT_EMITS = {
893
+ claude: emitClaude, bob: emitBob, copilot: emitCopilot,
894
+ // P2. `opencode` joins the three for `cmdValidate`, which has to be able to render EVERY
895
+ // `--emit` choice into its sandbox and not only the three doctor already scans. Its call
896
+ // shape is `emitClaude`'s exactly — `(cfg, args, out)` — so the row is the whole change.
897
+ opencode: emitOpencode,
898
+ };
899
+
900
+ export function emitProjectInto(host, { theme, out, root, footprint = 'full' }) {
901
+ const emit = PROJECT_EMITS[host];
902
+ return withPyNewlines(() => emit(makeCfg(), { theme, footprint, root }, out));
903
+ }
904
+
905
+ /**
906
+ * The ninth `--emit` choice — a plain `files` bundle — for the same caller and the same
907
+ * reason: `_validate_only`'s `else` branch is `build(args.theme, sandbox, args.footprint)`,
908
+ * a DIRECT call that reaches neither `writeMarkers` nor `registryRecord`.
909
+ *
910
+ * `nativeCatalog: false` is that three-positional call's signature default, inherited here
911
+ * exactly as `run` inherits it below.
912
+ */
913
+ export function buildInto({ theme, out, footprint = 'lean' }) {
914
+ return withPyNewlines(
915
+ () => build(makeCfg(), theme, out, { footprint, nativeCatalog: false }),
916
+ );
917
+ }
918
+
919
+ export function emitGlobalInto(host, {
920
+ theme, out, cfgDir, footprint, posture = null, mode = null,
921
+ }) {
922
+ // `build.HOSTS.get(host, build.HOSTS["opencode"])` — an unknown host falls back rather than
923
+ // raising, because the host comes from a marker file a user can edit.
924
+ const emit = GLOBAL_EMITS[host] ?? GLOBAL_EMITS.opencode;
925
+ // The same funnel `main` installs, for the same reason and not because `diff` asked: this
926
+ // runs the whole render tree, whose ~25 print sites write raw `\n`. `diff` swallows the
927
+ // emit's STDOUT and lets its stderr through (the Python's `redirect_stdout` does exactly
928
+ // that), so an untranslated WARN from `warnBobGlobalOverProject` would reach the user's
929
+ // terminal with the wrong bytes on the one stream the caller deliberately does not hide.
930
+ // POSTURE AND MODE TRAVEL WITH THE RENDER, and the `|| default` is load-bearing:
931
+ // `postureOfDir` returns null for "no install / undetectable", which `makeCfg`'s parameter
932
+ // default would NOT catch. Callers that pass nothing keep the reference's behaviour of
933
+ // rendering at `peer`/`direct`; the drift readers pass what the deployment actually says,
934
+ // because rendering `expected` at the defaults reports AGENT.md as edited on every install
935
+ // that chose a register — the same scar the footprint left in `diffCollect`, one axis over.
936
+ return withPyNewlines(
937
+ () => emit(
938
+ makeCfg({ posture: posture || 'peer', mode: mode || 'direct' }),
939
+ { theme, footprint, root: null, cfgDir }, out,
940
+ ),
941
+ );
942
+ }
943
+
944
+ /**
945
+ * EXPORTED because `harness build` is a passthrough to this program.
946
+ *
947
+ * `_harness_build.cmd_build` is `run([sys.executable, BUILD, *extra]).returncode` — Python
948
+ * needs a second process because `build.py` is a different PROGRAM. Here it is a module in
949
+ * the same one, and `bin/geneseed-cli.mjs` is under a transitive `child_process` ban, so
950
+ * `js/generate.mjs` calls this directly. The export is what makes the auto-run below need a
951
+ * guard: without one, importing this file to reach `main` would RUN the generator with the
952
+ * CLI's argv.
953
+ */
954
+ export function main(argv) {
955
+ // `withPyNewlines` is what makes this driver's bytes Python's bytes on Windows, for every
956
+ // print site in the whole render tree at once — see its docblock for why a funnel and not
957
+ // 25 calls to `pyPrint`. It wraps the `catch` as well as `run`, because `die` writes its
958
+ // line on the way out.
959
+ return withPyNewlines(() => {
960
+ try {
961
+ return run(argv);
962
+ } catch (e) {
963
+ // Every DELIBERATE refusal becomes this process's exit code — `die`'s, and equally
964
+ // `assertSourceComplete`'s and `effectiveTheme`'s, which have already explained
965
+ // themselves on stderr. An unmarked throw is a crash and keeps its stack, because
966
+ // Python prints a traceback for one and dressing it as a tidy refusal would hide a bug.
967
+ if (e && e.exitCode !== undefined) return e.exitCode;
968
+ throw e;
969
+ }
970
+ });
971
+ }
972
+
973
+ function run(argv) {
974
+ const args = parseArgs(argv, configDefaults());
975
+
976
+ // P2 crossed both of the flags this branch used to refuse, and they landed in two
977
+ // different places because the transitive `child_process` ban splits them.
978
+ //
979
+ // `--sync-themes` is HERE, because it needs nothing this driver may not have: it reads
980
+ // `themes/`, rewrites the files textually, and prints. `js/themes.mjs` carries it.
981
+ // Non-zero when files were CHANGED (0 == already in sync), so CI can run it as a drift
982
+ // check — build.py:393-397's mapping, and a refusal (exit 2) if the template is unreadable.
983
+ if (args.syncThemes) return syncThemes() ? 1 : 0;
984
+ // `--validate-only` is NOT, and cannot be: its source-tree half is the doctor, and
985
+ // `js/doctor.mjs` starts a process (`node --check` over the OpenCode plugins). This driver
986
+ // is under a transitive ban on reaching any such module, gated twice — by a source grep in
987
+ // `tests/test_node_cli_parity.py` and by an import walk in `tests/test_hook_cli_parity.py`.
988
+ // So the tool crossed onto the CLI binary, which already carries the doctor, and this flag
989
+ // points at it rather than silently building for real into the caller's `--out`.
990
+ if (args.validateOnly) {
991
+ die(2, '--validate-only lives on the CLI entry point, because it runs the doctor and '
992
+ + 'this generator may not start a process. Run: geneseed validate '
993
+ + '[--theme T] [--emit E] [--out O] [--root R] [--footprint F] [-v]');
994
+ }
995
+
996
+ // Every `--emit` choice has crossed as of P4e, so this branch is now unreachable — and it
997
+ // stays, because it is the partition `test_the_node_driver_classifies_every_emit` asserts:
998
+ // a tenth emit added to `EMITS` and not to `PORTED` refuses loudly here instead of falling
999
+ // through the dispatch's `else` and silently building a plain bundle.
1000
+ if (!PORTED.has(args.emit)) {
1001
+ // The `Run: …` tail naming the Python driver is gone. It named the fallback for an emit
1002
+ // this driver could not do, and there is no longer a second driver to fall back TO — the
1003
+ // partition is now "known" vs "not a thing", and only the refusal is left to say so.
1004
+ die(3, `--emit ${args.emit} is not one this driver builds (only `
1005
+ + `${[...PORTED].join(', ')} are).`);
1006
+ }
1007
+
1008
+ const out = resolveOut(args.out);
1009
+ const cfg = makeCfg(args);
1010
+
1011
+ // The marker directory is the emit's TARGET, which for a global emit is the config dir it
1012
+ // just rendered into and not `--out` at all — so the emit returns it rather than the
1013
+ // caller guessing (build.py:427-436 re-resolves; returning it keeps one resolution).
1014
+ let markerDir = out;
1015
+ if (args.emit === 'opencode') {
1016
+ emitOpencode(cfg, args, out);
1017
+ } else if (args.emit === 'opencode-global') {
1018
+ markerDir = emitOpencodeGlobal(cfg, args, out);
1019
+ } else if (args.emit === 'copilot') {
1020
+ // The PROJECT emits keep their markers in `out`, not in the host config dir the emit
1021
+ // wrote to (build.py:435-436) — `.github/` is the layer, `out` is the install.
1022
+ emitCopilot(cfg, args, out);
1023
+ } else if (args.emit === 'copilot-global') {
1024
+ markerDir = emitCopilotGlobal(cfg, args, out);
1025
+ } else if (args.emit === 'claude') {
1026
+ emitClaude(cfg, args, out);
1027
+ } else if (args.emit === 'claude-global') {
1028
+ markerDir = emitClaudeGlobal(cfg, args, out);
1029
+ } else if (args.emit === 'bob') {
1030
+ emitBob(cfg, args, out);
1031
+ } else if (args.emit === 'bob-global') {
1032
+ markerDir = emitBobGlobal(cfg, args, out);
1033
+ } else {
1034
+ // `nativeCatalog: false` is build.py:421's three-positional-argument call reproduced:
1035
+ // `build(args.theme, out, args.footprint)` leaves `native_catalog` at its signature
1036
+ // default. It is the one signature default this driver DOES inherit, and it is
1037
+ // inherited because the Python CLI inherits it too.
1038
+ build(cfg, args.theme, out, { footprint: args.footprint, nativeCatalog: false });
1039
+ }
1040
+
1041
+ writeMarkers(markerDir, args.emit, args.footprint);
1042
+ // build() drops a .geneseed-theme in `out` for the emits that call it; the global emits
1043
+ // render into the config dir WITHOUT calling build(), so the theme is recorded here.
1044
+ // Deliberately not written for the claude/bob/copilot PROJECT emits — they carry none,
1045
+ // and `_harness_setup._installed_defaults` detects those by an AGENT.md sigil scan
1046
+ // instead. Writing one for them would change the emitted tree.
1047
+ if (args.emit.endsWith('-global')) {
1048
+ try {
1049
+ writeText(path.join(markerDir, '.geneseed-theme'), `${args.theme}\n`);
1050
+ } catch { /* best-effort, as build.py:449-452 */ }
1051
+ }
1052
+ // An ALLOW-LIST, not "everything that is not global": a plain `--emit files` dev build —
1053
+ // the default — must never pollute the registry, and only the four per-repo host emits
1054
+ // are ones `_EMIT_HOST_SCOPE` can map back to a row. Records `out`, where the marker is.
1055
+ if (['opencode', 'claude', 'bob', 'copilot'].includes(args.emit)) registryRecord(markerDir);
1056
+ return 0;
1057
+ }
1058
+
1059
+ /**
1060
+ * Run only when this file IS the entry point, not when `js/generate.mjs` imports it.
1061
+ *
1062
+ * `import.meta.main` would say this in one word and is Node 24; the spec's `engines` floor
1063
+ * is still open and this machine runs v22, the same reason `js/emit.mjs` avoids it. So the
1064
+ * check is the manual one, through `realpathSync` because `process.argv[1]` is whatever the
1065
+ * caller typed and `import.meta.url` is always the resolved file — an `npx`-generated shim
1066
+ * or a `geneseed link` symlink makes those differ as strings while naming the same file.
1067
+ *
1068
+ * BOTH ways of getting this wrong are gated, which is why it is a guard and not a comment,
1069
+ * and both were measured rather than argued:
1070
+ *
1071
+ * * stuck FALSE — `node bin/geneseed.mjs` becomes a silent no-op: 259 golden cells.
1072
+ * * stuck TRUE — importing this file runs the generator on the IMPORTER's argv, so it is
1073
+ * not only `build` that breaks. `bin/geneseed-cli.mjs` reaches `js/generate.mjs` at
1074
+ * module load, so every CLI verb dies in the driver's flag parser: **62 of the 166
1075
+ * acceptance cells**, `exclude` and `status` and `version` among them.
1076
+ */
1077
+ const entry = (() => {
1078
+ if (!process.argv[1]) return false;
1079
+ try {
1080
+ return realpathSync.native(process.argv[1]) === realpathSync.native(fileURLToPath(import.meta.url));
1081
+ } catch {
1082
+ return path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
1083
+ }
1084
+ })();
1085
+
1086
+ if (entry) process.exitCode = main(process.argv.slice(2));