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/lib/fs.mjs ADDED
@@ -0,0 +1,913 @@
1
+ /**
2
+ * The primitives — how this tool reads, writes, quotes, compares and prints.
3
+ *
4
+ * These are the definitions, not an adaptation of somebody else's. Every rule below was
5
+ * originally MEASURED against CPython, because the generator these functions serve had to
6
+ * emit bytes indistinguishable from a Python one; that second party is gone and the rules
7
+ * are not. What holds them now is the recorded corpora under `tests/__snapshots__/`, which
8
+ * compare this module's output byte for byte and can never be re-recorded — so a rule here
9
+ * is a frozen fact about what this tool emits, and changing one is a product change.
10
+ *
11
+ * The `py`-prefixed export names are kept on purpose. Each marks a function whose contract
12
+ * is a language primitive's exact behaviour rather than a convenience wrapper, and that mark
13
+ * is what stops a later reader "simplifying" one back to the Node default it deliberately is
14
+ * not. For the same reason the per-function comments still name what was measured: that
15
+ * provenance is the only surviving record of WHY a rule is the shape it is, and it is the
16
+ * first thing to read before touching one.
17
+ *
18
+ * Everything here is stdlib-only, ESM, and deliberately tiny — it is imported on hook paths,
19
+ * where load time is a per-tool-call cost.
20
+ */
21
+ import {
22
+ accessSync, appendFileSync, constants, writeFileSync, readFileSync, copyFileSync, statSync,
23
+ utimesSync,
24
+ } from 'node:fs';
25
+ import { EOL } from 'node:os';
26
+ import path from 'node:path';
27
+
28
+ const { X_OK } = constants;
29
+
30
+ /**
31
+ * `Path.write_text(text, encoding="utf-8")`.
32
+ *
33
+ * Python opens that file in TEXT mode with `newline=None`, so every `\n` is translated
34
+ * to `os.linesep` on the way out — a freshly emitted AGENT.md is 504 CRLF and 0 bare LF
35
+ * on Windows. `fs.writeFileSync` translates nothing. Without this wrapper *every* text
36
+ * file the port emits differs from Python's on Windows, and the byte-identity gate that
37
+ * is the whole acceptance test of the port yields exactly zero signal: it reports 100%
38
+ * failure whether the render is right or wrong.
39
+ *
40
+ * Two categories must NOT go through here, and they are the reason this is a named
41
+ * function rather than a blanket rule:
42
+ * - the hook shim, which Python writes with `newline=""` (raw);
43
+ * - anything Python copies with `shutil.copy2` (the OpenCode `plugins/*.js` and the
44
+ * vendored skill folders), which is a byte copy and keeps its LF.
45
+ * The same vendored skill folder can contain files from both categories.
46
+ */
47
+ export function writeText(path, text) {
48
+ writeFileSync(path, EOL === '\n' ? text : text.replaceAll('\n', EOL), 'utf8');
49
+ }
50
+
51
+ /**
52
+ * `path.open("a", encoding="utf-8").write(text)` — the APPEND half of the rule above.
53
+ *
54
+ * Same translation, second mode. It lives here rather than inlined at its one call site
55
+ * (`js/update.mjs`'s install log) because the newline rule has exactly one owner in this
56
+ * port and a second copy of it is how the rule rots: an appended line written with
57
+ * `appendFileSync` alone is the only LF-terminated line in an otherwise CRLF file, which is
58
+ * invisible in an editor and a whole-file difference to the gate.
59
+ */
60
+ export function appendText(path, text) {
61
+ appendFileSync(path, EOL === '\n' ? text : text.replaceAll('\n', EOL), 'utf8');
62
+ }
63
+
64
+ /**
65
+ * `Path.read_text(encoding="utf-8")`.
66
+ *
67
+ * Python decodes text files in universal-newlines mode: `\r\n` and a lone `\r` both
68
+ * become `\n` before any regex ever sees them. Node hands back the bytes as-is. The
69
+ * repo's `.gitattributes` normalises `src/` to LF so today this is a no-op, but a
70
+ * checkout with `core.autocrlf=true`, a copied tree, or an editor that rewrote one file
71
+ * would otherwise change what `^...$` and `splitlines()` match — a divergence that shows
72
+ * up as mangled output in one theme and nowhere else.
73
+ */
74
+ export function readText(path) {
75
+ return readFileSync(path, 'utf8').replaceAll('\r\n', '\n').replaceAll('\r', '\n');
76
+ }
77
+
78
+ /**
79
+ * `shutil.copy2(src, dest)`.
80
+ *
81
+ * A byte copy, so the file keeps its own line endings — this is the OTHER half of the
82
+ * `writeText` rule above, and the reason both exist as named functions: the vendored
83
+ * skill folders contain files taken by each route, and picking the wrong one is a silent
84
+ * whole-file diff. `copy2` also carries the source's mtime across, which `copyFileSync`
85
+ * alone does not.
86
+ *
87
+ * Deliberate deviation: `copy2` copies the permission bits too (`copymode`), which on
88
+ * Windows means the read-only attribute. Reproducing that would make a second emit fail
89
+ * to overwrite its own output if a source file were ever marked read-only — a failure
90
+ * mode the Python side has and nothing depends on. Mode is not copied here.
91
+ */
92
+ export function copyFile(src, dest) {
93
+ copyFileSync(src, dest);
94
+ const st = statSync(src);
95
+ utimesSync(dest, st.atime, st.mtime);
96
+ }
97
+
98
+ /**
99
+ * `json.dumps(s)` for a STRING, with Python's default `ensure_ascii=True`.
100
+ *
101
+ * Every emitted `description:` line is built this way, and 44-50 of them per theme carry
102
+ * an em dash today: Python writes `"a — b"`, `JSON.stringify` writes `"a — b"`. The
103
+ * two agree on everything below U+007F — including the control-character table
104
+ * (`\b \t \n \f \r`, then lowercase `\u00xx`) — so escaping the rest is the whole delta.
105
+ * Astral characters fall out correctly without a special case: JS strings are UTF-16, so
106
+ * the surrogate pair is escaped one code unit at a time, which is exactly what Python
107
+ * emits (`😀`).
108
+ *
109
+ * Strings only, and loudly so — a COMPACT container is the one shape that genuinely
110
+ * differs: Python's default separators are `(', ', ': ')`, so it writes `{"a": 1, "b": 2}`
111
+ * where `JSON.stringify` writes `{"a":1,"b":2}`. Indented containers do NOT have that
112
+ * problem (see `jsonDumpsIndent`), which is why the split is compact-vs-indented rather
113
+ * than string-vs-container.
114
+ */
115
+ export function jsonDumps(s) {
116
+ if (typeof s !== 'string') {
117
+ throw new TypeError('jsonDumps takes a string; a COMPACT container differs from '
118
+ + "json.dumps by Python's ', ' / ': ' separators too \u2014 use jsonDumpsIndent or "
119
+ + 'jsonDumpsCompact');
120
+ }
121
+ return escapeNonAscii(JSON.stringify(s));
122
+ }
123
+
124
+ /**
125
+ * `ensure_ascii=True`: escape everything Python's encoder does and JS's does not.
126
+ *
127
+ * Applied to the SERIALISED text, which is safe because a JSON document can only carry
128
+ * non-ASCII inside string literals. Spelled with \\u escapes, never literals: a JS source
129
+ * file carrying a raw U+2028 or U+2029 terminates the line it sits on, and the parse
130
+ * error points somewhere else entirely.
131
+ */
132
+ function escapeNonAscii(text) {
133
+ return text.replace(/[\u007f-\uffff]/g,
134
+ (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
135
+ }
136
+
137
+ /**
138
+ * `json.dumps(obj, indent=2)` \u2014 and with `{ ensureAscii: false }`, the
139
+ * `ensure_ascii=False` variant. Those are the only two container shapes the generator
140
+ * writes; measured across all 27 `json.dumps` call sites.
141
+ *
142
+ * Indentation is what makes this tractable: passing `indent` switches Python's
143
+ * separators from `(', ', ': ')` to `(',', ': ')`, which is exactly what
144
+ * `JSON.stringify(v, null, 2)` emits. Verified rather than argued \u2014
145
+ * `tests/test_opencode_extras_parity.py` compares both variants against Python over a
146
+ * corpus of container shapes.
147
+ */
148
+ export function jsonDumpsIndent(value, { ensureAscii = true } = {}) {
149
+ const text = JSON.stringify(value, null, 2);
150
+ return ensureAscii ? escapeNonAscii(text) : text;
151
+ }
152
+
153
+ /**
154
+ * `json.dumps(obj)` and `json.dumps(obj, sort_keys=True)` — the COMPACT container form.
155
+ *
156
+ * Deferred through three phases because guessing its signature would have been worse than
157
+ * not having it; `js/settings.mjs` is the code that finally needs it, and the shapes were
158
+ * counted with `ast` rather than assumed: four bare `json.dumps(container)` and two
159
+ * `sort_keys=True`, all inside `_build_settings.py`, none of them `ensure_ascii=False`.
160
+ *
161
+ * It cannot delegate to `JSON.stringify` the way `jsonDumpsIndent` does. Without an indent
162
+ * Python's separators are `(', ', ': ')` — `{"a": 1, "b": [1, 2]}` where `JSON.stringify`
163
+ * writes `{"a":1,"b":[1,2]}` — and patching the separators back into the serialised text
164
+ * would corrupt any string containing `,` or `:`. So the walk is written out.
165
+ *
166
+ * `sortKeys` sorts with JS's default comparator (UTF-16 code units) where Python sorts by
167
+ * code point; the two disagree only above the BMP, which no settings.json key reaches. A
168
+ * non-finite number throws rather than emitting `NaN`/`Infinity`: Python's encoder writes
169
+ * those bare tokens, which is not valid JSON, and no caller here can produce one.
170
+ *
171
+ * `bareInts` admits a BARE JS number, reading it as a Python `int`. `pyStr` refuses one by
172
+ * default and is right to: a value that came out of `json.loads` carries the int/float
173
+ * distinction and a bare double has lost it. But P6's caller is `_send_json`, whose values
174
+ * are COMPUTED here — counts, a port, a pid, a unix second — and Python types every one of
175
+ * them `int`, which both languages render identically. The flag is where that claim is
176
+ * stated; the byte gate over every endpoint body is what would catch a float. A
177
+ * non-integral value still renders through `repr(float)`, which is the honest reading of
178
+ * `0.5`; only an INTEGRAL float (`1.0` against JS's `1`) is unrepresentable, and it is
179
+ * unrepresentable whatever this flag says.
180
+ */
181
+ export function jsonDumpsCompact(value, { sortKeys = false, ensureAscii = true,
182
+ bareInts = false } = {}) {
183
+ const text = compactImpl(value, sortKeys, bareInts);
184
+ return ensureAscii ? escapeNonAscii(text) : text;
185
+ }
186
+
187
+ function compactImpl(v, sortKeys, bareInts) {
188
+ if (v === null || v === undefined) return 'null';
189
+ if (v === true) return 'true';
190
+ if (v === false) return 'false';
191
+ if (typeof v === 'string') return JSON.stringify(v);
192
+ if (v instanceof PyNumber || typeof v === 'number') {
193
+ const n = v instanceof PyNumber ? v.value : v;
194
+ if (!Number.isFinite(n)) {
195
+ throw new TypeError(`jsonDumpsCompact got ${n}; Python's encoder writes a bare `
196
+ + 'NaN/Infinity token there, which is not JSON, and nothing here can produce one');
197
+ }
198
+ if (bareInts && typeof v === 'number') {
199
+ return Number.isSafeInteger(n) ? String(n) : pyFloat(n);
200
+ }
201
+ return pyStr(v);
202
+ }
203
+ if (Array.isArray(v)) {
204
+ return `[${v.map((x) => compactImpl(x, sortKeys, bareInts)).join(', ')}]`;
205
+ }
206
+ if (typeof v === 'object') {
207
+ const keys = sortKeys ? Object.keys(v).sort() : Object.keys(v);
208
+ return `{${keys.map((k) => `${JSON.stringify(k)}: ${compactImpl(v[k], sortKeys, bareInts)}`)
209
+ .join(', ')}}`;
210
+ }
211
+ throw new TypeError(`jsonDumpsCompact has no rendering for ${typeof v}`);
212
+ }
213
+
214
+ /**
215
+ * Python's `==` for values that came out of `json.loads` \u2014 the operator behind
216
+ * `group in arr`, `rec in canon_flat` and `arr.remove(group)`.
217
+ *
218
+ * `===` is identity for containers, so a naive port of `if group in arr` never matches and
219
+ * `_merge_claude_settings` re-adds its own hook group on every emit \u2014 the exact defect the
220
+ * prior-claim pruning exists to prevent, reintroduced by a one-word translation.
221
+ *
222
+ * `PyNumber` compares by its VALUE, which is right: Python's `{"a": 1} == {"a": 1.0}` is
223
+ * True even though `repr` renders the two differently. The one gap left is Python's
224
+ * `1 == True`; a settings.json holding a bool where the manifest recorded a number is not
225
+ * a shape any writer here produces.
226
+ */
227
+ export function pyEq(a, b) {
228
+ const x = a instanceof PyNumber ? a.value : a;
229
+ const y = b instanceof PyNumber ? b.value : b;
230
+ if (x === null || y === null || typeof x !== 'object' || typeof y !== 'object') {
231
+ return x === y;
232
+ }
233
+ if (Array.isArray(x) !== Array.isArray(y)) return false;
234
+ if (Array.isArray(x)) {
235
+ return x.length === y.length && x.every((v, i) => pyEq(v, y[i]));
236
+ }
237
+ const kx = Object.keys(x);
238
+ const ky = Object.keys(y);
239
+ return kx.length === ky.length
240
+ && kx.every((k) => Object.prototype.hasOwnProperty.call(y, k) && pyEq(x[k], y[k]));
241
+ }
242
+
243
+ /** `value in list` and `list.index(value)` under `pyEq` \u2014 Python's `in`, not `includes`. */
244
+ export function indexOfEq(arr, value) {
245
+ return arr.findIndex((v) => pyEq(v, value));
246
+ }
247
+
248
+ /**
249
+ * `PurePath._str_normcase` \u2014 Windows compares and sorts paths case-folded, POSIX does
250
+ * not. This is what makes `sorted(SRC.rglob('*'))` and `sorted(d.glob('*.json'))`
251
+ * platform-dependent, and the order is observable: the prompt emitter embeds items in
252
+ * order, and each emit's written list becomes the ownership manifest the prune diffs.
253
+ *
254
+ * Two residual differences, both unreachable with this repo's ASCII filenames: Python
255
+ * compares by code point where JS compares by UTF-16 code unit (differs only above the
256
+ * BMP), and `str.lower()` is not `toLowerCase()` for a handful of characters.
257
+ *
258
+ * THE SEPARATOR CONVERSION IS NOT OPTIONAL, and it was missing until the byte-bearing
259
+ * corpus in `tests/test_pure_function_parity.py` asked. `ntpath.normcase` is
260
+ * `s.replace('/', '\\').lower()`, and `PurePath._str_normcase` gets the same conversion for
261
+ * free because `Path` has already done it — so BOTH readings of the reference fold slashes
262
+ * and this did not. Every sort call site feeds it `path.join` output or a bare `readdir`
263
+ * name, which is why no emitted byte ever moved; `fnTranslate(normcase(pattern))` in
264
+ * `js/hooks.mjs` is where it was reachable, because an exclude pattern is USER-WRITTEN and
265
+ * `memory/*` never matched a native `memory\x` the way `fnmatch` does.
266
+ */
267
+ export const normcase = process.platform === 'win32'
268
+ ? (s) => s.replaceAll('/', '\\').toLowerCase()
269
+ : (s) => s;
270
+
271
+ /**
272
+ * `sorted()` over paths \u2014 one owner, so two call sites cannot drift apart.
273
+ *
274
+ * COMPONENT-WISE, and that was a flat string compare until P2 measured it. `PurePath.__lt__`
275
+ * compares `_parts_normcase` \u2014 a TUPLE of path components \u2014 so the separator never takes part
276
+ * in the comparison, and the two readings disagree in BOTH directions:
277
+ *
278
+ * sorted : skills/geneseed/SKILL.md < skills/geneseed-code-review/SKILL.md
279
+ * flat cmp : the reverse, because '-' (0x2d) sorts before the separator '\\' (0x5c)
280
+ * sorted : x/a/b.md < x/a.md (component 'a' < 'a.md')
281
+ * flat cmp : the reverse again
282
+ *
283
+ * Nothing in the emitted tree could see it: `js/render.mjs` sorts a list whose members share
284
+ * a directory depth, and `golden.py` compares CONTENT rather than order. It became visible
285
+ * the first time a full path list was PRINTED \u2014 `geneseed validate -v` \u2014 against a real
286
+ * skills tree that happens to contain both `skills/geneseed/` and `skills/geneseed-code-review/`.
287
+ * `tests/test_maintainer_tools_parity.py` is the gate that found it and the one that holds it.
288
+ */
289
+ export function comparePaths(a, b) {
290
+ const A = normcase(a).split(/[\\/]/);
291
+ const B = normcase(b).split(/[\\/]/);
292
+ for (let i = 0; i < Math.min(A.length, B.length); i += 1) {
293
+ if (A[i] !== B[i]) return A[i] < B[i] ? -1 : 1;
294
+ }
295
+ return A.length - B.length;
296
+ }
297
+
298
+ /**
299
+ * `_harness_core._within` \u2014 containment through `normcase`, segment-wise.
300
+ *
301
+ * MOVED HERE IN P6i, from `js/web/api.mjs` where P6b first needed it. The Python is a harness
302
+ * CORE primitive (`child.relative_to(parent)` in a try/except), and its second caller is
303
+ * `_install_move_list` in `js/uninstall.mjs` \u2014 a CLI-level module that must not import the
304
+ * web tree to reach it. Beside `normcase`, which is the thing it is actually about.
305
+ * `js/web/api.mjs` re-exports it so its existing importers are unchanged.
306
+ */
307
+ export function within(child, parent) {
308
+ const c = normcase(child).split(/[\\/]/);
309
+ const p = normcase(parent).split(/[\\/]/);
310
+ return p.length <= c.length && p.every((seg, i) => c[i] === seg);
311
+ }
312
+
313
+ /**
314
+ * A number as `json.loads` produced it, carrying the literal it was parsed from.
315
+ *
316
+ * `json.loads` yields an `int` for `20` and a `float` for `1.0`, and Python's `str()`
317
+ * renders those as `20` and `1.0`. `JSON.parse` collapses both to the IEEE double `1`,
318
+ * and `String(1.0)` is `"1"` — so an `agent-overrides.json` carrying `"temperature": 1.0`
319
+ * emits `temperature: 1.0` from Python and `temperature: 1` from Node.
320
+ *
321
+ * That divergence is invisible to the golden harness: the emit always writes an EMPTY
322
+ * overrides stub, so no golden cell has ever had an override to render. It is only
323
+ * reachable on a real user's machine, which is the worst place to find it.
324
+ */
325
+ class PyNumber {
326
+ constructor(value, source) { this.value = value; this.source = source; }
327
+ valueOf() { return this.value; }
328
+ toString() { return pyStr(this); }
329
+
330
+ /**
331
+ * Re-serialising parsed JSON must round-trip the way Python's does. Without this,
332
+ * `JSON.stringify` would walk the wrapper and emit `{"value":1,"source":"1.0"}`.
333
+ * `JSON.rawJSON` (Node >= 21) writes the text verbatim, and `pyStr` supplies the text
334
+ * Python would have \u2014 NOT the original literal, because `json.dumps(json.loads('1.50'))`
335
+ * is `1.5`: Python re-formats through repr rather than echoing the source.
336
+ */
337
+ toJSON() { return JSON.rawJSON(pyStr(this)); }
338
+ }
339
+
340
+ /**
341
+ * `json.loads(text)`, preserving the int/float distinction (see `PyNumber`).
342
+ *
343
+ * Uses the reviver's `context.source` (Node >= 21). Where it is unavailable the numbers
344
+ * come back bare and `pyStr` throws rather than formatting them wrongly — a loud failure
345
+ * on an old runtime beats bytes that differ from Python's only for some values.
346
+ */
347
+ export function parseJson(text) {
348
+ return JSON.parse(text, function (key, value, context) {
349
+ return typeof value === 'number' && context && typeof context.source === 'string'
350
+ ? new PyNumber(value, context.source)
351
+ : value;
352
+ });
353
+ }
354
+
355
+ /** `repr(float)` — the half of `pyStr` where the two languages disagree most. */
356
+ function pyFloat(n) {
357
+ if (Number.isNaN(n)) return 'nan';
358
+ if (!Number.isFinite(n)) return n > 0 ? 'inf' : '-inf';
359
+ const a = Math.abs(n);
360
+ if (a !== 0 && (a >= 1e16 || a < 1e-4)) {
361
+ // Python leaves positional notation outside [1e-4, 1e16); JS's own thresholds are
362
+ // 1e-6 and 1e21, and it writes a one-digit exponent where Python pads to two.
363
+ const [mantissa, exp] = n.toExponential().split('e');
364
+ return `${mantissa}e${exp[0] === '-' ? '-' : '+'}${exp.slice(1).padStart(2, '0')}`;
365
+ }
366
+ // Both engines print the shortest round-tripping decimal, but JS drops the fractional
367
+ // part of an integral value: `String(1.0)` is '1' where `repr(1.0)` is '1.0'. And
368
+ // `String(-0)` is '0' where `repr(-0.0)` keeps the sign.
369
+ const s = Object.is(n, -0) ? '-0' : String(n);
370
+ return s.includes('.') || s.includes('e') ? s : `${s}.0`;
371
+ }
372
+
373
+ /**
374
+ * `str(value)` for a value that came out of `json.loads` — what an f-string interpolates.
375
+ *
376
+ * Only the types the generator actually renders are handled. A list, dict or bool reaching
377
+ * here would print as `[object Object]` / `true` where Python prints `[1, 2]` / `True`, so
378
+ * it throws instead: an override file with a value nobody has decided the semantics for is
379
+ * a question to answer, not a byte to guess at.
380
+ */
381
+ /**
382
+ * Python truthiness — `if value:` — for a value that came out of `json.loads`.
383
+ *
384
+ * `Boolean()` is not it, and the gap is not academic: a `PyNumber` is an OBJECT, so
385
+ * `Boolean(PyNumber(0))` is `true` where Python's `if 0` is false. In the other direction
386
+ * `Number('0')` is `0` where Python's `if "0"` is true. Both spellings appear in the
387
+ * override tests, so neither shortcut is safe.
388
+ */
389
+ export function pyTruthy(value) {
390
+ if (value === undefined || value === null || value === false) return false;
391
+ if (typeof value === 'string') return value !== '';
392
+ if (value instanceof PyNumber) return value.value !== 0;
393
+ if (Array.isArray(value)) return value.length > 0;
394
+ if (typeof value === 'object') return Object.keys(value).length > 0;
395
+ return Boolean(value);
396
+ }
397
+
398
+ export function pyStr(value) {
399
+ if (typeof value === 'string') return value;
400
+ if (value instanceof PyNumber) {
401
+ // `json.decoder` picks parse_float for any literal carrying a fraction or an exponent
402
+ // and parse_int otherwise — so the literal, not the resulting double, is what decides.
403
+ // Ints are exact in Python at any width; BigInt keeps that (and normalises `-0`).
404
+ return /[.eE]/.test(value.source) ? pyFloat(value.value) : BigInt(value.source).toString();
405
+ }
406
+ if (typeof value === 'number') {
407
+ throw new TypeError(`pyStr got a bare number (${value}); parse JSON with parseJson so `
408
+ + `int 1 and float 1.0 stay distinguishable, as they are in Python`);
409
+ }
410
+ throw new TypeError(`pyStr has no Python-agreeing rendering for ${typeof value}`);
411
+ }
412
+
413
+ /**
414
+ * `repr(value)` for the values a theme or an override file can hold.
415
+ *
416
+ * Python quotes strings with `'`, switching to `"` only when the string contains a `'`
417
+ * and no `"`, and spells the three singletons with a capital. Anything else is a
418
+ * container, whose repr differs structurally; guessing at a rendering would be worse
419
+ * than failing, so it falls through to `pyStr`.
420
+ *
421
+ * Lives here rather than beside its first caller because `pyAscii` below is the same
422
+ * function with one more escaping rule, and two copies of the quote choice would be two
423
+ * things to keep in agreement.
424
+ *
425
+ * Containers ARE rendered, unlike in `pyStr`, and the difference is not an inconsistency.
426
+ * `pyStr` interpolates into an emitted file, where a shape nobody decided the semantics
427
+ * for must be a question rather than a guessed byte. `repr` renders a value into a WARNING
428
+ * about that very value — a list in `.geneseed-srcdirs.json` is precisely what the warning
429
+ * exists to report, and throwing there would turn "this file is corrupt" into a crash. The
430
+ * value always came from JSON, so the type set is closed and the rendering is exact.
431
+ */
432
+ function pyReprImpl(v, ascii) {
433
+ if (typeof v === 'string') {
434
+ let body = '';
435
+ for (const ch of v) { // by CODE POINT: `\U########` is one escape
436
+ const cp = ch.codePointAt(0);
437
+ if (ch === '\\') body += '\\\\';
438
+ else if (ch === '\n') body += '\\n';
439
+ else if (ch === '\r') body += '\\r';
440
+ else if (ch === '\t') body += '\\t';
441
+ else if (cp < 0x20 || cp === 0x7f) body += `\\x${cp.toString(16).padStart(2, '0')}`;
442
+ else if (cp < 0x7f) body += ch;
443
+ else if (!ascii) body += ch; // repr() keeps printable non-ASCII verbatim
444
+ else if (cp <= 0xff) body += `\\x${cp.toString(16).padStart(2, '0')}`;
445
+ else if (cp <= 0xffff) body += `\\u${cp.toString(16).padStart(4, '0')}`;
446
+ else body += `\\U${cp.toString(16).padStart(8, '0')}`;
447
+ }
448
+ // The quote choice reads the ESCAPED body, which is safe: no escape sequence above
449
+ // introduces a quote character, so a `'` or `"` in it came from the input.
450
+ return body.includes("'") && !body.includes('"')
451
+ ? `"${body}"`
452
+ : `'${body.replaceAll("'", "\\'")}'`;
453
+ }
454
+ if (v === null) return 'None';
455
+ if (v === true) return 'True';
456
+ if (v === false) return 'False';
457
+ if (Array.isArray(v)) return `[${v.map((x) => pyReprImpl(x, ascii)).join(', ')}]`;
458
+ if (typeof v === 'object' && !(v instanceof PyNumber)) {
459
+ // JSON object keys are always strings, so `repr(key)` is the string branch above.
460
+ return `{${Object.entries(v)
461
+ .map(([k, x]) => `${pyReprImpl(k, ascii)}: ${pyReprImpl(x, ascii)}`)
462
+ .join(', ')}}`;
463
+ }
464
+ return pyStr(v);
465
+ }
466
+
467
+ export function pyRepr(v) {
468
+ return pyReprImpl(v, false);
469
+ }
470
+
471
+ /**
472
+ * `ascii(value)` — `repr()` with every non-ASCII character escaped as well.
473
+ *
474
+ * Used where a warning quotes a value that came out of a file a user (or another tool)
475
+ * may have written: `build`'s prune guard names the suspicious dir name it refused to
476
+ * delete from `.geneseed-srcdirs.json`. Escaping is the point — a dir name carrying a
477
+ * newline or a right-to-left override must be readable in the warning, not act on the
478
+ * terminal, and Python already made that choice by spelling it `ascii()` rather than an
479
+ * f-string.
480
+ *
481
+ * The two share ONE implementation on purpose. The first draft gave `pyAscii` its own
482
+ * container and quote handling, and a mutation exposed the consequence immediately:
483
+ * `pyRepr`'s container branches became unreachable, so half of the code was dead and
484
+ * neither copy's escaping could drift without the other silently keeping the gate green.
485
+ */
486
+ export function pyAscii(v) {
487
+ return pyReprImpl(v, true);
488
+ }
489
+
490
+ /**
491
+ * `print()` — the two output funnels, with Python's newline translation reproduced.
492
+ *
493
+ * The STREAM twin of `writeText` above, and it lives beside it because it is the same rule:
494
+ * `sys.stdout` is a TextIOWrapper with `newline=None`, so on Windows every `\n` a CLI prints
495
+ * leaves the process as `\r\n` — and `harness.py`'s `reconfigure(encoding="utf-8")` changes
496
+ * the encoding only, not that. `process.stdout` translates nothing, so a Node CLI would hand
497
+ * its caller different BYTES than the Python one: measured at 176 vs 171 for one `context`
498
+ * run before this existed.
499
+ *
500
+ * `harness_golden.py` structurally cannot see the difference — `subprocess` decodes with
501
+ * universal newlines on the way back, folding both shapes to `\n` before any cell compares
502
+ * them, the same shape as the shim's exclusion from `golden.py`. So the gate for it is
503
+ * `test_the_two_entry_points_agree_on_stdout_BYTES`, which captures raw bytes. Moved here
504
+ * from `js/hooks.mjs` in P5c, when `js/excludes.mjs` became the second caller: a translation
505
+ * that exists twice is a translation that can be fixed once.
506
+ */
507
+ const xlate = (s) => (EOL === '\n' ? s : s.replaceAll('\n', EOL));
508
+ export const pyPrint = (s) => process.stdout.write(xlate(s));
509
+ export const pyPrintErr = (s) => process.stderr.write(xlate(s));
510
+
511
+ /**
512
+ * Hold `fn`'s stderr, and emit it only if `fn` RETURNS.
513
+ *
514
+ * The reproduction of `except SystemExit` for a port that writes its refusal at the raise
515
+ * site (`js/generate.mjs`'s `sysExit` docblock states the convention). `sys.exit(msg)`
516
+ * attaches the message to the EXCEPTION and the interpreter prints it on the way out, so a
517
+ * Python caller that catches sees no output at all — which makes write-at-the-raise-site
518
+ * identical for every caller that lets the throw propagate, and wrong for every caller that
519
+ * catches.
520
+ *
521
+ * MOVED HERE beside `pyPrintErr`, whose writes it intercepts, when the hook became the
522
+ * second and third such caller: `js/hosts.mjs`'s `expanduser` refuses a `~user` path by
523
+ * printing and throwing, and `js/hooks.mjs` catches it twice — once per `excludes.json`
524
+ * entry and once around `$GENESEED_ROOT`/`--root`. Its old docblock in `js/doctor.mjs`
525
+ * warned that a general helper "would invite a second caller that wanted silence the
526
+ * noise". That warning stands and is the contract: this is for a message that belonged to
527
+ * an exception NOBODY LET ESCAPE, never for quieting output a call legitimately makes —
528
+ * which is why the held chunks are replayed in full on the success path.
529
+ */
530
+ export function withDiscardableStderr(fn) {
531
+ const real = process.stderr.write.bind(process.stderr);
532
+ const held = [];
533
+ process.stderr.write = (chunk) => { held.push(chunk); return true; };
534
+ try {
535
+ const value = fn();
536
+ process.stderr.write = real;
537
+ for (const chunk of held) real(chunk);
538
+ return value;
539
+ } finally {
540
+ process.stderr.write = real;
541
+ }
542
+ }
543
+
544
+ /**
545
+ * The same rule applied to a whole CALL rather than to one string — `bin/geneseed.mjs`'s
546
+ * answer to the newline item P5e recorded and could not gate.
547
+ *
548
+ * WHY THE DRIVER NEEDS A FUNNEL WHERE EVERY OTHER CALLER NEEDS `pyPrint`. The generator
549
+ * prints from ~25 sites across `bin/geneseed.mjs`, `js/emit.mjs`, `js/settings.mjs`,
550
+ * `js/opencode.mjs`, `js/native.mjs` and `js/render.mjs`, and those modules are ALSO the
551
+ * body of the `GENESEED_NO_JS` seam child, whose `main` buffers both streams and hands them
552
+ * to a Python parent that re-prints them through `print()`. Converting those sites to
553
+ * `pyPrint` would translate once in Node and again in Python — `\r\r\n` on every line, on the
554
+ * path `tests/golden.py` drives 259 times. So the translation belongs to whichever process
555
+ * OWNS the stream, and the seam child does not own it. This wraps the driver's `main`, and
556
+ * only the driver's `main`.
557
+ *
558
+ * The item it closes: a plain `--emit files` handed its caller 104 bytes where `python
559
+ * build.py` handed over 105, and `tests/golden.py`'s `text=True` capture folds both to the
560
+ * same string. `test_the_two_entry_points_agree_on_stdout_BYTES` grew a `build` row (through
561
+ * `harness build`, which calls this `main` in-process) and a `rebuild-all` row, and those are
562
+ * what can see it.
563
+ *
564
+ * A Buffer chunk passes through untouched: `writeText` owns file bytes and nothing on this
565
+ * path writes binary to a stream, so translating one would be corrupting data rather than
566
+ * reproducing Python.
567
+ */
568
+ export function withPyNewlines(fn) {
569
+ if (EOL === '\n') return fn();
570
+ const outW = process.stdout.write.bind(process.stdout);
571
+ const errW = process.stderr.write.bind(process.stderr);
572
+ const wrap = (real) => (chunk, ...rest) => real(
573
+ typeof chunk === 'string' ? xlate(chunk) : chunk, ...rest,
574
+ );
575
+ process.stdout.write = wrap(outW);
576
+ process.stderr.write = wrap(errW);
577
+ try {
578
+ return fn();
579
+ } finally {
580
+ process.stdout.write = outW;
581
+ process.stderr.write = errW;
582
+ }
583
+ }
584
+
585
+ /**
586
+ * `len(s)` — CODE POINTS, where `String.length` counts UTF-16 units.
587
+ *
588
+ * They differ on every astral character: `len("𝔊")` is 1 and `"𝔊".length` is 2. Only
589
+ * matters where a length becomes layout, which is exactly what the status panel does with
590
+ * it — a theme name or a memory path outside the BMP would shear the frame by one column
591
+ * per character under `.length`.
592
+ */
593
+ export const pyLen = (s) => [...s].length;
594
+
595
+ /**
596
+ * `str.ljust(width)` — pad with spaces to `width` CODE POINTS, never truncate.
597
+ *
598
+ * `String.padEnd` counts UTF-16 units, so it is `pyLen`'s difference again, one call later.
599
+ */
600
+ export const pyLjust = (s, width) => s + ' '.repeat(Math.max(0, width - pyLen(s)));
601
+
602
+ /**
603
+ * `str(PurePath(s))` — separators normalised to the platform's, `.` components dropped,
604
+ * duplicate separators collapsed, a trailing one removed.
605
+ *
606
+ * `path.normalize` is NOT this: it also collapses `a/../b` to `b`, and `PurePath` keeps the
607
+ * `..` because a symlinked `a` makes those two different directories. The distinction is
608
+ * live in `js/excludes.mjs`, where a hand-edited `excludes.json` entry is compared against a
609
+ * RESOLVED repo path — Python never matches a `..` entry there, and a normalising port would
610
+ * match it and unwire a file the reference would have left alone.
611
+ */
612
+ /**
613
+ * `shutil.which(cmd, path=None)` — the first executable named `cmd` on PATH, or null.
614
+ *
615
+ * `doctor` runs `node --check` over the OpenCode plugins and SKIPS the whole check when node
616
+ * is not on PATH. `process.execPath` is not that: it is always set, so a port that used it
617
+ * would run the check in a world where the reference silently declines to — the divergence a
618
+ * PATH-removal refutation is aimed at, and the reason this is a real lookup rather than one
619
+ * line of "we are node, we know where node is".
620
+ *
621
+ * The Windows half is the part with behaviour in it, and three of its four rules were
622
+ * MEASURED against CPython 3.13 rather than assumed. `PATHEXT` supplies the suffixes, and the
623
+ * returned path carries PATHEXT's OWN SPELLING of the extension rather than the file's
624
+ * (`zzcmd.CMD` for a `zzcmd.cmd` on disk). An extension already in that set means the command
625
+ * is spelled in full. An EMPTY PATH entry is not skipped either: `join('', cmd)` is a
626
+ * relative path, and Python answers with one.
627
+ *
628
+ * THE FOURTH RULE IS THE CURRENT DIRECTORY, and it was measured on a machine that could not
629
+ * see it. `shutil.which` prepends `os.curdir` to the search — even to a `path=` given
630
+ * explicitly — when `_winapi.NeedCurrentDirectoryForExePath` says so, and that Win32 call
631
+ * answers TRUE unless `NoDefaultCurrentDirectoryInExePath` is DEFINED in the environment.
632
+ * Git Bash and this repo's developer machine both define it, so the reference answered None
633
+ * there and the first draft's "the current directory is NOT prepended — it was until 3.12"
634
+ * described an environment variable it had mistaken for a version change. GitHub's Windows
635
+ * runner does not define it: the reference answered `.\\geneseed.CMD` for a `geneseed` looked
636
+ * up beside the repo root, this returned null, and the parity corpus said so on the first CI
637
+ * run. The variable is an INPUT, so it is read here rather than baked, and
638
+ * `test_the_curdir_rule_agrees_in_both_environment_states` runs the corpus with it both ways.
639
+ *
640
+ * Gated as a pure function over a seeded directory in `tests/test_pure_function_parity.py`:
641
+ * a cell can only ever observe the one answer this machine's PATH gives.
642
+ */
643
+ export function pyWhich(cmd, searchPath = null) {
644
+ const win = process.platform === 'win32';
645
+ const isExec = (p) => {
646
+ try {
647
+ if (!statSync(p).isFile()) return false;
648
+ accessSync(p, X_OK);
649
+ return true;
650
+ } catch { return false; }
651
+ };
652
+ // A command with a directory separator is not looked up at all — Python checks it as given.
653
+ if (cmd.includes('/') || (win && cmd.includes('\\'))) return isExec(cmd) ? cmd : null;
654
+ const exts = win
655
+ ? (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
656
+ : [];
657
+ const spelled = win && exts.some((e) => cmd.toLowerCase().endsWith(e.toLowerCase()));
658
+ // `os.path.join`, which CONCATENATES where `path.join` NORMALISES. The difference shows the
659
+ // moment a PATH entry is spelled with the other separator — routine on Windows, where Git
660
+ // Bash and MSYS put forward-slash entries in it — and the answer is the string a caller
661
+ // then spawns. Measured: the corpus case with a posix-spelled directory was the one that
662
+ // differed, and `path.join` had rewritten the whole path rather than appending to it.
663
+ const joinRaw = (dir, name) => (
664
+ dir === '' || /[\\/]$/.test(dir) || (win && dir.endsWith(':'))
665
+ ? dir + name : dir + path.sep + name);
666
+ const dirs = (searchPath ?? process.env.PATH ?? '').split(path.delimiter);
667
+ // `NeedCurrentDirectoryForExePath(cmd)`: TRUE — so `.` goes in FRONT — unless the variable
668
+ // is defined. Only reachable with a separator-free `cmd`, which is the only way execution
669
+ // gets this far. Inserted before the dedupe, exactly where `shutil.which` inserts it, so a
670
+ // PATH that already names `.` is searched once.
671
+ if (win && process.env.NoDefaultCurrentDirectoryInExePath === undefined) dirs.unshift('.');
672
+ const seen = new Set();
673
+ for (const dir of dirs) {
674
+ const key = win ? dir.toLowerCase() : dir;
675
+ if (seen.has(key)) continue;
676
+ seen.add(key);
677
+ for (const ext of (!win || spelled ? [''] : exts)) {
678
+ const candidate = joinRaw(dir, cmd + ext);
679
+ if (isExec(candidate)) return candidate;
680
+ }
681
+ }
682
+ return null;
683
+ }
684
+
685
+ /**
686
+ * `pathlib.Path(s).is_absolute()`, which is NOT `path.isAbsolute(s)` on Windows.
687
+ *
688
+ * MEASURED against CPython 3.13, and the two disagree on exactly one shape: a ROOTLESS
689
+ * absolute path. `/x/AGENT.md` and `\x\AGENT.md` are `True` to `path.isAbsolute` and `False`
690
+ * to Python, which requires a DRIVE (`C:/x`) or a UNC root (`//server/share/x`) before it
691
+ * calls a Windows path absolute. `C:x/AGENT.md` — a drive-RELATIVE path — is False to both.
692
+ * On POSIX the two rules coincide and this is `s.startsWith('/')`.
693
+ *
694
+ * Written in P5h for `installAgentEntry`, which filters an OpenCode config's `instructions`
695
+ * for the entries that are NOT absolute in order to tell a project install's relative
696
+ * `…/AGENT.md` from a global install's absolute one. A hand-edited config naming
697
+ * `/repo/AGENT.md` is the reachable input: Python keeps that entry and Node would have
698
+ * skipped it and fallen through to the literal `AGENT.md`, unwiring nothing.
699
+ *
700
+ * ONE OWNER, and the five pre-existing `path.isAbsolute` calls in `js/` were READ rather than
701
+ * assumed to be copies. Three cannot reach the divergence — `js/doctor.mjs` and
702
+ * `bin/geneseed.mjs` test the output of `path.relative`, which is never rootless-absolute,
703
+ * and `js/emit.mjs`'s `safePriorDirName` is `&&`-guarded by `basename(p) === p` immediately
704
+ * after. The two in `js/hooks.mjs` (a context entry's `path`) DO carry the same hazard and
705
+ * are deliberately not repointed here: that is the hook entry's shipped behaviour, no
706
+ * `context` cell varies it, and changing it in this phase would be an ungated edit to a verb
707
+ * that crossed three phases ago. Recorded in the spec's "Still owed" with this reproduction.
708
+ *
709
+ * Gated by a corpus in `tests/test_pure_function_parity.py`: no cell can vary the shape.
710
+ */
711
+ export function pyIsAbsolute(s) {
712
+ if (process.platform !== 'win32') return s.startsWith('/');
713
+ // Python is absolute iff the path has a DRIVE (or UNC share) *and* a root — `C:x` has the
714
+ // drive and no root, `/x` has the root and no drive, and neither is absolute. In
715
+ // `path.parse` terms that is exactly "the root is longer than a bare separator AND ends in
716
+ // one": `C:` for `C:x`, `\` for `\x`, `C:\` for `C:\x`, `\\srv\share\` for a UNC path.
717
+ // The first draft tested `root !== '\\'`, which called `C:x` absolute; the corpus caught
718
+ // it on the two drive-relative cases and nothing else would have.
719
+ const root = path.parse(s).root;
720
+ return root.length > 1 && /[\\/]$/.test(root);
721
+ }
722
+
723
+ /**
724
+ * `int(s)` for a base-10 string — the VALUE, or null where Python raises `ValueError`.
725
+ *
726
+ * Null rather than a throw because both call sites branch on the failure rather than
727
+ * propagate it: `askChoice` falls through to matching the answer against the option KEYS,
728
+ * and `javaMajorOk` treats an unparseable major as "not new enough". Python spells the same
729
+ * two branches as `except ValueError`.
730
+ *
731
+ * Three things separate this from `Number(s)` and each one changes an answer:
732
+ *
733
+ * * `Number('')` is 0 and `int('')` raises. So is `Number(' ')`, and `Number('0x10')` is
734
+ * 16 where `int('0x10')` raises — a wizard answer of `0x2` must fall through to the key
735
+ * match, not select option 16.
736
+ * * PEP 515 underscores: `int('1_0')` is 10, `Number('1_0')` is NaN. Leading, trailing and
737
+ * doubled ones still raise, so the rule is "between digits" and not "strip them".
738
+ * * `int()` accepts any Unicode decimal digit — `int('٣')` is 3 — and `Number('٣')` is NaN.
739
+ * Every `Nd` block is exactly ten contiguous code points, so a digit's value is its
740
+ * offset from the first `Nd` code point at or below it, which is what the walk finds.
741
+ *
742
+ * Surrounding whitespace is Python's to strip and both callers have already done it, so this
743
+ * does NOT strip: `int(' 1')` is 1, but reproducing that here would need Python's
744
+ * `str.isspace()` set, which is a separate standing item.
745
+ *
746
+ * Gated by a corpus in `tests/test_pure_function_parity.py` — a primitive reproduction gets
747
+ * one owner and a corpus, never a cell (P5c).
748
+ */
749
+ export function pyInt(s) {
750
+ if (typeof s !== 'string' || s === '') return null;
751
+ let body = s;
752
+ let sign = 1;
753
+ if (body[0] === '+' || body[0] === '-') {
754
+ if (body[0] === '-') sign = -1;
755
+ body = body.slice(1);
756
+ }
757
+ const chars = [...body];
758
+ if (chars.length === 0) return null;
759
+ let value = 0n;
760
+ let prevWasDigit = false;
761
+ for (const ch of chars) {
762
+ if (ch === '_') {
763
+ // Only BETWEEN digits: a leading, trailing or doubled underscore is a ValueError.
764
+ if (!prevWasDigit) return null;
765
+ prevWasDigit = false;
766
+ continue;
767
+ }
768
+ if (!/^\p{Nd}$/u.test(ch)) return null;
769
+ const cp = ch.codePointAt(0);
770
+ let zero = cp;
771
+ while (zero > 0 && /^\p{Nd}$/u.test(String.fromCodePoint(zero - 1))) zero -= 1;
772
+ value = value * 10n + BigInt(cp - zero);
773
+ prevWasDigit = true;
774
+ }
775
+ if (!prevWasDigit) return null; // a trailing underscore
776
+ return Number(sign === -1 ? -value : value);
777
+ }
778
+
779
+ /**
780
+ * `str(Path(x))` — and it is PLATFORM-SHAPED, because `Path` is.
781
+ *
782
+ * `Path` is `WindowsPath` on Windows and `PosixPath` everywhere else, and the two parsers
783
+ * disagree about the two things below. A body that is right on one host is therefore wrong
784
+ * on the other, which is exactly how this one passed a whole Windows suite and failed two
785
+ * cases on the first Linux run of `tests/test_pure_function_parity.py`:
786
+ *
787
+ * * SEPARATORS. `ntpath` treats `\` and `/` alike; `posixpath` treats `\` as an ordinary
788
+ * filename character. `str(PurePosixPath('C:\\x\\bin\\'))` is that string VERBATIM —
789
+ * one component, trailing backslash and all — where splitting on both answered
790
+ * `C:/x/bin`. A backslash is legal in a Linux filename, so this is not academic.
791
+ * * THE DOUBLE-SLASH ROOT. POSIX gives EXACTLY two leading slashes an
792
+ * implementation-defined meaning and `PurePosixPath` preserves them; three or more
793
+ * collapse to one. `path.posix.parse` reports a one-character root for every case, so
794
+ * `//server/share` came back as `/server/share`.
795
+ *
796
+ * NOT reproduced, and named here rather than left as a gap nobody wrote down: `//` and
797
+ * `///x` on WINDOWS, where `PureWindowsPath` answers `\\` and `\\\x` and this answers `\`
798
+ * and `\x`. Those are incomplete UNC prefixes and matching them needs ntpath's share-name
799
+ * parsing; both inputs are kept OUT of the corpus in `tests/test_pure_function_parity.py`
800
+ * instead of sitting in it wrong.
801
+ *
802
+ * The root's separators are still rewritten ONE AT A TIME, never `replace(/[\\/]+/g, sep)`:
803
+ * a UNC root's leading pair is part of it, so collapsing runs turns `//server/share/x` into
804
+ * `\server\share\x`.
805
+ */
806
+ export function pyPathStr(s) {
807
+ const win = path.sep === '\\';
808
+ let raw = path.parse(s).root;
809
+ if (!win && raw && /^\/\/(?!\/)/.test(s)) raw = '//';
810
+ const root = raw.replace(/[\\/]/g, path.sep);
811
+ const seps = win ? /[\\/]+/ : /\/+/;
812
+ const parts = s.slice(raw.length).split(seps).filter((p) => p !== '' && p !== '.');
813
+ const tail = parts.join(path.sep);
814
+ if (!root) return tail || '.';
815
+ return root + tail;
816
+ }
817
+
818
+ /**
819
+ * `urllib.parse.unquote(s)` — percent-decoding as Python does it, which is NOT
820
+ * `decodeURIComponent`.
821
+ *
822
+ * The whole difference is what happens to a sequence that is not an escape.
823
+ * `decodeURIComponent('a%ZZ')` throws a `URIError`, and so does a trailing bare `%`; the
824
+ * web shell would answer both as a 500 where the reference answers a 404 naming the
825
+ * literal text. `unquote` leaves them alone: CPython builds a table of every two-hex-digit
826
+ * pair in both cases and treats a `KeyError` as "this was not an escape", emitting `%` and
827
+ * the rest of the fragment verbatim.
828
+ *
829
+ * The outer split on `([\x00-\x7f]+)` is Python's too, and it is not decoration: only the
830
+ * ASCII runs are candidates for decoding, so a literal non-ASCII character already in the
831
+ * path is passed through rather than mixed into the byte buffer. Consecutive escapes ARE
832
+ * accumulated before decoding, which is what makes `%C3%A9` one `é` and not two
833
+ * replacement characters.
834
+ *
835
+ * `Buffer.toString('utf-8')` substitutes U+FFFD for an undecodable sequence, which is
836
+ * `errors='replace'`. The two decoders can disagree on HOW MANY replacement characters a
837
+ * truncated multi-byte sequence yields — Python's maximal-subpart rule against WHATWG's —
838
+ * and the corpus in `tests/test_pure_function_parity.py` is where that is measured rather
839
+ * than assumed.
840
+ */
841
+ const HEX_PAIR = /^[0-9A-Fa-f]{2}$/;
842
+
843
+ function unquoteToBytes(run) {
844
+ const bits = run.split('%');
845
+ if (bits.length === 1) return Buffer.from(run, 'latin1');
846
+ const out = [Buffer.from(bits[0], 'latin1')];
847
+ for (const item of bits.slice(1)) {
848
+ const pair = item.slice(0, 2);
849
+ if (HEX_PAIR.test(pair)) {
850
+ out.push(Buffer.from([parseInt(pair, 16)]), Buffer.from(item.slice(2), 'latin1'));
851
+ } else {
852
+ out.push(Buffer.from(`%${item}`, 'latin1'));
853
+ }
854
+ }
855
+ return Buffer.concat(out);
856
+ }
857
+
858
+ export function pyUnquote(s) {
859
+ if (!s.includes('%')) return s;
860
+ // `re.split` with ONE capture group alternates [gap, group, gap, group, …], so the ASCII
861
+ // runs are the odd indices on both sides.
862
+ const bits = s.split(/([\x00-\x7f]+)/);
863
+ let res = bits[0];
864
+ for (let i = 1; i < bits.length; i += 2) {
865
+ res += unquoteToBytes(bits[i]).toString('utf-8');
866
+ res += bits[i + 1] ?? '';
867
+ }
868
+ return res;
869
+ }
870
+
871
+ /**
872
+ * Python's `\s` for a `str` pattern, as a character-class BODY — measured, not recalled.
873
+ *
874
+ * The two languages' whitespace classes are nearly the same and the difference had been
875
+ * carried as a standing item reading "differing from JS `\s` only at U+FEFF". That was
876
+ * incomplete in both directions, and a corpus over `slugifyHeading` is what found it:
877
+ *
878
+ * python \s = js \s - U+FEFF + U+001C..U+001F + U+0085
879
+ *
880
+ * JavaScript counts the byte-order mark as whitespace and Python does not; Python counts
881
+ * the four C0 information separators and the NEL, and JavaScript does not. Neither shows
882
+ * up in a docs heading anyone has written — which is exactly why it took a corpus rather
883
+ * than a cell, and why it is spelled out here rather than approximated with `\s`.
884
+ */
885
+ export const PY_SPACE = '\t\n\v\f\r \u001c-\u001f\u0085\u00a0\u1680'
886
+ + '\u2000-\u200a\u2028\u2029\u202f\u205f\u3000';
887
+
888
+ const PY_STRIP_RE = new RegExp(`^[${PY_SPACE}]+|[${PY_SPACE}]+$`, 'g');
889
+ const PY_LSTRIP_RE = new RegExp(`^[${PY_SPACE}]+`);
890
+ const PY_RSTRIP_RE = new RegExp(`[${PY_SPACE}]+$`);
891
+
892
+ /** `str.strip()` — Python's whitespace set, which `String.trim()`'s is not. */
893
+ export function pyStripSpace(s) {
894
+ return s.replace(PY_STRIP_RE, '');
895
+ }
896
+
897
+ /**
898
+ * `str.lstrip()` / `str.rstrip()` — the ONE-SIDED halves, over the same `PY_SPACE`.
899
+ *
900
+ * P2 added them for `_build_render._insert_theme_keys`, which uses all three spellings within
901
+ * four lines (`lines[0].strip()`, `ln.lstrip().startswith('"')`,
902
+ * `lines[pred].rstrip().endswith(",")`). They are here rather than in `js/themes.mjs` for the
903
+ * reason this file exists at all: `js/settings.mjs` already carries a private second copy of
904
+ * the character class, and a THIRD one — in the module that decides where a comma goes in a
905
+ * committed theme file — is how the three drift apart. One owner of the set, three views of it.
906
+ */
907
+ export function pyLStripSpace(s) {
908
+ return s.replace(PY_LSTRIP_RE, '');
909
+ }
910
+
911
+ export function pyRStripSpace(s) {
912
+ return s.replace(PY_RSTRIP_RE, '');
913
+ }