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/hooks.mjs ADDED
@@ -0,0 +1,1026 @@
1
+ /**
2
+ * The four HOOK verbs — `context`, `git-gate`, `rule-gate`, `learn` — in Node.
3
+ *
4
+ * These are the commands the emitted `settings.json` actually invokes on a user's machine,
5
+ * every session: a SessionStart injector, two PreToolUse gates, and a Stop/SubagentStop
6
+ * distiller. Since P5b they are also what the emitted hooks name: `bin/geneseed.mjs` bakes
7
+ * `<node> <checkout>/bin/geneseed-hook.mjs` into the machine-wide shim, so an install this
8
+ * driver emits has no Python in its hook path at all — which is what let the interpreter
9
+ * discovery and its exit-4 refusal be deleted rather than merely bypassed.
10
+ *
11
+ * Ported from `rituals/_harness_context.py` and `rituals/_harness_learn.py`, whose shared
12
+ * primitives live in `_harness_core.py`. `rituals/harness.py` links all fourteen
13
+ * submodules into one namespace at import time, so a Python `harness context` loads
14
+ * ~11,600 lines and the generator behind them. This module loads none of that, and that is
15
+ * deliberate rather than incidental: the Python originals already refuse to import `build`
16
+ * for the marker filenames they need ("literals keep the hook dependency-free"), and
17
+ * `bin/geneseed.mjs` could not be imported anyway, since its last statement runs the
18
+ * generator. What that rule does NOT license is a second copy of a shared resolver under a
19
+ * different name — see the primitives section below, where P5d found one that had silently
20
+ * diverged from its twin.
21
+ *
22
+ * THE CONTRACT EVERY VERB HOLDS. Exit 0 on every path, and signal through stdout. A hook
23
+ * that fails is a tool call that fails, so an unreadable payload, a missing file, a
24
+ * malformed user config and a bypass all end the same way: quietly, with 0. The single
25
+ * exception is `learn`, which returns the model CLI's own exit code — an auth or quota
26
+ * failure has to be diagnosable, and its hook command carries `|| exit 0` for exactly that
27
+ * reason.
28
+ *
29
+ * The gate is `tests/harness_golden.py`, which runs each verb through both CLIs in a
30
+ * seeded sandbox and compares stdout, stderr, the exit code and every file left behind.
31
+ */
32
+ import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, realpathSync }
33
+ from 'node:fs';
34
+ import path from 'node:path';
35
+ import { spawnSync } from 'node:child_process';
36
+ import { fileURLToPath } from 'node:url';
37
+ import { readText, writeText, jsonDumpsCompact, normcase, comparePaths, pyPathStr, pyPrint,
38
+ pyPrintErr, withDiscardableStderr } from './lib/fs.mjs';
39
+ import { NO_WINDOW } from './lib/proc.mjs';
40
+ // `harness status` reports the memory store, and `bin/geneseed-cli.mjs` cannot import THIS
41
+ // module to find it: the CLI is under a hard transitive `child_process` ban and `learn`
42
+ // spawns. So the resolver has one owner in `js/hosts.mjs` rather than a second copy — and
43
+ // with it went this file's private `opencodeConfigDir`, whose only caller it was.
44
+ import { resolveMemoryDir, expanduser } from './hosts.mjs';
45
+
46
+ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
47
+
48
+ // ---- Python path and text primitives -------------------------------------------------
49
+ // Small, and each one is here because a JS-idiomatic equivalent differs observably.
50
+ //
51
+ // THE RULE, CORRECTED IN P5d. It used to read "duplicating four helpers is cheaper than
52
+ // coupling a hook to the emitter", with `readText`/`writeText`/`normcase` as the stated
53
+ // exceptions. That is the wrong line, and `pyStrPath` is what proved it: it was a second
54
+ // implementation of `str(Path(p))` under a different NAME from `js/lib/fs.mjs`'s
55
+ // `pyPathStr`, and the two disagreed. P5c found that `path.normalize` collapses `a/../b`
56
+ // where `PurePath` keeps it, fixed `pyPathStr`, and gated it with a 25-path corpus — and
57
+ // none of that reached this file, because a corpus finds what it is pointed at and nothing
58
+ // named `pyStrPath` was. The consequence was live: `sovereignBypass` compares an
59
+ // `excludes.json` entry against cwd, so a hand-edited `..` entry made the NODE hook stand
60
+ // down for a repo Python still gates. `context/bypass-does-not-match-a-dotdot-entry` is
61
+ // the cell.
62
+ //
63
+ // So the line is not "duplicate rather than couple" — it is "one owner for anything that
64
+ // reproduces a language primitive, wherever it is used". `fs.mjs` carries no
65
+ // `child_process` and costs nothing at hook latency. What stays local is what is genuinely
66
+ // only the hook's (`fnmatch`, the transcript readers, `pyOsError`'s errno table).
67
+ //
68
+ // AND `expanduser` WAS THE SECOND VIOLATION, closed here. This file carried its own, which
69
+ // returned a `~user` path UNCHANGED while `js/hosts.mjs`'s single owner REFUSES the form —
70
+ // an adjudicated product decision (`TheTildeUserFormIsADeliberateDivergence`) that the hook
71
+ // was quietly exempt from because it had a private copy. It now imports the owner. The
72
+ // refusal throws, so `sovereignBypass` and `cmdContext` below contain it per entry / per
73
+ // call: a hook that CRASHES reports success, because these gates exit 0 and signal on
74
+ // stdout, so "the gate blew up" and "the gate found nothing" are the same observation to
75
+ // Claude. `pyResolve` just below is the THIRD copy of a primitive `js/hosts.mjs` also
76
+ // exports, and is the next item in this series.
77
+
78
+ /**
79
+ * `Path(p).resolve()` — absolute, symlinks followed, and the filesystem's OWN casing.
80
+ *
81
+ * `realpathSync` throws on a path that does not exist, which is routine here (an
82
+ * `excludes.json` may name a folder that was deleted), so the existing prefix is
83
+ * canonicalised and the rest appended verbatim — `resolve(strict=False)`'s behaviour.
84
+ */
85
+ function pyResolve(p) {
86
+ let cur = path.resolve(expanduser(p));
87
+ const tail = [];
88
+ for (;;) {
89
+ try {
90
+ return tail.length ? path.join(realpathSync.native(cur), ...tail)
91
+ : realpathSync.native(cur);
92
+ } catch {
93
+ const parent = path.dirname(cur);
94
+ if (parent === cur) return path.resolve(expanduser(p));
95
+ tail.unshift(path.basename(cur));
96
+ cur = parent;
97
+ }
98
+ }
99
+ }
100
+
101
+ const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
102
+ const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
103
+
104
+ /** `(p, *p.parents)` — the path and every ancestor, nearest first. */
105
+ function selfAndParents(p) {
106
+ const out = [p];
107
+ let cur = p;
108
+ for (;;) {
109
+ const up = path.dirname(cur);
110
+ if (up === cur) return out;
111
+ out.push(up);
112
+ cur = up;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * `str.splitlines()`.
118
+ *
119
+ * `split('\n')` is not it: Python breaks on eleven boundaries, and a transcript or a
120
+ * memory file carrying a form feed or U+2028 would be split by one implementation and not
121
+ * the other. The trailing empty is dropped the way Python drops it (`"a\n"` is one line,
122
+ * `""` is none).
123
+ */
124
+ function pySplitlines(text) {
125
+ if (!text) return [];
126
+ const parts = text.split(/\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/);
127
+ if (parts.length && parts[parts.length - 1] === '') parts.pop();
128
+ return parts;
129
+ }
130
+
131
+ /** `str.split()` with no argument — runs of whitespace, ends stripped, no empties. */
132
+ function pyWords(s) {
133
+ return s.split(/\s+/).filter(Boolean);
134
+ }
135
+
136
+ /** `s[-n:]` in CODE POINTS. JS slices UTF-16 units and can cut a surrogate pair in half. */
137
+ function tail(s, n) {
138
+ const cps = Array.from(s);
139
+ return cps.length <= n ? s : cps.slice(-n).join('');
140
+ }
141
+
142
+ /**
143
+ * `str(OSError)` — `[Errno N] strerror: 'filename'`, with the filename REPR'd.
144
+ *
145
+ * This is a compared string, not a log line: `cmd_context` prints it verbatim when an
146
+ * eager entry is missing, so `ENOENT: no such file or directory, open '…'` (Node's
147
+ * wording, Node's quoting, no errno) is a whole-line difference in that cell. Only the
148
+ * codes a verb here can actually raise are mapped; anything else falls back to Node's
149
+ * message, which is at least honest about being different.
150
+ */
151
+ const _ERRNO = {
152
+ ENOENT: [2, 'No such file or directory'],
153
+ EACCES: [13, 'Permission denied'],
154
+ EISDIR: [21, 'Is a directory'],
155
+ ENOTDIR: [20, 'Not a directory'],
156
+ EPERM: [1, 'Operation not permitted'],
157
+ };
158
+
159
+ function pyOsError(e, filename) {
160
+ const hit = _ERRNO[e && e.code];
161
+ if (!hit) return String((e && e.message) || e);
162
+ // Python reprs the filename, so a Windows path comes out with its backslashes doubled.
163
+ return `[Errno ${hit[0]}] ${hit[1]}: ${JSON.stringify(filename).replace(/^"|"$/g, "'")
164
+ .replace(/\\"/g, '"')}`;
165
+ }
166
+
167
+ /**
168
+ * `fnmatch.fnmatch(name, pattern)` — case-folded on Windows on BOTH sides, and `*` matches
169
+ * a separator (it is shell-glob semantics, not path-glob: `docs/*.md` really does select
170
+ * `docs/nested/deep.md`, in both implementations).
171
+ */
172
+ function fnmatch(name, pattern) {
173
+ return fnTranslate(normcase(pattern)).test(normcase(name));
174
+ }
175
+
176
+ function fnTranslate(pat) {
177
+ let out = '';
178
+ for (let i = 0; i < pat.length; i += 1) {
179
+ const c = pat[i];
180
+ if (c === '*') { out += '[\\s\\S]*'; continue; }
181
+ if (c === '?') { out += '[\\s\\S]'; continue; }
182
+ if (c === '[') {
183
+ let j = i + 1;
184
+ if (pat[j] === '!') j += 1;
185
+ if (pat[j] === ']') j += 1;
186
+ while (j < pat.length && pat[j] !== ']') j += 1;
187
+ // An unterminated `[` is a LITERAL bracket in Python's translate, not an error.
188
+ if (j >= pat.length) { out += '\\['; continue; }
189
+ let body = pat.slice(i + 1, j).replaceAll('\\', '\\\\');
190
+ if (body.startsWith('!')) body = `^${body.slice(1)}`;
191
+ out += `[${body}]`;
192
+ i = j;
193
+ continue;
194
+ }
195
+ out += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
196
+ }
197
+ return new RegExp(`^(?:${out})$`);
198
+ }
199
+
200
+ /** stdin, decoded as UTF-8 with Python's universal newlines. Absent stdin reads empty. */
201
+ function readStdin() {
202
+ try {
203
+ return readFileSync(0, 'utf8').replaceAll('\r\n', '\n').replaceAll('\r', '\n');
204
+ } catch {
205
+ return '';
206
+ }
207
+ }
208
+
209
+ // The two output funnels, with Python's newline translation reproduced. They moved to
210
+ // `js/lib/fs.mjs` in P5c — beside `writeText`, which is the same rule for FILES — when
211
+ // `js/excludes.mjs` became the second caller; see that docblock for why the translation
212
+ // exists and which gate can see it.
213
+ const out = pyPrint;
214
+ const err = pyPrintErr;
215
+
216
+ // ======================================================================================
217
+ // context — project-context discovery (Rule XVIII)
218
+ // ======================================================================================
219
+
220
+ // Kept in step with adapters/opencode/plugins/geneseed-context.js, same as the Python.
221
+ const EAGER_ROOT = ['AGENTS.md', 'AGENT.md', 'CLAUDE.md', '.cursorrules',
222
+ 'README.md', 'CONTRIBUTING.md', 'user-rules.md', 'PROFILE.md'];
223
+ const LAZY_DIRS = ['docs', 'doc', 'documentation', 'architecture', 'adr', 'ADR'];
224
+ const EXCLUDE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'vendor', '.next',
225
+ 'target', '.venv', '__pycache__', '.opencode', '.harness']);
226
+
227
+ const CLAUDE_MARKERS = ['.claude', '.bob'];
228
+ const GENESEED_MANIFEST = '.geneseed-manifest.json';
229
+ const EXCLUDES_FILE = 'excludes.json';
230
+
231
+ /**
232
+ * `_global_hook_standing_down` — project-bypasses-global.
233
+ *
234
+ * A GLOBAL install's hook passes its own dir as `--root`; when a Geneseed PROJECT install
235
+ * of the SAME host sits at or above cwd, the project's hook injects and this one must not
236
+ * double up.
237
+ */
238
+ export function globalHookStandingDown(hookRoot, cwd) {
239
+ const marker = path.basename(hookRoot);
240
+ if (!CLAUDE_MARKERS.includes(marker)) return false;
241
+ for (const d of selfAndParents(cwd)) {
242
+ const cand = path.join(d, marker);
243
+ if (isFile(path.join(cand, GENESEED_MANIFEST))) {
244
+ // Path equality, which is case-folded on Windows — `~/.claude` and `~/.Claude` are
245
+ // the same install there and two different ones on Linux.
246
+ return normcase(pyResolve(cand)) !== normcase(pyResolve(hookRoot));
247
+ }
248
+ }
249
+ return false;
250
+ }
251
+
252
+ /**
253
+ * `sovereign_bypass` — the user's own excludes.json, read on EVERY hook call so an edit
254
+ * takes effect without a re-emit. Every failure mode degrades to false: a hook must never
255
+ * fail or block on a file the user owns.
256
+ */
257
+ export function sovereignBypass(root) {
258
+ if (!root) return false;
259
+ let entries;
260
+ try {
261
+ const data = JSON.parse(readText(path.join(root, EXCLUDES_FILE)));
262
+ entries = Array.isArray(data && data.excludes) ? data.excludes : [];
263
+ } catch {
264
+ return false;
265
+ }
266
+ let cwd;
267
+ try {
268
+ cwd = normcase(pyResolve(process.cwd()));
269
+ } catch {
270
+ return false;
271
+ }
272
+ for (const entry of entries) {
273
+ const raw = (entry && typeof entry === 'object' ? entry.path : entry) || '';
274
+ if (typeof raw !== 'string' || !raw.trim()) continue;
275
+ let base;
276
+ try {
277
+ // `.rstrip("\\/")` AFTER normcase, exactly as the Python orders it, so the separator
278
+ // test below cannot be fooled by a trailing slash in the user's file.
279
+ //
280
+ // `withDiscardableStderr` because `expanduser` writes its refusal at the RAISE SITE
281
+ // and this caller catches: the reference prints nothing here, so replaying the
282
+ // message would be a stderr divergence on every hook call for one bad entry.
283
+ base = withDiscardableStderr(
284
+ () => normcase(pyPathStr(expanduser(raw.trim()))).replace(/[\\/]+$/, ''));
285
+ } catch {
286
+ // PER ENTRY, and the loop continues. One unusable line in a file the USER hand-edits
287
+ // must not decide the whole function — `expanduser` refuses a `~user` form, and the
288
+ // reference's `Path.expanduser()` raises RuntimeError on the same input on POSIX.
289
+ // Skipping errs toward the gate staying ACTIVE, which is the safe direction: this
290
+ // feeds `git-gate`/`rule-gate`, which exit 0 and signal on stdout, so a crash here
291
+ // would read as a clean pass while gating nothing.
292
+ continue;
293
+ }
294
+ if (cwd === base || cwd.startsWith(base + path.sep)) return true;
295
+ }
296
+ return false;
297
+ }
298
+
299
+ /** `_disp` — relative to the repo root when it sits under it, else verbatim. */
300
+ function disp(pathStr, root) {
301
+ // `os.path.relpath` raises ValueError across drives and the Python falls back to the
302
+ // path as given; `path.relative` silently returns an absolute path instead, which would
303
+ // print a DIFFERENT string rather than the same one.
304
+ if (path.parse(path.resolve(pathStr)).root.toLowerCase()
305
+ !== path.parse(path.resolve(root)).root.toLowerCase()) {
306
+ return pathStr;
307
+ }
308
+ return path.relative(root, pathStr).split(path.sep).join('/');
309
+ }
310
+
311
+ /** `Path.glob`/`rglob` order: `sorted()` over paths, which is case-folded on Windows. */
312
+ function sortPaths(list) {
313
+ return list.slice().sort(comparePaths);
314
+ }
315
+
316
+ function listDir(dir) {
317
+ try {
318
+ return readdirSync(dir);
319
+ } catch {
320
+ return [];
321
+ }
322
+ }
323
+
324
+ function rglobMd(dir, acc = []) {
325
+ for (const name of listDir(dir)) {
326
+ const full = path.join(dir, name);
327
+ if (isDir(full)) rglobMd(full, acc);
328
+ else if (isFile(full) && path.extname(name).toLowerCase() === '.md') acc.push(full);
329
+ }
330
+ return acc;
331
+ }
332
+
333
+ /**
334
+ * `_discover_context` — the no-manifest path, mirroring the OpenCode context plugin.
335
+ * Root entry docs are eager; other root markdown, the doc trees and monorepo package
336
+ * READMEs are lazy.
337
+ */
338
+ export function discoverContext(root) {
339
+ const eager = new Map();
340
+ const lazy = new Map();
341
+ for (const full of sortPaths(listDir(root).map((n) => path.join(root, n)))) {
342
+ if (!isFile(full)) continue;
343
+ const name = path.basename(full);
344
+ if (EAGER_ROOT.includes(name)) eager.set(full, null);
345
+ else if (path.extname(name).toLowerCase() === '.md') lazy.set(full, null);
346
+ }
347
+ for (const d of LAZY_DIRS) {
348
+ const sub = path.join(root, d);
349
+ if (!isDir(sub)) continue;
350
+ for (const md of sortPaths(rglobMd(sub))) {
351
+ const parts = path.relative(root, md).split(/[\\/]/);
352
+ if (parts.some((part) => EXCLUDE_DIRS.has(part))) continue;
353
+ if (!eager.has(md) && !lazy.has(md)) lazy.set(md, null);
354
+ }
355
+ }
356
+ for (const group of ['packages', 'apps']) {
357
+ const base = path.join(root, group);
358
+ if (!isDir(base)) continue;
359
+ for (const pkg of sortPaths(listDir(base).map((n) => path.join(base, n)))) {
360
+ if (!isDir(pkg) || EXCLUDE_DIRS.has(path.basename(pkg))) continue;
361
+ const readme = path.join(pkg, 'README.md');
362
+ if (isFile(readme) && !eager.has(readme) && !lazy.has(readme)) lazy.set(readme, null);
363
+ }
364
+ }
365
+ return [
366
+ [...eager.keys()].map((p) => ({ path: p, description: '' })),
367
+ [...lazy.keys()].filter((p) => !eager.has(p)).map((p) => ({ path: p, description: '' })),
368
+ ];
369
+ }
370
+
371
+ /**
372
+ * `_resolve_context_sets` — an explicit manifest wins, `"extend": true` layers it on top
373
+ * of discovery, and an empty stub falls through to pure discovery.
374
+ *
375
+ * `recs` is a Map, not an object: JS reorders integer-like keys on a plain object, and
376
+ * these keys are absolute paths whose iteration order becomes the printed order.
377
+ */
378
+ export function resolveContextSets(root) {
379
+ let manifest = null;
380
+ const env = process.env.GENESEED_CONTEXT;
381
+ if (env && isFile(env)) {
382
+ // `Path(env)`, and the label is printed — so the separators are folded exactly the
383
+ // way `str(Path(...))` folds them, or the source line differs by every slash in it.
384
+ manifest = pyPathStr(env);
385
+ } else {
386
+ for (const cand of [path.join(root, '.harness', 'context.json'),
387
+ path.join(root, 'context.json')]) {
388
+ if (isFile(cand)) { manifest = cand; break; }
389
+ }
390
+ }
391
+
392
+ if (manifest === null) {
393
+ const [e, l] = discoverContext(root);
394
+ return [e, l, `auto-discovery [${root}]`];
395
+ }
396
+
397
+ let raw;
398
+ try {
399
+ raw = readText(manifest);
400
+ } catch (e) {
401
+ err(`[context] could not read ${manifest}: ${pyOsError(e, manifest)}\n`);
402
+ return [[], [], String(manifest)];
403
+ }
404
+ let entries;
405
+ let extend;
406
+ try {
407
+ let data = JSON.parse(raw);
408
+ // Valid JSON of the wrong SHAPE, guarded on both sides — see the Python original. JS
409
+ // would not have thrown here (`[].context` is merely undefined), which is exactly why
410
+ // the guard is written out rather than left to fall out of the language: without it,
411
+ // the two CLIs disagree about a file a user really does hand-edit.
412
+ data = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
413
+ entries = data.context || [];
414
+ if (!Array.isArray(entries)) entries = [];
415
+ extend = Boolean(data.extend);
416
+ } catch {
417
+ // See the Python original for why the decoder's own message is not quoted: the two
418
+ // engines disagree on the wording and on the offset, and V8 supplies no offset at all
419
+ // for the commonest shapes. Reporting the file is what the user needs; reporting
420
+ // WHICH decoder found it is what makes two implementations impossible.
421
+ err(`[context] ${manifest} is not valid JSON — no context was loaded `
422
+ + '(fix the syntax, then re-run).\n');
423
+ return [[], [], String(manifest)];
424
+ }
425
+
426
+ if (!entries.length && !extend) {
427
+ const [e, l] = discoverContext(root);
428
+ return [e, l, `auto-discovery [${root}] (empty ${path.basename(manifest)})`];
429
+ }
430
+
431
+ const recs = new Map();
432
+ const put = (pathStr, load, desc) => {
433
+ const prev = recs.get(pathStr);
434
+ recs.set(pathStr, { path: pathStr, load, description: desc || (prev ? prev.description : '') });
435
+ };
436
+
437
+ if (extend) {
438
+ const [de, dl] = discoverContext(root);
439
+ for (const x of de) put(x.path, 'eager', '');
440
+ for (const x of dl) put(x.path, 'lazy', '');
441
+ }
442
+
443
+ for (const entry of entries) {
444
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
445
+ const raw = typeof entry.path === 'string' ? entry.path.trim() : '';
446
+ if (!raw) continue;
447
+ const load = entry.load === undefined ? 'eager' : entry.load;
448
+ const desc = entry.description === undefined ? '' : entry.description;
449
+ if (raw.includes('*')) {
450
+ // A glob RECLASSIFIES files already known; it never pulls in new ones.
451
+ for (const [pathStr, rec] of [...recs.entries()]) {
452
+ if (fnmatch(disp(pathStr, root), raw)) put(pathStr, load, desc || rec.description);
453
+ }
454
+ continue;
455
+ }
456
+ const abs = path.isAbsolute(raw) ? raw : pyResolve(path.join(root, raw));
457
+ put(abs, load, desc);
458
+ }
459
+
460
+ const eager = [];
461
+ const lazy = [];
462
+ for (const r of recs.values()) {
463
+ (r.load === 'eager' ? eager : lazy).push({ path: r.path, description: r.description });
464
+ }
465
+ return [eager, lazy, String(manifest)];
466
+ }
467
+
468
+ export function cmdContext(args) {
469
+ // Discovery runs against the project root the hook was launched from — Claude runs
470
+ // SessionStart hooks with cwd = repo root — not the harness package dir.
471
+ // The only two `pyResolve` calls in this file whose argument can be a `~user` path the
472
+ // user typed — `$GENESEED_ROOT` and `--root` — and `pyResolve` expands, so both can now
473
+ // throw. Every other caller either builds its argument by `path.join` from an absolute
474
+ // root (so no leading tilde survives) or already sits inside a `try`. Refused input
475
+ // injects nothing, which is the same degrade as "nothing to load" below.
476
+ let root;
477
+ let hookRoot;
478
+ try {
479
+ ({ root, hookRoot } = withDiscardableStderr(() => ({
480
+ root: pyResolve(process.env.GENESEED_ROOT || process.cwd()),
481
+ hookRoot: args.root ? pyResolve(args.root) : null,
482
+ })));
483
+ } catch {
484
+ return 0;
485
+ }
486
+ if (hookRoot && sovereignBypass(hookRoot)) return 0;
487
+ if (hookRoot && !process.env.GENESEED_STACK_GLOBAL
488
+ && globalHookStandingDown(hookRoot, root)) return 0;
489
+ const [eager, lazy, source] = resolveContextSets(root);
490
+ if (!eager.length && !lazy.length) {
491
+ err(`[context] nothing to load for ${root} `
492
+ + '(no docs discovered, no manifest entries).\n');
493
+ return 0;
494
+ }
495
+
496
+ const lines = [
497
+ `=== PROJECT CONTEXT \u2014 binding for this repo per Rule XVIII (via ${source}) ===`,
498
+ '',
499
+ ];
500
+ for (const entry of eager) {
501
+ const p = entry.path === undefined ? '' : entry.path;
502
+ const desc = entry.description === undefined ? '' : entry.description;
503
+ const target = path.isAbsolute(p) ? p : path.join(root, p);
504
+ lines.push(`----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`);
505
+ try {
506
+ lines.push(readText(target).replace(/\n+$/, ''));
507
+ } catch (e) {
508
+ lines.push(`[context] MISSING eager file: ${pyOsError(e, target)}`);
509
+ }
510
+ lines.push('');
511
+ }
512
+
513
+ if (lazy.length) {
514
+ lines.push('--- Lazy entries (load only when the task needs them) ---');
515
+ for (const entry of lazy) {
516
+ const p = entry.path === undefined ? '' : entry.path;
517
+ const desc = entry.description === undefined ? '' : entry.description;
518
+ lines.push(` - ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''}`);
519
+ }
520
+ lines.push('');
521
+ }
522
+
523
+ out(`${lines.join('\n')}\n`);
524
+ return 0;
525
+ }
526
+
527
+ // ======================================================================================
528
+ // git-gate — Law XX's tool-boundary backstop
529
+ // ======================================================================================
530
+
531
+ // A `git` verb ANYWHERE in the command trips the gate, including the chained
532
+ // (`git add . && git commit … && git push`) and `-C <path>` forms, so a compound
533
+ // one-liner can never slip a commit past the prompt. `[^\n]*` is the boundary: a `git` on
534
+ // one line and a `commit` on the next are two commands, not one.
535
+ const GIT_GATE_RE = /\bgit\b[^\n]*\b(?:commit|push)\b/;
536
+
537
+ /** The `permissionDecision: "ask"` document, in Python's compact `json.dumps` spelling. */
538
+ function askDecision(reason) {
539
+ return `${jsonDumpsCompact({
540
+ hookSpecificOutput: {
541
+ hookEventName: 'PreToolUse',
542
+ permissionDecision: 'ask',
543
+ permissionDecisionReason: reason,
544
+ },
545
+ })}\n`;
546
+ }
547
+
548
+ export function cmdGitGate(args) {
549
+ if (sovereignBypass(args.root)) return 0;
550
+ let command;
551
+ try {
552
+ const payload = JSON.parse(readStdin() || '{}');
553
+ command = ((payload && payload.tool_input) || {}).command;
554
+ } catch {
555
+ return 0;
556
+ }
557
+ if (typeof command !== 'string' || !GIT_GATE_RE.test(command)) return 0;
558
+ out(askDecision('Geneseed Law XX \u2014 every git commit/push needs explicit approval'));
559
+ return 0;
560
+ }
561
+
562
+ // ======================================================================================
563
+ // rule-gate — Law VI's tool-boundary backstop
564
+ // ======================================================================================
565
+
566
+ /**
567
+ * `_rule_gate_target` — name the store a write would land in, or null for ordinary work.
568
+ *
569
+ * A deployed install's folder and file names are theme-INDEPENDENT by design, so this
570
+ * matches literal names and needs no install lookup. `user-rules.md` and `MEMORY.md` are
571
+ * Geneseed's own coinage and match anywhere; any other markdown matches only inside THIS
572
+ * install's `<root>/memory/`.
573
+ */
574
+ export function ruleGateTarget(p, root) {
575
+ if (typeof p !== 'string') return null;
576
+ // Split on BOTH separators rather than taking a basename: a payload carrying a Windows
577
+ // path reaches a POSIX host with its backslashes intact, and `path.basename` there
578
+ // returns the whole string — so the two coined names would go unrecognised exactly
579
+ // where they are meant to match on whichever host reads them.
580
+ const name = p.split(/[\\/]/).pop().toLowerCase();
581
+ if (name === 'user-rules.md') return 'user-rules.md';
582
+ if (name === 'memory.md') return 'the memory index';
583
+ if (!root || !name.endsWith('.md')) return null;
584
+ try {
585
+ const store = normcase(pyResolve(path.join(root, 'memory')));
586
+ // `.parents`, so the store dir itself is not one of its own ancestors.
587
+ return selfAndParents(pyResolve(p)).slice(1).some((d) => normcase(d) === store)
588
+ ? 'memory' : null;
589
+ } catch {
590
+ return null;
591
+ }
592
+ }
593
+
594
+ export function cmdRuleGate(args) {
595
+ if (sovereignBypass(args.root)) return 0;
596
+ let p;
597
+ try {
598
+ const payload = JSON.parse(readStdin() || '{}');
599
+ const ti = (payload && payload.tool_input) || {};
600
+ p = ti.file_path || ti.path || '';
601
+ } catch {
602
+ return 0;
603
+ }
604
+ if (typeof p !== 'string' || !p) return 0;
605
+ const target = ruleGateTarget(p, args.root);
606
+ if (!target) return 0;
607
+ out(askDecision(`Geneseed Law VI \u2014 writing to ${target}: a standing rule, or a fact to `
608
+ + "remember? That choice is the user's. Run the rule skill first."));
609
+ return 0;
610
+ }
611
+
612
+ // ======================================================================================
613
+ // learn — distil notes/transcripts into deduped memory entries
614
+ // ======================================================================================
615
+
616
+ const FRONTMATTER_RE = /^\s*---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/;
617
+ const FILE_SEP_RE = /^---FILE---[^\S\n]*$/m;
618
+ const AGENT_NAME_RE = /^[a-z][a-z0-9-]{1,40}$/;
619
+ const MAX_AGENT_BULLETS = 100;
620
+ const MAX_NOTES_CHARS = 16000;
621
+ // Host payloads name the finished subagent inconsistently; try each.
622
+ const SUBAGENT_NAME_FIELDS = ['agent_name', 'agent_type', 'subagent_type', 'agent',
623
+ 'agent_id'];
624
+
625
+ const PLUGIN_LEARN = path.join(ROOT, 'adapters', 'opencode', 'plugins', 'geneseed-learn.js');
626
+
627
+ /**
628
+ * `_load_learn_prompt_head` / `_load_agent_lesson_prompt`.
629
+ *
630
+ * The single source of truth for both prompts is the OpenCode plugin — the artifact that
631
+ * ships to the primary runtime — and both CLIs extract the template literal from its
632
+ * SOURCE rather than keeping a copy. Reading it means this port cannot drift from the
633
+ * plugin either; a copied constant here would be a third place to edit.
634
+ */
635
+ function pluginLiteral(name, fallback) {
636
+ try {
637
+ const m = new RegExp(`const ${name} = \`([\\s\\S]*?)\``).exec(readText(PLUGIN_LEARN));
638
+ if (m) return m[1];
639
+ } catch { /* unreadable plugin must never crash a Stop hook */ }
640
+ return fallback;
641
+ }
642
+
643
+ export const LEARN_PROMPT_HEAD = pluginLiteral('LEARN_PROMPT_HEAD',
644
+ 'Distil at most one durable, reusable memory from the notes below. '
645
+ + 'When in doubt, output exactly: NOTHING.');
646
+ const AGENT_LESSON_PROMPT = pluginLiteral('AGENT_LESSON_PROMPT',
647
+ 'Output AT MOST ONE line: a durable lesson about how this agent should '
648
+ + 'operate on a future dispatch. Else output exactly: NOTHING.');
649
+
650
+ /** `resolve_agent_name` — a safe agent slug from a raw field value, or null. */
651
+ export function resolveAgentName(raw) {
652
+ const name = typeof raw === 'string' ? raw.trim().toLowerCase() : null;
653
+ return name && AGENT_NAME_RE.test(name) ? name : null;
654
+ }
655
+
656
+ /** `append_agent_lesson` — one dated bullet, capped at the newest MAX_AGENT_BULLETS. */
657
+ export function appendAgentLesson(memDir, agent, lesson) {
658
+ const d = path.join(memDir, 'agents');
659
+ mkdirSync(d, { recursive: true });
660
+ const f = path.join(d, `${agent}.md`);
661
+ let bullets = [];
662
+ if (existsSync(f)) bullets = pySplitlines(readText(f)).filter((l) => l.startsWith('- '));
663
+ const now = new Date();
664
+ const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-`
665
+ + `${String(now.getDate()).padStart(2, '0')}`;
666
+ bullets.push(`- ${day}: ${pyWords(lesson).join(' ')}`);
667
+ bullets = bullets.slice(-MAX_AGENT_BULLETS);
668
+ writeText(f, `# ${agent} \u2014 lessons\n${bullets.join('\n')}\n`);
669
+ return f;
670
+ }
671
+
672
+ /** The lifecycle-hook payload if stdin is one, else `{}`. */
673
+ function hookMeta(raw) {
674
+ const s = raw.trim();
675
+ if (s.slice(0, 1) !== '{') return {};
676
+ try { return JSON.parse(s); } catch { return {}; }
677
+ }
678
+
679
+ /** Flatten a message `content` field (string, or a list of blocks) to text. */
680
+ function contentText(content) {
681
+ if (typeof content === 'string') return content;
682
+ if (Array.isArray(content)) {
683
+ const parts = [];
684
+ for (const block of content) {
685
+ if (typeof block === 'string') parts.push(block);
686
+ else if (block && typeof block === 'object' && block.type === 'text') {
687
+ parts.push(block.text === undefined ? '' : block.text);
688
+ }
689
+ }
690
+ return parts.join('\n');
691
+ }
692
+ return '';
693
+ }
694
+
695
+ /** Render a Claude-Code-style JSONL transcript into `role: text` notes. */
696
+ function flattenTranscript(p) {
697
+ let raw;
698
+ try {
699
+ // `errors="replace"`: Node's utf8 decoder already substitutes U+FFFD.
700
+ raw = readText(p);
701
+ } catch {
702
+ return '';
703
+ }
704
+ const acc = [];
705
+ for (let line of pySplitlines(raw)) {
706
+ line = line.trim();
707
+ if (!line) continue;
708
+ let obj;
709
+ try { obj = JSON.parse(line); } catch { continue; }
710
+ const msg = obj.message || {};
711
+ const role = msg.role || obj.role || obj.type;
712
+ if (role !== 'user' && role !== 'assistant') continue;
713
+ const text = contentText(msg.content === undefined ? obj.content : msg.content).trim();
714
+ if (text) acc.push(`${role}: ${text}`);
715
+ }
716
+ return acc.join('\n\n');
717
+ }
718
+
719
+ /**
720
+ * `_read_notes` — a lifecycle-hook payload with a `transcript_path` is read and flattened
721
+ * (this is what makes wiring `learn` to a Stop hook work with no redirection); anything
722
+ * else is used as-is.
723
+ */
724
+ /**
725
+ * EXPORTED FOR THE UNIT TIER, and the rule this follows is written down once here for the three
726
+ * `learn` helpers below it.
727
+ *
728
+ * `docs/specs`'s P3 rule is: drive a property through the PUBLIC entry by default, and export a
729
+ * decision only when the property is invisible through that face. The `learn` verb's WIRING is
730
+ * already gated — 78 cells in the CLI matrix drive it end to end — so what a unit test would add
731
+ * there is nothing. What no cell can see is these three functions' behaviour on inputs a cell
732
+ * cannot produce: a model that answers NOTHING, a duplicate slug, a `README.md` beside the
733
+ * store, a transcript payload that is not JSON. Those are decisions over text, and the corpus
734
+ * reaches none of them.
735
+ */
736
+ export function readNotes(raw) {
737
+ const s = raw.trim();
738
+ if (!s) return '';
739
+ if (s[0] === '{') {
740
+ let payload;
741
+ try { payload = JSON.parse(s); } catch { return raw; }
742
+ const tp = payload && payload.transcript_path;
743
+ if (tp) return flattenTranscript(tp);
744
+ return raw;
745
+ }
746
+ return raw;
747
+ }
748
+
749
+ // `resolveMemoryDir` moved to `js/hosts.mjs` in P5d — see the import at the top of this
750
+ // file. Re-exported here so the hook entry's own import list does not change.
751
+ export { resolveMemoryDir };
752
+
753
+ /**
754
+ * `_frontmatter` — leading YAML-ish frontmatter as a flat map, plus the body.
755
+ *
756
+ * Exported since P6b, whose `memoryItems` reads `name` and `description` off a fact file
757
+ * exactly as `_web_catalog._memory_items` does. That makes the web its SECOND owner, which
758
+ * this port treats as a coverage event rather than a tidiness one — the fields it produces
759
+ * are unreachable from a P6b cell (only the COUNT is consumed) and P6c's catalog cells are
760
+ * what gate them.
761
+ */
762
+ export function frontmatter(md) {
763
+ const m = FRONTMATTER_RE.exec(md);
764
+ if (!m) return [new Map(), md];
765
+ const fm = new Map();
766
+ for (const line of pySplitlines(m[1])) {
767
+ const at = line.indexOf(':');
768
+ if (at >= 0) {
769
+ // `str.strip('"')` strips the character from BOTH ends, however many there are.
770
+ fm.set(line.slice(0, at).trim(), line.slice(at + 1).trim().replace(/^"+|"+$/g, ''));
771
+ }
772
+ }
773
+ return [fm, m[2]];
774
+ }
775
+
776
+ const fmGet = (fm, key) => (fm.has(key) ? fm.get(key) : '');
777
+
778
+ /** `Path.stem` for a filename. */
779
+ const stem = (name) => path.basename(name, path.extname(name));
780
+
781
+ /** `mem_dir.glob("*.md")` — case-INSENSITIVE on Windows, as pathlib's glob is. */
782
+ function globMd(dir) {
783
+ return listDir(dir).filter((n) => normcase(n).endsWith('.md')
784
+ && isFile(path.join(dir, n)));
785
+ }
786
+
787
+ /** `_existing_slugs` — slugs already stored, so learn never re-emits a known fact. */
788
+ export function existingSlugs(memDir) {
789
+ const skip = new Set(['memory', 'readme']);
790
+ return new Set(globMd(memDir).map(stem).filter((s) => !skip.has(s.toLowerCase())));
791
+ }
792
+
793
+ function buildLearnPrompt(notes, existing) {
794
+ const parts = [LEARN_PROMPT_HEAD, ''];
795
+ if (existing.size) {
796
+ parts.push('ALREADY STORED \u2014 do NOT emit a memory matching any of these '
797
+ + 'slugs (skip updates too; only genuinely new facts):');
798
+ parts.push(...[...existing].sort().map((slug) => `- ${slug}`));
799
+ parts.push('');
800
+ }
801
+ parts.push('NOTES:', notes);
802
+ return parts.join('\n');
803
+ }
804
+
805
+ /**
806
+ * `_write_memories` — split the model output into files, write each NEW one, and append a
807
+ * pointer line to MEMORY.md. `existing` is MUTATED, so a reply carrying the same slug
808
+ * twice writes it once.
809
+ */
810
+ export function writeMemories(modelOutput, memDir, existing) {
811
+ const written = [];
812
+ const indexLines = [];
813
+ for (let chunk of modelOutput.split(FILE_SEP_RE)) {
814
+ chunk = chunk.trim();
815
+ if (!chunk || chunk.toUpperCase() === 'NOTHING') continue;
816
+ const [fm] = frontmatter(chunk);
817
+ const name = fmGet(fm, 'name').trim();
818
+ if (!name || existing.has(name)) continue;
819
+ writeText(path.join(memDir, `${name}.md`), `${chunk.replace(/\n+$/, '')}\n`);
820
+ existing.add(name);
821
+ written.push(name);
822
+ const desc = fmGet(fm, 'description').trim();
823
+ indexLines.push(`- [${name}](${name}.md)${desc ? ` \u2014 ${desc}` : ''}`);
824
+ }
825
+ if (indexLines.length) {
826
+ const index = path.join(memDir, 'MEMORY.md');
827
+ const current = existsSync(index) ? `${readText(index).replace(/\n+$/, '')}\n`
828
+ : '# Memory Index\n';
829
+ writeText(index, `${current}${indexLines.join('\n')}\n`);
830
+ }
831
+ return written;
832
+ }
833
+
834
+ /**
835
+ * `_harness_tui_views._memory_drop_index` — remove the index line(s) naming `<name>.md`.
836
+ *
837
+ * ITS PYTHON HOME IS THE TUI, and it is here because this is where its SIBLINGS' twins are:
838
+ * `appendAgentLesson` writes the index lines this deletes and `consolidateMemory` rebuilds
839
+ * them, so the three rules about MEMORY.md's format live together rather than one of them
840
+ * living beside a web endpoint. P7 ports the TUI's memory view onto this same function.
841
+ *
842
+ * The P6f plan asked whether P5's `learn` had already brought it across. It had not —
843
+ * nothing in `js/` referenced it — which is the third time a plan's "already ported" column
844
+ * was answered by reading rather than by trusting.
845
+ *
846
+ * SUBSTRING, NOT PARSE, exactly as the reference is: any line containing `(<name>.md)` goes,
847
+ * so a hand-written index in a different shape still loses its entry. And the file is only
848
+ * REWRITTEN when something actually changed, which keeps a delete of an unindexed fact from
849
+ * touching the user's mtime.
850
+ */
851
+ export function memoryDropIndex(memDir, name) {
852
+ const idx = path.join(memDir, 'MEMORY.md');
853
+ let lines;
854
+ try {
855
+ lines = pySplitlines(readText(idx));
856
+ } catch {
857
+ return; // the reference's `except OSError: return`
858
+ }
859
+ const keep = lines.filter((ln) => !ln.includes(`(${name}.md)`));
860
+ if (keep.length === lines.length) return;
861
+ try {
862
+ writeText(idx, `${keep.join('\n')}\n`);
863
+ } catch { /* the reference swallows OSError here too */ }
864
+ }
865
+
866
+ /**
867
+ * `consolidate_memory` — rebuild MEMORY.md from the fact files actually on disk: index
868
+ * orphans, prune dead lines, report duplicate descriptions (never auto-merge — nuance is
869
+ * the user's to keep).
870
+ */
871
+ export function consolidateMemory(memDir) {
872
+ const skip = new Set(['memory', 'readme']);
873
+ const facts = new Map();
874
+ for (const name of sortPaths(globMd(memDir))) {
875
+ const s = stem(name);
876
+ if (skip.has(s.toLowerCase())) continue;
877
+ try {
878
+ facts.set(s, fmGet(frontmatter(readText(path.join(memDir, name)))[0], 'description').trim());
879
+ } catch { continue; }
880
+ }
881
+ const index = path.join(memDir, 'MEMORY.md');
882
+ const oldSlugs = new Set();
883
+ if (existsSync(index)) {
884
+ for (const line of pySplitlines(readText(index))) {
885
+ const m = /^- \[[^\]]*\]\(([^)]+)\.md\)/.exec(line.trim());
886
+ if (m) oldSlugs.add(m[1]);
887
+ }
888
+ }
889
+ const lines = ['# Memory Index', ''];
890
+ for (const [slug, desc] of facts) {
891
+ lines.push(`- [${slug}](${slug}.md)${desc ? ` \u2014 ${desc}` : ''}`);
892
+ }
893
+ writeText(index, `${lines.join('\n')}\n`);
894
+ const seen = new Map();
895
+ const dups = [];
896
+ for (const [slug, desc] of facts) {
897
+ if (desc && seen.has(desc)) dups.push([seen.get(desc), slug]);
898
+ if (!seen.has(desc)) seen.set(desc, slug);
899
+ }
900
+ const added = [...facts.keys()].filter((s) => !oldSlugs.has(s)).sort();
901
+ const pruned = [...oldSlugs].filter((s) => !facts.has(s)).sort();
902
+ return { added, pruned, duplicates: dups };
903
+ }
904
+
905
+ /**
906
+ * The model CLI, run as `$GENESEED_LLM`'s words plus the prompt.
907
+ *
908
+ * `encoding="utf-8"` on the Python side and `'utf8'` here, never the console code page: a
909
+ * distilled memory carrying an accent or an em dash is WRITTEN to the store, so a
910
+ * mis-decode outlives the process. `maxBuffer` is lifted off Node's 1 MB default, which
911
+ * would truncate a long reply into a silently-half-parsed set of memory files.
912
+ */
913
+ function runLlm(llm, prompt) {
914
+ const argv = pyWords(llm);
915
+ // `_harness_learn.py:85` runs this through the wrapped `run(..., capture_output=True)`,
916
+ // which folds in `CREATE_NO_WINDOW`; a model CLI invoked from the windowless daemon
917
+ // otherwise pops a console for as long as it takes to answer.
918
+ const proc = spawnSync(argv[0], [...argv.slice(1), prompt],
919
+ { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...NO_WINDOW });
920
+ // Universal newlines, because Python reads the pipe in TEXT mode: a model CLI that is
921
+ // itself a Python script emits CRLF on Windows, `subprocess` folds it to `\n`, and
922
+ // `write_text` expands it again on the way into the memory file. Node hands back the raw
923
+ // bytes, so without this the stored fact is written with `\r\r\n` on every line.
924
+ const universal = (s) => (s === null || s === undefined ? ''
925
+ : String(s).replaceAll('\r\n', '\n').replaceAll('\r', '\n'));
926
+ return {
927
+ stdout: universal(proc.stdout),
928
+ stderr: universal(proc.stderr),
929
+ // A spawn that never started is Python's FileNotFoundError, which propagates there;
930
+ // here it surfaces as a non-zero code with the reason on stderr rather than a stack.
931
+ returncode: proc.error ? 1 : (proc.status === null ? 1 : proc.status),
932
+ error: proc.error,
933
+ };
934
+ }
935
+
936
+ /**
937
+ * `_learn_agent_lesson` — the SubagentStop path: at most one per-agent lesson into
938
+ * `memory/agents/<name>.md`. Skips silently when the host does not name the subagent:
939
+ * parity of mechanism, degrading to a no-op, never a crash.
940
+ */
941
+ function learnAgentLesson(meta, notes, args) {
942
+ let agent = null;
943
+ for (const field of SUBAGENT_NAME_FIELDS) {
944
+ agent = resolveAgentName(meta[field]);
945
+ if (agent) break;
946
+ }
947
+ if (!agent) return 0;
948
+ const memDir = resolveMemoryDir(args.memory);
949
+ if (!memDir) return 0;
950
+ const prompt = [AGENT_LESSON_PROMPT, '', 'NOTES:', notes].join('\n');
951
+ const llm = process.env.GENESEED_LLM;
952
+ if (!llm) {
953
+ err('[learn] $GENESEED_LLM unset \u2014 printing agent-lesson prompt.\n\n');
954
+ out(`${prompt}\n`);
955
+ return 0;
956
+ }
957
+ const proc = runLlm(llm, prompt);
958
+ const text = proc.stdout.trim();
959
+ if (text && text.toUpperCase() !== 'NOTHING') {
960
+ const lesson = pySplitlines(text)[0].replace(/^[-*]+/, '').trim();
961
+ const n = Array.from(lesson).length; // code points, as `len()` counts them
962
+ if (n >= 10 && n <= 300) {
963
+ err(`[learn] agent lesson -> ${appendAgentLesson(memDir, agent, lesson)}\n`);
964
+ }
965
+ }
966
+ if (proc.returncode !== 0 && proc.stderr) err(proc.stderr);
967
+ return proc.returncode;
968
+ }
969
+
970
+ export function cmdLearn(args) {
971
+ if (args.consolidate) {
972
+ const memDir = resolveMemoryDir(args.memory);
973
+ if (!memDir) {
974
+ err('[learn] no memory store found \u2014 nothing to consolidate.\n');
975
+ return 0;
976
+ }
977
+ const report = consolidateMemory(memDir);
978
+ err(`[learn] consolidated ${memDir}: +${report.added.length} indexed, `
979
+ + `-${report.pruned.length} pruned, `
980
+ + `${report.duplicates.length} duplicate description(s)\n`);
981
+ for (const [a, b] of report.duplicates) err(` duplicate: ${a} <-> ${b}\n`);
982
+ return 0;
983
+ }
984
+
985
+ // The Stop/SubagentStop hook always passes `--memory <cfg>/memory`; inside an excluded
986
+ // folder the global install must not learn.
987
+ if (args.memory && sovereignBypass(path.dirname(pyPathStr(args.memory)))) return 0;
988
+
989
+ const raw = args.file ? readText(args.file) : readStdin();
990
+ let notes = readNotes(raw);
991
+ if (!notes.trim()) {
992
+ err('[learn] no notes or transcript content \u2014 nothing to distil.\n');
993
+ return 0;
994
+ }
995
+ notes = tail(notes, MAX_NOTES_CHARS); // keep the tail: most recent, most durable
996
+
997
+ const meta = hookMeta(raw);
998
+ if (meta.hook_event_name === 'SubagentStop') return learnAgentLesson(meta, notes, args);
999
+
1000
+ const memDir = resolveMemoryDir(args.memory);
1001
+ const existing = memDir ? existingSlugs(memDir) : new Set();
1002
+ const prompt = buildLearnPrompt(notes, existing);
1003
+
1004
+ const llm = process.env.GENESEED_LLM;
1005
+ if (!llm) {
1006
+ err('[learn] $GENESEED_LLM unset \u2014 printing prompt instead.\n\n');
1007
+ out(`${prompt}\n`);
1008
+ return 0;
1009
+ }
1010
+
1011
+ const proc = runLlm(llm, prompt);
1012
+ const output = proc.stdout;
1013
+ if (memDir && output.trim() && output.trim().toUpperCase() !== 'NOTHING') {
1014
+ const written = writeMemories(output, memDir, existing);
1015
+ if (written.length) {
1016
+ err(`[learn] wrote ${written.length} memory file(s) to `
1017
+ + `${memDir}: ${written.join(', ')}\n`);
1018
+ } else {
1019
+ err('[learn] nothing new to store (all duplicates).\n');
1020
+ }
1021
+ } else {
1022
+ out(output);
1023
+ }
1024
+ if (proc.returncode !== 0 && proc.stderr) err(proc.stderr);
1025
+ return proc.returncode;
1026
+ }