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/mcp.mjs ADDED
@@ -0,0 +1,335 @@
1
+ /**
2
+ * MCP server presets, state and config I/O — `rituals/_harness_mcp.py`'s MCP block.
3
+ *
4
+ * WHAT THE PLAN SAID AND WHAT READING FOUND. The P6f plan scored `api_mcp` + `api_mcp_toggle`
5
+ * at "~90 new, already in js/: `_mcp_load`/`readJsonc` (P6c), `settings.mjs`'s atomic write".
6
+ * Measured against `js/`: `readJsonc` had crossed and NOTHING ELSE HAD. `js/web/api.mjs`
7
+ * carries a five-line `mcpLoad` that is the no-host, comment-tolerant reader P6c needed for
8
+ * `wiki.jsonc`, which is one arm of one of these thirteen functions. `_MCP_PRESETS`,
9
+ * `_mcp_servers_key`, `_mcp_preset_block`, `_mcp_apply`, `_mcp_state`, `_mcp_set_enabled`,
10
+ * `_mcp_load`'s host fork, `_mcp_commented`, `_mcp_save`, `_mcp_config_for`,
11
+ * `_mcp_install_targets`, `_mcp_known_names` and `_mcp_meta` are 168 lines of Python, all
12
+ * new. The endpoints on top of them are the small half.
13
+ *
14
+ * AND `settings.mjs`'s ATOMIC WRITE IS NOT THIS ONE. `atomicWriteJson` is
15
+ * `_build_settings._atomic_write_json`; `_mcp_save` is a different function with a different
16
+ * temp suffix (`.tmp`, not `.geneseed-tmp`) that also creates the parent directory. Reusing
17
+ * the first would be right until the moment a write fails, which is the moment the temp
18
+ * file's name becomes the only evidence of what happened.
19
+ *
20
+ * THE ROUND TRIP IS THE HAZARD, and it is why every read here goes through `parseJson`.
21
+ * `_mcp_save` rewrites the WHOLE config, not the servers map — and for a Claude global
22
+ * install that config is `~/.claude.json`, which holds projects and history far beyond MCP
23
+ * wiring. A `JSON.parse` would turn every `"temperature": 1.0` in somebody's real file into
24
+ * `1` on the way back out. `readJsonc` already learned this; the strict-JSON fork has to
25
+ * learn it separately, because it does not go through `readJsonc` at all.
26
+ *
27
+ * A PyNumber IS AN OBJECT TO `typeof`, which is why `isDict` is spelled out rather than
28
+ * written as `typeof v === 'object'`. Python's `isinstance(x, dict)` is false for a float;
29
+ * the JS twin has to be false for its wrapper.
30
+ */
31
+ import { existsSync, mkdirSync, renameSync } from 'node:fs';
32
+ import os from 'node:os';
33
+ import path from 'node:path';
34
+
35
+ import { bobConfigDir, copilotConfigDir, pyResolve } from './hosts.mjs';
36
+ import { installState, installTargets } from './installs.mjs';
37
+ import { jsonDumpsIndent, parseJson, pyLjust, pyPrint, pyPrintErr, readText, writeText }
38
+ from './lib/fs.mjs';
39
+ import { opencodeTarget, readJsonc } from './settings.mjs';
40
+
41
+ /**
42
+ * `isinstance(x, dict)` for a value that came out of `json.loads`.
43
+ *
44
+ * A JSON value is null, a boolean, a string, a NUMBER, an array or an object — and
45
+ * `parseJson` hands numbers back wrapped, so `typeof v === 'object'` says yes to a float.
46
+ * `constructor === Object` is the plain-object test, and every object `JSON.parse` builds
47
+ * is one.
48
+ */
49
+ const isDict = (v) => v !== null && v !== undefined && typeof v === 'object'
50
+ && v.constructor === Object;
51
+
52
+ /** `dict.get(key, default)`. */
53
+ const dget = (obj, key, dflt) => (isDict(obj) && Object.hasOwn(obj, key) ? obj[key] : dflt);
54
+
55
+ /** The hosts whose MCP config is strict JSON under `mcpServers`, with no `enabled` flag. */
56
+ const FLAGLESS = ['claude', 'bob', 'copilot'];
57
+
58
+ /**
59
+ * `_harness_mcp._MCP_PRESETS` — the ready-to-wire servers the MCP screen can toggle.
60
+ *
61
+ * The `desc` strings go on the wire verbatim (`api_mcp` returns them), so they are the one
62
+ * place in this file where a byte matters more than the shape. Insertion order is the
63
+ * listing order.
64
+ */
65
+ export const MCP_PRESETS = {
66
+ markitdown: {
67
+ label: 'MarkItDown',
68
+ desc: 'PDF / Office / HTML -> Markdown for the ingest skill. Runs via `uvx` '
69
+ + '(needs uv; zero-install, fetches on first call). No uv? Switch the command '
70
+ + 'to ["markitdown-mcp"] after `pipx install markitdown-mcp`. Exposes one '
71
+ + 'tool: convert_to_markdown(uri).',
72
+ block: { type: 'local', command: ['uvx', 'markitdown-mcp'], enabled: true },
73
+ },
74
+ gitlab: {
75
+ label: 'GitLab',
76
+ desc: 'GitLab repo / MR / issue / CI tools via @zereight/mcp-gitlab (npx, no '
77
+ + 'install). Edit GITLAB_PERSONAL_ACCESS_TOKEN (scopes: api, read_repository) '
78
+ + 'and GITLAB_API_URL before use. Add a second entry (gitlab-2) for another '
79
+ + 'instance.',
80
+ block: {
81
+ type: 'local',
82
+ command: ['npx', '-y', '@zereight/mcp-gitlab'],
83
+ environment: { GITLAB_PERSONAL_ACCESS_TOKEN: '',
84
+ GITLAB_API_URL: 'https://gitlab.com/api/v4' },
85
+ enabled: true,
86
+ },
87
+ },
88
+ 'gitlab-2': {
89
+ label: 'GitLab (2nd instance)',
90
+ desc: 'A second GitLab instance (e.g. a self-hosted server) via the same '
91
+ + '@zereight/mcp-gitlab command. Point GITLAB_API_URL at the other instance '
92
+ + "and give it that instance's own token.",
93
+ block: {
94
+ type: 'local',
95
+ command: ['npx', '-y', '@zereight/mcp-gitlab'],
96
+ environment: { GITLAB_PERSONAL_ACCESS_TOKEN: '',
97
+ GITLAB_API_URL: 'https://gitlab.example.com/api/v4' },
98
+ enabled: true,
99
+ },
100
+ },
101
+ filesystem: {
102
+ label: 'Filesystem',
103
+ desc: 'Read/write files under explicitly allowed directories via '
104
+ + '@modelcontextprotocol/server-filesystem (npx, no install). Replace the '
105
+ + 'trailing path arg(s) with only the dir(s) the agent may touch '
106
+ + '(least-privilege).',
107
+ block: {
108
+ type: 'local',
109
+ command: ['npx', '-y', '@modelcontextprotocol/server-filesystem',
110
+ '/path/to/allowed/dir'],
111
+ enabled: true,
112
+ },
113
+ },
114
+ };
115
+
116
+ /** `_mcp_servers_key` — OpenCode nests servers under `mcp`, the other three `mcpServers`. */
117
+ const serversKey = (host) => (FLAGLESS.includes(host) ? 'mcpServers' : 'mcp');
118
+
119
+ /** `(config.get(key) or {})` — the servers map, or an empty one. */
120
+ const serversOf = (config, host) => {
121
+ const s = dget(config, serversKey(host), null);
122
+ return isDict(s) ? s : {};
123
+ };
124
+
125
+ /**
126
+ * `_mcp_preset_block` — the block to WRITE for a preset, shaped for `host`.
127
+ *
128
+ * OpenCode takes it verbatim. The flagless three split the command list into `command`
129
+ * (head) + `args` (tail) and rename `environment` to `env`; Copilot additionally requires
130
+ * `type` and a `tools` allowlist.
131
+ */
132
+ export function mcpPresetBlock(name, host = 'opencode') {
133
+ const block = { ...MCP_PRESETS[name].block };
134
+ if (!FLAGLESS.includes(host)) return block;
135
+ const cmd = [...(block.command || [])];
136
+ const out = { command: cmd.length ? cmd[0] : '', args: cmd.slice(1) };
137
+ const env = block.environment;
138
+ if (env) out.env = { ...env };
139
+ if (host === 'copilot') {
140
+ out.type = 'local';
141
+ out.tools = ['*'];
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /**
147
+ * `_mcp_apply` — a COPY of `config` with server `name` set to `block`, or removed when
148
+ * `block` is null. Never touches another key; drops an emptied servers map.
149
+ *
150
+ * The key ORDER is behaviour, not cosmetics: `setdefault` appends `$schema` at the end of a
151
+ * fresh dict and leaves an existing one where it is, and `_mcp_save` writes the result out
152
+ * as the file the user then reads. `{...config}` plus a guarded assignment is the same rule.
153
+ */
154
+ export function mcpApply(config, name, block, host = 'opencode') {
155
+ const cfg = { ...config };
156
+ const key = serversKey(host);
157
+ if (host === 'opencode' && !Object.hasOwn(cfg, '$schema')) {
158
+ cfg.$schema = 'https://opencode.ai/config.json';
159
+ }
160
+ const servers = { ...serversOf(cfg, host) };
161
+ if (block === null) delete servers[name];
162
+ else servers[name] = block;
163
+ if (Object.keys(servers).length) cfg[key] = servers;
164
+ else delete cfg[key];
165
+ return cfg;
166
+ }
167
+
168
+ /**
169
+ * `_mcp_state` — 'enabled' | 'disabled' | 'absent'.
170
+ *
171
+ * OpenCode: a present server with no explicit `enabled` counts as enabled (its default).
172
+ * The flagless three have no such key, so present IS enabled and 'disabled' never occurs —
173
+ * toggling one off removes its entry instead.
174
+ */
175
+ export function mcpState(config, name, host = 'opencode') {
176
+ const server = serversOf(config, host)[name];
177
+ if (!isDict(server)) return 'absent';
178
+ if (FLAGLESS.includes(host)) return 'enabled';
179
+ return dget(server, 'enabled', true) ? 'enabled' : 'disabled';
180
+ }
181
+
182
+ /** `_mcp_set_enabled` — flip a present server's enabled-ness. No-op when it is absent. */
183
+ export function mcpSetEnabled(config, name, enabled, host = 'opencode') {
184
+ const server = serversOf(config, host)[name];
185
+ if (!isDict(server)) return config;
186
+ if (FLAGLESS.includes(host)) {
187
+ return enabled ? config : mcpApply(config, name, null, host);
188
+ }
189
+ return mcpApply(config, name, { ...server, enabled }, host);
190
+ }
191
+
192
+ /**
193
+ * `_mcp_load` — a config as a dict; `{}` when missing, unreadable, unparseable or not an
194
+ * object.
195
+ *
196
+ * The host fork is the whole function. OpenCode is read comment-tolerantly because a
197
+ * hand-maintained `opencode.jsonc` carries `//`; the other three are read STRICTLY, because
198
+ * the comment stripper's trailing-comma pass is not string-aware and would silently drop a
199
+ * comma from any string value containing `,]` — in a file that holds projects and history.
200
+ */
201
+ export function mcpLoad(p, host = 'opencode') {
202
+ if (!existsSync(p)) return {};
203
+ let text;
204
+ try {
205
+ text = readText(p);
206
+ } catch {
207
+ return {};
208
+ }
209
+ if (FLAGLESS.includes(host)) {
210
+ let data;
211
+ try {
212
+ data = parseJson(text);
213
+ } catch {
214
+ return {};
215
+ }
216
+ return isDict(data) ? data : {};
217
+ }
218
+ const [data] = readJsonc(text);
219
+ return isDict(data) ? data : {};
220
+ }
221
+
222
+ /** `_mcp_commented` — an existing `.jsonc` that carries comments, which must not be rewritten. */
223
+ export function mcpCommented(p) {
224
+ if (path.extname(p) !== '.jsonc' || !existsSync(p)) return false;
225
+ try {
226
+ const [, had] = readJsonc(readText(p));
227
+ return had;
228
+ } catch {
229
+ return false;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * `_mcp_save` — pretty JSON, written ATOMICALLY through a sibling temp file.
235
+ *
236
+ * Not `atomicWriteJson`: that is `_build_settings._atomic_write_json`, whose temp file is
237
+ * named `.geneseed-tmp` and which does not create the parent directory. Two Python
238
+ * functions, two twins.
239
+ */
240
+ export function mcpSave(p, config) {
241
+ mkdirSync(path.dirname(p), { recursive: true });
242
+ const tmp = `${p}.tmp`;
243
+ writeText(tmp, `${jsonDumpsIndent(config)}\n`);
244
+ renameSync(tmp, p);
245
+ }
246
+
247
+ /**
248
+ * `_mcp_config_for` — the file an install writes its MCP servers into, or null for a host
249
+ * that carries no MCP wiring at that scope.
250
+ */
251
+ export function mcpConfigFor(host, scope, root) {
252
+ if (host === 'opencode') return opencodeTarget(path.join(root, 'opencode.json'));
253
+ if (host === 'claude') {
254
+ return scope === 'project' ? path.join(root, '.mcp.json')
255
+ : pyResolve(path.join(os.homedir(), '.claude.json'));
256
+ }
257
+ if (host === 'bob') {
258
+ return scope === 'project' ? path.join(root, '.bob', 'settings.json')
259
+ : path.join(bobConfigDir(), 'settings.json');
260
+ }
261
+ if (host === 'copilot') {
262
+ // The Copilot CLI reads MCP servers from ~/.copilot/mcp-config.json only — no per-repo
263
+ // file is documented, so a project install carries no MCP wiring.
264
+ return scope === 'global' ? path.join(copilotConfigDir(), 'mcp-config.json') : null;
265
+ }
266
+ return null;
267
+ }
268
+
269
+ /** `_mcp_install_targets` — (label, configFile, host, scope, installRoot) per detected install. */
270
+ export function mcpInstallTargets() {
271
+ const out = [];
272
+ for (const [host, scope, root] of installTargets()) {
273
+ const cfg = mcpConfigFor(host, scope, root);
274
+ if (cfg !== null) out.push([`${scope} config`, cfg, host, scope, root]);
275
+ }
276
+ return out;
277
+ }
278
+
279
+ /** `_mcp_known_names` — the presets first, then any server already in THIS config. */
280
+ export function mcpKnownNames(config, host = 'opencode') {
281
+ const names = Object.keys(MCP_PRESETS);
282
+ const present = isDict(config) ? Object.keys(serversOf(config, host)) : [];
283
+ return names.concat(present.filter((n) => !Object.hasOwn(MCP_PRESETS, n)));
284
+ }
285
+
286
+ /** `_mcp_meta` — (label, description); the bare name and a generic note for an unknown one. */
287
+ export function mcpMeta(name) {
288
+ const p = MCP_PRESETS[name];
289
+ if (p) return [p.label, p.desc];
290
+ return [name, 'User-defined MCP server (not a Harness preset). It lives in this '
291
+ + "config already — 'e' enables/disables it, Enter removes it."];
292
+ }
293
+
294
+ export { isDict };
295
+
296
+ // ---- the verb ----------------------------------------------------------------------------
297
+
298
+ /**
299
+ * `geneseed mcp` — the CLI FACE this library never had.
300
+ *
301
+ * Everything above crossed with the web console rather than with a subcommand: the reference
302
+ * kept it as a preset/state library whose only command was the uninstall path, and the one
303
+ * way to see which servers an install had wired was to start a server and look. That is the
304
+ * gap this verb closes, and it closes it by CALLING the library — the same `mcpInstallTargets`
305
+ * / `mcpLoad` / `mcpKnownNames` / `mcpState` / `mcpMeta` sequence the console's read endpoint
306
+ * runs, rendered as text instead of as JSON.
307
+ *
308
+ * ponytail: READ-ONLY, on purpose, and this is the ceiling. Wiring a server from here needs
309
+ * the console's toggle orchestration, whose load-bearing part is not the toggle at all — it
310
+ * is the refusal to rewrite a config it could not parse strictly, because for a global
311
+ * Claude install that file holds projects and history and a rewrite from an empty object
312
+ * would erase them. A second copy of that guard is a second place for it to be wrong. When a
313
+ * terminal toggle is wanted, move the console's non-HTTP core down into this module and have
314
+ * both faces call it; do not grow a parallel one here.
315
+ */
316
+ export function cmdMcp() {
317
+ const targets = mcpInstallTargets()
318
+ .filter(([, , host, scope, root]) => installState(root, host, scope) === 'active');
319
+ if (!targets.length) {
320
+ pyPrintErr('[mcp] no active Geneseed install found. Run `geneseed setup` first.\n');
321
+ return 1;
322
+ }
323
+ for (const [label, p, host] of targets) {
324
+ const cfg = mcpLoad(p, host);
325
+ pyPrint(`[mcp] ${host} ${label}: ${p}${existsSync(p) ? '' : ' (not written yet)'}\n`);
326
+ if (mcpCommented(p)) {
327
+ pyPrint(' this config carries comments — edit it by hand so they survive.\n');
328
+ }
329
+ for (const name of mcpKnownNames(cfg, host)) {
330
+ const [lbl] = mcpMeta(name);
331
+ pyPrint(` ${pyLjust(mcpState(cfg, name, host), 8)} ${pyLjust(name, 12)} ${lbl}\n`);
332
+ }
333
+ }
334
+ return 0;
335
+ }
package/js/memory.mjs ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `geneseed memory` — the memory store from a terminal.
3
+ *
4
+ * WHY THIS VERB IS NEW RATHER THAN PORTED. The reference had no `memory` subcommand: a fact
5
+ * could be written by the `learn` hook and deleted from the web console, and there was no
6
+ * third way. So the semantics here are the DELETE ENDPOINT's, deliberately and field for
7
+ * field — the same reserved names, the same "a bare slug, never a path" rule, the same
8
+ * index line dropped from MEMORY.md after the file goes — because two ways to remove a fact
9
+ * that disagree about what a fact is would be worse than one way.
10
+ *
11
+ * WHERE IT PARTS COMPANY WITH THE ENDPOINT, AND WHY. Two places, both forced by the
12
+ * difference between a browser talking to a running server and a person typing in a shell:
13
+ *
14
+ * * THE STORE IS RESOLVED, NOT CONFIGURED. The endpoint is always `<target>/memory`,
15
+ * because a server has a target. A CLI has a cwd, so this takes the resolver every other
16
+ * terminal-side reader of the store already uses — `$GENESEED_MEMORY`, then a store
17
+ * beside the cwd, then the global config dir — with `--memory` as the explicit override.
18
+ * `status` prints the answer of that same resolver, so the two agree about which store
19
+ * is "the" store.
20
+ * * THE RESERVED-NAME CHECK IS CASE-INSENSITIVE, WHICH THE ENDPOINT'S IS NOT. It compares
21
+ * `MEMORY` and `README` exactly. On a case-insensitive filesystem — which is every
22
+ * Windows install and the default macOS one — `rm memory` then resolves to the index
23
+ * file, passes the guard, and deletes the thing the agent reads at session start. This
24
+ * is a NARROWING of what the endpoint accepts, on a destructive path, and it is the one
25
+ * divergence in this file that is not about resolution.
26
+ *
27
+ * `memoryDropIndex` is the hook module's, not a copy: writing the fact and unwriting its
28
+ * index line are two halves of one format, and the half that writes lives there.
29
+ */
30
+ import { readdirSync, statSync, unlinkSync } from 'node:fs';
31
+ import path from 'node:path';
32
+
33
+ import { memoryDropIndex } from './hooks.mjs';
34
+ import { resolveMemoryDir } from './hosts.mjs';
35
+ import { pyPrint, pyPrintErr } from './lib/fs.mjs';
36
+
37
+ const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
38
+
39
+ /**
40
+ * The two names the store owns rather than holds: the index the agent reads at session
41
+ * start, and the file that documents the store. `api_memory_delete` refuses both.
42
+ */
43
+ const RESERVED = ['memory', 'readme'];
44
+
45
+ /** Whether `name` is a fact this verb may act on — the endpoint's rule, plus the case fold. */
46
+ export function isFactName(name) {
47
+ return Boolean(name) && !name.includes('/') && !name.includes('\\')
48
+ && !RESERVED.includes(name.toLowerCase());
49
+ }
50
+
51
+ /**
52
+ * The removable facts in `dir`, as bare slugs, sorted.
53
+ *
54
+ * DERIVED FROM `isFactName` RATHER THAN FROM ITS OWN FILTER, so a listing can never offer a
55
+ * name the removal then refuses, and can never hide one it would accept.
56
+ */
57
+ export function memoryFacts(dir) {
58
+ let names;
59
+ try { names = readdirSync(dir); } catch { return []; }
60
+ return names.filter((n) => n.endsWith('.md') && isFile(path.join(dir, n)))
61
+ .map((n) => n.slice(0, -3)).filter(isFactName).sort();
62
+ }
63
+
64
+ /** `api_memory_delete`, minus the HTTP: the file, then its line in the index. */
65
+ export function memoryDelete(dir, name) {
66
+ if (!isFactName(name)) return false;
67
+ const p = path.join(dir, `${name}.md`);
68
+ if (!isFile(p)) return false;
69
+ unlinkSync(p);
70
+ memoryDropIndex(dir, name);
71
+ return true;
72
+ }
73
+
74
+ /** The verb. */
75
+ export function cmdMemory(args) {
76
+ const dir = resolveMemoryDir(args.memory);
77
+ if (dir === null) {
78
+ // The resolver answers null for "no store anywhere on the chain", which is also what an
79
+ // explicit `--memory` that is not a directory gives — so the message names both.
80
+ pyPrintErr('[memory] no memory store found. Pass --memory <dir>, set $GENESEED_MEMORY, '
81
+ + 'or run this from a directory with a memory/ store.\n');
82
+ return 1;
83
+ }
84
+ if (args.action === 'list') {
85
+ const facts = memoryFacts(dir);
86
+ if (!facts.length) {
87
+ pyPrint(`[memory] ${dir}: no facts.\n`);
88
+ return 0;
89
+ }
90
+ pyPrint(`[memory] ${dir}\n`);
91
+ for (const name of facts) pyPrint(` ${name}\n`);
92
+ return 0;
93
+ }
94
+ if (!args.name) {
95
+ pyPrintErr('[memory] rm needs the name of a fact.\n');
96
+ return 2;
97
+ }
98
+ if (!memoryDelete(dir, args.name)) {
99
+ // ONE MESSAGE FOR BOTH REFUSALS, which is the endpoint's shape too — it raises the same
100
+ // `NotFound(name)` for a reserved name, a name with a separator and a fact that is not
101
+ // there. Telling a caller that `MEMORY` exists but is protected is a distinction this
102
+ // verb has no reason to draw.
103
+ pyPrintErr(`[memory] no such fact '${args.name}' in ${dir}.\n`);
104
+ return 1;
105
+ }
106
+ pyPrint(`[memory] deleted ${args.name}.\n`);
107
+ return 0;
108
+ }
package/js/menu.mjs ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `menu` and `home` — `rituals/_harness_menu.py`'s two dispatchers.
3
+ *
4
+ * WHAT CROSSES HERE AND WHAT DOES NOT, said plainly, because this is the one module in the
5
+ * port whose Python twin is 3,398 lines and whose Node twin is ninety.
6
+ *
7
+ * `cmd_menu` is a FORK, not a menu. Off a TTY it prints five lines of command list and
8
+ * returns 0; on a TTY it hands control to `curses.wrapper(_main_menu)` — the full-screen
9
+ * panel, which is P7c's and is not here. The fork's off-TTY arm is what every cell of this
10
+ * port can reach, and it is ported byte for byte.
11
+ *
12
+ * ON A TTY, THIS ENTRY FALLS BACK RATHER THAN INVENTING A SECOND MENU. The reference already
13
+ * has that behaviour: `cmd_menu` wraps the panel in `except Exception`, writes `[menu] TUI
14
+ * unavailable (<reason>)` to stderr and prints the same command list. Node's reason is
15
+ * permanent rather than accidental, and it is stated as such.
16
+ *
17
+ * ⚠ THE REASON THIS HEADER ORIGINALLY GAVE WAS WRONG, and P7b measured it: it said the
18
+ * reference reaches that arm often because "`import curses` fails on stock Windows Python".
19
+ * A bare `import curses` does fail here, but `rituals/_harness_core.py` installs
20
+ * `rituals/_winterm.py` under the name `curses` when it does — so inside the harness the
21
+ * import succeeds, the panel opens, and it is the PORT that has no window. The fallback is
22
+ * still the right shape; only its explanation moved. The alternative that was considered and
23
+ * refused: building a numbered line-menu out of `js/setup.mjs`'s `askChoice`. That is not a
24
+ * port of `_main_menu` — it is a THIRD user interface, differing from the reference on every
25
+ * screen, unreachable from every cell (no cell has a TTY), and it would have to be deleted
26
+ * the day P7c lands the real panel. The fallback is the reference's own contract for "no
27
+ * full-screen menu available here"; a new UI would not be.
28
+ *
29
+ * `cmd_home` is the default for a bare `geneseed`, and it crosses WHOLE. Its two arms are
30
+ * `_web_first_ok()` — the web console, which is P6h's `startDaemon` and is fully ported —
31
+ * and `cmd_menu` above. Nothing in it was deferred.
32
+ *
33
+ * `_web_first_ok`'s FIVE REFUSALS ARE THE PAYLOAD, and only two of them are reachable from a
34
+ * cell: a cell's stdin is a pipe, so `isTTY` is false and everything past it is dead. The
35
+ * other three (SSH, the Linux display server, the browser) are gated by the corpus in
36
+ * `tests/test_pure_function_parity.py`, which drives this function with `isTTY` FAKED — the
37
+ * one input no cell can vary. See that file's P7a section for what it reaches and what it
38
+ * declares.
39
+ */
40
+ import { pyPrint, pyPrintErr, pyWhich } from './lib/fs.mjs';
41
+ import { startDaemon } from './web/server.mjs';
42
+
43
+ /** `_menu_help` — the off-TTY arm, and the fallback both TTY arms end in. */
44
+ export function menuHelp() {
45
+ pyPrint('Geneseed — no interactive menu here. Get started with: geneseed setup\n');
46
+ pyPrint('Other commands: bootstrap · update · build · doctor · diff · tui · web\n');
47
+ pyPrint('On a VT-capable terminal, a bare `./geneseed` opens the interactive menu of these.\n');
48
+ return 0;
49
+ }
50
+
51
+ /**
52
+ * `webbrowser.get()`, which raises `webbrowser.Error` when nothing on the machine can open
53
+ * a URL — and has no Node equivalent, because there is no stdlib registry of browsers.
54
+ *
55
+ * The twin answers the question this program actually needs: can `openUrl` (js/web/server.mjs)
56
+ * do its job? That makes the answer PER PLATFORM and identical to what `openUrl` will run —
57
+ * `cmd /c start` on Windows and `open` on macOS are OS builtins that are always there, and
58
+ * everywhere else it is `xdg-open`, which is not.
59
+ *
60
+ * DECLARED DIVERGENCE: on Linux the reference consults `$BROWSER` and a list of a dozen
61
+ * browser binaries before giving up, so a box with `firefox` on PATH and no `xdg-open`
62
+ * answers True there and false here. It is unreachable from this port's machine (the
63
+ * previous branch already returned false without a display server, and Windows never gets
64
+ * here), and closing it would mean reproducing `webbrowser`'s registry — a table of browser
65
+ * names that would go stale silently, which is the shape this port refuses.
66
+ */
67
+ function browserAvailable() {
68
+ if (process.platform === 'win32' || process.platform === 'darwin') return true;
69
+ return pyWhich('xdg-open') !== null;
70
+ }
71
+
72
+ /**
73
+ * `_web_first_ok` — whether a bare `geneseed` opens the web UI rather than the TUI menu.
74
+ * Deliberately conservative in the reference and here: any doubt falls back to the menu, so
75
+ * a server box is never dropped into a browser launch that cannot open.
76
+ */
77
+ export function webFirstOk() {
78
+ if (process.env.GENESEED_NO_WEB) return false;
79
+ // `process.stdin.isTTY` is `true` or UNDEFINED — never `false` — which is why this is a
80
+ // truthiness test and not a comparison.
81
+ if (!process.stdin.isTTY) return false;
82
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY) return false;
83
+ // On Linux a browser needs a display server; macOS/Windows always have a GUI.
84
+ if (process.platform === 'linux'
85
+ && !(process.env.DISPLAY || process.env.WAYLAND_DISPLAY)) return false;
86
+ return browserAvailable();
87
+ }
88
+
89
+ /** `cmd_menu`. See this module's header for why the TTY arm falls back. */
90
+ export function cmdMenu() {
91
+ if (!process.stdin.isTTY) return menuHelp();
92
+ // The reference's own `except Exception` arm, with a reason that is permanent here rather
93
+ // than machine-dependent. stderr, and CRLF on Windows, because `sys.stderr.write` does.
94
+ //
95
+ // P7b CORRECTED THE REASON RATHER THAN THE ARM. This module's header said the fallback was
96
+ // reached because "`import curses` fails on stock Windows Python", and P7b measured that
97
+ // to be false where it matters: `rituals/_harness_core.py` installs `rituals/_winterm.py`
98
+ // as `sys.modules["curses"]` when the stdlib module is missing, so the reference opens its
99
+ // panel on this machine and `tests/test_tui_boundary.py` drives it doing so. What is
100
+ // missing is on THIS side — there is no window implementation in the port — and that is
101
+ // what the message now says.
102
+ // NOTHING TO POINT AT ANY MORE. The message used to end by sending the reader at the
103
+ // Python panel, and P2 took the pointer out rather than re-aiming it: the panel it named
104
+ // is the Python this migration deletes, and a refusal that sends a user to a file which
105
+ // will not be there is worse than one that simply says the screen does not exist.
106
+ pyPrintErr('[menu] TUI unavailable (this entry has no full-screen menu).\n');
107
+ return menuHelp();
108
+ }
109
+
110
+ /** `cmd_home` — the default for a bare `geneseed`. */
111
+ export async function cmdHome(args) {
112
+ if (webFirstOk()) {
113
+ pyPrint('[geneseed] opening the web console — `geneseed menu` for the terminal UI, '
114
+ + 'GENESEED_NO_WEB=1 to disable.\n');
115
+ return startDaemon(null, 4747, true);
116
+ }
117
+ return cmdMenu(args);
118
+ }