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,28 @@
1
+ /**
2
+ * The spawn flag — one rule for every child process this tool starts, in one place.
3
+ *
4
+ * Windows gives every console child of a CONSOLE-LESS parent a brand-new console window.
5
+ * The web daemon is console-less by design (`DETACHED_PROCESS`), so without this flag every
6
+ * short-lived process it shells out to pops a window and closes it — the Doctor page is the
7
+ * worst case, running `node --check` once per plugin, and it is what a user actually sees:
8
+ * seven consoles flashing on a dashboard load.
9
+ *
10
+ * WHY IT LIVES HERE AND NOT IN `js/update.mjs`, WHICH DEFINED IT FIRST. It was private to
11
+ * that module and six of its spawns used it, so the four spawn sites in `js/doctor.mjs`,
12
+ * `js/hooks.mjs`, `js/setup.mjs` and `js/link.mjs` each had to remember the flag on their
13
+ * own. Two of them did not — and the miss is BYTE-INVISIBLE: a console window is not
14
+ * something the CLI prints, so every recorded cell under `tests/__snapshots__/` stays green
15
+ * while the windows pop. The property under test never reaches the comparison, which is why
16
+ * a recorded corpus cannot be the gate here. A shared constant plus
17
+ * `tests/unit/spawn_hygiene.test.mjs` is what makes the next spawn site inherit the answer
18
+ * instead of re-deciding it.
19
+ *
20
+ * CAPTURING SPAWNS ONLY, and the asymmetry is deliberate. The rule was measured on Windows:
21
+ * a process that sets `STARTF_USESTDHANDLES` only when a stream is redirected gives an
22
+ * INHERITING child spawned with `CREATE_NO_WINDOW` a fresh hidden console and discards
23
+ * everything it prints — `geneseed build > log.txt` wrote an empty file. libuv always passes
24
+ * the handles explicitly, so the flag on an inheriting spawn would be harmless here; it stays
25
+ * off anyway, because an inheriting child of a console-owning parent shows no window in the
26
+ * first place, so it would buy nothing and re-open a failure mode already paid for once.
27
+ */
28
+ export const NO_WINDOW = { windowsHide: process.platform === 'win32' };
@@ -0,0 +1,275 @@
1
+ /**
2
+ * The unified diff — this tool's sequence matcher, and the hunks it produces.
3
+ *
4
+ * WHY A HAND-WRITTEN MATCHER RATHER THAN A LIBRARY. The diff is the ENTIRE user-visible
5
+ * payload of `geneseed diff`: the hunks in `--full` and in the exported improvements file
6
+ * are this file's output and nothing else's. A generic JS diff would produce a CORRECT diff
7
+ * and a DIFFERENT one, and `tests/__snapshots__/primitives/` compares the hunks character
8
+ * for character, so "also a valid diff" fails every cell. The alignment below IS the
9
+ * specification of what this tool prints, and three of its choices are choices no other
10
+ * algorithm makes:
11
+ *
12
+ * * It is NOT Myers. `findLongestMatch` takes the longest common block, recurses left and
13
+ * right, and therefore prefers a different alignment than an edit-script minimiser
14
+ * wherever the two disagree.
15
+ * * Ties resolve to the EARLIEST match, in `a` first and then in `b`, because `bestsize` is
16
+ * only replaced on a strict `>`.
17
+ * * **`autojunk`.** For a `b` of 200 elements or more, any line occurring more than
18
+ * `floor(len(b) / 100) + 1` times is deleted from the index and stops matching entirely.
19
+ * On this tool's own files — hundreds of lines, many of them blank — that fires
20
+ * constantly and changes the hunks. It is off by construction for anything shorter, so a
21
+ * small fixture cannot see it at all; the recorded corpus is what carries inputs long
22
+ * enough to trip it.
23
+ *
24
+ * PROVENANCE, not a contract with a live second party. Every rule above was measured from
25
+ * CPython's `difflib`, which is why the odd ones are odd — `autojunk` in particular is a
26
+ * heuristic no one would invent from scratch. That history explains the shape; it does not
27
+ * govern it any more. The recording does, and the recording cannot be re-blessed.
28
+ *
29
+ * No junk predicate is implemented: every call site is `opcodes(a, b)` with no `isjunk`, so
30
+ * the junk set is empty and its test is constant false. That deletes two of the four
31
+ * extension loops in `findLongestMatch` — noted here rather than left as an unexplained
32
+ * absence, because their omission is a consequence of the call sites and not of the
33
+ * algorithm.
34
+ */
35
+
36
+ /**
37
+ * `SequenceMatcher.__chain_b` — b's element index, minus the popular elements.
38
+ *
39
+ * A `Map`, never an object: the elements are LINES, and a file whose first line is `0` would
40
+ * have its index hoisted to the front of an object literal (the P3a hazard). Nothing here
41
+ * iterates `b2j`, so the hoist would be invisible until the day something did.
42
+ */
43
+ function chainB(b, autojunk) {
44
+ const b2j = new Map();
45
+ for (let i = 0; i < b.length; i += 1) {
46
+ const cur = b2j.get(b[i]);
47
+ if (cur === undefined) b2j.set(b[i], [i]);
48
+ else cur.push(i);
49
+ }
50
+ const n = b.length;
51
+ if (autojunk && n >= 200) {
52
+ const ntest = Math.floor(n / 100) + 1;
53
+ for (const [elt, idxs] of [...b2j]) {
54
+ if (idxs.length > ntest) b2j.delete(elt);
55
+ }
56
+ }
57
+ return b2j;
58
+ }
59
+
60
+ /**
61
+ * `SequenceMatcher.find_longest_match`, with `isbjunk` constant False.
62
+ *
63
+ * `j2len` is a `Map` keyed by INTEGER j, where the Python uses a dict. Nothing iterates it
64
+ * either, so the key type is only about lookup; a plain object would coerce every key to a
65
+ * string and work, and a Map says what the keys are.
66
+ */
67
+ function findLongestMatch(a, b, b2j, alo, ahi, blo, bhi) {
68
+ let besti = alo;
69
+ let bestj = blo;
70
+ let bestsize = 0;
71
+ let j2len = new Map();
72
+ for (let i = alo; i < ahi; i += 1) {
73
+ const newj2len = new Map();
74
+ for (const j of b2j.get(a[i]) ?? []) {
75
+ if (j < blo) continue;
76
+ if (j >= bhi) break;
77
+ const k = (j2len.get(j - 1) ?? 0) + 1;
78
+ newj2len.set(j, k);
79
+ // A strict `>`: a later match of equal length never displaces an earlier one, which is
80
+ // what makes the result the FIRST longest block rather than the last.
81
+ if (k > bestsize) { besti = i - k + 1; bestj = j - k + 1; bestsize = k; }
82
+ }
83
+ j2len = newj2len;
84
+ }
85
+ // The two junk-free extension loops. The popular elements `chainB` dropped are NOT junk —
86
+ // `bjunk` stays empty — so a popular line adjacent to a match is still extended over here
87
+ // even though it could never have started one. That asymmetry is the Python's.
88
+ while (besti > alo && bestj > blo && a[besti - 1] === b[bestj - 1]) {
89
+ besti -= 1; bestj -= 1; bestsize += 1;
90
+ }
91
+ while (besti + bestsize < ahi && bestj + bestsize < bhi
92
+ && a[besti + bestsize] === b[bestj + bestsize]) {
93
+ bestsize += 1;
94
+ }
95
+ return [besti, bestj, bestsize];
96
+ }
97
+
98
+ /** `SequenceMatcher.get_matching_blocks` — the recursion, the sort, and the adjacent merge. */
99
+ function matchingBlocks(a, b, b2j) {
100
+ const la = a.length;
101
+ const lb = b.length;
102
+ const queue = [[0, la, 0, lb]];
103
+ const blocks = [];
104
+ while (queue.length) {
105
+ // `queue.pop()` — LIFO, as the Python. The order reaches the output through the sort
106
+ // below only when two blocks tie, which they cannot, so this is faithfulness rather
107
+ // than necessity.
108
+ const [alo, ahi, blo, bhi] = queue.pop();
109
+ const [i, j, k] = findLongestMatch(a, b, b2j, alo, ahi, blo, bhi);
110
+ if (k) {
111
+ blocks.push([i, j, k]);
112
+ if (alo < i && blo < j) queue.push([alo, i, blo, j]);
113
+ if (i + k < ahi && j + k < bhi) queue.push([i + k, ahi, j + k, bhi]);
114
+ }
115
+ }
116
+ // `list.sort()` on tuples is lexicographic; `Array.prototype.sort` is string-wise by
117
+ // default and would put [10,...] before [9,...].
118
+ blocks.sort((x, y) => x[0] - y[0] || x[1] - y[1] || x[2] - y[2]);
119
+
120
+ let i1 = 0;
121
+ let j1 = 0;
122
+ let k1 = 0;
123
+ const nonAdjacent = [];
124
+ for (const [i2, j2, k2] of blocks) {
125
+ if (i1 + k1 === i2 && j1 + k1 === j2) {
126
+ k1 += k2;
127
+ } else {
128
+ if (k1) nonAdjacent.push([i1, j1, k1]);
129
+ i1 = i2; j1 = j2; k1 = k2;
130
+ }
131
+ }
132
+ if (k1) nonAdjacent.push([i1, j1, k1]);
133
+ nonAdjacent.push([la, lb, 0]);
134
+ return nonAdjacent;
135
+ }
136
+
137
+ /** `SequenceMatcher.get_opcodes` — [tag, i1, i2, j1, j2]. */
138
+ export function opcodes(a, b, { autojunk = true } = {}) {
139
+ const b2j = chainB(b, autojunk);
140
+ let i = 0;
141
+ let j = 0;
142
+ const answer = [];
143
+ for (const [ai, bj, size] of matchingBlocks(a, b, b2j)) {
144
+ let tag = '';
145
+ if (i < ai && j < bj) tag = 'replace';
146
+ else if (i < ai) tag = 'delete';
147
+ else if (j < bj) tag = 'insert';
148
+ if (tag) answer.push([tag, i, ai, j, bj]);
149
+ i = ai + size;
150
+ j = bj + size;
151
+ if (size) answer.push(['equal', ai, i, bj, j]);
152
+ }
153
+ return answer;
154
+ }
155
+
156
+ /**
157
+ * `SequenceMatcher.get_grouped_opcodes` — the hunks, with `n` lines of context.
158
+ *
159
+ * Returns a list rather than a generator: the Python's caller is a `for` loop that consumes
160
+ * it once, and a lazy JS equivalent would buy nothing but a second shape to reason about.
161
+ */
162
+ export function groupedOpcodes(a, b, n = 3, opts) {
163
+ let codes = opcodes(a, b, opts);
164
+ if (!codes.length) codes = [['equal', 0, 1, 0, 1]];
165
+ if (codes[0][0] === 'equal') {
166
+ const [tag, i1, i2, j1, j2] = codes[0];
167
+ codes[0] = [tag, Math.max(i1, i2 - n), i2, Math.max(j1, j2 - n), j2];
168
+ }
169
+ if (codes[codes.length - 1][0] === 'equal') {
170
+ const [tag, i1, i2, j1, j2] = codes[codes.length - 1];
171
+ codes[codes.length - 1] = [tag, i1, Math.min(i2, i1 + n), j1, Math.min(j2, j1 + n)];
172
+ }
173
+ const nn = n + n;
174
+ const out = [];
175
+ let group = [];
176
+ for (const code of codes) {
177
+ let [, i1, i2, j1, j2] = code;
178
+ const tag = code[0];
179
+ if (tag === 'equal' && i2 - i1 > nn) {
180
+ group.push([tag, i1, Math.min(i2, i1 + n), j1, Math.min(j2, j1 + n)]);
181
+ out.push(group);
182
+ group = [];
183
+ i1 = Math.max(i1, i2 - n);
184
+ j1 = Math.max(j1, j2 - n);
185
+ }
186
+ group.push([tag, i1, i2, j1, j2]);
187
+ }
188
+ if (group.length && !(group.length === 1 && group[0][0] === 'equal')) out.push(group);
189
+ return out;
190
+ }
191
+
192
+ /** `difflib._format_range_unified`. */
193
+ function formatRangeUnified(start, stop) {
194
+ let beginning = start + 1;
195
+ const length = stop - start;
196
+ if (length === 1) return `${beginning}`;
197
+ if (!length) beginning -= 1; // an empty range begins at the line just before it
198
+ return `${beginning},${length}`;
199
+ }
200
+
201
+ /**
202
+ * `difflib.unified_diff` — the lines, WITHOUT trailing newlines when `lineterm` is `""`.
203
+ *
204
+ * Every call site in this port passes `lineterm=""` and feeds it `splitlines()` output, which
205
+ * is the form the Python's own caller uses; the parameter is kept so the shape is the
206
+ * stdlib's rather than a specialisation that would have to be widened later.
207
+ */
208
+ export function unifiedDiff(a, b, {
209
+ fromfile = '', tofile = '', fromfiledate = '', tofiledate = '', n = 3, lineterm = '\n',
210
+ } = {}) {
211
+ const out = [];
212
+ let started = false;
213
+ for (const group of groupedOpcodes(a, b, n)) {
214
+ if (!started) {
215
+ started = true;
216
+ const fromdate = fromfiledate ? `\t${fromfiledate}` : '';
217
+ const todate = tofiledate ? `\t${tofiledate}` : '';
218
+ out.push(`--- ${fromfile}${fromdate}${lineterm}`);
219
+ out.push(`+++ ${tofile}${todate}${lineterm}`);
220
+ }
221
+ const first = group[0];
222
+ const last = group[group.length - 1];
223
+ out.push(`@@ -${formatRangeUnified(first[1], last[2])} `
224
+ + `+${formatRangeUnified(first[3], last[4])} @@${lineterm}`);
225
+ for (const [tag, i1, i2, j1, j2] of group) {
226
+ if (tag === 'equal') {
227
+ for (const line of a.slice(i1, i2)) out.push(` ${line}`);
228
+ continue;
229
+ }
230
+ if (tag === 'replace' || tag === 'delete') {
231
+ for (const line of a.slice(i1, i2)) out.push(`-${line}`);
232
+ }
233
+ if (tag === 'replace' || tag === 'insert') {
234
+ for (const line of b.slice(j1, j2)) out.push(`+${line}`);
235
+ }
236
+ }
237
+ }
238
+ return out;
239
+ }
240
+
241
+ /**
242
+ * `str.splitlines()`, which is NOT `String.prototype.split('\n')`.
243
+ *
244
+ * Python breaks on eight boundaries beyond `\n`. Two of them — `\r\n` and a lone `\r` — are
245
+ * already gone by the time this is called, because `readText` decodes in universal-newlines
246
+ * mode exactly as `Path.read_text` does; they are matched anyway so this reproduces
247
+ * `splitlines` rather than "splitlines after readText". Of the six that survive that
248
+ * translation, the two a real file plausibly carries are U+2028 and U+2029: paste a paragraph
249
+ * from a web page into a deployed AGENT.md and the separator comes with it. Python then
250
+ * reports one more line than a `split('\n')` would, and since `diff` feeds these lists
251
+ * straight to `unifiedDiff`, every hunk after the paste shifts by one on one side only — a
252
+ * whole-file disagreement between the two implementations, on a file no fixture can reach,
253
+ * caused by a character neither diff prints visibly.
254
+ *
255
+ * A trailing terminator does NOT produce a final empty element, in either language.
256
+ *
257
+ * The escapes are `\u2028`/`\u2029` and not the characters themselves for a reason this file
258
+ * learned the hard way: a literal U+2028 in a `.mjs` source is a LineTerminator to the JS
259
+ * grammar, so writing one inside a regex literal ends the line and the file no longer parses.
260
+ */
261
+ const LINE_BOUNDARY = /\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/g;
262
+
263
+ export function pySplitLines(text) {
264
+ const out = [];
265
+ let last = 0;
266
+ LINE_BOUNDARY.lastIndex = 0;
267
+ let m = LINE_BOUNDARY.exec(text);
268
+ while (m !== null) {
269
+ out.push(text.slice(last, m.index));
270
+ last = m.index + m[0].length;
271
+ m = LINE_BOUNDARY.exec(text);
272
+ }
273
+ if (last < text.length) out.push(text.slice(last));
274
+ return out;
275
+ }
package/js/link.mjs ADDED
@@ -0,0 +1,286 @@
1
+ /**
2
+ * `harness link` / `harness unlink` — run-from-anywhere, and the phase that had to decide
3
+ * whether they should exist at all under npm.
4
+ *
5
+ * ---------------------------------------------------------------------------------------
6
+ * DO THEY SHIP? YES — AND NOT FOR THE REASON THE HANDOFF PREDICTED.
7
+ *
8
+ * The case against was strong and it was measured, not assumed. `npm i -g geneseed` already
9
+ * puts `geneseed`, `geneseed-hook` and `geneseed-build` on PATH through npm's own bin
10
+ * linking, so `geneseed link` from an npm install writes a SECOND `geneseed.cmd` — into
11
+ * `%LOCALAPPDATA%\Geneseed\bin` rather than `%APPDATA%\npm` — and edits the persistent USER
12
+ * Path to reach it. `npm uninstall -g` removes neither. That is PATH litter with a registry
13
+ * edit behind it, for a job the package manager already did.
14
+ *
15
+ * What decided it against that was the WEB CONSOLE, not the CLI. `web/src/pages/Settings`
16
+ * has "Run from anywhere" and its inverse as two buttons; both POST `/api/actions/link` and
17
+ * `/api/actions/unlink`; and the Node job runner can only answer them by spawning
18
+ * `node bin/geneseed-cli.mjs link`. So the honest options were exactly two:
19
+ *
20
+ * * PORT THE VERBS — the npm package gains a command that duplicates what npm did; or
21
+ * * DELETE THE TWO ACTIONS from `action_commands` in BOTH implementations, delete the two
22
+ * buttons, and rebuild the tracked `web/dist`.
23
+ *
24
+ * The second removes a working feature from every GIT-CHECKOUT user — for whom `link` is
25
+ * the only thing that puts `geneseed` on PATH, because there is no package manager in that
26
+ * story — in order to spare npm users a duplicate. It also spends a `web/dist` rebuild on a
27
+ * deletion. So: they ship, they port, and `NOT_PORTED_ACTIONS` empties.
28
+ *
29
+ * The npm duplication is left as it is rather than papered over with a new "you already have
30
+ * this" refusal: that would be a REDESIGN of the verb, the reference has no such arm, and a
31
+ * divergence invented by the port is worse than one it inherited.
32
+ *
33
+ * ---------------------------------------------------------------------------------------
34
+ * THE ONE DIVERGENCE, AND IT IS P5b's, NOT A NEW ONE.
35
+ *
36
+ * On Windows the verb writes a shim naming an INTERPRETER and an ENTRY. The reference names
37
+ * `sys.executable` and `rituals/harness.py`; a Node process cannot know which Python would
38
+ * have run, and inventing one writes a shim that names an interpreter the user may not have.
39
+ * P5b settled this shape for the hook shim — "Node bakes node" — and P3a settled the gate:
40
+ * the shim is byte-comparable once runner and entry are ARGUMENTS. So this bakes
41
+ * `process.execPath` + `bin/geneseed-cli.mjs`, `tests/harness_golden.py` normalises the
42
+ * shim's argv line the way `js/web/jobs.mjs` normalises the job runner's, and each side's
43
+ * real content is asserted ABSOLUTELY in `tests/test_win_user_path.py`. A shim written by
44
+ * the Node CLI reaches sixteen verbs rather than twenty-four; that is the cost of the port
45
+ * being a port, and it disappears when P7 lands `tui`/`menu`/`home`.
46
+ *
47
+ * THE DOUBLE CARRIAGE RETURN IS NOT A TYPO. The reference builds the shim with explicit
48
+ * `\r\n` and writes it with `Path.write_text`, whose default newline handling then turns
49
+ * each `\n` into `\r\n` again — the file on disk really is `@echo off\r\r\n`. `writeText`
50
+ * carries exactly that rule (it is why the helper exists), so passing the same string
51
+ * through it reproduces the same bytes. cmd.exe does not care; the byte gate does.
52
+ *
53
+ * ---------------------------------------------------------------------------------------
54
+ * NO TEST IN THIS REPO MAY TOUCH THE REAL USER PATH.
55
+ *
56
+ * `winUserPath` writes to the registry through PowerShell. That is not sandboxable and a
57
+ * cell that ran it would edit the developer's machine, so the module is shaped so the gate
58
+ * never needs to:
59
+ *
60
+ * * `winUserPathScript` is PURE — the escaping, the split and the idempotence live there
61
+ * and `tests/test_win_user_path.py` runs it over a corpus (apostrophes, trailing
62
+ * separators, UNC roots) against the Python twin, byte for byte.
63
+ * * `cmdLink` SHORT-CIRCUITS when the bin dir is already on `PATH`, so a cell that seeds
64
+ * `PATH` with its own sandboxed bin dir exercises the whole verb and never spawns.
65
+ * * `cmdUnlink` has no such short-circuit, so its cells seed a `PATH` with no `powershell`
66
+ * on it: the spawn fails to launch, `winUserPath` returns false through the ENOENT arm,
67
+ * and the verb takes its "removed the shim, said nothing about PATH" branch. A real arm,
68
+ * reached honestly, with the registry untouched.
69
+ *
70
+ * THE SUCCESS ARM OF `winUserPath` IS THEREFORE UNGATED, DELIBERATELY, IN BOTH
71
+ * IMPLEMENTATIONS — declared here rather than faked, because the only way to gate it is to
72
+ * edit the machine running the gate.
73
+ */
74
+ import {
75
+ chmodSync, existsSync, lstatSync, mkdirSync, readlinkSync, rmSync,
76
+ } from 'node:fs';
77
+ import { spawnSync } from 'node:child_process';
78
+ import path from 'node:path';
79
+ import os from 'node:os';
80
+
81
+ import { ROOT } from './checkout.mjs';
82
+ import { readMaybe } from './installs.mjs';
83
+ import { pyPathStr, pyPrint, pyPrintErr, writeText } from './lib/fs.mjs';
84
+
85
+ const IS_WIN = process.platform === 'win32';
86
+
87
+ /** `_harness_lifecycle._win_bin_dir`. */
88
+ export function winBinDir() {
89
+ // `os.environ.get(...) or str(Path.home())` — an EMPTY LOCALAPPDATA falls back too,
90
+ // which `??` would not do.
91
+ const base = process.env.LOCALAPPDATA || os.homedir();
92
+ return path.join(base, 'Geneseed', 'bin');
93
+ }
94
+
95
+ /**
96
+ * `_harness_lifecycle._win_user_path_script` — the PowerShell one-liner, built but not run.
97
+ *
98
+ * Every quoting hazard in this verb is in this string, which is why it is a separate
99
+ * function on both sides: `''` is PowerShell's single-quote escape, the `;` split drops
100
+ * empty segments so a trailing separator cannot append an empty entry, and `-notcontains`
101
+ * is what makes `link` idempotent.
102
+ */
103
+ export function winUserPathScript(action, directory) {
104
+ const d = directory.replaceAll("'", "''"); // PS single-quote escape (O'Brien)
105
+ if (action === 'add') {
106
+ return `$d='${d}';`
107
+ + "$p=[Environment]::GetEnvironmentVariable('Path','User');"
108
+ + "if (-not $p) {$p=''};"
109
+ + "$parts=$p.Split(';') | Where-Object {$_ -ne ''};"
110
+ + 'if ($parts -notcontains $d) {'
111
+ + " $np=(@($parts)+$d) -join ';';"
112
+ + " [Environment]::SetEnvironmentVariable('Path',$np,'User')}";
113
+ }
114
+ return `$d='${d}';`
115
+ + "$p=[Environment]::GetEnvironmentVariable('Path','User');"
116
+ + 'if ($p) {'
117
+ + " $np=(($p.Split(';') | Where-Object {$_ -ne '' -and $_ -ne $d}) -join ';');"
118
+ + " [Environment]::SetEnvironmentVariable('Path',$np,'User')}";
119
+ }
120
+
121
+ /**
122
+ * `_harness_lifecycle._win_user_path` — the spawn, and the sixth row of `_ALLOWED_SPAWNS`.
123
+ *
124
+ * There is no in-process equivalent: the persistent USER Path lives in the registry
125
+ * (`HKCU\Environment`), not in this process's environment, and Node has no registry API.
126
+ * `[Environment]::SetEnvironmentVariable(...,'User')` also broadcasts `WM_SETTINGCHANGE`,
127
+ * which a hand-rolled `reg add` would not — so even a registry binding would be the wrong
128
+ * primitive.
129
+ *
130
+ * `except OSError -> False`: Python raises when the executable cannot be launched at all.
131
+ * Node reports the same condition in `r.error` instead of throwing, so the two arms are
132
+ * spelled differently and mean the same thing — a `powershell` that is not on PATH returns
133
+ * false rather than crashing the verb.
134
+ */
135
+ function winUserPath(action, directory) {
136
+ const r = spawnSync('powershell',
137
+ ['-NoProfile', '-Command', winUserPathScript(action, directory)],
138
+ { stdio: 'inherit', windowsHide: true });
139
+ if (r.error) return false;
140
+ return r.status === 0;
141
+ }
142
+
143
+ /** `_harness_lifecycle.cmd_link` — put `geneseed` on PATH so it runs from any directory. */
144
+ export function cmdLink(args) {
145
+ const here = ROOT;
146
+ if (IS_WIN) {
147
+ const bindir = winBinDir();
148
+ const shim = path.join(bindir, 'geneseed.cmd');
149
+ try {
150
+ mkdirSync(bindir, { recursive: true });
151
+ // The running runtime, not a bare name — the reference's reason (a bare `python` may
152
+ // be missing or resolve to the Microsoft Store alias stub) applies to `node` too, and
153
+ // an npm install is exactly where a second Node on PATH is likeliest.
154
+ writeText(shim, '@echo off\r\n'
155
+ + `"${process.execPath}" "${path.join(here, 'bin', 'geneseed-cli.mjs')}" %*\r\n`);
156
+ } catch (e) {
157
+ pyPrintErr(`geneseed: could not write ${shim} (${pyOsError(e)})\n`);
158
+ return 1;
159
+ }
160
+ pyPrint(`geneseed: wrote shim ${shim}\n`);
161
+ const on_path = (process.env.PATH || '').toLowerCase().includes(bindir.toLowerCase());
162
+ if (on_path || winUserPath('add', bindir)) {
163
+ pyPrint(`geneseed: '${bindir}' is on your user PATH — open a NEW terminal, then run \`geneseed\`.\n`);
164
+ } else {
165
+ pyPrint(`geneseed: add '${bindir}' to your PATH manually, then run \`geneseed\` from anywhere.\n`);
166
+ }
167
+ return 0;
168
+ }
169
+ // Unix: write a shim into a bin dir (default ~/.local/bin, no sudo). See the shim
170
+ // comment below for why this is no longer a symlink to `ROOT/geneseed`.
171
+ // `Path(args.dir)` — and `pyPathStr` is that conversion, not decoration. The reference
172
+ // builds a `Path` here and prints `str(target_dir)` twice (the PATH notice and the `export
173
+ // PATH=` line) as well as comparing it against `PATH.split(os.pathsep)`, so an argument
174
+ // that is not already normalised diverges in three places at once. A trailing slash is the
175
+ // cheapest one: `str(Path('/x/bin/'))` is `/x/bin` and the raw string is not.
176
+ // `harness_golden`'s `link/an-explicit-dir-argument-is-used-instead-of-the-default` passes
177
+ // one, and it is the cell that found this.
178
+ let targetDir = args.dir ? pyPathStr(args.dir) : null;
179
+ if (targetDir === null) {
180
+ const local = path.join(os.homedir(), '.local', 'bin');
181
+ try {
182
+ mkdirSync(local, { recursive: true });
183
+ targetDir = local;
184
+ } catch {
185
+ targetDir = '/usr/local/bin';
186
+ }
187
+ }
188
+ const dest = path.join(targetDir, 'geneseed');
189
+ const entry = path.join(here, 'bin', 'geneseed-cli.mjs');
190
+ try {
191
+ mkdirSync(targetDir, { recursive: true });
192
+ // `dest.is_symlink() or dest.exists()` — the first half is what catches a BROKEN
193
+ // symlink, which `existsSync` follows and reports absent.
194
+ if (isSymlink(dest) || existsSync(dest)) rmSync(dest, { force: true });
195
+ // A WRITTEN SHIM, not a symlink to `ROOT/geneseed`. The bash launcher execs a Python
196
+ // interpreter, so symlinking it puts Python on PATH from the Node verb. The Windows
197
+ // arm above has always written a shim; this is the same shape. `GENESEED_LINK_SHIM`
198
+ // is the marker `cmdUnlink` recognises — without it, unlink cannot tell our file from
199
+ // a stranger's and would have to refuse every regular file, which is a no-op.
200
+ writeText(dest, '#!/bin/sh\n'
201
+ + '# GENESEED_LINK_SHIM\n'
202
+ + `exec "${process.execPath}" "${entry}" "$@"\n`);
203
+ chmodSync(dest, 0o755);
204
+ } catch (e) {
205
+ pyPrintErr(`geneseed: could not write ${dest} (${pyOsError(e)}) — pick a writable dir: `
206
+ + 'geneseed link <dir>\n');
207
+ return 1;
208
+ }
209
+ pyPrint(`geneseed: linked ${dest} -> ${entry}\n`);
210
+ if ((process.env.PATH || '').split(path.delimiter).includes(targetDir)) {
211
+ pyPrint(`geneseed: '${targetDir}' is on PATH — run 'geneseed' from anywhere.\n`);
212
+ } else {
213
+ pyPrint(`geneseed: NOTE '${targetDir}' is not on your PATH. Add it, e.g.:\n`);
214
+ pyPrint(` echo 'export PATH="${targetDir}:$PATH"' >> ~/.zshrc # or ~/.bashrc\n`);
215
+ }
216
+ return 0;
217
+ }
218
+
219
+ /** `_harness_lifecycle.cmd_unlink`. */
220
+ export function cmdUnlink() {
221
+ if (IS_WIN) {
222
+ const bindir = winBinDir();
223
+ const shim = path.join(bindir, 'geneseed.cmd');
224
+ let removed = false;
225
+ if (existsSync(shim)) {
226
+ try {
227
+ rmSync(shim);
228
+ removed = true;
229
+ pyPrint(`geneseed: removed ${shim}\n`);
230
+ } catch (e) {
231
+ pyPrintErr(`geneseed: could not remove ${shim} (${pyOsError(e)})\n`);
232
+ }
233
+ }
234
+ if (winUserPath('remove', bindir)) {
235
+ pyPrint(`geneseed: removed '${bindir}' from your user PATH (open a new terminal).\n`);
236
+ }
237
+ if (!removed) pyPrint('geneseed: no linked launcher found.\n');
238
+ return 0;
239
+ }
240
+ let removed = false;
241
+ const candidates = [path.join(os.homedir(), '.local', 'bin'), '/usr/local/bin'];
242
+ for (const d of (process.env.PATH || '').split(path.delimiter)) if (d) candidates.push(d);
243
+ const seen = new Set();
244
+ for (const d of candidates) {
245
+ if (seen.has(d)) continue;
246
+ seen.add(d);
247
+ const f = path.join(d, 'geneseed');
248
+ // TWO shapes, because two shapes exist on disk. (a) `os.readlink(f)` raw, then `.name`
249
+ // — NOT a resolved path: the reference asks whether the link's own target BASENAME is
250
+ // `geneseed`, so a symlink pointing at some other program that happens to sit in a
251
+ // `geneseed` directory is left alone. That is every install made before the shim.
252
+ // (b) a regular file we wrote, identified by its marker — never "any regular file
253
+ // called geneseed", which would delete a stranger's program.
254
+ const ours = (isSymlink(f) && path.basename(readlinkSync(f)) === 'geneseed')
255
+ || (!isSymlink(f) && existsSync(f) && (readMaybe(f) ?? '').includes('GENESEED_LINK_SHIM'));
256
+ if (ours) {
257
+ try {
258
+ rmSync(f);
259
+ pyPrint(`geneseed: removed ${f}\n`);
260
+ removed = true;
261
+ } catch { /* `except OSError: pass` */ }
262
+ }
263
+ }
264
+ if (!removed) pyPrint('geneseed: no linked launcher found on PATH\n');
265
+ return 0;
266
+ }
267
+
268
+ /** `Path.is_symlink()` — false for a path that does not exist, never a throw. */
269
+ function isSymlink(p) {
270
+ try {
271
+ return lstatSync(p).isSymbolicLink();
272
+ } catch {
273
+ return false;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * `str(OSError)` — what Python interpolates into these two messages.
279
+ *
280
+ * `[Errno 13] Permission denied: 'C:\\...'`, and Node's `e.message` is the same three parts
281
+ * in a different order. Not byte-reproducible and not worth pretending otherwise: both cells
282
+ * that can reach it assert the arm, not the errno text.
283
+ */
284
+ function pyOsError(e) {
285
+ return e && e.message ? e.message : String(e);
286
+ }