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,480 @@
1
+ /**
2
+ * The web console's BACKGROUND JOBS — `rituals/_web_jobs.py`'s `JobManager`, its process-tree
3
+ * kill and the `action_commands` table.
4
+ *
5
+ * ---------------------------------------------------------------------------------------
6
+ * WHY THIS SPAWNS, WHEN NOTHING ELSE IN THE PORT IS ALLOWED TO.
7
+ *
8
+ * `bin/geneseed-cli.mjs` carries a transitive `child_process` ALLOW-LIST
9
+ * (`tests/test_hook_cli_parity.py::_ALLOWED_SPAWNS`) whose criterion is not "this verb
10
+ * spawns" — `build` spawns on the reference and is refused there — but "there is no
11
+ * in-process equivalent, and the spawned thing is not this program". This module spawns THIS
12
+ * PROGRAM, which reads like the passthrough the whole port exists to remove, so the argument
13
+ * has to be made rather than assumed.
14
+ *
15
+ * ISOLATION IS THE DISCRIMINATOR, and it is a property of the RUNTIME, not of the verbs.
16
+ * The reference's daemon is threaded: `start()` hands the work to a `threading.Thread` and
17
+ * the HTTP server keeps answering from another. Node's server is single-threaded and every
18
+ * ported `cmdX` is synchronous, so calling one in-process would block the event loop for the
19
+ * whole run — and the console's only progress mechanism is `web/src/api/jobs.js` polling
20
+ * `GET /api/jobs/<id>`. An in-process job therefore freezes the poll that exists to watch it,
21
+ * which is worse than not showing progress: the UI cannot tell a running job from a hung
22
+ * daemon. And a `process.exit` anywhere in a callee kills the DAEMON instead of the job —
23
+ * `cmdRebuildAll` already had to grow a throwing `die` for exactly that reason (P5f).
24
+ *
25
+ * `worker_threads` IS THE ALTERNATIVE AND IT IS WORSE. It restores concurrency, but a worker
26
+ * re-imports the world per job (the generator, the renderer, every host module), needs a
27
+ * message protocol for stdout and the exit code that a pipe gives for nothing, and cannot
28
+ * isolate a `process.exit` any better than the main thread can — `process.exit` in a worker
29
+ * takes the worker down mid-write, which is the same truncation with more machinery. Strictly
30
+ * more work for strictly less isolation.
31
+ *
32
+ * THE EXIT CODE IS EASIER OUT OF PROCESS, not harder. `_run` reads `p.returncode` and stops
33
+ * the chain on the first non-zero. In-process that would be a return value threaded through
34
+ * every verb's error paths, several of which currently end at `process.exitCode`.
35
+ *
36
+ * SO THE ARGV IS `node bin/geneseed-cli.mjs <verb>` — or `node bin/geneseed.mjs <build args>`
37
+ * for the three rows that run the GENERATOR, which is `build.py`'s twin and the same program
38
+ * by a different entry. `process.execPath` is the running interpreter by absolute path, so
39
+ * stripping `node` off PATH cannot change what starts. Nothing here can name `python`.
40
+ *
41
+ * THE SECOND SPAWN IS `taskkill`, and it is a different argument. `cancel()` must kill the
42
+ * job's whole process TREE — the reference's comment says why: killing only the direct child
43
+ * leaves ITS children holding the stdout pipe, the read loop stays blocked and the job wedges
44
+ * on `running` forever. POSIX gets that in-process (`detached: true` makes a process group and
45
+ * `process.kill(-pid)` signals it). Windows has no process-group kill in Node's standard
46
+ * library at all, and `taskkill /T` is what the reference reaches for too. It is a machine
47
+ * primitive, not this program.
48
+ *
49
+ * ---------------------------------------------------------------------------------------
50
+ * THE PARTITION — six of the eight action rows cross and two do not.
51
+ *
52
+ * `doctor`, `build`, `build-all`, `export`, `uninstall` and — since P8c — `update` all name a
53
+ * verb that has crossed. `link`/`unlink` run `harness.py link|unlink` (P10b). A Node runner
54
+ * that spawned the Python harness for those would put Python back into a "no Python
55
+ * needed" install, so they are DECLARED in `NOT_PORTED_ACTIONS` and answered 501 by the
56
+ * dispatcher, and P10b ends by deleting both rows. `tests/test_web_jobs.py` cross-checks the
57
+ * two sets against `action_commands`' own keys with `ast`, so a NINTH row added to the
58
+ * reference cannot quietly answer "unknown action".
59
+ *
60
+ * ---------------------------------------------------------------------------------------
61
+ * THREE DETAILS OF `_run`, EACH A COMMENT EXPLAINING A BUG IT ALREADY FIXED.
62
+ *
63
+ * `GENESEED_WEB_JOB=1` tells `upgrade` it is running INSIDE this daemon so it must not bounce
64
+ * the daemon mid-job — which killed the job's own tracking and left the console on `running`
65
+ * forever. It was set here before any ported row read it, because it was P6g's CONTRACT WITH
66
+ * P8, and a contract written after the fact is a contract that was broken once first. P8a is
67
+ * where `js/update.mjs` began honouring it, and P8c is where the `update` row that triggers
68
+ * it crossed — so it is a live pair now rather than a promise.
69
+ *
70
+ * `PYTHONUNBUFFERED=1` reaches the Python child AND its own Python children, or their stdout
71
+ * is block-buffered into the pipe and the console looks stuck. THE EQUIVALENT THOUGHT FOR
72
+ * NODE, which is the port and not the copy: there is no unbuffering flag to set, because
73
+ * Node's `process.stdout` on a pipe is not block-buffered by libc — every `write` is queued on
74
+ * the stream and flushed by the event loop. The analogous hazard is a child calling
75
+ * `process.exit()` with writes still queued, which truncates them; both `bin/` entries set
76
+ * `process.exitCode` and return instead, which is what makes the flag unnecessary rather than
77
+ * merely inapplicable. So the variable is NOT set: it would be cargo, and the honest
78
+ * statement is this paragraph.
79
+ *
80
+ * `MEM_CAP` truncates a runaway job's output FROM THE FRONT, so a job that prints forever
81
+ * cannot eat the daemon's memory. `OUTPUT_CAP` is the smaller cap the history FILE keeps.
82
+ *
83
+ * ---------------------------------------------------------------------------------------
84
+ * TWO DIVERGENCES, DECLARED.
85
+ *
86
+ * 1. THE TWO STREAMS ARE TWO PIPES. `stderr=subprocess.STDOUT` merges them in the OPERATING
87
+ * SYSTEM, so the reference's interleaving is exact. Node's `stdio` cannot name an
88
+ * already-created pipe, so `stdout` and `stderr` are read separately and appended in
89
+ * arrival order — identical for a line-buffered child writing to one stream (which is
90
+ * every ported row: `rebuild-all` writes stdout only, `diff` writes stderr only), and
91
+ * potentially reordered for a child interleaving both within one tick. No cell can see it,
92
+ * and it is named here rather than left to be discovered.
93
+ * 2. `_append`'s cap slices UTF-16 code UNITS where Python slices code POINTS. It differs only
94
+ * past two million characters and only by a lone surrogate at the truncation front. The
95
+ * history file's cap does the exact thing (`Array.from`), because it runs once per job
96
+ * rather than once per line — doing it here would make a runaway job quadratic, which is
97
+ * the failure the cap exists to prevent.
98
+ *
99
+ * NOT PORTED: `JobManager.wait`. It has no caller in `rituals/` — `tests/test_web.py` is its
100
+ * only consumer, twenty times over. Its Node twin is owed the day a Node-side corpus drives
101
+ * the manager directly, which is P9's; until then it would be a function with no caller and no
102
+ * cell, which is the shape this port keeps being bitten by.
103
+ */
104
+ import { spawn, spawnSync } from 'node:child_process';
105
+ import { randomBytes } from 'node:crypto';
106
+ import { statSync } from 'node:fs';
107
+ import { StringDecoder } from 'node:string_decoder';
108
+ import path from 'node:path';
109
+
110
+ import { ROOT } from '../checkout.mjs';
111
+ import { setupBuildArgs } from '../generate.mjs';
112
+ import { isDict } from '../mcp.mjs';
113
+ import { jsonDumpsCompact, parseJson, pyTruthy, readText, writeText } from '../lib/fs.mjs';
114
+
115
+ const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
116
+
117
+ /**
118
+ * `_kill_job_tree` — terminate a job's WHOLE process tree.
119
+ *
120
+ * NEVER THROWS, as the reference's two nested `except`s do not: it runs from `cancel()` and
121
+ * from the crash path in `_run`'s `catch`, and an exception in either would leave the child
122
+ * alive with the manager believing it dead.
123
+ */
124
+ export function killJobTree(child) {
125
+ try {
126
+ if (process.platform === 'win32') {
127
+ // The one machine primitive with no Node equivalent — see this module's header.
128
+ spawnSync('taskkill', ['/T', '/F', '/PID', String(child.pid)], {
129
+ timeout: 15000, windowsHide: true, stdio: 'ignore',
130
+ });
131
+ } else {
132
+ // `os.killpg(p.pid, SIGTERM)`. The group exists because `_run` spawns with
133
+ // `detached: true`, which is `start_new_session=True`.
134
+ process.kill(-child.pid, 'SIGTERM');
135
+ }
136
+ } catch {
137
+ try {
138
+ child.kill();
139
+ } catch { /* `except OSError: pass` */ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * `Popen(..., text=True, encoding="utf-8", errors="replace")` — the decode Python's text mode
145
+ * performs and Node's pipes do not.
146
+ *
147
+ * TWO THINGS, and the second is the one a port drops. `StringDecoder` holds a partial UTF-8
148
+ * sequence across a chunk boundary (a naive `buf.toString()` per chunk turns one multi-byte
149
+ * character split across two reads into two replacement characters — the exact defect P5i
150
+ * found when a helper reached its second owner). And Python decodes in UNIVERSAL NEWLINES
151
+ * mode, so `\r\n` and a lone `\r` are both `\n` before the caller ever sees them — while Node
152
+ * hands back the bytes. The reference's children are Python processes writing CRLF on Windows;
153
+ * the twin's are Node processes doing the same. Without the fold the job output would carry
154
+ * CRLF on one side and LF on the other, which is P5b's eighth coverage hole with the
155
+ * normalising transport removed.
156
+ *
157
+ * The pending `\r` is held rather than translated on the spot: a `\r\n` split across two
158
+ * chunks would otherwise become two newlines.
159
+ */
160
+ function textDecoder(sink) {
161
+ const dec = new StringDecoder('utf8');
162
+ let pendingCR = false;
163
+ const emit = (s) => {
164
+ if (pendingCR) {
165
+ s = `\r${s}`;
166
+ pendingCR = false;
167
+ }
168
+ if (s.endsWith('\r')) {
169
+ s = s.slice(0, -1);
170
+ pendingCR = true;
171
+ }
172
+ if (s) sink(s.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
173
+ };
174
+ return {
175
+ push: (buf) => emit(dec.write(buf)),
176
+ end: () => {
177
+ let s = dec.end();
178
+ if (pendingCR) {
179
+ s = `\r${s}`;
180
+ pendingCR = false;
181
+ }
182
+ if (s) sink(s.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
183
+ },
184
+ };
185
+ }
186
+
187
+ /** `s[-n:]` by CODE POINT, which is what Python slices. */
188
+ const tailChars = (s, n) => (s.length <= n ? s : Array.from(s).slice(-n).join(''));
189
+
190
+ /** The signals `killJobTree` sends, for the `-SIGNUM` return code Python reports. */
191
+ const SIGNUM = { SIGTERM: 15, SIGKILL: 9, SIGINT: 2, SIGHUP: 1 };
192
+
193
+ /**
194
+ * `_web_jobs.JobManager` — one mutating action at a time, in the background, output captured.
195
+ *
196
+ * A CLASS rather than P6b's factory-of-a-plain-object, because this one has real state and
197
+ * eleven methods over it; `webState` is a factory because its only behaviour is two cached
198
+ * getters.
199
+ *
200
+ * THE LOCK IS GONE AND ITS ABSENCE IS THE PORT. `self._lock` guards `_jobs`, `_busy` and
201
+ * `_procs` against the run thread and the request threads. Node has one thread and no
202
+ * pre-emption inside a synchronous block, so every critical section here is already atomic —
203
+ * a mutex would be a no-op object whose presence claimed a hazard that cannot occur. What the
204
+ * lock's placement DOES carry over is which sequences must not be interrupted, and none of
205
+ * them awaits.
206
+ */
207
+ export class JobManager {
208
+ static HISTORY_MAX = 20;
209
+
210
+ static OUTPUT_CAP = 20000; // chars of output kept per job in the history file
211
+
212
+ static MEM_CAP = 2000000; // chars kept in memory per job while it runs
213
+
214
+ constructor(historyPath = null) {
215
+ this._jobs = new Map();
216
+ this._busy = false;
217
+ this._procs = new Map();
218
+ this._historyPath = historyPath;
219
+ this._loadHistory();
220
+ }
221
+
222
+ /**
223
+ * A `running` entry in the file means the SERVER died mid-run, so it is dropped rather than
224
+ * restored: nothing will ever finish it, and a console showing it would wait forever.
225
+ */
226
+ _loadHistory() {
227
+ if (!this._historyPath || !isFile(this._historyPath)) return;
228
+ let jobs;
229
+ try {
230
+ jobs = parseJson(readText(this._historyPath));
231
+ } catch {
232
+ return;
233
+ }
234
+ for (const j of Array.isArray(jobs) ? jobs : []) {
235
+ if (isDict(j) && pyTruthy(j.id) && j.status !== 'running') this._jobs.set(String(j.id), j);
236
+ }
237
+ }
238
+
239
+ _saveHistory() {
240
+ if (!this._historyPath) return;
241
+ const jobs = [...this._jobs.values()].filter((j) => j.status !== 'running')
242
+ .map((j) => ({ ...j }));
243
+ // `sort(key=...)` is STABLE in both languages, which matters: two jobs started inside the
244
+ // same clock tick keep insertion order rather than swapping between the two runtimes.
245
+ jobs.sort((a, b) => (Number(a.started ?? 0) || 0) - (Number(b.started ?? 0) || 0));
246
+ const kept = jobs.slice(-JobManager.HISTORY_MAX)
247
+ .map((j) => ({ ...j, output: tailChars(String(j.output), JobManager.OUTPUT_CAP) }));
248
+ try {
249
+ writeText(this._historyPath, jsonDumpsCompact(kept, { bareInts: true }));
250
+ } catch { /* `except OSError: pass` — a console that cannot persist still runs */ }
251
+ }
252
+
253
+ /** Last `n` jobs, oldest first — the order the console appends in. */
254
+ recent(n = JobManager.HISTORY_MAX) {
255
+ const jobs = [...this._jobs.values()]
256
+ .sort((a, b) => (Number(a.started ?? 0) || 0) - (Number(b.started ?? 0) || 0));
257
+ return jobs.slice(-n).map((j) => ({ ...j }));
258
+ }
259
+
260
+ /**
261
+ * `start(action, *cmds, on_done=None)` — `cmds` is a LIST OF ARGVS, run in order, stopping
262
+ * at the first failure. `null` when a job is already running, which the HTTP layer maps
263
+ * to 409.
264
+ */
265
+ start(action, cmds, onDone = null) {
266
+ if (this._busy) return null;
267
+ this._busy = true;
268
+ const jid = randomBytes(8).toString('hex');
269
+ this._jobs.set(jid, {
270
+ id: jid,
271
+ action,
272
+ status: 'running',
273
+ output: '',
274
+ returncode: null,
275
+ started: Date.now() / 1000,
276
+ duration: null,
277
+ });
278
+ // The reference's `threading.Thread(...).start()`: hand the work to the scheduler and
279
+ // return the id NOW, so the 202 goes out before the child has produced a byte.
280
+ void this._run(jid, cmds, onDone);
281
+ return jid;
282
+ }
283
+
284
+ _append(jid, text) {
285
+ const j = this._jobs.get(jid);
286
+ j.output += text;
287
+ if (j.output.length > JobManager.MEM_CAP) {
288
+ // Code UNITS, not code points — see this module's header for why the exact slice lives
289
+ // in `_saveHistory` and not here.
290
+ j.output = j.output.slice(-JobManager.MEM_CAP);
291
+ }
292
+ }
293
+
294
+ async _run(jid, cmds, onDone = null) {
295
+ let rc = 0;
296
+ let p = null;
297
+ try {
298
+ for (let i = 0; i < cmds.length; i += 1) {
299
+ const cmd = cmds[i];
300
+ this._append(jid, `$ ${cmd.map((c) => String(c)).join(' ')}\n`);
301
+ p = spawn(cmd[0], cmd.slice(1), {
302
+ cwd: ROOT,
303
+ stdio: ['ignore', 'pipe', 'pipe'],
304
+ // `harness.NO_WINDOW`: no console window for a child of a windowless daemon.
305
+ windowsHide: true,
306
+ // `start_new_session=True` on POSIX — the process GROUP `killJobTree` signals.
307
+ detached: process.platform !== 'win32',
308
+ env: { ...process.env, GENESEED_WEB_JOB: '1' },
309
+ });
310
+ this._procs.set(jid, p); // reachable for cancel()
311
+ rc = await this._pump(jid, p);
312
+ this._procs.delete(jid);
313
+ if (rc !== 0) {
314
+ const left = cmds.length - i - 1;
315
+ this._append(jid, `\n[web] ✗ command exited with code ${rc}`
316
+ + (left ? ` — skipping the ${left} remaining step(s).\n` : '.\n'));
317
+ break;
318
+ }
319
+ }
320
+ } catch (e) {
321
+ this._append(jid, `\n[web] job crashed: ${e && e.message ? e.message : e}`);
322
+ rc = 1;
323
+ // Don't orphan the child on a crash of OUR side: kill its tree, or it lingers.
324
+ // `p.pid` is undefined when the spawn itself failed, which is the `p is not None`
325
+ // arm the reference cannot reach at all (its `Popen` raises before the assignment).
326
+ if (p !== null && p.pid && p.exitCode === null && p.signalCode === null) killJobTree(p);
327
+ } finally {
328
+ this._procs.delete(jid);
329
+ const j = this._jobs.get(jid);
330
+ j.status = rc === 0 ? 'done' : 'failed';
331
+ j.returncode = rc;
332
+ // `round(x, 1)`. ponytail: JS rounds a .x5 tie away from zero where Python rounds to
333
+ // even — unreachable for a wall-clock delta, and the field is a display value.
334
+ j.duration = Math.round((Date.now() / 1000 - Number(j.started)) * 10) / 10;
335
+ this._busy = false;
336
+ this._saveHistory();
337
+ if (onDone) {
338
+ try {
339
+ onDone(rc); // callbacks take the exit code (0 = every step ok)
340
+ } catch { /* refresh must never kill the job */ }
341
+ }
342
+ }
343
+ }
344
+
345
+ /**
346
+ * `for line in p.stdout: ...` then `p.wait()` — read to EOF, then the exit code.
347
+ *
348
+ * BOTH `close` AND the two stream ENDS are awaited, not just `exit`: `exit` fires when the
349
+ * process dies and `close` when its stdio have also closed, and resolving on the former
350
+ * would let a job report `done` with its last lines still in the pipe. That is the same
351
+ * ordering the reference gets for free by draining `p.stdout` to EOF before `p.wait()`.
352
+ */
353
+ _pump(jid, p) {
354
+ return new Promise((resolve, reject) => {
355
+ const sink = (s) => this._append(jid, s);
356
+ const out = textDecoder(sink);
357
+ const err = textDecoder(sink);
358
+ p.stdout.on('data', (b) => out.push(b));
359
+ p.stderr.on('data', (b) => err.push(b));
360
+ p.on('error', reject);
361
+ p.on('close', (code, signal) => {
362
+ out.end();
363
+ err.end();
364
+ // `p.returncode` for a signalled child is `-SIGNUM`, and Node splits the exit code
365
+ // and the signal into two arguments with the code `null` when a signal won. Only
366
+ // `cancel()` produces one, and only on POSIX — which no cell can reach, so the map
367
+ // covers what this program SENDS rather than every signal that exists.
368
+ resolve(code === null ? -(SIGNUM[signal] ?? 1) : code);
369
+ });
370
+ });
371
+ }
372
+
373
+ /**
374
+ * Terminate the running job's subprocess; the run thread then winds down normally (stdout
375
+ * closes, the exit code is non-zero, the status becomes `failed`).
376
+ */
377
+ cancel(jid) {
378
+ const p = this._procs.get(jid);
379
+ const j = this._jobs.get(jid);
380
+ if (p === undefined || !j || j.status !== 'running') return false;
381
+ this._append(jid, '\n[web] cancelled by user.\n');
382
+ killJobTree(p);
383
+ return true;
384
+ }
385
+
386
+ get(jid) {
387
+ const j = this._jobs.get(jid);
388
+ return j ? { ...j } : null;
389
+ }
390
+ }
391
+
392
+ /**
393
+ * `_web_jobs.action_commands` — action name -> a list of argvs, each a separate step, stopping
394
+ * at the first failure.
395
+ *
396
+ * THE TABLE IS THE DISPATCH, so `PORTED_ACTIONS` is its own keys rather than a list beside it
397
+ * (the rule `POST_ROUTES` established one file over, for the reason M23 gave).
398
+ *
399
+ * `build` renders the DEPLOYED install in its detected theme + emit + footprint + posture +
400
+ * mode, so a rebuild from an imperial, lean, mentor, foreman opencode-global install stays all
401
+ * five. `update` and `export` self-resolve the deployed theme downstream and take no args.
402
+ *
403
+ * THE ARGV HEAD IS THE PORT. The reference names `sys.executable` and `rituals/harness.py` (or
404
+ * `build.py`); this names `process.execPath` and `bin/geneseed-cli.mjs` (or `bin/geneseed.mjs`,
405
+ * which is `build.py`'s twin). The two can never be byte-equal, `tests/web_golden.py`
406
+ * normalises the `$ <argv>` echo line for that reason, and `tests/test_web_jobs.py` asserts
407
+ * each side's head ABSOLUTELY while comparing the tails literally — which is where a real port
408
+ * bug would live.
409
+ */
410
+ const NODE = () => process.execPath;
411
+ const CLI = () => path.join(ROOT, 'bin', 'geneseed-cli.mjs');
412
+ const GEN = () => path.join(ROOT, 'bin', 'geneseed.mjs');
413
+
414
+ /**
415
+ * The rows whose VERB has not crossed. **EMPTY SINCE P10b, AND STILL DECLARED.**
416
+ *
417
+ * P6g opened it with three rows and wrote the rule that closes it: each removed by the phase
418
+ * that ports its verb. `update` left in P8c; `link` and `unlink` left in P10b, which is the
419
+ * phase that also had to decide whether those two verbs should exist under npm at all
420
+ * (`js/link.mjs`'s docblock carries the argument, and the deciding vote was these two rows —
421
+ * the console's Settings buttons POST here, and only a ported verb can answer them).
422
+ *
423
+ * WHY AN EMPTY SET RATHER THAN A DELETED ONE. The `ast` cross-check in
424
+ * `tests/test_web_jobs.py` reads this set against `action_commands`' own keys, and an empty
425
+ * set is the strongest form that check ever takes: it asserts that EVERY action the reference
426
+ * dispatches on is answered by the Node runner. Deleting the declaration would delete that
427
+ * assertion, and the next unported action would come back as a silent 404 — the thing the
428
+ * 501 exists to prevent.
429
+ *
430
+ * A 501 AND NOT THE 404 THE TABLE WOULD PRODUCE. `action_commands` returns `None` for an
431
+ * unknown action and the reference maps that to `{"error": "unknown action <x>"}` at 404 — so
432
+ * a real-but-unported action falling through would answer the same thing a typo gets. With
433
+ * this set empty the 501 branch is now UNREACHABLE by construction, which is exactly what the
434
+ * dispatcher probe in `tests/test_web_server.py` asserts: every real action answers 202 and
435
+ * only an invented one answers 404.
436
+ */
437
+ export const NOT_PORTED_ACTIONS = new Set();
438
+
439
+ /**
440
+ * The three actions `_post_routes` answers INLINE, without consulting the table: `restore` is
441
+ * synchronous and returns a result rather than a job id, and `install`/`deploy` resolve their
442
+ * argv from the request body first. Declared so the `ast` cross-check covers every `action ==`
443
+ * the reference dispatches on, not only the table's keys.
444
+ */
445
+ export const INLINE_ACTIONS = ['restore', 'install', 'deploy'];
446
+
447
+ function actionTable({
448
+ theme = 'neutral', emit = 'opencode-global', footprint = 'full',
449
+ posture = 'peer', mode = 'direct',
450
+ } = {}) {
451
+ const buildArgv = setupBuildArgs(theme, emit, null, null, footprint, posture, mode);
452
+ return {
453
+ doctor: [[NODE(), CLI(), 'doctor']],
454
+ build: [[NODE(), GEN(), ...buildArgv]],
455
+ // Rebuild EVERY active install in place (each in its own theme+emit). The per-install
456
+ // resolution lives in the rebuild-all subcommand, so the web layer threads no theme/emit.
457
+ 'build-all': [[NODE(), CLI(), 'rebuild-all']],
458
+ // P8c. The row names `upgrade`, which is the VERB; `update` is the action name here and
459
+ // argparse's alias there, and the reference's own row is `[py, harness.py, "upgrade"]` for
460
+ // the same reason. The daemon bounce this job must not do itself is `GENESEED_WEB_JOB`'s
461
+ // job, set by `_run` above and read by `js/update.mjs` — P6g's contract with P8, honoured.
462
+ update: [[NODE(), CLI(), 'upgrade']],
463
+ export: [[NODE(), CLI(), 'diff', '--out']],
464
+ // Local-machine maintenance, surfaced in the web Settings. uninstall keeps memory (never
465
+ // deleted) and runs non-interactively with --yes.
466
+ link: [[NODE(), CLI(), 'link']],
467
+ unlink: [[NODE(), CLI(), 'unlink']],
468
+ uninstall: [[NODE(), CLI(), 'uninstall', '--yes']],
469
+ };
470
+ }
471
+
472
+ export function actionCommands(action, opts = {}) {
473
+ const table = actionTable(opts);
474
+ // `dict.get(action)` — `None` for anything the table does not name, which the HTTP layer
475
+ // maps to `{"error": "unknown action <x>"}` at 404.
476
+ return Object.hasOwn(table, action) ? table[action] : null;
477
+ }
478
+
479
+ /** `action_commands`' own keys — the table IS the declaration, so this cannot drift from it. */
480
+ export const PORTED_ACTIONS = Object.keys(actionTable());