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,924 @@
1
+ /**
2
+ * `_build_settings.py` in Node — the host-config WIRING layer.
3
+ *
4
+ * This is the half of the emit that edits files the USER co-owns: the JSONC reader, the
5
+ * `opencode.json` / `settings.json` merges, the hook shim, the managed-block machinery and
6
+ * the integrity check. It is the last unit of the generator to cross, and the reason it is
7
+ * last is that it is also the unit the RUNTIME drives: eleven of its names have a consumer
8
+ * outside the emit tree, ten of them in `rituals/_harness_mcp.py` (remerge, deactivate,
9
+ * reactivate, uninstall), two in `rituals/_harness_exclude.py`, one in
10
+ * `rituals/_harness_build.py`. Nothing imports this module yet — P3a proves the unit,
11
+ * P3b flips the call sites.
12
+ *
13
+ * THE STDOUT RULE BINDS HARDEST HERE. The hooks this module writes signal their verdict as
14
+ * a JSON object on stdout and return 0 on EVERY path (`|| exit 0` is not what it looks
15
+ * like — see the shim comment below), so a stray byte printed on a hook path does not make
16
+ * noise, it silently disables a gate. Everything printed here is a generator-time message;
17
+ * the streams are compared byte for byte by `tests/test_settings_parity.py`, and the
18
+ * asymmetry between the two (`_warn_commented_jsonc` prints to STDOUT, every other message
19
+ * to stderr) is the Python's own and is reproduced, not tidied.
20
+ *
21
+ * HOOKS ARE APPEND-ONLY AND FAIL-CLOSED. Nothing here rewrites a hook group in place:
22
+ * `mergeClaudeSettings` appends what is missing and removes only groups the manifest
23
+ * recorded, and cleanup goes through `GENESEED_HOOK_SNIFF`, which has to keep recognising
24
+ * BOTH the legacy interpreter+checkout form and the shim form. Dropping either spelling
25
+ * strands live hooks in every config emitted before the change.
26
+ *
27
+ * ONE KNOWN DIVERGENCE, recorded rather than reproduced. JS objects order integer-like
28
+ * keys first, so a settings.json carrying a `"1"` or `"2"` at any level comes back from
29
+ * `JSON.parse` reordered and this module writes it back reordered — Python preserves the
30
+ * file's order. Measured (`{"b":1,"2":2,"a":3}` → `{"2":2,"b":1,"a":3}`); no key any
31
+ * writer here produces is numeric, and no data is lost, but a user's own numeric key moves.
32
+ * Fixing it means parsing into a Map and threading that through every accessor, which is a
33
+ * much larger change than the defect.
34
+ */
35
+ import {
36
+ chmodSync, existsSync, mkdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync,
37
+ } from 'node:fs';
38
+ import path from 'node:path';
39
+ import os from 'node:os';
40
+ import {
41
+ jsonDumps, jsonDumpsCompact, jsonDumpsIndent, parseJson, pyEq, pyRepr, indexOfEq,
42
+ pyPrintErr, readText, writeText,
43
+ } from './lib/fs.mjs';
44
+
45
+ export const OPENCODE_SCHEMA = 'https://opencode.ai/config.json';
46
+
47
+ /** `dict.get(key)` with Python's semantics — OWN properties only.
48
+ *
49
+ * `loaded[event]` where `event` came out of a manifest hands back `Object.prototype`'s
50
+ * member for `constructor`, `toString` or `valueOf`, where Python's `.get` returns None.
51
+ * The same hazard `js/native.mjs` hit with `overrides[stem]`, on a dict the USER edits. */
52
+ function get(obj, key) {
53
+ return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
54
+ }
55
+
56
+ function has(obj, key) {
57
+ return Object.prototype.hasOwnProperty.call(obj, key);
58
+ }
59
+
60
+ /** `except OSError` — an fs failure carries a `code`; anything else is a bug, so rethrow. */
61
+ function isOsError(e) {
62
+ return Boolean(e) && typeof e.code === 'string';
63
+ }
64
+
65
+ function isDict(v) {
66
+ return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
67
+ }
68
+
69
+ /** `_build_settings._default_permission`. */
70
+ export function defaultPermission() {
71
+ return {
72
+ bash: {
73
+ 'rm -rf *': 'ask',
74
+ 'git commit*': 'ask',
75
+ 'git push*': 'ask',
76
+ 'git push --force*': 'ask',
77
+ 'git push -f*': 'ask',
78
+ },
79
+ };
80
+ }
81
+
82
+ /**
83
+ * `Path.with_suffix(suffix)` for the one shape this module needs.
84
+ *
85
+ * `PurePath.suffix` and `path.extname` disagree on leading-dot and trailing-dot names
86
+ * (`.bashrc`, `x.`); the only caller passes `…/opencode.json`, and the emit's own callers
87
+ * pass a literal. Spelled off `path.extname` with the empty-extension case handled so a
88
+ * future caller with a suffix-less path appends rather than truncates.
89
+ */
90
+ function withSuffix(p, suffix) {
91
+ const ext = path.extname(p);
92
+ return (ext ? p.slice(0, p.length - ext.length) : p) + suffix;
93
+ }
94
+
95
+ /** `_build_settings._opencode_target`. */
96
+ export function opencodeTarget(jsonPath) {
97
+ const jsonc = withSuffix(jsonPath, '.jsonc');
98
+ return existsSync(jsonc) ? jsonc : jsonPath;
99
+ }
100
+
101
+ /**
102
+ * `_build_settings._read_jsonc` — an EXPORTED PRIMITIVE, deliberately.
103
+ *
104
+ * Four runtime call sites use it as a plain JSONC parser rather than as wiring, all four
105
+ * in `rituals/_harness_mcp.py` (P1b counted the call sites and named them callers; they
106
+ * are one module). Folding it inside a wire entry point would leave those four with
107
+ * nothing to call.
108
+ *
109
+ * Returns `[data, hadComments]`, with `data === null` for UNPARSEABLE input — distinct
110
+ * from a legitimately empty `{}`, because writers must refuse to rewrite a file they could
111
+ * not parse.
112
+ */
113
+ export function readJsonc(text) {
114
+ const out = [];
115
+ let hadComments = false;
116
+ let i = 0;
117
+ let inStr = false;
118
+ const n = text.length;
119
+ while (i < n) {
120
+ const ch = text[i];
121
+ if (inStr) {
122
+ out.push(ch);
123
+ if (ch === '\\' && i + 1 < n) {
124
+ out.push(text[i + 1]);
125
+ i += 2;
126
+ continue;
127
+ }
128
+ if (ch === '"') inStr = false;
129
+ i += 1;
130
+ continue;
131
+ }
132
+ if (ch === '"') {
133
+ inStr = true;
134
+ out.push(ch);
135
+ i += 1;
136
+ continue;
137
+ }
138
+ if (ch === '/' && i + 1 < n && text[i + 1] === '/') {
139
+ hadComments = true;
140
+ i += 2;
141
+ while (i < n && text[i] !== '\n' && text[i] !== '\r') i += 1;
142
+ continue;
143
+ }
144
+ if (ch === '/' && i + 1 < n && text[i + 1] === '*') {
145
+ hadComments = true;
146
+ i += 2;
147
+ while (i + 1 < n && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
148
+ i += 2;
149
+ continue;
150
+ }
151
+ if (ch === '}' || ch === ']') {
152
+ while (out.length && ' \t\r\n'.includes(out[out.length - 1])) out.pop();
153
+ if (out.length && out[out.length - 1] === ',') out.pop();
154
+ out.push(ch);
155
+ i += 1;
156
+ continue;
157
+ }
158
+ out.push(ch);
159
+ i += 1;
160
+ }
161
+ const stripped = out.join('');
162
+ try {
163
+ // `parseJson`, not `JSON.parse`: this file gets written back out, and a user's
164
+ // `"temperature": 1.0` would come back as `1` from a bare parse. The int/float
165
+ // distinction has to survive a round trip through somebody's real settings.json.
166
+ return [parseJson(stripped), hadComments];
167
+ } catch {
168
+ return [null, hadComments];
169
+ }
170
+ }
171
+
172
+ /** `_build_settings._warn_commented_jsonc`. Prints to STDOUT — the Python's own choice. */
173
+ export function warnCommentedJsonc(target, agentPath, includePermission,
174
+ includeLsp = false, prefix = 'geneseed') {
175
+ process.stdout.write(`[${prefix}] ${path.basename(target)} has comments — not rewriting `
176
+ + 'it (your edits are kept). Add this to its "instructions" array by hand:\n');
177
+ process.stdout.write(`[${prefix}] ${jsonDumps(agentPath)}\n`);
178
+ if (includePermission) {
179
+ process.stdout.write(`[${prefix}] and, for Geneseed's default ask-gates, a `
180
+ + '"permission" key:\n');
181
+ for (const line of jsonDumpsIndent(defaultPermission()).split('\n')) {
182
+ process.stdout.write(`[${prefix}] ${line}\n`);
183
+ }
184
+ }
185
+ if (includeLsp) {
186
+ process.stdout.write(`[${prefix}] and, to enable code intelligence, a top-level `
187
+ + '"lsp": true\n');
188
+ }
189
+ }
190
+
191
+ /** `_build_settings._atomic_write_json`. Throws on failure, as the Python does. */
192
+ export function atomicWriteJson(p, config) {
193
+ const tmp = `${p}.geneseed-tmp`;
194
+ writeText(tmp, `${jsonDumpsIndent(config)}\n`);
195
+ try {
196
+ renameSync(tmp, p);
197
+ } catch (e) {
198
+ if (!isOsError(e)) throw e;
199
+ rmSync(tmp, { force: true });
200
+ throw e;
201
+ }
202
+ }
203
+
204
+ /** `_build_settings._merge_opencode_json`. Returns the resolved target path.
205
+ *
206
+ * Zero runtime callers — re-measured this phase and still true. `rituals/_harness_mcp.py`
207
+ * re-implements the inverse itself rather than inherit this one's permission/lsp side
208
+ * effects, so the only crossings are the two emit-tree ones (`_build_emit.emit_opencode`,
209
+ * `_build_global.emit_opencode_global`). */
210
+ export function mergeOpencodeJson(p, agentPath) {
211
+ const target = opencodeTarget(p);
212
+ let config = { $schema: OPENCODE_SCHEMA, instructions: [] };
213
+ let hadComments = false;
214
+ if (existsSync(target)) {
215
+ let raw;
216
+ try {
217
+ raw = readText(target);
218
+ } catch (e) {
219
+ if (!isOsError(e)) throw e;
220
+ process.stderr.write(`[geneseed] WARN: could not read ${target} (${pyOsError(e)}) `
221
+ + `— NOT touching it. Add ${jsonDumps(agentPath)} to its "instructions" array by `
222
+ + "hand once it's readable again.\n");
223
+ return target;
224
+ }
225
+ const [loaded, hc] = readJsonc(raw);
226
+ hadComments = hc;
227
+ if (loaded === null || !isDict(loaded)) {
228
+ process.stderr.write(`[geneseed] ${path.basename(target)} is not a JSON object — NOT `
229
+ + `rewriting it (fix the file, then re-run). Add ${jsonDumps(agentPath)} to its `
230
+ + '"instructions" once repaired.\n');
231
+ return target;
232
+ }
233
+ config = loaded;
234
+ }
235
+ if (!has(config, '$schema')) config.$schema = OPENCODE_SCHEMA;
236
+ let instr = get(config, 'instructions');
237
+ if (!Array.isArray(instr)) instr = [];
238
+ const addInstr = indexOfEq(instr, agentPath) < 0;
239
+ if (addInstr) instr.push(agentPath);
240
+ config.instructions = instr;
241
+ const addPerm = !has(config, 'permission');
242
+ if (addPerm) config.permission = defaultPermission();
243
+ const addLsp = !has(config, 'lsp');
244
+ if (addLsp) config.lsp = true;
245
+ if (!addInstr && !addPerm && !addLsp) return target;
246
+ if (path.extname(target) === '.jsonc' && hadComments) {
247
+ warnCommentedJsonc(target, agentPath, addPerm, addLsp);
248
+ return target;
249
+ }
250
+ try {
251
+ atomicWriteJson(target, config);
252
+ } catch (e) {
253
+ if (!isOsError(e)) throw e;
254
+ process.stderr.write(`[geneseed] WARN: could not write ${target} (${pyOsError(e)}) — `
255
+ + `the harness will NOT auto-load until this is fixed. Add ${jsonDumps(agentPath)} `
256
+ + 'to its "instructions" array by hand.\n');
257
+ }
258
+ return target;
259
+ }
260
+
261
+ /**
262
+ * `str(OSError)` — what Python interpolates for `except OSError as e: f"({e})"`.
263
+ *
264
+ * Python renders `[Errno 13] Permission denied: 'C:\\x\\y'`; Node's message is
265
+ * `EACCES: permission denied, open 'C:\x\y'`. The two cannot be made to agree, so the
266
+ * parity gate does not drive a cell through a real fs failure — it exercises the branch
267
+ * with an injectable failure instead and compares everything except the OS's own wording.
268
+ * Named here so the gap is a decision rather than a surprise at GA.
269
+ */
270
+ function pyOsError(e) {
271
+ return e.message;
272
+ }
273
+
274
+ // ---- the hook shim ----------------------------------------------------------------
275
+ // `|| exit 0` on the emitted commands does NOT mean "ignore failures": git-gate and
276
+ // rule-gate return 0 on EVERY path and signal their verdict as a JSON object on stdout.
277
+ // The `|| exit 0` is there because a hook that fails to LAUNCH (a moved checkout, a dead
278
+ // interpreter) must not block the tool call. A stray byte on stdout — a cmd.exe command
279
+ // echo, a "created ~/.geneseed" notice — corrupts that JSON and the gate stops gating
280
+ // while still reporting success. Hence `@echo off`, and a shim that prints nothing.
281
+ //
282
+ // The marker lives in the FILENAME, not the directory: GENESEED_HOME relocates the dir,
283
+ // and a relocated install's hooks must stay recognisable to `GENESEED_HOOK_SNIFF`.
284
+ export const SHIM_MARK = 'geneseed-hook';
285
+
286
+ /**
287
+ * `_build_settings._SHIM_ARGV` — the tokens in a shim body that are NOT paths.
288
+ *
289
+ * `shimProblems` reads every quoted token back out of the body and requires each to name an
290
+ * existing file. On Windows that is exactly the two baked paths (`%*` is bare); on POSIX the
291
+ * body forwards argv as `"$@"`, quoted, so both implementations' `doctor` reported a healthy
292
+ * shim as dead on every Linux and macOS host. A defect the port faithfully inherited, and
293
+ * one no Windows cell could see.
294
+ */
295
+ export const SHIM_ARGV = new Set(['$@', '%*']);
296
+
297
+ /** `_SHIM_REL`, with the platform as an argument.
298
+ *
299
+ * Python computes it at IMPORT time from `sys.platform`, which is what makes the other
300
+ * platform's branch unreachable on any one machine. Taking it as a parameter is what lets
301
+ * the parity gate compare both branches in one run (Python's side is reachable because
302
+ * `_hook_shim_body` reads `sys.platform` at CALL time). */
303
+ export function shimRel(platform = process.platform) {
304
+ return ['bin', SHIM_MARK + (platform === 'win32' ? '.cmd' : '')];
305
+ }
306
+
307
+ /** `_build_settings._shim_home`. */
308
+ export function shimHome() {
309
+ const env = process.env.GENESEED_HOME;
310
+ return env ? expanduser(env) : path.join(os.homedir(), '.geneseed');
311
+ }
312
+
313
+ /**
314
+ * `Path(x).expanduser()` — private twin of `js/hosts.mjs`'s `expanduser`, same guard.
315
+ *
316
+ * `GENESEED_HOME` is user-set, same as any config-dir env var there, so a `~user` value here
317
+ * has the identical failure mode (a hook shim written under a literal `~user` directory) and
318
+ * gets the identical fix — refuse rather than pass through. See that file's docblock for why
319
+ * the refusal lives IN the primitive rather than at `shimHome`'s caller.
320
+ */
321
+ function expanduser(p) {
322
+ if (p === '~') return os.homedir();
323
+ if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(os.homedir(), p.slice(2));
324
+ if (p.startsWith('~')) {
325
+ const msg = `refusing '${p}': a '~user' path is not expanded by this port; pass an `
326
+ + 'absolute path, or "~" / "~/…" for your own home directory';
327
+ pyPrintErr(`${msg}\n`);
328
+ const e = new Error(msg);
329
+ e.exitCode = 1;
330
+ throw e;
331
+ }
332
+ return p;
333
+ }
334
+
335
+ /** `_build_settings._hook_shim_path`. */
336
+ export function hookShimPath(platform = process.platform) {
337
+ return path.join(shimHome(), ...shimRel(platform));
338
+ }
339
+
340
+ /**
341
+ * `_build_settings._hook_shim_body`, with the two volatile values INJECTED.
342
+ *
343
+ * This is the one function in the unit whose Python output is legitimately
344
+ * runtime-dependent: it bakes `sys.executable` and `<checkout>/rituals/harness.py`, and a
345
+ * Node twin at GA bakes `process.execPath` and a different entry point. Golden already
346
+ * refuses to compare the emitted shim for that reason (`_SHIM_GLOB`) and asserts
347
+ * `_shim_health` instead.
348
+ *
349
+ * So the parity gate's answer here is NOT "skip it". Taking `runner` and `entry` as
350
+ * arguments makes the BODY a pure function of three inputs, and the gate feeds Node the
351
+ * same two values Python computed — which makes every byte of it comparable: the `@echo
352
+ * off`, the CRLF, `exit /b` vs `exec`, the quoting that keeps `--root "<cfg>"` intact. What
353
+ * the gate does not prove is WHICH values a Node driver will pass, and that is a one-line
354
+ * decision at P3b's call site rather than anything in this body.
355
+ */
356
+ export function hookShimBody(runner, entry, platform = process.platform) {
357
+ if (platform === 'win32') {
358
+ // Bare `exit /b` propagates the LIVE errorlevel; `%ERRORLEVEL%` would expand at parse
359
+ // time and return a stale one. Never plain `exit` — that kills the parent cmd.exe, so
360
+ // the emitted `|| exit 0` would never get to evaluate.
361
+ return '@echo off\r\n'
362
+ + 'rem Generated by Geneseed - do not edit. Rewritten on every emit.\r\n'
363
+ + 'setlocal\r\n'
364
+ + `"${runner}" "${entry}" %*\r\n`
365
+ + 'exit /b\r\n';
366
+ }
367
+ return '#!/bin/sh\n'
368
+ + '# Generated by Geneseed - do not edit. Rewritten on every emit.\n'
369
+ + `exec "${runner}" "${entry}" "$@"\n`;
370
+ }
371
+
372
+ /**
373
+ * `_build_settings._write_hook_shim` — create or refresh the shim, or null on failure.
374
+ *
375
+ * The path and body are arguments for the same reason `hookShimBody`'s two values are: the
376
+ * routine itself has no runtime dependency at all once they are supplied, so all of it is
377
+ * comparable — the unchanged-content fast path (a Windows shim a hook is executing right
378
+ * now cannot be replaced), the newline-folded comparison that makes that fast path
379
+ * reachable, the pid-suffixed temp name that keeps concurrent emits from unlinking each
380
+ * other's file, and the chmod.
381
+ */
382
+ export function writeHookShim(p, body, platform = process.platform) {
383
+ try {
384
+ // Newline-normalised: the body carries explicit CRLF on Windows but `read_text()`
385
+ // translates back to `\n`, so a raw `===` would never match and the "unchanged" fast
386
+ // path — the whole point of this branch — would never be taken.
387
+ if (existsSync(p) && statSync(p).isFile()
388
+ && readText(p).replaceAll('\r\n', '\n') === body.replaceAll('\r\n', '\n')) {
389
+ return p;
390
+ }
391
+ mkdirSync(path.dirname(p), { recursive: true });
392
+ const tmp = `${p}.${process.pid}.tmp`;
393
+ // `newline=''` on the Python side: raw, so the CRLF in the body survives verbatim.
394
+ writeFileSync(tmp, body, 'utf8');
395
+ if (platform !== 'win32') chmodSync(tmp, 0o755);
396
+ renameSync(tmp, p);
397
+ return p;
398
+ } catch (e) {
399
+ if (!isOsError(e)) throw e;
400
+ return null;
401
+ }
402
+ }
403
+
404
+ /**
405
+ * `_build_settings._hook_prefix` — the `<runner> <entrypoint>` every emitted hook starts
406
+ * with, falling back to the pre-shim direct form when the shim cannot be written.
407
+ *
408
+ * The fallback is strictly no worse than the old behaviour and far better than emitting
409
+ * commands naming a shim that does not exist — those fail on every hook (9009 under
410
+ * cmd.exe, 127 under sh) and take both gates down with them.
411
+ */
412
+ export function hookPrefix({ runner, entry, platform = process.platform } = {}) {
413
+ // NOT a default. `runner`/`entry` have no computable fallback on this side — the child's
414
+ // own `process.execPath` is node and the hooks it wires are Python — and the failure mode
415
+ // of letting them through undefined is the worst kind this unit has: `hookShimBody` bakes
416
+ // `"undefined" "undefined" %*`, the shim is rewritten with it, every hook in the install
417
+ // dies, and because the hooks signal through stdout and return 0 on every path, nothing
418
+ // reports it. The shim is also excluded from golden's byte comparison by name, so no
419
+ // acceptance gate would catch it either. Throw where it is cheap to see.
420
+ if (!runner || !entry) {
421
+ throw new Error('hookPrefix: runner and entry are required — the emitted hook shim '
422
+ + 'bakes them, and there is no correct value to guess from inside Node');
423
+ }
424
+ const p = hookShimPath(platform);
425
+ const shim = writeHookShim(p, hookShimBody(runner, entry, platform), platform);
426
+ if (shim !== null) return `"${shim}"`;
427
+ process.stderr.write(`[geneseed] WARN: could not write the hook shim at ${p} — emitting `
428
+ + 'hooks that call the interpreter directly. They will break if this checkout moves; '
429
+ + 're-run the build to repair them.\n');
430
+ return `"${runner}" "${entry}"`;
431
+ }
432
+
433
+ /** `_build_settings._claude_hook_groups` — Geneseed's Claude hooks, keyed by event. */
434
+ export function claudeHookGroups(cfg, hookOpts) {
435
+ const run = hookPrefix(hookOpts);
436
+ const mem = `--memory "${path.join(cfg, 'memory')}"`;
437
+ // --root carries the install's own dir so a GLOBAL hook can stand down when a project
438
+ // install of the same host sits at/above cwd (project-bypasses-global).
439
+ const context = `${run} context --root "${cfg}" || exit 0`;
440
+ const gate = `${run} git-gate --root "${cfg}"`;
441
+ const ruleGate = `${run} rule-gate --root "${cfg}"`;
442
+ const learn = `${run} learn ${mem} || exit 0`;
443
+ return {
444
+ PreToolUse: [
445
+ { matcher: 'Bash', hooks: [{ type: 'command', command: gate }] },
446
+ {
447
+ matcher: 'Write|Edit|MultiEdit|NotebookEdit',
448
+ hooks: [{ type: 'command', command: ruleGate }],
449
+ },
450
+ ],
451
+ SessionStart: [
452
+ { matcher: 'startup|clear', hooks: [{ type: 'command', command: context }] },
453
+ { matcher: 'resume', hooks: [{ type: 'command', command: context }] },
454
+ ],
455
+ // `|| exit 0` (not `|| true`): hooks run under cmd.exe on native Windows, where `true`
456
+ // is not a command and the swallow-failures intent would invert into a 9009 error.
457
+ Stop: [{ hooks: [{ type: 'command', command: learn }] }],
458
+ // Same command as Stop: `learn` reads the payload's hook_event_name and routes a
459
+ // SubagentStop to the per-agent lesson path.
460
+ SubagentStop: [{ hooks: [{ type: 'command', command: learn }] }],
461
+ };
462
+ }
463
+
464
+ /**
465
+ * `_build_settings._merge_claude_settings` — returns `[target, managed]`.
466
+ *
467
+ * Surgical: every other key and the user's own hook entries survive. `priorHooks` is the
468
+ * manifest's previously-recorded managed groups; any still in the file but no longer
469
+ * canonical (a moved checkout, the pre-`|| exit 0` hook form) is PRUNED, because without
470
+ * that a re-emit stacks the new group beside the stale one and `learn` runs twice per Stop.
471
+ * `managed` is the complete current claim set, so unwire removes exactly those.
472
+ *
473
+ * `scope` is accepted and unused, exactly as in the Python.
474
+ */
475
+ export function mergeClaudeSettings(p, scope = 'global', priorHooks = null, hookOpts = {}) {
476
+ const prior = (priorHooks || []).filter(isDict);
477
+ let config = {};
478
+ let hadComments = false;
479
+ if (existsSync(p)) {
480
+ try {
481
+ const [loaded, hc] = readJsonc(readText(p));
482
+ hadComments = hc;
483
+ if (loaded === null) {
484
+ process.stderr.write(`[geneseed] ${path.basename(p)} is not valid JSON — NOT `
485
+ + 'rewriting it (fix the syntax, then re-run). Hooks were not wired.\n');
486
+ return [p, prior];
487
+ }
488
+ if (isDict(loaded)) config = loaded;
489
+ } catch (e) {
490
+ if (!isOsError(e)) throw e;
491
+ }
492
+ }
493
+ let hooks = get(config, 'hooks');
494
+ if (!isDict(hooks)) hooks = {};
495
+ const canonical = claudeHookGroups(path.dirname(p), hookOpts);
496
+ const canonFlat = [];
497
+ for (const [event, groups] of Object.entries(canonical)) {
498
+ for (const g of groups) canonFlat.push({ event, group: g });
499
+ }
500
+ let pruned = false;
501
+ for (const rec of prior) {
502
+ if (indexOfEq(canonFlat, rec) >= 0) continue;
503
+ const event = get(rec, 'event');
504
+ const group = get(rec, 'group');
505
+ const arr = get(hooks, event);
506
+ if (Array.isArray(arr)) {
507
+ const at = indexOfEq(arr, group);
508
+ if (at >= 0) {
509
+ arr.splice(at, 1);
510
+ pruned = true;
511
+ if (!arr.length) delete hooks[event];
512
+ }
513
+ }
514
+ }
515
+ const added = [];
516
+ for (const [event, newGroups] of Object.entries(canonical)) {
517
+ let arr = get(hooks, event);
518
+ if (!Array.isArray(arr)) arr = [];
519
+ for (const g of newGroups) {
520
+ if (indexOfEq(arr, g) >= 0) continue;
521
+ arr.push(g);
522
+ added.push({ event, group: g });
523
+ }
524
+ hooks[event] = arr;
525
+ }
526
+ const survivors = prior.filter((r) => indexOfEq(canonFlat, r) >= 0);
527
+ const managedNow = survivors.concat(added.filter((a) => indexOfEq(survivors, a) < 0));
528
+ if (!added.length && !pruned) return [p, managedNow];
529
+ if (hadComments) {
530
+ process.stderr.write(`[geneseed] ${path.basename(p)} has comments — not rewriting it `
531
+ + "(your edits are kept). Add Geneseed's hooks by hand from "
532
+ + 'adapters/claude-code/settings.json.\n');
533
+ return [p, prior];
534
+ }
535
+ // The `else` is DEAD on both sides and reproduced rather than dropped: `canonical` always
536
+ // carries four events, so by the time control reaches here `hooks` is never empty. A
537
+ // mutation keeping the empty block instead of deleting it is the one of thirty-three that
538
+ // stays green, and it stays green because there is nothing to detect — recorded the way
539
+ // `themed_rel` and `lstripNewlines` are, so "the gate cannot see it" and "there is
540
+ // nothing to see" keep their distance.
541
+ if (Object.keys(hooks).length) config.hooks = hooks;
542
+ else delete config.hooks;
543
+ mkdirSync(path.dirname(p), { recursive: true });
544
+ atomicWriteJson(p, config);
545
+ return [p, managedNow];
546
+ }
547
+
548
+ /**
549
+ * `_build_settings._unwire_claude_settings` — remove exactly the recorded groups.
550
+ *
551
+ * Returns true when the file was actually rewritten, false when it bailed, so uninstall
552
+ * can report reality instead of assuming success.
553
+ */
554
+ export function unwireClaudeSettings(p, added) {
555
+ if (!existsSync(p) || !added || !added.length) return false;
556
+ let loaded;
557
+ let hadComments;
558
+ try {
559
+ [loaded, hadComments] = readJsonc(readText(p));
560
+ } catch (e) {
561
+ if (!isOsError(e)) throw e;
562
+ return false;
563
+ }
564
+ if (hadComments || !isDict(loaded)) return false;
565
+ const hooks = get(loaded, 'hooks');
566
+ if (!isDict(hooks)) return false;
567
+ for (const rec of added) {
568
+ const event = get(rec, 'event');
569
+ const group = get(rec, 'group');
570
+ let arr = get(hooks, event);
571
+ if (Array.isArray(arr)) {
572
+ const at = indexOfEq(arr, group);
573
+ if (at >= 0) arr.splice(at, 1);
574
+ }
575
+ arr = get(hooks, event);
576
+ if (Array.isArray(arr) && !arr.length) delete hooks[event];
577
+ }
578
+ if (!Object.keys(hooks).length) delete loaded.hooks;
579
+ try {
580
+ atomicWriteJson(p, loaded);
581
+ } catch (e) {
582
+ if (!isOsError(e)) throw e;
583
+ return false;
584
+ }
585
+ return true;
586
+ }
587
+
588
+ /**
589
+ * `_build_settings._wire_claude_excludes` — append-if-absent into `claudeMdExcludes`.
590
+ * Returns the entries actually written.
591
+ */
592
+ export function wireClaudeExcludes(p, excludes) {
593
+ const want = (excludes || []).filter(Boolean);
594
+ if (!want.length) return [];
595
+ let config = {};
596
+ let hadComments = false;
597
+ if (existsSync(p)) {
598
+ try {
599
+ const [loaded, hc] = readJsonc(readText(p));
600
+ hadComments = hc;
601
+ if (loaded === null) {
602
+ process.stderr.write(`[geneseed] ${path.basename(p)} is not valid JSON — NOT `
603
+ + 'rewriting it (fix the syntax, then re-run). Excludes were not wired.\n');
604
+ return [];
605
+ }
606
+ if (isDict(loaded)) config = loaded;
607
+ } catch (e) {
608
+ if (!isOsError(e)) throw e;
609
+ }
610
+ }
611
+ let cur = get(config, 'claudeMdExcludes');
612
+ if (!Array.isArray(cur)) cur = [];
613
+ const added = want.filter((e) => indexOfEq(cur, e) < 0);
614
+ if (!added.length) return [];
615
+ if (hadComments) {
616
+ process.stderr.write(`[geneseed] ${path.basename(p)} has comments — not rewriting it `
617
+ + '(your edits are kept). Add to its "claudeMdExcludes" array by hand: '
618
+ + `${jsonDumpsCompact(added)}\n`);
619
+ return [];
620
+ }
621
+ cur.push(...added);
622
+ config.claudeMdExcludes = cur;
623
+ mkdirSync(path.dirname(p), { recursive: true });
624
+ try {
625
+ atomicWriteJson(p, config);
626
+ } catch (e) {
627
+ if (!isOsError(e)) throw e;
628
+ process.stderr.write(`[geneseed] WARN: could not write ${p} (${pyOsError(e)}) — `
629
+ + 'claudeMdExcludes were not wired. Add to its "claudeMdExcludes" array by hand: '
630
+ + `${jsonDumpsCompact(added)}\n`);
631
+ return [];
632
+ }
633
+ return added;
634
+ }
635
+
636
+ /** `_build_settings._unwire_claude_excludes`. */
637
+ export function unwireClaudeExcludes(p, excludes) {
638
+ if (!existsSync(p) || !excludes || !excludes.length) return;
639
+ let loaded;
640
+ let hadComments;
641
+ try {
642
+ [loaded, hadComments] = readJsonc(readText(p));
643
+ } catch (e) {
644
+ if (!isOsError(e)) throw e;
645
+ return;
646
+ }
647
+ if (hadComments || !isDict(loaded)) return;
648
+ const cur = get(loaded, 'claudeMdExcludes');
649
+ if (!Array.isArray(cur)) return;
650
+ for (const e of excludes) {
651
+ const at = indexOfEq(cur, e);
652
+ if (at >= 0) cur.splice(at, 1);
653
+ }
654
+ if (cur.length) loaded.claudeMdExcludes = cur;
655
+ else delete loaded.claudeMdExcludes;
656
+ try {
657
+ atomicWriteJson(p, loaded);
658
+ } catch (e) {
659
+ if (!isOsError(e)) throw e;
660
+ }
661
+ }
662
+
663
+ // ---- Settings integrity check -----------------------------------------------------
664
+ // Substrings that mark a hook command as Geneseed's. An ARRAY, not one string, because two
665
+ // shapes are in the wild and both must stay recognisable: the legacy direct form
666
+ // (interpreter + checkout harness.py) written by every install emitted before the shim, and
667
+ // the shim form. Dropping the legacy entry makes every not-yet-migrated install invisible
668
+ // to the orphan scan — the one place a stranded hook can still surface — so cleanup would
669
+ // silently leave live hooks behind in every config emitted before the shim landed.
670
+ export const GENESEED_HOOK_SNIFF = ['harness.py', SHIM_MARK];
671
+
672
+ // P10d. `GENESEED_HOOK_SNIFF` answers "is this hook Geneseed's?". A MIGRATION needs the other
673
+ // question — "is this Geneseed's OLD one?" — and the two are not the same sniff.
674
+ //
675
+ // THREE SHAPES ARE IN THE WILD, AND TWO OF THEM ARE IDENTICAL IN THE SETTINGS FILE:
676
+ // 1. legacy-direct the command itself names `harness.py` (pre-shim, P0 and earlier)
677
+ // 2. shim-python the command names the shim; the shim BODY runs python + harness.py
678
+ // 3. shim-node the command names the shim; the shim BODY runs node + the .mjs entry
679
+ //
680
+ // 2 and 3 differ ONLY inside the shim body. A classifier reading the host config alone would
681
+ // call a fully-unmigrated machine "already migrated" — silently — and `migrate` would no-op on
682
+ // exactly the installs it exists for. The shim body is a REQUIRED input, not a refinement.
683
+ export const SHIM_ENTRY_MARK = 'geneseed-hook.mjs';
684
+
685
+ /**
686
+ * `_build_settings._migrate_shape` — 'legacy' | 'current' | 'none'.
687
+ *
688
+ * PURE, so `tests/test_settings_parity.py` runs it over a corpus of real config text no cell
689
+ * can seed. 'none' is not a fault: a Copilot install wires no hooks, and a user's own
690
+ * settings.json may carry three hand-written hooks and no Geneseed entry. Both must read as
691
+ * "nothing to migrate" rather than "unrecognised", or `migrate` refuses on the commonest
692
+ * config on any machine.
693
+ */
694
+ export function migrateShape(commands, shimBody) {
695
+ if (commands.some((c) => c.includes('harness.py'))) return 'legacy';
696
+ if (commands.some((c) => c.includes(SHIM_MARK))) {
697
+ return shimBody.includes(SHIM_ENTRY_MARK) ? 'current' : 'legacy';
698
+ }
699
+ return 'none';
700
+ }
701
+
702
+ /**
703
+ * `_build_settings._autostart_paths` — where a hand-written web-daemon autostart entry lives.
704
+ *
705
+ * NOTHING IN THIS REPOSITORY HAS EVER WRITTEN ONE — `SETUP.md` tells the user to create them
706
+ * by hand, and no .py/.mjs/.sh/.cmd in the tree contains `vbs`, `LaunchAgents` or `plist`. So
707
+ * `migrate` REPORTS a stale one and never rewrites it, on the rule `settingsIntegrityCheck`
708
+ * already states: an entry the manifest does not claim is "possibly user-authored; left alone".
709
+ *
710
+ * Both platforms' paths on both platforms, deliberately — the scan is a read that misses
711
+ * harmlessly, and returning only the host platform's would make the macOS arm unreachable
712
+ * from the Windows machine this port is developed on: an ungated branch dressed as a fork.
713
+ */
714
+ export function autostartPaths() {
715
+ const home = os.homedir();
716
+ return [
717
+ path.join(home, 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu',
718
+ 'Programs', 'Startup', 'geneseed-web.vbs'),
719
+ path.join(home, 'Library', 'LaunchAgents', 'dev.geneseed.web.plist'),
720
+ ];
721
+ }
722
+
723
+ /**
724
+ * `_build_settings._autostart_stale` — the entry names a Geneseed launcher somewhere else.
725
+ *
726
+ * Weak in one direction and strict in the other on purpose: it fires only when the file
727
+ * mentions geneseed at all (an unrelated Startup entry is never named), and clears only when
728
+ * the CURRENT root appears verbatim. A false positive costs one printed line; a false negative
729
+ * leaves a login task pointing at a checkout npm is about to make stale.
730
+ */
731
+ export function autostartStale(text, root) {
732
+ if (!text.toLowerCase().includes('geneseed')) return false;
733
+ return !text.includes(root) && !text.includes(root.replaceAll('\\', '/'));
734
+ }
735
+
736
+ /** `_build_settings._settings_hook_groups` — flatten `hooks` to [event, group] pairs. */
737
+ export function settingsHookGroups(loaded) {
738
+ const hooks = get(loaded, 'hooks');
739
+ if (!isDict(hooks)) return [];
740
+ const out = [];
741
+ for (const [event, groups] of Object.entries(hooks)) {
742
+ if (Array.isArray(groups)) {
743
+ for (const g of groups) if (isDict(g)) out.push([event, g]);
744
+ }
745
+ }
746
+ return out;
747
+ }
748
+
749
+ /**
750
+ * `_build_settings._settings_integrity_check` — does the file match what the manifest
751
+ * claims was wired (`expect='present'`) or unwired (`expect='absent'`)?
752
+ *
753
+ * Returns the problems and ALSO prints them; never raises. A COMMENTED file IS still
754
+ * checked: emit and unwire both refuse to touch one, which is exactly how hooks linger
755
+ * after an uninstall, so the one state they will not write is the one that must not escape
756
+ * verification.
757
+ */
758
+ export function settingsIntegrityCheck(p, managed, expect = 'present') {
759
+ const problems = [];
760
+ const flush = () => {
761
+ for (const x of problems) process.stderr.write(`[geneseed] WARN: ${x}\n`);
762
+ return problems;
763
+ };
764
+ const m = isDict(managed) ? managed : {};
765
+ if (!existsSync(p)) {
766
+ if (expect === 'present') {
767
+ problems.push(`${p}: file does not exist, but hooks/excludes were supposed to be `
768
+ + 'wired into it');
769
+ }
770
+ return flush();
771
+ }
772
+ let loaded;
773
+ try {
774
+ [loaded] = readJsonc(readText(p));
775
+ } catch (e) {
776
+ if (!isOsError(e)) throw e;
777
+ problems.push(`${p}: could not read the file (${pyOsError(e)})`);
778
+ return flush();
779
+ }
780
+ if (!isDict(loaded)) {
781
+ problems.push(`${p}: not a JSON object — cannot verify hooks/excludes`);
782
+ return flush();
783
+ }
784
+
785
+ const presentGroups = settingsHookGroups(loaded);
786
+ const recordedHooks = (get(m, 'settings_hooks') || []).filter(isDict);
787
+ // Plain deep equality here (not the sorted-dump key the orphan scan below uses) is
788
+ // deliberate: both sides came out of a JSON parse, so it is already order-independent.
789
+ // The orphan scan needs the dumped-string form because it builds a set for O(1)
790
+ // membership, which a dict cannot join.
791
+ for (const rec of recordedHooks) {
792
+ const event = get(rec, 'event');
793
+ const group = get(rec, 'group');
794
+ const hit = presentGroups.some(([e, g]) => pyEq(e, event) && pyEq(g, group));
795
+ if (expect === 'present' && !hit) {
796
+ problems.push(`${p}: recorded hook group missing — event=${pyRepr(event ?? null)} `
797
+ + `group=${jsonDumpsCompact(group ?? null)}`);
798
+ } else if (expect === 'absent' && hit) {
799
+ problems.push(`${p}: recorded hook group still present after unwire — `
800
+ + `event=${pyRepr(event ?? null)} group=${jsonDumpsCompact(group ?? null)}`);
801
+ }
802
+ }
803
+
804
+ let exclCur = get(loaded, 'claudeMdExcludes');
805
+ exclCur = Array.isArray(exclCur) ? exclCur : [];
806
+ for (const entry of (get(m, 'settings_excludes') || [])) {
807
+ const hit = indexOfEq(exclCur, entry) >= 0;
808
+ if (expect === 'present' && !hit) {
809
+ problems.push(`${p}: recorded claudeMdExcludes entry missing: ${pyRepr(entry)}`);
810
+ } else if (expect === 'absent' && hit) {
811
+ problems.push(`${p}: recorded claudeMdExcludes entry still present after unwire: `
812
+ + `${pyRepr(entry)}`);
813
+ }
814
+ }
815
+
816
+ // Geneseed-PATTERN entries present but not in the recorded claim set — warn only, never
817
+ // auto-delete (they may be user-authored, or a claim this run legitimately did not
818
+ // inherit).
819
+ const recordedSet = new Set(recordedHooks.map(
820
+ (r) => `${pyRepr(get(r, 'event') ?? null)}\u0000`
821
+ + `${jsonDumpsCompact(get(r, 'group') ?? null, { sortKeys: true })}`));
822
+ for (const [event, group] of presentGroups) {
823
+ const key = `${pyRepr(event)}\u0000${jsonDumpsCompact(group, { sortKeys: true })}`;
824
+ if (recordedSet.has(key)) continue;
825
+ const cmds = (get(group, 'hooks') || []).filter(isDict)
826
+ .map((h) => (has(h, 'command') ? h.command : ''));
827
+ if (cmds.some((c) => typeof c === 'string'
828
+ && GENESEED_HOOK_SNIFF.some((mk) => c.includes(mk)))) {
829
+ problems.push(`${p}: Geneseed-pattern hook present but NOT recorded in the manifest `
830
+ + `(event=${pyRepr(event)}) — possibly user-authored; left alone`);
831
+ }
832
+ }
833
+
834
+ return flush();
835
+ }
836
+
837
+ const BLOCK_BEGIN = '<!-- BEGIN {id} -->';
838
+ const BLOCK_END = '<!-- END {id} -->';
839
+
840
+ function delimiters(blockId) {
841
+ return [BLOCK_BEGIN.replace('{id}', blockId), BLOCK_END.replace('{id}', blockId)];
842
+ }
843
+
844
+ /**
845
+ * `_build_settings._managed_block_write` — 'created' | 'updated' | 'merged'.
846
+ * Idempotent: a re-emit replaces the block, never stacks them.
847
+ */
848
+ export function managedBlockWrite(p, content, blockId = 'GENESEED') {
849
+ const [begin, end] = delimiters(blockId);
850
+ const block = `${begin}\n${pyRstrip(content)}\n${end}\n`;
851
+ if (!existsSync(p)) {
852
+ mkdirSync(path.dirname(p), { recursive: true });
853
+ writeText(p, block);
854
+ return 'created';
855
+ }
856
+ const existing = readText(p);
857
+ if (existing.includes(begin) && existing.includes(end)) {
858
+ const pre = existing.split(begin)[0];
859
+ const post = existing.slice(existing.indexOf(end) + end.length);
860
+ writeText(p, pre + block + lstripNewlines(post));
861
+ return 'updated';
862
+ }
863
+ const sep = existing.endsWith('\n') ? '' : '\n';
864
+ writeText(p, `${existing + sep}\n${block}`);
865
+ return 'merged';
866
+ }
867
+
868
+ /** `_build_settings._managed_block_remove`. */
869
+ export function managedBlockRemove(p, blockId = 'GENESEED', whole = false) {
870
+ if (!existsSync(p)) return;
871
+ if (whole) {
872
+ unlinkSync(p);
873
+ return;
874
+ }
875
+ const [begin, end] = delimiters(blockId);
876
+ const existing = readText(p);
877
+ if (!existing.includes(begin) || !existing.includes(end)) return;
878
+ const pre = existing.split(begin)[0];
879
+ const post = existing.slice(existing.indexOf(end) + end.length);
880
+ const rest = pyStrip(`${pyRstripNewlines(pre)}\n${lstripNewlines(post)}`);
881
+ if (rest) writeText(p, `${rest}\n`);
882
+ else unlinkSync(p);
883
+ }
884
+
885
+ /** `_build_settings._managed_block_read` — the block's inner content, or null. */
886
+ export function managedBlockRead(p, blockId = 'GENESEED') {
887
+ if (!existsSync(p)) return null;
888
+ const [begin, end] = delimiters(blockId);
889
+ const text = readText(p);
890
+ if (!text.includes(begin) || !text.includes(end)) return null;
891
+ const inner = text.slice(text.indexOf(begin) + begin.length);
892
+ return stripNewlines(inner.slice(0, inner.indexOf(end)));
893
+ }
894
+
895
+ // `str.strip()` and friends. JS's `trim()` is NOT `strip()`, and the difference runs both
896
+ // ways: `trim()` eats U+FEFF, which Python does not strip, and Python strips U+0085 and
897
+ // U+001C–U+001F, which `trim()` leaves. A managed block is user-editable prose in a file
898
+ // Geneseed shares with the user, so the set is spelled out rather than approximated — and
899
+ // spelled with escapes, never literals: a raw U+2028 in a JS source file ends the line.
900
+ const PY_SPACE = '[\t\n\v\f\r\x1c-\x1f \x85\xa0\u1680\u2000-\u200a'
901
+ + '\u2028\u2029\u202f\u205f\u3000]';
902
+
903
+ const RSTRIP_RE = new RegExp(`${PY_SPACE}+$`);
904
+ const LSTRIP_RE = new RegExp(`^${PY_SPACE}+`);
905
+
906
+ function pyRstrip(s) {
907
+ return s.replace(RSTRIP_RE, '');
908
+ }
909
+
910
+ function pyStrip(s) {
911
+ return s.replace(LSTRIP_RE, '').replace(RSTRIP_RE, '');
912
+ }
913
+
914
+ function lstripNewlines(s) {
915
+ return s.replace(/^\n+/, '');
916
+ }
917
+
918
+ function pyRstripNewlines(s) {
919
+ return s.replace(/\n+$/, '');
920
+ }
921
+
922
+ function stripNewlines(s) {
923
+ return lstripNewlines(pyRstripNewlines(s));
924
+ }