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/emit.mjs ADDED
@@ -0,0 +1,1373 @@
1
+ /**
2
+ * The emit half, in Node — and the process boundary Python drives it across.
3
+ *
4
+ * `js/render.mjs` renders, `js/native.mjs` and `js/opencode.mjs` write the host-native
5
+ * layers, `js/settings.mjs` edits the files the user co-owns; this module is the piece that
6
+ * turns them into an install on disk and the CLI that a `build.py` emit spawns. It is a
7
+ * translation of `_build_render.build` plus BOTH halves of `_build_emit.emit_opencode`,
8
+ * `_build_global.emit_opencode_global` and `_build_global._emit_claude_core` — the last of
9
+ * which is the shared engine behind six emits, so the four job kinds below cover all NINE.
10
+ *
11
+ * "ALL NINE" IS A MEASUREMENT HERE, NOT A CLAIM. It was a claim for two phases and it was
12
+ * false: `emit_opencode_global` spawned Node zero times while four documents said it did,
13
+ * because it was the one mode with no boundary cell able to contradict them.
14
+ * `tests/test_seam_coverage.py` now counts `run_node` invocations per mode and pins the
15
+ * table, and it refuses a mode that crosses without a cell in `tests/test_emit_boundary.py`
16
+ * — so the sentence above cannot come back without the observation behind it.
17
+ *
18
+ * THE HANDOFF THIS MODULE WAS SHAPED BY. Python drove, one spawn per emit:
19
+ *
20
+ * the Python driver, one emit
21
+ * +- spawn node js/emit.mjs <job.json>
22
+ * | RENDER Node writes every file Geneseed owns wholesale
23
+ * | WIRE Node merges the CLAUDE.md managed block, settings(.local).json
24
+ * | and opencode.json — the files the USER co-owns
25
+ * | Node returns { owned, stats, managed, stdout, stderr } as JSON on stdout
26
+ * +- Python PRUNE old_owned - owned
27
+ * +- Python MANIFEST owned + managed
28
+ * +- Python VERIFY _settings_integrity_check
29
+ *
30
+ * WIRE joined the child at P3b and VERIFY deliberately did not. VERIFY runs after MANIFEST
31
+ * and MANIFEST is Python, so with one spawn per emit there is no child left to run it in —
32
+ * and that is worth having rather than merely tolerable: `_settings_integrity_check` never
33
+ * writes, so every Claude-shaped emit now ends with PYTHON re-reading the settings file
34
+ * NODE just wrote and checking it against the claims NODE just returned. Two
35
+ * implementations of the wiring layer have to interoperate on a real file, on every build.
36
+ *
37
+ * `hookOpts` is the one job field this module cannot compute and must not default. It
38
+ * carries the interpreter and entry point the hook shim bakes, and the child's own
39
+ * `process.execPath` is *node* while the hooks it is wiring are still Python. Left
40
+ * undefined, `hookShimBody` would write `"undefined" "undefined" %*` into the shim and every
41
+ * hook in the install would be dead — so `hookPrefix` throws instead of defaulting.
42
+ *
43
+ * Python still drives because the runtime *is* Python and calls `build.emit_*` in-process
44
+ * from doctor, web deploy, setup and rebuild-all; Node-as-driver would wrap each of those
45
+ * in a subprocess. The seam is designed to collapse: the driver becomes `geneseed.mjs`, the
46
+ * spawn becomes an import, and this job object becomes the function signature.
47
+ *
48
+ * STDOUT CARRIES THE PROTOCOL AND NOTHING ELSE, STRUCTURALLY.
49
+ *
50
+ * The generator prints progress to stdout, and so does this process's protocol — the same
51
+ * stream. Rather than ask every future caller to remember which is which, `main` REPLACES
52
+ * `process.stdout.write` and `process.stderr.write` with buffers for the whole run and
53
+ * restores them only to emit the single JSON document. So:
54
+ *
55
+ * - a stray `console.log` anywhere in the render tree cannot corrupt the protocol; it
56
+ * lands in `payload.stdout`, Python re-prints it, and the byte comparison against the
57
+ * Python generator FAILS. Loud, not silent.
58
+ * - Python re-emits both buffers through its own `print`, so the emitted bytes carry
59
+ * Python's encoding behaviour (including its cp1252 failure modes) unchanged. The
60
+ * alternative — letting Node inherit the streams — would have written UTF-8 where
61
+ * Python writes the console's locale encoding, a divergence on exactly the machines
62
+ * least able to report it.
63
+ *
64
+ * This matters beyond tidiness because the same modules reach hook paths at P3, where the
65
+ * emitted git-gate and rule-gate signal their verdict as JSON on stdout and return 0 on
66
+ * every path: one stray byte there turns a blocking gate into a silently permissive one.
67
+ */
68
+ import {
69
+ existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync,
70
+ } from 'node:fs';
71
+ import { createHash } from 'node:crypto';
72
+ import { pathToFileURL } from 'node:url';
73
+ import path from 'node:path';
74
+ import { destRel, renderAll, renderFile, SRC_DIR_TOKENS } from './render.mjs';
75
+ import { writeNativeLayer, loadAgentOverrides } from './native.mjs';
76
+ import {
77
+ ensureAgentOverridesStub, writePrimaryAgent, writeCommandLayer, writePonytailCommand,
78
+ writeTheme, writeColorThemes, copyPlugins, copyWorkflows, sourceReleaseVersion,
79
+ } from './opencode.mjs';
80
+ import {
81
+ mergeOpencodeJson, mergeClaudeSettings, wireClaudeExcludes, unwireClaudeExcludes,
82
+ unwireClaudeSettings, managedBlockWrite, managedBlockRemove,
83
+ } from './settings.mjs';
84
+ import { VERSION_MARKER } from './hosts.mjs';
85
+ import {
86
+ writeText, readText, copyFile, jsonDumpsIndent, parseJson, pyAscii, pyTruthy,
87
+ } from './lib/fs.mjs';
88
+ /** `_build_render.SRC_DIRS_MARKER`. */
89
+ const SRC_DIRS_MARKER = '.geneseed-srcdirs.json';
90
+ /** `_build_render.OWNED_SRC_DIRS` — wiped and regenerated each run. */
91
+ const OWNED_SRC_DIRS = ['laws', 'agents', 'skills'];
92
+
93
+ /**
94
+ * `_build_core.CAPABILITY_LINK_RE` — the DEFAULT only.
95
+ *
96
+ * `cfg.capabilityLinkRe` overrides it and the driver always supplies one, for the same
97
+ * reason `cfg.structure` exists: the constant is in `_OWNED` because a doctor test
98
+ * redirects it to the pre-fix form and asserts the emit then renders dead links, and a
99
+ * redirect that stops at the process boundary half-works silently. The literal stays as
100
+ * the fallback for the parity harnesses, which drive this module in-process.
101
+ */
102
+ const CAPABILITY_LINK_RE =
103
+ /\[([^\]]+)\]\((?:(?!https?:\/\/|\/)[A-Za-z0-9_.-]+\/)*(?:agents|skills)\/[A-Za-z0-9_-]+\.md\)/g;
104
+
105
+ /** `_build_render._TMPL_SPEC_RE`. */
106
+ const TMPL_SPEC_RE = /\{\{DIR_(AGENTS|SKILLS)\}\}\/([A-Za-z0-9_-]+)\.md/g;
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Seeded stubs. Written once into the bundle and NEVER overwritten — each holds
110
+ // something the user owns. Duplicated from `_build_render.py` rather than read from a
111
+ // shared file: the parity gate drives both sides, so drift surfaces as a failing cell
112
+ // instead of as two copies rotting apart, and a shared file would be a third format to
113
+ // keep in agreement. Backticks are escaped; a raw one would end the literal.
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /** `_build_render.CONTEXT_STUB`. */
117
+ const CONTEXT_STUB = {
118
+ _comment: "Point the agent at this project's own documentation. Each entry: 'path' "
119
+ + "(absolute, or relative to the repo root), 'load' ('eager' = read every "
120
+ + "session for small always-relevant rules; 'lazy' = read only when the task "
121
+ + "needs it), and 'description'. This file is host-specific — git-ignore it. "
122
+ + 'The build creates it once, empty, and never overwrites it.',
123
+ context: [],
124
+ };
125
+
126
+ /** `_build_render.WIKI_STUB`. */
127
+ const WIKI_STUB = `\
128
+ // Geneseed wiki.jsonc — declare your machine-wide knowledge base(s) here, typically
129
+ // an Obsidian vault (AGENT.md: the Wiki section). Comments are allowed in this file
130
+ // (JSONC). It is host-specific — never commit it. The build created it once, empty,
131
+ // and will never overwrite it.
132
+ //
133
+ // Each wiki carries:
134
+ // name a short label
135
+ // path absolute root of the vault (use forward slashes, also on Windows)
136
+ // description one line shown to the agent
137
+ // entries notes OR folders to load: path relative to the vault root ("." =
138
+ // the whole vault); load "eager" = read every session, "lazy" =
139
+ // read on demand; a folder applies its mode to every note beneath
140
+ // it, a file entry overrides its folder, "exclude" prunes
141
+ // conventions the vault's authoring-rules note — read before the first write
142
+ // inbox drop folder for notes the agent cannot confidently file
143
+ // protected folders the agent must never write to (guard-enforced on OpenCode)
144
+ //
145
+ // Example — copy this object into the "wikis" array below and edit:
146
+ // {
147
+ // "name": "Brain",
148
+ // "path": "C:/Users/me/Documents/Brain",
149
+ // "description": "my machine-wide knowledge base",
150
+ // "entries": [
151
+ // { "path": "ARCHITECTURE.md", "load": "eager", "description": "the root map" },
152
+ // { "path": ".", "load": "lazy" }
153
+ // ],
154
+ // "conventions": "STYLE.md",
155
+ // "inbox": "Inbox/",
156
+ // "protected": ["Journal/"]
157
+ // }
158
+ {
159
+ "wikis": []
160
+ }
161
+ `;
162
+
163
+ /** `_build_render.RULES_FILE`. */
164
+ const RULES_FILE = 'user-rules.md';
165
+
166
+ /** `_build_render.RULES_STUB`. */
167
+ const RULES_STUB = `\
168
+ # User rules
169
+
170
+ Your own standing rules. The agent obeys every rule in this file exactly as it
171
+ obeys the laws in AGENT.md §1 — always in force, in every task. A user rule may
172
+ *tighten* a law, never repeal one: where they conflict, the law wins.
173
+
174
+ Geneseed seeded this file once and will never overwrite it. The laws file is
175
+ regenerated on every update — never edit that one; this file is where your own
176
+ governance lives, and it survives updates, reinstalls, and theme switches.
177
+ Unlike \`context.json\`, it is safe to commit: project rules are meant to travel
178
+ with the repo and bind the whole team.
179
+
180
+ Keep the set small — every rule here is loaded every session, and a bloated
181
+ rule set dilutes the rules that matter. A durable fact belongs in memory, a
182
+ pointer to documentation belongs in \`context.json\`; only a standing *behaviour*
183
+ belongs here.
184
+
185
+ Format — one rule per \`## R<n> — Title\` heading, an optional metadata line in
186
+ parentheses, then the rule stated plainly:
187
+
188
+ ## R1 — No emoji in commit subjects
189
+ (scope: project | source: written by hand)
190
+ Commit subjects are plain text; no emoji, no decorative unicode.
191
+
192
+ \`trial until: YYYY-MM-DD\` in the metadata line marks a rule on probation —
193
+ usually one promoted from a recurring memory. Review it by that date, then
194
+ graduate it (remove the marker) or demote it back to memory.
195
+ `;
196
+
197
+ /** `_build_render.EXCLUDES_FILE`. */
198
+ export const EXCLUDES_FILE = 'excludes.json';
199
+
200
+ /**
201
+ * `_build_render.EXCLUDES_STUB` — one long line, exactly as Python spells it.
202
+ *
203
+ * Exported since P5c because `js/excludes.mjs` reads it as the single source of the shape a
204
+ * missing or corrupt `excludes.json` degrades to, exactly as `_harness_exclude._read_excludes`
205
+ * reads `build.EXCLUDES_STUB` rather than spelling `{"excludes": []}` a second time.
206
+ */
207
+ export const EXCLUDES_STUB = `\
208
+ {
209
+ "_comment": "Folders where this global Geneseed install goes dormant (hooks silent, preamble suppressed). Managed by \`harness exclude add|remove|list\`; safe to edit by hand. Paths are absolute.",
210
+ "excludes": []
211
+ }
212
+ `;
213
+
214
+ /**
215
+ * `_build_global._BOB_RULES_STUB` — the workspace shadow stub a PROJECT Bob emit ships.
216
+ *
217
+ * Exported since P5c: `harness exclude add` writes the SAME stub into an excluded repo, and
218
+ * decides ownership on the next `remove` by comparing the file's content to it byte for byte
219
+ * — so a second spelling here would orphan every stub the other writer created.
220
+ */
221
+ export const BOB_RULES_STUB = `\
222
+ <!-- geneseed: workspace shadow stub -->
223
+ This project's Geneseed instructions are the repo-root \`AGENTS.md\`, which Bob
224
+ auto-loads. This file exists only to shadow the same-named global Geneseed rules
225
+ file (\`~/.bob/rules/geneseed.md\`) so the global preamble does not stack on top of
226
+ the project's own. Follow the root \`AGENTS.md\`.
227
+ `;
228
+
229
+ /** `_build_render.PROFILE_FILE`. */
230
+ const PROFILE_FILE = 'PROFILE.md';
231
+
232
+ /** `_build_render.PROFILE_STUB`. */
233
+ const PROFILE_STUB = `\
234
+ # Your profile
235
+
236
+ Who you are and how you like to work — so the agent can meet you where you are
237
+ instead of guessing. Every section is optional; delete what you don't want to
238
+ share, add what you do. Geneseed seeded this file once and will never overwrite
239
+ it, so it survives updates, reinstalls, and theme switches.
240
+
241
+ This is *identity, not rules*. A standing rule the agent must obey belongs in
242
+ \`user-rules.md\` (AGENT.md §1); this file only colours how the agent works — tone,
243
+ depth, defaults. Where the two ever seem to conflict, the rule wins: precedence
244
+ is laws, then user-rules, then this profile.
245
+
246
+ ## Who I am
247
+
248
+ Role, domains you know deeply, domains you're learning. What you're usually here
249
+ to do.
250
+
251
+ ## How I work
252
+
253
+ Habits, tools, and environment worth knowing — your stack, your shell, the
254
+ conventions you hold to, the things that reliably annoy you.
255
+
256
+ ## Register preferences
257
+
258
+ How you like answers pitched: terse or expansive, teach-me or just-do-it, how
259
+ much pushback you want, which language(s) you think in.
260
+ `;
261
+
262
+ /** `_build_render.BUNDLE_GITIGNORE`. */
263
+ const BUNDLE_GITIGNORE = `\
264
+ # Generated by Geneseed. The rendered harness — AGENT.md, the laws, agents, and
265
+ # skills — is safe to commit; track it if you want it versioned with your project.
266
+ # Only the host-specific / personal files below are kept out of git.
267
+
268
+ # Project-context manifest — may hold private paths; never commit.
269
+ context.json
270
+
271
+ # Knowledge-base manifest — holds private machine paths; never commit.
272
+ # (wiki.json is the legacy name from earlier seeds.)
273
+ wiki.jsonc
274
+ wiki.json
275
+
276
+ # Per-agent model/temperature overrides — host-specific; never commit.
277
+ agent-overrides.json
278
+
279
+ # Which theme + emit mode + footprint this host last built (local build state, must not travel).
280
+ .geneseed-theme
281
+ .geneseed-emit
282
+ .geneseed-footprint
283
+ .geneseed-srcdirs.json
284
+
285
+ # memory/ keeps its own .gitignore so learned facts stay on this machine.
286
+ # notebook/ keeps its own .gitignore so the agent's own files stay on this machine.
287
+ `;
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // Small filesystem predicates. Python spells these `Path.is_file()` / `is_dir()`, both
291
+ // of which answer False for a missing path rather than raising; `statSync` throws.
292
+ // ---------------------------------------------------------------------------
293
+
294
+ function isFile(p) {
295
+ try { return statSync(p).isFile(); } catch { return false; }
296
+ }
297
+
298
+ function isDir(p) {
299
+ try { return statSync(p).isDirectory(); } catch { return false; }
300
+ }
301
+
302
+ /** `dict.get(key)` with Python's semantics — OWN properties only.
303
+ *
304
+ * The same hazard `js/settings.mjs` and `js/native.mjs` both spell out: the keys here come
305
+ * out of a manifest the user can edit, and `old['constructor']` hands back
306
+ * `Object.prototype`'s member where Python's `.get` returns None. */
307
+ function get(obj, key) {
308
+ return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
309
+ }
310
+
311
+ function isDict(v) {
312
+ return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
313
+ }
314
+
315
+ /** `Path.rglob("*")` restricted to files, skipping `__pycache__` as Python's callers do. */
316
+ function rglobFiles(root, out = []) {
317
+ for (const name of readdirSync(root)) {
318
+ if (name === '__pycache__') continue;
319
+ const full = path.join(root, name);
320
+ if (isDir(full)) rglobFiles(full, out);
321
+ else out.push(full);
322
+ }
323
+ return out;
324
+ }
325
+
326
+ /**
327
+ * `Path.rglob("*")` restricted to files, keeping EVERYTHING — including `__pycache__`.
328
+ *
329
+ * Not a duplicate of `rglobFiles` by accident: `source_fingerprint`'s Python filters
330
+ * `__pycache__` and the legacy-store migration in `_global_memory` does not, so a single
331
+ * walk would be wrong for one caller either way. A user's legacy bundle really can carry
332
+ * one (the harness ships `.py` skill scripts), and copying it or not is a byte difference
333
+ * the two runtimes would disagree on silently.
334
+ */
335
+ function rglobAllFiles(root, out = []) {
336
+ for (const name of readdirSync(root)) {
337
+ const full = path.join(root, name);
338
+ if (isDir(full)) rglobAllFiles(full, out);
339
+ else out.push(full);
340
+ }
341
+ return out;
342
+ }
343
+
344
+ /** `p.relative_to(base).as_posix()`. */
345
+ function relPosix(base, p) {
346
+ return path.relative(base, p).split(path.sep).join('/');
347
+ }
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // Version identity
351
+ // ---------------------------------------------------------------------------
352
+
353
+ /**
354
+ * `_build_render.source_fingerprint`.
355
+ *
356
+ * Sorted by the ROOT-relative POSIX path as a plain string — NOT `comparePaths`. Python
357
+ * sorts by an explicit `key=` here rather than by `Path` objects, so this one sort is
358
+ * case-SENSITIVE on every platform while `sorted(SRC.rglob("*"))` in `render_all` is not.
359
+ * Two sorts of the same files, two different orders, and only one of them is observable
360
+ * in the output; reproducing the wrong one changes the fingerprint of every install.
361
+ */
362
+ export function sourceFingerprint(cfg) {
363
+ const h = createHash('sha256');
364
+ const files = [];
365
+ for (const r of [cfg.src, cfg.themes, cfg.pluginSrc, cfg.workflowSrc]) {
366
+ if (isDir(r)) files.push(...rglobFiles(r));
367
+ }
368
+ const keyed = files.map((p) => [relPosix(cfg.root, p), p]);
369
+ keyed.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
370
+ for (const [rel, p] of keyed) {
371
+ h.update(Buffer.from(`${rel}\0`, 'utf8'));
372
+ h.update(readFileSync(p));
373
+ h.update(Buffer.from([0]));
374
+ }
375
+ return h.digest('hex').slice(0, 12);
376
+ }
377
+
378
+ /**
379
+ * `_build_render.read_version` — the FINGERPRINT token, which is a different name and a
380
+ * different value from `read_release_version` right below it. `harness version` and the
381
+ * status panel both compare this one against `sourceFingerprint`.
382
+ *
383
+ * `txt.split()[0] if txt else None`: a marker that is empty or whitespace-only reads as no
384
+ * version at all, so the caller's candidate walk carries on past it rather than stopping.
385
+ */
386
+ export function readVersion(dir) {
387
+ let txt;
388
+ try {
389
+ txt = readText(path.join(dir, VERSION_MARKER)).trim();
390
+ } catch {
391
+ return null;
392
+ }
393
+ // `str.split()` with no argument splits on runs of whitespace AND drops the leading
394
+ // empties; `String.split(/\s+/)` does not, so an indented marker would yield ''.
395
+ const first = txt.split(/\s+/).filter(Boolean)[0];
396
+ return first === undefined ? null : first;
397
+ }
398
+
399
+ /** `_build_render.read_release_version`. */
400
+ function readReleaseVersion(out) {
401
+ let txt;
402
+ try {
403
+ txt = readText(path.join(out, VERSION_MARKER)).trim();
404
+ } catch {
405
+ // Python catches OSError only. A marker that is not valid UTF-8 raises there and
406
+ // degrades to U+FFFD here (Node's decoder substitutes rather than reporting) — the
407
+ // same asymmetry already recorded for `agent-overrides.json`, and on no covered path.
408
+ return null;
409
+ }
410
+ const m = /\[release ([^\]]+)\]/.exec(txt);
411
+ return m ? m[1] : null;
412
+ }
413
+
414
+ /** `_build_core._parse_version_tuple`. */
415
+ function parseVersionTuple(v) {
416
+ const parts = v.trim().split('.');
417
+ if (!parts.length) return null;
418
+ const out = [];
419
+ for (const p of parts) {
420
+ // `str.isdigit()`, not `Number.isInteger(+p)`: '' and '1e2' and ' 1' are all
421
+ // non-digit to Python, and `+p` accepts every one of them.
422
+ if (!/^[0-9]+$/.test(p)) return null;
423
+ out.push(Number(p));
424
+ }
425
+ return out;
426
+ }
427
+
428
+ /**
429
+ * `_build_core.version_is_newer` — null when either side fails to parse.
430
+ *
431
+ * EXPORTED FOR ITS GATE, and the argument belongs here rather than in the test. Its only
432
+ * caller is `warnIfDowngrade`, one line down, whose whole observable behaviour is "print a
433
+ * warning or say nothing" — so through the emit face `false` and `null` are the SAME
434
+ * observation, and six of the nine claims this function makes are invisible. That numeric
435
+ * comparison beats lexical (`1.10.0` > `1.9.0`), that a short tuple is zero-padded on the
436
+ * right (`1.2` == `1.2.0`), and that an unparseable side answers `null` rather than `false`
437
+ * are each a decision with a wrong answer that no cell and no emit can distinguish.
438
+ *
439
+ * Nothing else changes: it is the same function, called the same way, from the same one
440
+ * place. See `tests/unit/lifecycle.test.mjs`.
441
+ */
442
+ export function versionIsNewer(a, b) {
443
+ const ta = parseVersionTuple(a);
444
+ const tb = parseVersionTuple(b);
445
+ if (ta === null || tb === null) return null;
446
+ const width = Math.max(ta.length, tb.length);
447
+ for (let i = 0; i < width; i++) {
448
+ const x = ta[i] ?? 0;
449
+ const y = tb[i] ?? 0;
450
+ if (x !== y) return x > y;
451
+ }
452
+ return false;
453
+ }
454
+
455
+ /** `_build_render._warn_if_downgrade` — STDOUT, matching the Python. */
456
+ function warnIfDowngrade(cfg, out) {
457
+ const deployed = readReleaseVersion(out);
458
+ if (deployed === null) return;
459
+ const current = sourceReleaseVersion(cfg);
460
+ if (versionIsNewer(deployed, current)) {
461
+ process.stdout.write(`[geneseed] WARN: installing older Geneseed ${current} over newer `
462
+ + `${deployed} at ${out} — did you forget git pull?\n`);
463
+ }
464
+ }
465
+
466
+ /** `_build_render.write_version`. */
467
+ function writeVersion(cfg, out) {
468
+ warnIfDowngrade(cfg, out);
469
+ const fp = sourceFingerprint(cfg);
470
+ const release = sourceReleaseVersion(cfg);
471
+ // `datetime.date.today().isoformat()` — the LOCAL date. `toISOString()` is UTC and
472
+ // would stamp tomorrow's date for anyone east of Greenwich after their evening.
473
+ //
474
+ // No gate can catch this one, and it is recorded rather than left to be discovered:
475
+ // both runtimes read their own clock at almost the same instant, so a UTC-vs-local
476
+ // mutation produces identical bytes except during the hour the two dates disagree.
477
+ // Mutating it away stays green here for a reason no cell can remove — freezing the
478
+ // clock across a process boundary is not something the harness can do — which is
479
+ // exactly why the choice is spelled out instead of relying on the comparison.
480
+ const d = new Date();
481
+ const today = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-`
482
+ + `${String(d.getDate()).padStart(2, '0')}`;
483
+ writeText(path.join(out, VERSION_MARKER),
484
+ `${fp} (built ${today}) [release ${release}]\n`);
485
+ return fp;
486
+ }
487
+
488
+ // ---------------------------------------------------------------------------
489
+ // Source completeness
490
+ // ---------------------------------------------------------------------------
491
+
492
+ /** `_build_render._missing_referenced_specs` — scans the TEMPLATE, not the render. */
493
+ function missingReferencedSpecs(cfg) {
494
+ let tmpl;
495
+ try {
496
+ tmpl = readText(path.join(cfg.src, 'AGENT.md.tmpl'));
497
+ } catch {
498
+ return [];
499
+ }
500
+ const missing = new Set();
501
+ for (const [, kind, name] of tmpl.matchAll(TMPL_SPEC_RE)) {
502
+ const dir = kind.toLowerCase();
503
+ if (name !== '_template' && !isFile(path.join(cfg.src, dir, `${name}.md`))) {
504
+ missing.add(`${dir}/${name}.md`);
505
+ }
506
+ }
507
+ return [...missing].sort();
508
+ }
509
+
510
+ /**
511
+ * `_build_render.assert_source_complete`.
512
+ *
513
+ * Python raises `SystemExit(1)` after writing to stderr. Here the message goes to the
514
+ * captured stderr buffer and the throw carries the exit code, which the driver turns
515
+ * back into a `SystemExit(1)` on the Python side — so the caller sees the same stream
516
+ * bytes and the same exit status whichever runtime rendered.
517
+ */
518
+ function assertSourceComplete(cfg, context = '') {
519
+ const missing = missingReferencedSpecs(cfg);
520
+ if (!missing.length) return;
521
+ const where = context ? ` (${context})` : '';
522
+ process.stderr.write(
523
+ `[geneseed][E-INCOMPLETE] ✗ source is incomplete${where}: AGENT.md references `
524
+ + `${missing.length} spec(s) with no file under src/:\n`
525
+ + missing.map((m) => ` - ${m}\n`).join('')
526
+ + '[geneseed] ✗ Refusing to emit — a partial source would write dead links '
527
+ + 'and a global re-emit would delete the good copies in an existing install.\n'
528
+ + '[geneseed] ✗ Re-sync the source (./geneseed update, or re-run the upgrade) '
529
+ + 'and try again.\n');
530
+ const e = new Error('source is incomplete');
531
+ e.exitCode = 1;
532
+ throw e;
533
+ }
534
+
535
+ // ---------------------------------------------------------------------------
536
+ // Seeded stubs — write-once, never overwrite
537
+ // ---------------------------------------------------------------------------
538
+
539
+ /** `_build_render.ensure_context_stub`. */
540
+ function ensureContextStub(out) {
541
+ const dest = path.join(out, 'context.json');
542
+ if (!existsSync(dest)) {
543
+ writeText(dest, `${jsonDumpsIndent(CONTEXT_STUB, { ensureAscii: false })}\n`);
544
+ }
545
+ }
546
+
547
+ /** `_build_render.ensure_wiki_stub` — a legacy `wiki.json` counts as present. */
548
+ function ensureWikiStub(out) {
549
+ const dest = path.join(out, 'wiki.jsonc');
550
+ if (!existsSync(dest) && !existsSync(path.join(out, 'wiki.json'))) writeText(dest, WIKI_STUB);
551
+ }
552
+
553
+ /** `_build_render.ensure_rules_stub`. */
554
+ function ensureRulesStub(out) {
555
+ const dest = path.join(out, RULES_FILE);
556
+ if (!existsSync(dest)) writeText(dest, RULES_STUB);
557
+ }
558
+
559
+ /** `_build_render.ensure_profile_stub`. */
560
+ function ensureProfileStub(out) {
561
+ const dest = path.join(out, PROFILE_FILE);
562
+ if (!existsSync(dest)) writeText(dest, PROFILE_STUB);
563
+ }
564
+
565
+ /**
566
+ * `_build_render.ensure_excludes_stub` — the sovereign-repo list, seeded once and NEVER
567
+ * overwritten. Reachable only from the Claude-shaped emits, which is why it arrived with
568
+ * `emitClaudeRender` rather than with the bundle stubs beside it.
569
+ */
570
+ function ensureExcludesStub(out) {
571
+ const dest = path.join(out, EXCLUDES_FILE);
572
+ if (!existsSync(dest)) writeText(dest, EXCLUDES_STUB);
573
+ }
574
+
575
+ /** `_build_render.ensure_bundle_gitignore`. */
576
+ function ensureBundleGitignore(out) {
577
+ const dest = path.join(out, '.gitignore');
578
+ if (!existsSync(dest)) writeText(dest, BUNDLE_GITIGNORE);
579
+ }
580
+
581
+ /** `_build_render.ensure_memory_index` — only inside an EXISTING store dir. */
582
+ function ensureMemoryIndex(memDir) {
583
+ if (!isDir(memDir)) return;
584
+ const idx = path.join(memDir, 'MEMORY.md');
585
+ if (!existsSync(idx)) writeText(idx, '# Memory Index\n');
586
+ }
587
+
588
+ /** `_build_render.ensure_notebook_index`. */
589
+ function ensureNotebookIndex(nbDir) {
590
+ if (!isDir(nbDir)) return;
591
+ const idx = path.join(nbDir, 'NOTEBOOK.md');
592
+ if (!existsSync(idx)) writeText(idx, '# Notebook Index\n');
593
+ }
594
+
595
+ // ---------------------------------------------------------------------------
596
+ // The owned-dir marker
597
+ // ---------------------------------------------------------------------------
598
+
599
+ /** `_build_render._read_prior_src_dirs` — `{}` on anything unreadable or not an object. */
600
+ function readPriorSrcDirs(out) {
601
+ let data;
602
+ try {
603
+ data = parseJson(readText(path.join(out, SRC_DIRS_MARKER)));
604
+ } catch {
605
+ return {};
606
+ }
607
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
608
+ }
609
+
610
+ /** `_build_render._write_src_dirs_marker` — temp + replace. */
611
+ function writeSrcDirsMarker(out, resolved) {
612
+ const tmp = path.join(out, `${SRC_DIRS_MARKER}.tmp`);
613
+ writeText(tmp, `${jsonDumpsIndent(resolved)}\n`);
614
+ renameSync(tmp, path.join(out, SRC_DIRS_MARKER));
615
+ }
616
+
617
+ /**
618
+ * The guard on the prune of a RENAMED owned dir — `build`'s one destructive path driven
619
+ * by file content rather than by code.
620
+ *
621
+ * `.geneseed-srcdirs.json` is a plain file a user (or another tool) can edit, and the
622
+ * name in it is joined onto `out` and handed to a recursive delete. `".."` would take out
623
+ * the bundle's PARENT, an absolute path replaces `out` entirely under Python's
624
+ * `Path.__truediv__`, and `"a/b"` reaches into nested content. Only a plain
625
+ * single-segment name resolving directly under `out` is ever deleted.
626
+ */
627
+ function safePriorDirName(out, priorName) {
628
+ return typeof priorName === 'string'
629
+ && priorName !== '.' && priorName !== '..'
630
+ && !path.isAbsolute(priorName)
631
+ && path.basename(priorName) === priorName
632
+ && path.dirname(path.resolve(out, priorName)) === path.resolve(out);
633
+ }
634
+
635
+ // ---------------------------------------------------------------------------
636
+ // build
637
+ // ---------------------------------------------------------------------------
638
+
639
+ /**
640
+ * `_build_render.build` — render the bundle into `out`.
641
+ *
642
+ * `out` arrives as the STRING Python already resolved, and is echoed into the progress
643
+ * lines verbatim: Python interpolates `str(Path)`, which is backslash-separated on
644
+ * Windows, and re-deriving it here would differ by separator in every message.
645
+ */
646
+ /**
647
+ * THE PHASE BOUNDARY MARKER — T8, and the whole of what this port owes
648
+ * `tests/test_emit_phase_order.py`.
649
+ *
650
+ * Every emit runs its stages in one order: RENDER* → WIRE* → PRUNE → MANIFEST → VERIFY. RENDER
651
+ * writes files Geneseed owns wholesale; WIRE reconciles files the USER co-owns (the
652
+ * CLAUDE.md/AGENTS.md managed block, settings(.local).json, opencode.json) by reading what is
653
+ * already there and merging into it. WIRE must precede MANIFEST because wiring is what fills the
654
+ * `managed` record the manifest writes, and every teardown path unwires exactly what was wired.
655
+ *
656
+ * WHY A RUNTIME LOG AND NOT A SOURCE WALK. The reference gated this with five `ast` walks over
657
+ * its own generator, and could, because it keeps `_claude_render` / `_claude_wire` as two
658
+ * dispatchers and therefore two statements at every call site — a shape its own docstring calls
659
+ * "not incidental, it is what leaves this walker something to check". THIS PORT DOES NOT HAVE
660
+ * THAT SHAPE: `claudeWire` is a real function but the two OpenCode emits inline their merge
661
+ * (`emitOpencodeRender`, `emitOpencodeGlobalRender`), and PRUNE/MANIFEST/VERIFY are not in this
662
+ * module at all — they are in `bin/geneseed.mjs`'s driver bodies. A walker would have to span two
663
+ * files and would still be reading source rather than watching a run.
664
+ *
665
+ * So the order is made OBSERVABLE instead, which is strictly the better gate: it reports what
666
+ * actually executed, in the order it executed, including the branch a given host took.
667
+ *
668
+ * ⚠ BEHIND AN ENV VAR THE THREE REPLAYERS DO NOT SET, and that is the entire safety argument.
669
+ * `cellEnv` clears every `GENESEED_*` knob by prefix, so no recorded cell can turn this on and
670
+ * the emit corpus cannot move by one byte. It writes to STDERR, never stdout: stdout is the
671
+ * decision channel every Geneseed hook signals through, and a stray byte there turns a blocking
672
+ * gate into a silently permissive one.
673
+ *
674
+ * Read at CALL time, not at import, so one process can drive several emits under it.
675
+ */
676
+ export function phaseLog(phase) {
677
+ if (!process.env.GENESEED_PHASE_LOG) return;
678
+ process.stderr.write(`[geneseed:phase] ${phase}\n`);
679
+ }
680
+
681
+ export function build(cfg, themeName, out, { footprint = 'full', nativeCatalog = false } = {}) {
682
+ phaseLog('RENDER');
683
+ const { theme, items } = renderAll(cfg, themeName, { footprint, nativeCatalog });
684
+ assertSourceComplete(cfg, `theme '${themeName}'`);
685
+ mkdirSync(out, { recursive: true });
686
+
687
+ // Wipe the owned dirs ONLY inside an established bundle: a first render into an
688
+ // arbitrary repo (`--out .`) must never delete an agents/ or skills/ the USER owns.
689
+ const isBundle = isFile(path.join(out, '.geneseed-theme')) || isFile(path.join(out, VERSION_MARKER));
690
+ const priorSrcDirs = isBundle ? readPriorSrcDirs(out) : {};
691
+ const resolvedSrcDirs = {};
692
+ for (const srcDir of OWNED_SRC_DIRS) {
693
+ // `theme.get(token, srcDir)` — `??` agrees with it except for an explicit JSON null,
694
+ // where Python would go on to join `None` onto a path and raise. No theme has one.
695
+ const dirname = theme[SRC_DIR_TOKENS[srcDir]] ?? srcDir;
696
+ resolvedSrcDirs[srcDir] = dirname;
697
+ const managed = path.join(out, dirname);
698
+
699
+ // `pyTruthy`, not `priorName &&`: the marker is user-editable JSON, and Python's
700
+ // `if prior_name` is false for `[]`, `{}` and `0` where JS's is true for the first
701
+ // two. Getting that wrong warns about a value Python skips in silence — a stderr
702
+ // divergence reachable only from a hand-edited file, which is the one place nobody
703
+ // would look for it.
704
+ const priorName = priorSrcDirs[srcDir];
705
+ if (isBundle && pyTruthy(priorName) && priorName !== dirname) {
706
+ if (safePriorDirName(out, priorName)) {
707
+ const stale = path.join(out, priorName);
708
+ if (isDir(stale)) rmSync(stale, { recursive: true, force: true });
709
+ } else {
710
+ process.stderr.write('[geneseed] WARN: ignoring suspicious prior dir name '
711
+ + `${pyAscii(priorName)} recorded in ${SRC_DIRS_MARKER} - not pruned.\n`);
712
+ }
713
+ }
714
+
715
+ if (!isDir(managed)) continue;
716
+ if (isBundle) {
717
+ rmSync(managed, { recursive: true, force: true });
718
+ } else {
719
+ // STDOUT, and ASCII-only: this print crashed with UnicodeEncodeError on a cp1252
720
+ // Windows console when it carried a warning-sign emoji.
721
+ process.stdout.write(`[geneseed] WARN: ${managed} already exists and ${out} is not a `
722
+ + 'Geneseed bundle — keeping it; rendered files merge into it.\n');
723
+ }
724
+ }
725
+
726
+ const nbDirname = theme[SRC_DIR_TOKENS.notebook] ?? 'notebook';
727
+ for (const { rel, text, src } of items) {
728
+ const dest = path.join(out, rel);
729
+ mkdirSync(path.dirname(dest), { recursive: true });
730
+ // The notebook is the agent's sovereign space: seeded files are written once and
731
+ // never re-emitted, so the agent may rewrite its own rules. Only `.gitignore` is
732
+ // re-asserted — the one law it cannot lift.
733
+ const parts = rel.split('/');
734
+ if (parts[0] === nbDirname && path.basename(rel) !== '.gitignore' && existsSync(dest)) {
735
+ continue;
736
+ }
737
+ if (text !== null) writeText(dest, text);
738
+ else copyFile(src, dest);
739
+ }
740
+
741
+ writeText(path.join(out, '.geneseed-theme'), `${themeName}\n`);
742
+ writeVersion(cfg, out);
743
+ writeSrcDirsMarker(out, resolvedSrcDirs);
744
+ ensureContextStub(out);
745
+ ensureWikiStub(out);
746
+ ensureRulesStub(out);
747
+ ensureProfileStub(out);
748
+ ensureBundleGitignore(out);
749
+ ensureMemoryIndex(path.join(out, theme[SRC_DIR_TOKENS.memory] ?? 'memory'));
750
+ ensureNotebookIndex(path.join(out, theme[SRC_DIR_TOKENS.notebook] ?? 'notebook'));
751
+ process.stdout.write(`[geneseed] built theme '${themeName}' -> ${out} (${items.length} files)\n`);
752
+ }
753
+
754
+ // ---------------------------------------------------------------------------
755
+ // emit_opencode — the RENDER stage only
756
+ // ---------------------------------------------------------------------------
757
+
758
+ /**
759
+ * `_build_emit._strip_capability_links` — `re.sub(r"\1")`, so the pattern needs `g`.
760
+ *
761
+ * Exported for `js/doctor.mjs`: `_rendered_problems` applies the same de-linking before
762
+ * comparing an OpenCode bundle's AGENT.md against a fresh render, because the emit puts
763
+ * that difference back by design and a check that did not would report the file stale on
764
+ * every run forever.
765
+ */
766
+ export function stripCapabilityLinks(cfg, text) {
767
+ const re = cfg.capabilityLinkRe
768
+ ? new RegExp(cfg.capabilityLinkRe, 'g')
769
+ : CAPABILITY_LINK_RE;
770
+ return text.replace(re, '$1');
771
+ }
772
+
773
+ /**
774
+ * The RENDER half of `_build_emit.emit_opencode`: everything up to (not including) the
775
+ * `_merge_opencode_json` call. WIRE, PRUNE and MANIFEST stay in Python.
776
+ *
777
+ * `oldOwned` and `manifestExisted` arrive in the job rather than being read here. The
778
+ * manifest is Python's stage — it writes it, prunes against it, and now reads it too, so
779
+ * there is one owner of the file instead of two processes racing to interpret it.
780
+ *
781
+ * `nativeCatalog` likewise arrives decided. It is `HOSTS['opencode']['native_catalog']`,
782
+ * and the HOSTS registry stays Python this phase; passing the decision keeps the registry
783
+ * in exactly one place rather than duplicating it for one boolean.
784
+ */
785
+ export function emitOpencodeRender(cfg, job) {
786
+ phaseLog('RENDER');
787
+ const {
788
+ theme: _theme, out, root, footprint, nativeCatalog, oldOwned, manifestExisted, agentPath,
789
+ } = job;
790
+ const oc = path.join(root, '.opencode');
791
+
792
+ build(cfg, _theme, out, { footprint, nativeCatalog });
793
+
794
+ // OpenCode loads agents/skills natively, so strip AGENT.md's per-row spec links to
795
+ // plain names (the portable build keeps them). A deliberate de-link, not a fix.
796
+ const agentMd = path.join(out, 'AGENT.md');
797
+ if (isFile(agentMd)) writeText(agentMd, stripCapabilityLinks(cfg, readText(agentMd)));
798
+
799
+ const owned = [];
800
+ const { theme, items } = renderAll(cfg, _theme);
801
+
802
+ ensureAgentOverridesStub(cfg, out);
803
+ const overrides = loadAgentOverrides(out);
804
+
805
+ const { nAgents, nSkills, written } = writeNativeLayer(
806
+ items, path.join(oc, 'agents'), path.join(oc, 'skills'), overrides,
807
+ { host: 'opencode', oldOwned, cfg: oc, manifestExisted, theme, src: cfg.src });
808
+ for (const p of written) owned.push(relPosix(oc, p));
809
+
810
+ const primary = writePrimaryAgent(cfg, path.join(oc, 'agents'), overrides);
811
+ if (primary) owned.push(relPosix(oc, primary));
812
+
813
+ const commands = writeCommandLayer(cfg, items, path.join(oc, 'command'));
814
+ commands.push(writePonytailCommand(path.join(oc, 'command'))); // always-on /ponytail
815
+ for (const p of commands) owned.push(relPosix(oc, p));
816
+
817
+ owned.push(relPosix(oc, writeTheme(path.join(oc, 'themes'), _theme, theme)));
818
+ for (const p of writeColorThemes(cfg, path.join(oc, 'themes'))) owned.push(relPosix(oc, p));
819
+
820
+ const nPlugins = copyPlugins(cfg, path.join(oc, 'plugins'), owned);
821
+ const nWorkflows = copyWorkflows(cfg, path.join(oc, 'workflows'), owned);
822
+
823
+ // WIRE — the one file of this emit the user co-owns. `agentPath` arrives decided
824
+ // (`_rel_under` is the Python side by design), and only the BASENAME is consumed
825
+ // downstream: `opencode.json`, or the `.jsonc` sibling when that is what is on disk.
826
+ phaseLog('WIRE');
827
+ const cfgName = path.basename(mergeOpencodeJson(path.join(root, 'opencode.json'), agentPath));
828
+
829
+ return {
830
+ owned,
831
+ stats: {
832
+ nAgents, nSkills, nPlugins, nWorkflows, nCommands: commands.length, primary: !!primary,
833
+ },
834
+ cfgName,
835
+ };
836
+ }
837
+
838
+ // ---------------------------------------------------------------------------
839
+ // The global-store helpers, shared by the `opencode-global` and `claude` jobs
840
+ // ---------------------------------------------------------------------------
841
+
842
+ /**
843
+ * `_build_global._global_memory` — ensure `<cfg>/memory` exists, without ever touching a
844
+ * store that already holds something.
845
+ *
846
+ * The three outcomes are the returned status string, which the emit prints, so they are
847
+ * compared through stdout as well as through the tree. Python's `theme` parameter is
848
+ * dropped: the store dir is ALWAYS the classic English `memory/`, never themed (the
849
+ * OpenCode config dir uses fixed names), so the argument was never read.
850
+ *
851
+ * The migration branch copies arbitrary USER files out of a legacy bundle, which is the
852
+ * reason this is not a plain translation: everything it touches is the user's, and the
853
+ * only thing keeping it safe is that it runs at all only when the destination is empty.
854
+ */
855
+ function globalMemory(cfgDir, items, legacy, srcRoot) {
856
+ const memName = 'memory';
857
+ const memDir = path.join(cfgDir, memName);
858
+ if (isDir(memDir) && readdirSync(memDir).length) return `kept ${memName}/`;
859
+ mkdirSync(memDir, { recursive: true });
860
+ if (legacy) {
861
+ // `dict.fromkeys([mem_name, "memory", "anamnesis"])` — de-duplicated, order kept.
862
+ // `mem_name` is the literal 'memory' and the Python docstring beside it says the
863
+ // store name is NEVER themed, so the first two entries always collapse and
864
+ // `anamnesis` — an older themed install's name — is the only live alias. The dedupe
865
+ // is what keeps the duplicate harmless rather than copying the same dir twice.
866
+ for (const nm of [...new Set([memName, 'memory', 'anamnesis'])]) {
867
+ const src = path.join(legacy, nm);
868
+ if (isDir(src) && readdirSync(src).length) {
869
+ for (const f of rglobAllFiles(src)) {
870
+ const dest = path.join(memDir, path.relative(src, f));
871
+ mkdirSync(path.dirname(dest), { recursive: true });
872
+ copyFile(f, dest);
873
+ }
874
+ return `migrated ${nm}/ -> ${memName}/`;
875
+ }
876
+ }
877
+ }
878
+ for (const { text, src } of items) {
879
+ const sp = relPosix(srcRoot, src).split('/');
880
+ if (sp[0] === 'memory' && sp.length > 1) {
881
+ // `destRel`, because this seeds from the SOURCE path and not from the item's `rel`
882
+ // (which is themed, and `memory/` never is). Without it the store would be seeded
883
+ // with the on-disk name `gitignore` — see `js/render.mjs`'s `destRel`.
884
+ const dest = path.join(memDir, destRel(path.join(...sp.slice(1))));
885
+ mkdirSync(path.dirname(dest), { recursive: true });
886
+ if (text !== null) writeText(dest, text);
887
+ else copyFile(src, dest);
888
+ }
889
+ }
890
+ return `seeded ${memName}/`;
891
+ }
892
+
893
+ /** `_build_global._global_notebook` — the same shape, with no `anamnesis` alias. */
894
+ function globalNotebook(cfgDir, items, legacy, srcRoot) {
895
+ const nbName = 'notebook';
896
+ const nbDir = path.join(cfgDir, nbName);
897
+ if (isDir(nbDir) && readdirSync(nbDir).length) return `kept ${nbName}/`;
898
+ mkdirSync(nbDir, { recursive: true });
899
+ if (legacy) {
900
+ const src = path.join(legacy, nbName);
901
+ if (isDir(src) && readdirSync(src).length) {
902
+ for (const f of rglobAllFiles(src)) {
903
+ const dest = path.join(nbDir, path.relative(src, f));
904
+ mkdirSync(path.dirname(dest), { recursive: true });
905
+ copyFile(f, dest);
906
+ }
907
+ return `migrated ${nbName}/`;
908
+ }
909
+ }
910
+ for (const { text, src } of items) {
911
+ const sp = relPosix(srcRoot, src).split('/');
912
+ if (sp[0] === nbName && sp.length > 1) {
913
+ // `destRel` — same reason as `globalMemory` above.
914
+ const dest = path.join(nbDir, destRel(path.join(...sp.slice(1))));
915
+ mkdirSync(path.dirname(dest), { recursive: true });
916
+ if (text !== null) writeText(dest, text);
917
+ else copyFile(src, dest);
918
+ }
919
+ }
920
+ return `seeded ${nbName}/`;
921
+ }
922
+
923
+ /**
924
+ * `_build_global._ship_lean_laws`.
925
+ *
926
+ * Historically the one RENDER that ran AFTER a WIRE, which is why the phase-order
927
+ * normalisation had to move it before the seam could be cut. Re-derived rather than
928
+ * trusted: its inputs are `items` and `theme` (both from `render_all`) and its only
929
+ * output besides the files is what it APPENDS to `owned` — which travels back in the
930
+ * payload for the manifest and the prune. It reads nothing any wiring stage writes, so
931
+ * its new position is not merely legal, it has no dependency on the old one.
932
+ */
933
+ function shipLeanLaws(items, theme, cfgDir, owned) {
934
+ const lawsDir = theme.DIR_LAWS ?? 'laws';
935
+ for (const { rel, text } of items) {
936
+ const parts = rel.split('/');
937
+ // `parts.length` mirrors Python's `parts and parts[0]`, where `Path("").parts` really
938
+ // is empty. `String.split` never returns an empty array, so this half can never be
939
+ // false here — kept for the mirror, and its mutation stays green because there is
940
+ // nothing to detect, not because the gate cannot see it.
941
+ if (text !== null && parts.length && parts[0] === lawsDir) {
942
+ const dest = path.join(cfgDir, ...parts);
943
+ mkdirSync(path.dirname(dest), { recursive: true });
944
+ writeText(dest, text);
945
+ owned.push(rel);
946
+ }
947
+ }
948
+ }
949
+
950
+ // ---------------------------------------------------------------------------
951
+ // emit_opencode_global — the ninth, and the last emit to cross
952
+ // ---------------------------------------------------------------------------
953
+
954
+ /**
955
+ * BOTH halves of `_build_global.emit_opencode_global`, the "everything global, zero
956
+ * per-repo" OpenCode deployment. PRUNE and MANIFEST stay in Python; there is no VERIFY,
957
+ * because this emit writes no settings file.
958
+ *
959
+ * ASSEMBLY, NOT TRANSLATION. Every unit it needs was already here — 58 of the 65 functions
960
+ * in its Python closure, counted with `ast` rather than by name-mapping, which is how the
961
+ * scout that produced this list first reported `_posture_body` and `_mode_body` as missing
962
+ * when both are `registerBody` in `js/render.mjs`. What is new is the assembly and the
963
+ * three values that had to be decided on the Python side.
964
+ *
965
+ * `cfgDir` IS THE FIRST OF THEM AND THE REASON THE OTHERS WERE ASKED ABOUT.
966
+ * `_build_core._opencode_config_dir` is an `_OWNED` name — the suite and `harness diff`
967
+ * both redirect it at a sandbox — so resolving it here would render 135 files into the
968
+ * developer's real `~/.config/opencode`. Python's entry point resolves it once, before
969
+ * anything else, and sends the answer. Fourth phase running (`STRUCTURE` at P2d,
970
+ * `capabilityLinkRe` at P2e, `_PREAMBLE_CONFIG_DIR` at P3b): send the decision, never the
971
+ * resolver.
972
+ *
973
+ * `out` IS NOT THE TARGET, and with the default nobody can tell. `emit_opencode_global`
974
+ * writes into `<cfg>` and takes `out` ONLY as the legacy bundle to migrate a memory or
975
+ * notebook store from; the CLI's `--out` defaults to a directory this emit never writes a
976
+ * byte into. A port that derived the legacy source from `<cfg>` would pass every cell that
977
+ * leaves them coincident — the `claude/bundle-in-subfolder` finding at a third call site.
978
+ *
979
+ * `agentPath` travels for the same reason `emitOpencodeRender`'s does: it is WIRE's one
980
+ * input, and `Path.as_posix()` is the Python side's spelling of it.
981
+ */
982
+ export function emitOpencodeGlobalRender(cfg, job) {
983
+ phaseLog('RENDER');
984
+ const {
985
+ theme: themeName, cfgDir, out, footprint, nativeCatalog, oldOwned, agentPath,
986
+ } = job;
987
+
988
+ // No `lawsPrefix`: the standalone laws dir sits beside AGENT.md in <cfg>, so the lean
989
+ // pointer's relative `laws/universal.md` resolves with no prefix.
990
+ const { theme, items } = renderAll(cfg, themeName, { footprint, nativeCatalog });
991
+ assertSourceComplete(cfg, 'opencode-global');
992
+ mkdirSync(cfgDir, { recursive: true });
993
+
994
+ const owned = [];
995
+ const agentText = items.find((i) => i.rel === 'AGENT.md' && i.text !== null)?.text ?? null;
996
+ if (agentText !== null) {
997
+ // OpenCode loads agents/skills natively, so drop AGENT.md's per-row spec links to
998
+ // plain names. Memory links stay RELATIVE: in the global layout AGENT.md and the
999
+ // store are siblings, so `memory/` resolves from AGENT.md's own location and stays
1000
+ // hermetic — no absolute path a doctor check would flag as an escape.
1001
+ writeText(path.join(cfgDir, 'AGENT.md'), stripCapabilityLinks(cfg, agentText));
1002
+ owned.push('AGENT.md');
1003
+ }
1004
+
1005
+ ensureAgentOverridesStub(cfg, cfgDir);
1006
+ const overrides = loadAgentOverrides(cfgDir);
1007
+
1008
+ // `manifestExisted` is deliberately not passed — the Python does not pass it either, so
1009
+ // the pre-manifest header line is unreachable from this emit on both sides.
1010
+ const { nAgents, nSkills, written } = writeNativeLayer(
1011
+ items, path.join(cfgDir, 'agents'), path.join(cfgDir, 'skills'), overrides,
1012
+ { host: 'opencode', oldOwned, cfg: cfgDir, theme, src: cfg.src });
1013
+ for (const p of written) owned.push(relPosix(cfgDir, p));
1014
+
1015
+ const primary = writePrimaryAgent(cfg, path.join(cfgDir, 'agents'), overrides);
1016
+ if (primary) owned.push(relPosix(cfgDir, primary));
1017
+
1018
+ const commands = writeCommandLayer(cfg, items, path.join(cfgDir, 'command'));
1019
+ commands.push(writePonytailCommand(path.join(cfgDir, 'command'))); // always-on /ponytail
1020
+ for (const p of commands) owned.push(relPosix(cfgDir, p));
1021
+
1022
+ owned.push(relPosix(cfgDir, writeTheme(path.join(cfgDir, 'themes'), themeName, theme)));
1023
+ for (const p of writeColorThemes(cfg, path.join(cfgDir, 'themes'))) {
1024
+ owned.push(relPosix(cfgDir, p));
1025
+ }
1026
+
1027
+ const nPlugins = copyPlugins(cfg, path.join(cfgDir, 'plugins'), owned);
1028
+ const nWorkflows = copyWorkflows(cfg, path.join(cfgDir, 'workflows'), owned);
1029
+
1030
+ const memStatus = globalMemory(cfgDir, items, out, cfg.src);
1031
+ ensureMemoryIndex(path.join(cfgDir, 'memory'));
1032
+ const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
1033
+ ensureNotebookIndex(path.join(cfgDir, 'notebook'));
1034
+ ensureWikiStub(cfgDir);
1035
+ ensureRulesStub(cfgDir);
1036
+ ensureProfileStub(cfgDir);
1037
+ ensureExcludesStub(cfgDir);
1038
+
1039
+ writeVersion(cfg, cfgDir);
1040
+ owned.push(VERSION_MARKER);
1041
+
1042
+ if (footprint === 'lean') shipLeanLaws(items, theme, cfgDir, owned);
1043
+
1044
+ // WIRE — the one file of this emit the user co-owns, and the last render is now behind
1045
+ // us. Inlined the way `emitOpencodeRender`'s is (one call, the same merge, the same
1046
+ // file name), while the Python side keeps it a separate `_opencode_global_wire_py`:
1047
+ // that split is what `tests/test_emit_phase_order.py` walks, and it walks the Python.
1048
+ phaseLog('WIRE');
1049
+ const cfgName = path.basename(mergeOpencodeJson(path.join(cfgDir, 'opencode.json'),
1050
+ agentPath));
1051
+
1052
+ return {
1053
+ owned,
1054
+ stats: {
1055
+ nAgents, nSkills, nPlugins, nWorkflows, nCommands: commands.length, primary: !!primary,
1056
+ },
1057
+ memStatus,
1058
+ nbStatus,
1059
+ cfgName,
1060
+ };
1061
+ }
1062
+
1063
+ // ---------------------------------------------------------------------------
1064
+ // _emit_claude_core — both halves
1065
+ // ---------------------------------------------------------------------------
1066
+
1067
+ /**
1068
+ * The RENDER half of `_build_global._emit_claude_core`: everything up to (not including)
1069
+ * the CLAUDE.md managed-block merge. WIRE, PRUNE, MANIFEST and VERIFY stay in Python.
1070
+ *
1071
+ * Six emits route through here — claude, bob and copilot at both scopes — so this one
1072
+ * job is what puts two thirds of the matrix on the seam.
1073
+ *
1074
+ * `claudeMdText` is the payload's one item that is not a file this process wrote. WIRE
1075
+ * needs the managed block's text, and that text is a RENDER (`prefixedAgentText`
1076
+ * re-renders AGENT.md with every store dir prefixed), so it is computed here and merged
1077
+ * there. `emitOpencodeRender` needed nothing of the kind — its wired file is derived from
1078
+ * a path, not from a render.
1079
+ */
1080
+ export function emitClaudeRender(cfg, job) {
1081
+ phaseLog('RENDER');
1082
+ const {
1083
+ theme: themeName, cfgDir, claudeMd, scope, out, footprint, host, nativeCatalog,
1084
+ oldOwned,
1085
+ } = job;
1086
+
1087
+ // `os.path.relpath(cfg, claude_md.parent)` — note the reversed argument order, and
1088
+ // that Python answers '.' for an identical pair where `path.relative` answers ''.
1089
+ const relCfg = path.relative(path.dirname(claudeMd), cfgDir).split(path.sep).join('/')
1090
+ || '.';
1091
+ const lawsPrefix = relCfg === '.' ? '' : `${relCfg}/`;
1092
+ const { theme, items } = renderAll(cfg, themeName, { footprint, lawsPrefix, nativeCatalog });
1093
+ assertSourceComplete(cfg, `claude-${scope}`);
1094
+ mkdirSync(cfgDir, { recursive: true });
1095
+
1096
+ /** `_emit_claude_core._prefixed_agent_text`. */
1097
+ const prefixedAgentText = (prefix) => {
1098
+ const tmplItem = items.find((i) => i.rel === 'AGENT.md');
1099
+ if (tmplItem === undefined) return null;
1100
+ // Deliberately NOT the same lookup as `agentText` below: that one skips an
1101
+ // AGENT.md whose text is null, this one does not. Unreachable today (`.md` is a
1102
+ // text suffix, so the render is never null) and reproduced anyway, because the two
1103
+ // spellings sit four lines apart in the Python and only one of them filters.
1104
+ //
1105
+ // The fast path itself is byte-inert, measured: removing it re-renders with a theme
1106
+ // whose DIR_* tokens gained an EMPTY prefix and with the same lawsPrefix, so it
1107
+ // produces exactly the item's own text. The mutation stays green, and that is
1108
+ // "nothing to detect" rather than "the gate cannot see it" — recorded the way
1109
+ // `themed_rel` and `lstripNewlines` are, so the two verdicts stay distinguishable.
1110
+ if (!prefix) return tmplItem.text;
1111
+ const ptheme = { ...theme };
1112
+ for (const tok of ['DIR_LAWS', 'DIR_AGENTS', 'DIR_SKILLS', 'DIR_MEMORY', 'DIR_NOTEBOOK']) {
1113
+ ptheme[tok] = prefix + (ptheme[tok] ?? tok.split('_').slice(1).join('_').toLowerCase());
1114
+ }
1115
+ // Same catalogue decision as the renderAll above — a re-render that forgot it would
1116
+ // quietly put the stripped tables back.
1117
+ return renderFile(cfg, tmplItem.src, ptheme, footprint, '', new Set(), nativeCatalog);
1118
+ };
1119
+
1120
+ const owned = [];
1121
+ const agentText = items.find((i) => i.rel === 'AGENT.md' && i.text !== null)?.text ?? null;
1122
+ const isBob = host === 'bob';
1123
+ const isCopilot = host === 'copilot';
1124
+
1125
+ // Bob's always-injected channel is the rules folder. At GLOBAL scope rules/geneseed.md
1126
+ // IS the preamble (a global ~/.bob/AGENTS.md is not auto-loaded), and its pointers need
1127
+ // a `../` prefix because it sits one level below the stores; at PROJECT scope it is the
1128
+ // slim shadow stub whose only job is to have the same filename.
1129
+ if (isBob && agentText !== null) {
1130
+ const rulesMd = path.join(cfgDir, 'rules', 'geneseed.md');
1131
+ mkdirSync(path.dirname(rulesMd), { recursive: true });
1132
+ writeText(rulesMd, scope === 'project' ? BOB_RULES_STUB
1133
+ : stripCapabilityLinks(cfg, prefixedAgentText('../') || agentText));
1134
+ owned.push('rules/geneseed.md');
1135
+ }
1136
+
1137
+ ensureAgentOverridesStub(cfg, cfgDir);
1138
+ // Bob's agents/skills use the Claude dialect verbatim; Copilot has its own frontmatter.
1139
+ // `manifestExisted` is deliberately not passed: the Python does not pass it either, so
1140
+ // the pre-manifest header line is unreachable from this emit on both sides.
1141
+ const { nAgents, nSkills, written } = writeNativeLayer(
1142
+ items, path.join(cfgDir, 'agents'), path.join(cfgDir, 'skills'),
1143
+ loadAgentOverrides(cfgDir),
1144
+ { host: isCopilot ? 'copilot' : 'claude', oldOwned, cfg: cfgDir, src: cfg.src });
1145
+ for (const p of written) owned.push(relPosix(cfgDir, p));
1146
+
1147
+ const memStatus = globalMemory(cfgDir, items, out, cfg.src);
1148
+ ensureMemoryIndex(path.join(cfgDir, 'memory'));
1149
+ const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
1150
+ ensureNotebookIndex(path.join(cfgDir, 'notebook'));
1151
+ ensureWikiStub(cfgDir);
1152
+ ensureRulesStub(cfgDir);
1153
+ ensureProfileStub(cfgDir);
1154
+ ensureExcludesStub(cfgDir);
1155
+
1156
+ // Project hygiene, claim-on-create: an existing (possibly user-authored) .gitignore is
1157
+ // never rewritten, but one we created stays owned across re-emits.
1158
+ if (scope === 'project') {
1159
+ const gi = path.join(cfgDir, '.gitignore');
1160
+ const giLines = (host === 'claude' ? ['settings.local.json'] : [])
1161
+ .concat(['wiki.jsonc', 'agent-overrides.json']);
1162
+ if (!existsSync(gi)) {
1163
+ writeText(gi, `${giLines.join('\n')}\n`);
1164
+ owned.push('.gitignore');
1165
+ } else if (oldOwned.includes('.gitignore')) {
1166
+ owned.push('.gitignore');
1167
+ }
1168
+ }
1169
+
1170
+ writeVersion(cfg, cfgDir);
1171
+ owned.push(VERSION_MARKER);
1172
+
1173
+ if (footprint === 'lean') shipLeanLaws(items, theme, cfgDir, owned);
1174
+
1175
+ // The last render of the emit, and the only one whose product is not written here.
1176
+ // Bob at GLOBAL scope gets no managed block at all, so nothing is rendered for it —
1177
+ // the condition is the merge's, kept here so RENDER produces exactly what WIRE
1178
+ // consumes and no more.
1179
+ let claudeMdText = null;
1180
+ if (agentText !== null && !(isBob && scope === 'global')) {
1181
+ claudeMdText = stripCapabilityLinks(cfg, prefixedAgentText(lawsPrefix) || agentText);
1182
+ }
1183
+
1184
+ // WIRE — see `claudeWire` below. One child per emit, so the two halves are two
1185
+ // functions rather than two spawns; the seam between them is a call, not a process.
1186
+ const managed = claudeWire(job, claudeMdText, agentText !== null);
1187
+
1188
+ return {
1189
+ owned,
1190
+ stats: { nAgents, nSkills },
1191
+ memStatus,
1192
+ nbStatus,
1193
+ hasAgentText: agentText !== null,
1194
+ claudeMdText,
1195
+ managed,
1196
+ };
1197
+ }
1198
+
1199
+ /**
1200
+ * `_build_global._claude_wire_py` — the files the Claude-shaped emits do NOT own: the
1201
+ * CLAUDE.md/AGENTS.md managed block and settings(.local).json.
1202
+ *
1203
+ * Everything the render half wrote, it wrote wholesale. Everything here reconciles
1204
+ * Geneseed's claim with content it did not write. The return value is `managed`, the claim
1205
+ * set Python records in the manifest so every teardown path unwires exactly what was wired
1206
+ * — which is why WIRE must precede MANIFEST, and why `managed` travels back rather than
1207
+ * being recomputed.
1208
+ *
1209
+ * `preambleExclude` arrives decided and is NOT derived here. Python computes it from
1210
+ * `_PREAMBLE_CONFIG_DIR`, which resolves through `_build_core._claude_config_dir` — an
1211
+ * `_OWNED` name precisely because the suite redirects it at a sandbox. Resolving `~/.claude`
1212
+ * on this side would write the developer's REAL CLAUDE.md path into a test's exclude list
1213
+ * and suppress it. Third phase running that a name had to travel rather than be re-derived
1214
+ * (`STRUCTURE` at P2d, `capabilityLinkRe` at P2e); this one edits a file the user co-owns.
1215
+ *
1216
+ * `GENESEED_STACK_GLOBAL` is read from the environment on BOTH sides — the child inherits
1217
+ * `process.env`, so the opt-out reaches here without travelling in the job. `pyTruthy` is
1218
+ * not needed for it: Python tests `os.environ.get(...)` for truthiness and an env var is
1219
+ * always a string, so `''` is the only falsy value either language sees.
1220
+ */
1221
+ function claudeWire(job, claudeMdText, hasAgentText) {
1222
+ phaseLog('WIRE');
1223
+ const { cfgDir, claudeMd, scope, host, oldManaged, preambleExclude, hookOpts } = job;
1224
+ const old = oldManaged && typeof oldManaged === 'object' && !Array.isArray(oldManaged)
1225
+ ? oldManaged : {};
1226
+ const isBob = host === 'bob';
1227
+ const isCopilot = host === 'copilot';
1228
+ const managed = {};
1229
+
1230
+ // CLAUDE.md — Claude auto-loads it by location; merge as a delimited block so any user
1231
+ // prose around it survives. `claudeMdText` is the render half's product; it is null
1232
+ // exactly when no block is to be written.
1233
+ // Exception — Bob GLOBAL: Bob never auto-loads a global ~/.bob/AGENTS.md, so a global
1234
+ // copy is pure disk weight; none is written, and a re-emit self-heals the one an older
1235
+ // install carries.
1236
+ if (claudeMdText !== null) {
1237
+ managedBlockWrite(claudeMd, claudeMdText);
1238
+ // No sticky "whole" flag: teardown always excises the block and deletes the file only
1239
+ // when nothing else remains — a whole-file delete would eat prose the user added AFTER
1240
+ // Geneseed created the file.
1241
+ //
1242
+ // `os.path.relpath(claude_md, cfg).replace(os.sep, '/')`. Python answers '.' where
1243
+ // `path.relative` answers '' for an identical pair, the same guard `lawsPrefix` needs
1244
+ // above — unreachable here (CLAUDE.md is a file, cfgDir a directory, so the pair is
1245
+ // never identical) and spelled anyway, because the two call sites are 400 lines apart
1246
+ // and only one of them is obviously safe.
1247
+ const rel = path.relative(cfgDir, claudeMd).split(path.sep).join('/') || '.';
1248
+ managed.claude_md = { rel };
1249
+ } else if (isBob && scope === 'global' && pyTruthy(get(old, 'claude_md'))) {
1250
+ const oldCm = isDict(get(old, 'claude_md')) ? get(old, 'claude_md') : {};
1251
+ // `.resolve()` on the Python side; `path.resolve` is its counterpart and both are
1252
+ // applied to a path already built from an absolute cfgDir.
1253
+ const victim = path.resolve(cfgDir, get(oldCm, 'rel') || path.basename(claudeMd));
1254
+ managedBlockRemove(victim);
1255
+ }
1256
+
1257
+ // Hooks embed machine-absolute paths. At PROJECT scope for Claude they go into
1258
+ // settings.local.json — the personal, untracked file — never the team-shared
1259
+ // settings.json, which would hand every teammate failing hooks pointing at this machine's
1260
+ // python. (Bob documents no local variant, so it keeps settings.json.) Copilot has NO
1261
+ // settings.json and no hook mechanism at all, so the whole stage is skipped: nothing
1262
+ // written, no settings_* keys recorded for the lifecycle to unwire.
1263
+ if (!isCopilot) {
1264
+ const settingsName = scope === 'project' && !isBob ? 'settings.local.json' : 'settings.json';
1265
+ const settingsPath = path.join(cfgDir, settingsName);
1266
+ managed.settings_file = settingsName;
1267
+ // Migration: an older install wired hooks/excludes into a different file — unwire the
1268
+ // recorded claims there, or they linger (and run) forever.
1269
+ const oldSf = get(old, 'settings_file') || 'settings.json';
1270
+ if (oldSf !== settingsName) {
1271
+ unwireClaudeSettings(path.join(cfgDir, oldSf), get(old, 'settings_hooks') || []);
1272
+ unwireClaudeExcludes(path.join(cfgDir, oldSf), get(old, 'settings_excludes') || []);
1273
+ }
1274
+ // The merge prunes recorded groups that are no longer canonical and returns the
1275
+ // COMPLETE current claim set — store it as-is; unioning with prior would resurrect the
1276
+ // stale claims.
1277
+ const [, managedHooks] = mergeClaudeSettings(
1278
+ settingsPath, scope, oldSf === settingsName ? get(old, 'settings_hooks') : null, hookOpts,
1279
+ );
1280
+ managed.settings_hooks = managedHooks;
1281
+
1282
+ // Project-bypasses-global (Claude only): a PROJECT install suppresses the GLOBAL
1283
+ // ~/.claude/CLAUDE.md while cwd is this repo, via Claude's native claudeMdExcludes.
1284
+ // Written only when this run actually emitted the project's own preamble (never
1285
+ // suppress with no replacement); GENESEED_STACK_GLOBAL=1 opts out, and a re-emit with
1286
+ // it set strips a prior exclude. Bob never gets one: its bypass is the same-named
1287
+ // workspace rules file.
1288
+ const priorRaw = get(old, 'settings_excludes');
1289
+ const priorExcl = Array.isArray(priorRaw) ? priorRaw : [];
1290
+ if (scope === 'project' && hasAgentText && preambleExclude) {
1291
+ const wantExcl = [preambleExclude];
1292
+ if (process.env.GENESEED_STACK_GLOBAL) {
1293
+ unwireClaudeExcludes(settingsPath, wantExcl);
1294
+ managed.settings_excludes = [];
1295
+ } else {
1296
+ const addedExcl = wireClaudeExcludes(settingsPath, wantExcl);
1297
+ // Claim only what Geneseed itself wired (prior + newly added) — folding `wantExcl`
1298
+ // in unconditionally would claim a user's own pre-existing exclude, and uninstall
1299
+ // would then strip it. `sorted(set(a) | set(b))` — Python sorts strings by code
1300
+ // point and so does the default `Array.sort`, which is why no comparator is passed.
1301
+ managed.settings_excludes = [...new Set([...priorExcl, ...addedExcl])].sort();
1302
+ }
1303
+ } else if (priorExcl.length && isBob) {
1304
+ // Self-heal older Bob installs: earlier versions wrote the global AGENTS.md into
1305
+ // claudeMdExcludes here. The key is Claude-only and its Bob semantics are unknown, so
1306
+ // a re-emit removes it instead of carrying it forward.
1307
+ unwireClaudeExcludes(settingsPath, priorExcl);
1308
+ } else if (priorExcl.length) {
1309
+ managed.settings_excludes = priorExcl;
1310
+ }
1311
+ }
1312
+ return managed;
1313
+ }
1314
+
1315
+ // ---------------------------------------------------------------------------
1316
+ // The CLI — one job in, one JSON document out
1317
+ // ---------------------------------------------------------------------------
1318
+
1319
+ const KINDS = {
1320
+ build: (cfg, job) => {
1321
+ build(cfg, job.theme, job.out,
1322
+ { footprint: job.footprint, nativeCatalog: job.nativeCatalog });
1323
+ return {};
1324
+ },
1325
+ opencode: emitOpencodeRender,
1326
+ 'opencode-global': emitOpencodeGlobalRender,
1327
+ claude: emitClaudeRender,
1328
+ };
1329
+
1330
+ function main(argv) {
1331
+ const captured = { stdout: '', stderr: '' };
1332
+ const realWrite = process.stdout.write.bind(process.stdout);
1333
+
1334
+ // Both streams become buffers for the whole run. See the module header: this is what
1335
+ // makes "the protocol owns stdout" a structural property rather than a convention
1336
+ // every future contributor has to remember.
1337
+ const sink = (key) => (chunk, encoding, cb) => {
1338
+ captured[key] += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8');
1339
+ const done = typeof encoding === 'function' ? encoding : cb;
1340
+ if (done) done();
1341
+ return true;
1342
+ };
1343
+ process.stdout.write = sink('stdout');
1344
+ process.stderr.write = sink('stderr');
1345
+
1346
+ let payload;
1347
+ try {
1348
+ const job = parseJson(readFileSync(argv[0], 'utf8'));
1349
+ const run = KINDS[job.kind];
1350
+ if (!run) throw new Error(`unknown job kind ${JSON.stringify(job.kind)}`);
1351
+ payload = { ok: true, ...run(job.cfg, job) };
1352
+ } catch (e) {
1353
+ // `exitCode` marks a deliberate refusal (assertSourceComplete) whose message is
1354
+ // already on the captured stderr; anything else is a crash and carries its stack, so
1355
+ // a port bug surfaces as a diagnosable failure instead of a truncated tree.
1356
+ payload = e && e.exitCode
1357
+ ? { ok: false, exit: e.exitCode }
1358
+ : { ok: false, exit: 1, error: (e && e.stack) || String(e) };
1359
+ }
1360
+ payload.stdout = captured.stdout;
1361
+ payload.stderr = captured.stderr;
1362
+
1363
+ process.stdout.write = realWrite;
1364
+ realWrite(JSON.stringify(payload));
1365
+ return 0;
1366
+ }
1367
+
1368
+ // Library AND entry point: the parity gates import `build` directly, and running `main`
1369
+ // on import would eat their argv and print a payload nobody reads. `import.meta.main` is
1370
+ // Node >= 24 and this machine runs v22, so the comparison is spelled out.
1371
+ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
1372
+ process.exitCode = main(process.argv.slice(2));
1373
+ }