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/hosts.mjs ADDED
@@ -0,0 +1,253 @@
1
+ /**
2
+ * The four host config dirs, and the two path primitives they are built on.
3
+ *
4
+ * EXTRACTED IN P5c, and the reason is arithmetic rather than taste. `bin/geneseed.mjs`
5
+ * owned these; `bin/geneseed-cli.mjs` needs the same four to find a global install, and a
6
+ * resolver that decides WHERE a driver writes is the last thing that should exist twice.
7
+ * `js/hooks.mjs` carries a third copy of `opencodeConfigDir` on purpose — the hook path is
8
+ * deliberately dependency-free and says so — but the CLI has no such reason, so this is one
9
+ * owner for both drivers rather than a second exception.
10
+ *
11
+ * The move is a pure one, which is why it could be made at all: `tests/golden.py` runs
12
+ * `bin/geneseed.mjs` over 259 cells and compares the tree byte-for-byte, and roughly half
13
+ * of those resolve a global config dir. A move that changed one of these by a character
14
+ * fails ~126 cells, not zero.
15
+ */
16
+ import { realpathSync, statSync } from 'node:fs';
17
+ import path from 'node:path';
18
+ import os from 'node:os';
19
+ import { pyPathStr, pyPrintErr, withDiscardableStderr } from './lib/fs.mjs';
20
+
21
+ const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
22
+
23
+ /** `_build_global.GLOBAL_MANIFEST` — the file whose presence means "a global install". */
24
+ export const GLOBAL_MANIFEST = '.geneseed-manifest.json';
25
+
26
+ /**
27
+ * `_build_core.VERSION_MARKER`. Third caller, so it moves here — `js/emit.mjs` and
28
+ * `js/diff.mjs` each had a private const of their own and `js/uninstall.mjs` would have made
29
+ * a third copy of a value whose whole job is being the same string everywhere. Same
30
+ * arithmetic that put `GLOBAL_MANIFEST` above it.
31
+ */
32
+ export const VERSION_MARKER = '.geneseed-version';
33
+
34
+ /**
35
+ * `Path.expanduser()` — a LEADING `~` only, and `~` alone counts.
36
+ *
37
+ * The docblock here used to claim "Python does not expand `~user` on Windows the way a
38
+ * shell would". That claim was FALSE, and `921384f`'s widened probe corpus caught it:
39
+ * `ntpath.expanduser('~someuser/x')` swaps the last component of `%USERPROFILE%` and
40
+ * returns `C:\Users\someuser\x` — it happily expands a name that may not even be a real
41
+ * account.
42
+ *
43
+ * AND THE POSIX HALF OF THAT SENTENCE WAS WRONG IN ITS TURN, corrected here while the
44
+ * reference is still on the machine to ask. `posixpath.expanduser` does the analogous thing
45
+ * through the password database and returns the path unchanged when the account is unknown
46
+ * — but the reference does not call it. It calls `Path.expanduser`, which re-checks the
47
+ * result and RAISES `RuntimeError: Could not determine home directory.` So the divergence
48
+ * below is WINDOWS-ONLY: on POSIX the reference and this port already agree in kind — both
49
+ * refuse — and differ only in which exception says so.
50
+ *
51
+ * This port deliberately does NEITHER. Reproducing `ntpath`'s swap faithfully needs its own
52
+ * corpus, and the Python reference is being deleted, so byte-parity on this one input stops
53
+ * mattering the moment it is. What matters instead is that a `~user` form never resolves
54
+ * SILENTLY to the wrong place: the old code below returned it untouched, so
55
+ * `--target ~someone/x` created a literal `~someone` directory in whatever cwd the user ran
56
+ * from. It now REFUSES the form outright. This is the single primitive every config-dir env
57
+ * var and every `--target`/`--dir`/`--bundle`/`--out` argument passes through on the way to
58
+ * becoming a filesystem path, so refusing here — rather than at each of those call sites —
59
+ * is one guard instead of several, and none of the paths that already exist there. `~` alone
60
+ * and `~/…`/`~\…` are unaffected; only a name after the tilde is rejected.
61
+ */
62
+ export function expanduser(p) {
63
+ if (p === '~') return os.homedir();
64
+ if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(os.homedir(), p.slice(2));
65
+ if (p.startsWith('~')) {
66
+ const msg = `refusing '${p}': a '~user' path is not expanded by this port (Python's `
67
+ + "ntpath/posixpath would guess a home directory for 'user' — this port does not); "
68
+ + 'pass an absolute path, or "~" / "~/…" for your own home directory';
69
+ pyPrintErr(`${msg}\n`);
70
+ const e = new Error(msg);
71
+ e.exitCode = 1;
72
+ throw e;
73
+ }
74
+ return p;
75
+ }
76
+
77
+ /**
78
+ * `Path.resolve()` — absolute, symlinks followed, and the filesystem's OWN casing.
79
+ *
80
+ * `path.resolve` does none of the last two and `realpathSync` THROWS on a path that does not
81
+ * exist yet, which is the normal case here: the first `--emit opencode-global` on a machine
82
+ * resolves a config dir nobody has created. Python's `resolve(strict=False)` canonicalises
83
+ * the part that exists and appends the rest verbatim, so that is what this reproduces.
84
+ * It matters because the resolved directory is printed on stdout and compared byte-for-byte.
85
+ */
86
+ export function pyResolve(p) {
87
+ let cur = path.resolve(expanduser(p));
88
+ const tail = [];
89
+ for (;;) {
90
+ try {
91
+ return tail.length ? path.join(realpathSync.native(cur), ...tail) : realpathSync.native(cur);
92
+ } catch {
93
+ const parent = path.dirname(cur);
94
+ if (parent === cur) return path.resolve(expanduser(p)); // nothing on this path exists
95
+ tail.unshift(path.basename(cur));
96
+ cur = parent;
97
+ }
98
+ }
99
+ }
100
+
101
+ /**
102
+ * `_build_core._opencode_config_dir` — and the four resolvers like it are the reason
103
+ * `bin/geneseed.mjs` exists rather than a child doing the work.
104
+ *
105
+ * P3c's rule was "the child must never resolve this": a render child that did would write
106
+ * 135 files into the developer's real `~/.config/opencode`. A driver is the PARENT, so the
107
+ * rule inverts — resolving it there is exactly the job. Precedence is the env var (which
108
+ * relocates the whole dir), then `$XDG_CONFIG_HOME/opencode`, then `~/.config/opencode`.
109
+ */
110
+ export function opencodeConfigDir() {
111
+ const env = process.env.OPENCODE_CONFIG_DIR;
112
+ if (env) return pyResolve(env);
113
+ const xdg = process.env.XDG_CONFIG_HOME;
114
+ const base = xdg ? expanduser(xdg) : path.join(os.homedir(), '.config');
115
+ return pyResolve(path.join(base, 'opencode'));
116
+ }
117
+
118
+ /**
119
+ * `_build_core._copilot_config_dir` — `~/.copilot`, relocatable via `$COPILOT_CONFIG_DIR`.
120
+ *
121
+ * Geneseed's own knob, mirroring `$BOB_CONFIG_DIR`: Copilot documents no such variable, but
122
+ * tests, doctor and locked-down setups still need to re-point the target. Like every
123
+ * relocation variable it is CLEARED by `golden.cell_env`, so no cell can observe whether
124
+ * a driver honours it — see `test_the_relocation_var_moves_the_global_target`.
125
+ */
126
+ export function copilotConfigDir() {
127
+ const env = process.env.COPILOT_CONFIG_DIR;
128
+ if (env) return pyResolve(env);
129
+ return pyResolve(path.join(os.homedir(), '.copilot'));
130
+ }
131
+
132
+ /**
133
+ * `_build_core._claude_config_dir` — `~/.claude`, and there is NO env branch BY DESIGN.
134
+ *
135
+ * Its three siblings all check a `*_CONFIG_DIR` variable first; this one does not, because
136
+ * Claude Code documents none and inventing one here would make the two CLIs disagree about
137
+ * where a global install lives. The absence is the specification, so it is asserted rather
138
+ * than merely not implemented — `test_every_relocation_var_moves_its_global_target` carries
139
+ * an INVERSE row for this host: setting `$CLAUDE_CONFIG_DIR` must NOT move the target.
140
+ * Without that row the table could only say "no cell covers Claude", which reads the same
141
+ * as an omission.
142
+ */
143
+ export function claudeConfigDir() {
144
+ return pyResolve(path.join(os.homedir(), '.claude'));
145
+ }
146
+
147
+ /** `_build_core._bob_config_dir` — `~/.bob`, relocatable via `$BOB_CONFIG_DIR`. */
148
+ export function bobConfigDir() {
149
+ const env = process.env.BOB_CONFIG_DIR;
150
+ if (env) return pyResolve(env);
151
+ return pyResolve(path.join(os.homedir(), '.bob'));
152
+ }
153
+
154
+ /**
155
+ * `_build_global.HOSTS`, reduced to the two columns a non-emitting caller needs, and IN ITS
156
+ * ORDER — opencode, claude, bob, copilot.
157
+ *
158
+ * An array rather than an object because the order is observable output, not an
159
+ * implementation detail: `harness exclude add` walks it and prints one message per host, so
160
+ * a reordering is a diff in stderr. (`emit_global` and `native_catalog` stay out;
161
+ * `bin/geneseed.mjs` receives those decisions from its own dispatch rather than reading a
162
+ * table.)
163
+ *
164
+ * `projectMarker` joined in P5f. `_install_targets` asks "does this cwd carry a project
165
+ * install of host H", and `_claude_cfg` asks "which subdir holds a project install's
166
+ * manifest" — both are the same `.opencode`/`.claude`/`.bob`/`.github` value the Python reads
167
+ * out of `build.HOSTS[host]["project_marker"]`, so it belongs beside the config dir rather
168
+ * than in a second table in `js/installs.mjs`.
169
+ *
170
+ * `agentFile` joined in P5h, and for one caller: `cmd_uninstall` names the managed block's
171
+ * carrier in its "removes:" preamble (`the CLAUDE.md managed block`, `the AGENTS.md managed
172
+ * block`). It is a column and not a literal in the message for the reason every other column
173
+ * here is one — Bob and Copilot both answer `AGENTS.md` while Claude answers `CLAUDE.md`, and
174
+ * a host added later must not need the message edited to stay true.
175
+ */
176
+ export const HOSTS = [
177
+ { host: 'opencode', configDir: opencodeConfigDir, projectMarker: '.opencode', agentFile: 'AGENT.md' },
178
+ { host: 'claude', configDir: claudeConfigDir, projectMarker: '.claude', agentFile: 'CLAUDE.md' },
179
+ { host: 'bob', configDir: bobConfigDir, projectMarker: '.bob', agentFile: 'AGENTS.md' },
180
+ { host: 'copilot', configDir: copilotConfigDir, projectMarker: '.github', agentFile: 'AGENTS.md' },
181
+ ];
182
+
183
+ /**
184
+ * `_build_global.host_catalogs_natively` — does `host` list every skill and agent to the
185
+ * model by itself?
186
+ *
187
+ * The third column of `HOSTS`, and the reason the docblock above no longer says
188
+ * `native_catalog` stays out: `doctor`'s `_rendered_problems` has to know, because a host
189
+ * that catalogues natively gets AGENT.md's tables collapsed to a pointer and comparing the
190
+ * portable shape against its bundle reports AGENT.md stale on every run forever. It is a
191
+ * column and not a set literal so that the capability is still declared in exactly one place
192
+ * — the Python's own reason for the helper.
193
+ *
194
+ * Unknown host -> false, which is the shape that KEEPS the tables. The value arrives from a
195
+ * user-editable `.geneseed-emit` marker, so an unrecognised one must degrade to the portable
196
+ * bundle rather than raise.
197
+ */
198
+ const NATIVE_CATALOG = { opencode: true, claude: true, bob: false, copilot: false };
199
+
200
+ export function hostCatalogsNatively(host) {
201
+ return Boolean(NATIVE_CATALOG[host]);
202
+ }
203
+
204
+ /** `_harness_learn.MEMORY_DIR_NAMES` — the neutral name and the imperial theme's. */
205
+ const MEMORY_DIR_NAMES = ['memory', 'anamnesis'];
206
+
207
+ /**
208
+ * `_harness_learn._resolve_memory_dir` — where `learn` dedups and indexes, and what
209
+ * `status` reports on its memory row.
210
+ *
211
+ * Precedence: `--memory` > `$GENESEED_MEMORY` > a `memory/` (or `anamnesis/`) beside cwd or
212
+ * under `./Harness` > `$GENESEED_HARNESS/memory` > the OpenCode GLOBAL config dir's store.
213
+ * The last two matter for the recommended opencode-global install, whose store lives in
214
+ * `~/.config/opencode` rather than beside any repo. null => stdout-only.
215
+ *
216
+ * HERE rather than in `js/hooks.mjs`, where it was written, because P5d gave it a second
217
+ * caller that cannot import that file: `bin/geneseed-cli.mjs` is under a transitive
218
+ * `child_process` ban and `learn` spawns the model CLI. This module is the one both can
219
+ * reach, and it already owns the `opencodeConfigDir` the last fallback needs — so the move
220
+ * DELETED a duplicate resolver rather than adding a shared one.
221
+ */
222
+ export function resolveMemoryDir(explicit) {
223
+ if (explicit) {
224
+ const p = pyPathStr(explicit);
225
+ return isDir(p) ? p : null;
226
+ }
227
+ const env = process.env.GENESEED_MEMORY;
228
+ if (env && isDir(env)) return pyPathStr(env);
229
+ const cwd = process.cwd();
230
+ const bases = [cwd, path.join(cwd, 'Harness')];
231
+ const gh = process.env.GENESEED_HARNESS;
232
+ if (gh) {
233
+ // PER BASE, and the walk continues. `$GENESEED_HARNESS` is the FOURTH user-controlled
234
+ // tilde input and the one the `~user` refusal was never swept onto: `expanduser` throws
235
+ // on `~someone/x`, and this resolver is reached by `geneseed status` (js/status.mjs's
236
+ // `resolveMemoryDir(null)`) and by the `learn` hook, whose entry point
237
+ // (`bin/geneseed-hook.mjs`) has NO top-level try — so an unguarded throw here is a stack
238
+ // trace on a hook path and a non-zero exit, the exact failure the other three guards
239
+ // exist to prevent. Skipping the base degrades to "no memory store from $GENESEED_HARNESS",
240
+ // which is what an unset variable already does. `withDiscardableStderr` because the
241
+ // refusal prints at the RAISE SITE and both callers byte-compare stderr; the reference
242
+ // (`rituals/_harness_learn.py`, same guard) prints nothing here.
243
+ try { bases.push(withDiscardableStderr(() => expanduser(gh))); } catch { /* skip it */ }
244
+ }
245
+ try { bases.push(opencodeConfigDir()); } catch { /* best-effort, as the Python */ }
246
+ for (const base of bases) {
247
+ for (const name of MEMORY_DIR_NAMES) {
248
+ const cand = path.join(base, name);
249
+ if (isDir(cand)) return cand;
250
+ }
251
+ }
252
+ return null;
253
+ }
@@ -0,0 +1,378 @@
1
+ /**
2
+ * How a DEPLOYED install is recognised — `_harness_setup`'s detection half and
3
+ * `_harness_mcp`'s registry-reading half, which are one subject in two Python files.
4
+ *
5
+ * EXTRACTED IN P5f, and by the same arithmetic that produced `js/hosts.mjs` and
6
+ * `js/checkout.mjs`: this is the third verb-set to need it. P5d wrote `themeOfDir` and
7
+ * `footprintOfDir` inside `js/status.mjs` because `status` was the only caller; `diff`
8
+ * renders its 'expected' copy in the theme and footprint the deployment actually uses, and
9
+ * `rebuild-all` re-emits every install in its own theme, emit, footprint, posture and mode.
10
+ * Three callers of one detector is where a private helper becomes a module.
11
+ *
12
+ * WHAT THE MOVE DID NOT DO, and it is the P5f brief's second blind spot answered by
13
+ * measurement. `installedDefaults` still omitted posture and mode after P5f, because
14
+ * `cmd_rebuild_all` never calls `_installed_defaults`: it calls `_posture_of_dir(root)` and
15
+ * `_mode_of_dir(root)` directly, per install root, which is a different question from "what
16
+ * did this machine install". **P5i is where the debt came due** — `collectSetupLines`
17
+ * pre-selects three pickers off it — and the missing keys are now returned.
18
+ * `status/posture-and-mode-in-the-carrier-change-nothing` stays exactly as valid as it was:
19
+ * it is the positive control that the PANEL prints neither, and the panel reads two keys of
20
+ * the five whatever the other three hold.
21
+ *
22
+ * WHAT A CELL CANNOT REACH HERE. `installTargets` walks `process.cwd()` and the four host
23
+ * config dirs, and both are fenceable — `golden.cell_env` redirects HOME/XDG and every cell
24
+ * chooses its own `cwd`. What it also walks is the persistent registry, which lives under
25
+ * the redirected `$XDG_CONFIG_HOME` and is therefore fenceable too. So unlike `status`, none
26
+ * of this module's inputs is the unfenceable `ROOT`; the `rebuild-all` cells seed real
27
+ * installs and get real answers.
28
+ *
29
+ * That sentence was FALSE until wave 2 of the P0/P1 review: `installedDefaults` walked
30
+ * `ROOT / "Harness"` and `ROOT.parent / "Harness"`, so on a checkout carrying a built
31
+ * bundle every cell that seeded no emit marker read this developer's own install — four
32
+ * web cells recorded it. Both candidates are gone (the argument is in the Python), and the
33
+ * claim is now true rather than aspirational.
34
+ */
35
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
36
+ import path from 'node:path';
37
+
38
+ import { CONFIG, THEMES, discoverNames } from './checkout.mjs';
39
+ import { GLOBAL_MANIFEST, HOSTS, pyResolve } from './hosts.mjs';
40
+ import { registryRoots } from './registry.mjs';
41
+ import { comparePaths, pyPrintErr, pyRepr, readText } from './lib/fs.mjs';
42
+
43
+ const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
44
+ const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
45
+
46
+ /**
47
+ * `Path.read_text(encoding="utf-8")`, or null where Python raises OSError.
48
+ *
49
+ * `readText`, not a bare `readFileSync`: `read_text` opens in TEXT mode, so Python's
50
+ * universal-newline decoding folds `\r\n` and a lone `\r` to `\n` before the caller ever
51
+ * sees them, and `readFileSync` does not. Every caller here reads a single-line marker and
52
+ * trims it, which is why the divergence sat latent through five phases — P6b's
53
+ * `/api/profile` is the first consumer that hands the whole decoded text back out, and
54
+ * `profile/a-seeded-profile-carries-its-fingerprint` failed on the CRLF the seeder wrote
55
+ * AND on the sha256 of it. Fixed here rather than at that one call site: the multi-line
56
+ * readers (`installs.mjs`'s AGENT.md sigil scan and carrier read, `uninstall.mjs`'s two)
57
+ * all mirror a Python `read_text` too, so every one of them was wrong in the same way and
58
+ * a guard at the new caller would have left them wrong.
59
+ */
60
+ export function readMaybe(p) {
61
+ try { return readText(p); } catch { return null; }
62
+ }
63
+
64
+ /** `json.loads(...)` of a file, or null — Python catches JSONDecodeError and OSError alike. */
65
+ export function readJsonMaybe(p) {
66
+ const txt = readMaybe(p);
67
+ if (txt === null) return null;
68
+ try { return JSON.parse(txt); } catch { return null; }
69
+ }
70
+
71
+ // ---- the configured defaults (`_harness_setup._default_*`) --------------------------------
72
+
73
+ /**
74
+ * `_harness_setup._default_theme` / `_default_posture` / `_default_mode`.
75
+ *
76
+ * One reader for three keys where the Python has three near-identical functions. The fallback
77
+ * differs per key and is the argument, so nothing is shared that is not actually the same:
78
+ * each is `harness.config.json`'s value or a literal, with an unreadable or non-JSON file
79
+ * falling through to the literal. NOT `configDefaults()` from the driver, which warns on a
80
+ * corrupt file — these three are silent, as their Python originals are.
81
+ */
82
+ function configuredDefault(key, fallback) {
83
+ if (existsSync(CONFIG)) {
84
+ const doc = readJsonMaybe(CONFIG);
85
+ if (doc && typeof doc === 'object') return doc[key] ?? fallback;
86
+ }
87
+ return fallback;
88
+ }
89
+
90
+ export const defaultTheme = () => configuredDefault('theme', 'neutral');
91
+ export const defaultPosture = () => configuredDefault('posture', 'peer');
92
+ export const defaultMode = () => configuredDefault('mode', 'direct');
93
+
94
+ // ---- theme, footprint, posture, mode, read back off a deployed tree -----------------------
95
+
96
+ /**
97
+ * `_build_render.theme_files` — shipped themes, `_`-prefixed scaffolds excluded.
98
+ *
99
+ * `dir` defaults to the checkout's own `themes/`, which is every caller but one: P2's
100
+ * `syncThemes` is driven over a FIXTURE directory by the cross-implementation corpus in
101
+ * `tests/test_maintainer_tools_parity.py`, because the tool it belongs to rewrites committed
102
+ * files and cannot be gated against the real ones. A parameter, not a second enumeration —
103
+ * a scaffold mistaken for a theme is exactly what this function exists to prevent, once.
104
+ *
105
+ * Sorted with `comparePaths` and not `.sort()`: the Python sorts `Path` objects, which compare
106
+ * through `_str_normcase`, so on Windows `Cyberpunk.json` sorts before `bar.json` there and
107
+ * after it under a bare code-unit sort. No shipped theme has an upper-case name, so no
108
+ * emitted byte moves — it is the fixture corpus that can reach a name that does.
109
+ */
110
+ export function themeFiles(dir = THEMES) {
111
+ let names;
112
+ try { names = readdirSync(dir); } catch { return []; }
113
+ return names.filter((n) => n.endsWith('.json') && !n.startsWith('_'))
114
+ .map((n) => path.join(dir, n)).sort(comparePaths);
115
+ }
116
+
117
+ /** `_harness_setup._theme_from_agent` — match a theme's unique LOADED_SIGIL in a carrier. */
118
+ function themeFromAgent(agentMd) {
119
+ const text = readMaybe(agentMd);
120
+ if (text === null) return null;
121
+ for (const tf of themeFiles()) {
122
+ const doc = readJsonMaybe(tf);
123
+ const sig = (doc && typeof doc === 'object' ? doc.LOADED_SIGIL : '') || '';
124
+ if (sig && text.includes(sig)) return path.basename(tf, '.json');
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /**
130
+ * `_harness_setup.CARRIERS` — the five instruction carriers, in the Python's order.
131
+ *
132
+ * A shared table rather than three copies of the same list: `_theme_of_dir`,
133
+ * `_posture_of_dir` and `_mode_of_dir` each walk it, and the order is observable (the first
134
+ * carrier that answers wins). `rules/geneseed.md` is spelled with `path.join` at each use
135
+ * because the Python writes `d / "rules" / "geneseed.md"` for that one entry.
136
+ */
137
+ const CARRIERS = ['AGENT.md', 'CLAUDE.md', path.join('rules', 'geneseed.md'),
138
+ 'copilot-instructions.md', 'AGENTS.md'];
139
+
140
+ /** `_harness_setup._theme_of_dir` — the marker, else the sigil in one of five carriers. */
141
+ export function themeOfDir(d) {
142
+ const marker = path.join(d, '.geneseed-theme');
143
+ if (isFile(marker)) {
144
+ const name = (readMaybe(marker) ?? '').trim();
145
+ if (name) return name;
146
+ }
147
+ for (const carrier of CARRIERS) {
148
+ const found = themeFromAgent(path.join(d, carrier));
149
+ if (found) return found;
150
+ }
151
+ return null;
152
+ }
153
+
154
+ /** `_harness_setup.FOOTPRINTS`. */
155
+ const FOOTPRINTS = ['lean', 'full'];
156
+
157
+ /**
158
+ * `_harness_setup._footprint_of_dir`.
159
+ *
160
+ * `status` reads no footprint row at all; the WARN is why this function is ported there, and
161
+ * `diff` and `rebuild-all` read the RETURN value as well. An unrecognised marker is reported
162
+ * rather than swallowed (Rule VII), and a compared stderr is what makes that observable —
163
+ * `status/a-bogus-footprint-marker-warns-on-stderr` is the cell.
164
+ */
165
+ export function footprintOfDir(d) {
166
+ const marker = path.join(d, '.geneseed-footprint');
167
+ if (isFile(marker)) {
168
+ const v = (readMaybe(marker) ?? '').trim();
169
+ if (FOOTPRINTS.includes(v)) return v;
170
+ if (v) {
171
+ pyPrintErr(`[geneseed] WARN: ${marker} holds unknown footprint ${pyRepr(v)} — `
172
+ + `reading it as 'full'. Known: ${FOOTPRINTS.join(', ')}.\n`);
173
+ }
174
+ }
175
+ return 'full';
176
+ }
177
+
178
+ /**
179
+ * `_harness_setup._posture_of_dir` / `_mode_of_dir` — CONTENT-detected, not markered.
180
+ *
181
+ * The inlined `## Posture` / `## Mode` section opens with a distinctive `**<Name>**` lead, so
182
+ * a deployed install carries its register in the prose and needs no extra marker file. The
183
+ * two Python functions are identical but for which name list they scan, which is the argument
184
+ * here — and the list comes from `discoverNames`, the same call the driver's `--posture`
185
+ * choices come from, so a new posture file is detectable and buildable in one change.
186
+ *
187
+ * `str.capitalize()` is NOT `s[0].toUpperCase() + s.slice(1)`: Python lowercases the REST of
188
+ * the string, so a posture file named `Foreman` renders `**Foreman**` and one named `FOREMAN`
189
+ * would be scanned for as `**Foreman**` by both implementations only if this reproduces the
190
+ * lowercasing. Every shipped name is already lowercase, which is exactly why the difference
191
+ * would go unnoticed — the corpus in `tests/test_pure_function_parity.py` is what covers it.
192
+ */
193
+ export function pyCapitalize(s) {
194
+ return s.length ? s[0].toUpperCase() + s.slice(1).toLowerCase() : s;
195
+ }
196
+
197
+ function leadOfDir(d, names) {
198
+ for (const carrier of CARRIERS) {
199
+ const text = readMaybe(path.join(d, carrier));
200
+ if (text === null) continue;
201
+ for (const name of names) {
202
+ if (text.includes(`**${pyCapitalize(name)}**`)) return name;
203
+ }
204
+ }
205
+ return null;
206
+ }
207
+
208
+ export const postureOfDir = (d) => leadOfDir(d, discoverNames('postures', 'peer'));
209
+ export const modeOfDir = (d) => leadOfDir(d, discoverNames('modes', 'direct'));
210
+
211
+ // ---- what host a deployed dir belongs to (`_harness_mcp`) ---------------------------------
212
+
213
+ /** `_harness_mcp._claude_read_manifest`. */
214
+ export function claudeReadManifest(cfgDir) {
215
+ const data = readJsonMaybe(path.join(cfgDir, GLOBAL_MANIFEST));
216
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
217
+ }
218
+
219
+ /**
220
+ * `_harness_mcp._manifest_is_claude` — keyed on the `managed` MAP, not on `claude_md`: a Bob
221
+ * global emit ships no AGENTS.md and records no `claude_md`, and still needs the
222
+ * Claude-style reversal.
223
+ */
224
+ export function manifestIsClaude(cfgDir) {
225
+ const mg = claudeReadManifest(cfgDir).managed;
226
+ return typeof mg === 'object' && mg !== null && !Array.isArray(mg);
227
+ }
228
+
229
+ /** `_harness_mcp._EMIT_HOST_SCOPE` — a marker's emit name fixes (host, scope). */
230
+ export const EMIT_HOST_SCOPE = new Map([
231
+ ['opencode', ['opencode', 'project']], ['opencode-global', ['opencode', 'global']],
232
+ ['claude', ['claude', 'project']], ['claude-global', ['claude', 'global']],
233
+ ['bob', ['bob', 'project']], ['bob-global', ['bob', 'global']],
234
+ ['copilot', ['copilot', 'project']], ['copilot-global', ['copilot', 'global']],
235
+ ]);
236
+
237
+ /** `_harness_mcp._emit_host_scope_of` — `root`'s own marker, resolved, or null. */
238
+ export function emitHostScopeOf(root) {
239
+ const emit = readMaybe(path.join(root, '.geneseed-emit'));
240
+ if (emit === null) return null;
241
+ return EMIT_HOST_SCOPE.get(emit.trim()) ?? null;
242
+ }
243
+
244
+ /** `_harness_mcp.DISABLED_STASH` — a sibling dir whose presence means "disabled". */
245
+ export const DISABLED_STASH = '.geneseed-disabled';
246
+
247
+ /**
248
+ * `_harness_mcp._claude_cfg` — where a Claude-STYLE install keeps its manifest.
249
+ *
250
+ * The config dir itself for a global install; `<repo>/<project_marker>` for a project one.
251
+ * Host-aware so Bob and Copilot ride the whole Claude lifecycle with only the subdir
252
+ * differing, and the marker comes from `HOSTS` rather than a literal for that reason.
253
+ */
254
+ export function claudeCfg(root, scope, host = 'claude') {
255
+ if (scope === 'global') return root;
256
+ const spec = HOSTS.find((h) => h.host === host);
257
+ return path.join(root, spec ? spec.projectMarker : '.claude');
258
+ }
259
+
260
+ /** `_harness_mcp._claude_state`. */
261
+ function claudeState(root, scope = 'global', host = 'claude') {
262
+ const cfg = claudeCfg(root, scope, host);
263
+ if (isDir(path.join(cfg, DISABLED_STASH, host))) return 'disabled';
264
+ return existsSync(path.join(cfg, GLOBAL_MANIFEST)) ? 'active' : 'absent';
265
+ }
266
+
267
+ /** `_harness_mcp._install_kind` — 'global' | 'project' | null. */
268
+ export function installKind(root) {
269
+ if (existsSync(path.join(root, GLOBAL_MANIFEST))) return 'global';
270
+ if (isDir(path.join(root, '.opencode'))) return 'project';
271
+ return null;
272
+ }
273
+
274
+ /** `_harness_mcp._install_state` — 'active' | 'disabled' | 'absent'. */
275
+ export function installState(root, host = 'opencode', scope = 'global') {
276
+ if (['claude', 'bob', 'copilot'].includes(host)) return claudeState(root, scope, host);
277
+ if (isDir(path.join(root, DISABLED_STASH))) return 'disabled';
278
+ return installKind(root) !== null ? 'active' : 'absent';
279
+ }
280
+
281
+ /** `_harness_mcp._registered_targets` — (host, scope, root) for every registered root. */
282
+ export function registeredTargets() {
283
+ const out = [];
284
+ for (const root of registryRoots()) {
285
+ const hs = emitHostScopeOf(root);
286
+ if (hs) out.push([hs[0], hs[1], root]);
287
+ }
288
+ return out;
289
+ }
290
+
291
+ /**
292
+ * `_harness_mcp._install_targets` — every (host, scope, root) an install may live at,
293
+ * most-local first.
294
+ *
295
+ * De-duplicated on the (host, RESOLVED path) pair, which is three behaviours in one rule: a
296
+ * cwd carrying both `.opencode/` and `.claude/` yields two correctly-typed rows, a cwd that
297
+ * IS a global config dir never doubles its own host's row, and a registered root that is also
298
+ * the cwd never doubles either. `pyResolve` rather than `path.resolve` because Python's
299
+ * `Path.resolve()` returns the filesystem's own casing — on Windows two spellings of the same
300
+ * directory differ as strings and not as installs, and this Set is keyed by string.
301
+ */
302
+ export function installTargets() {
303
+ const out = [];
304
+ const seen = new Set();
305
+
306
+ const add = (host, scope, root) => {
307
+ // A "project" whose marker dir IS this host's global config dir is the global install
308
+ // seen from its parent (the daemon's cwd is $HOME, where $HOME/.claude == ~/.claude) —
309
+ // not a separate project. Surfacing it would alias the global's files.
310
+ if (scope !== 'global') {
311
+ try {
312
+ const spec = HOSTS.find((h) => h.host === host);
313
+ if (pyResolve(path.join(root, spec.projectMarker)) === pyResolve(spec.configDir())) {
314
+ return;
315
+ }
316
+ } catch { /* as the Python's bare `except Exception: pass` */ }
317
+ }
318
+ const key = `${host}\0${pyResolve(root)}`;
319
+ if (!seen.has(key)) { seen.add(key); out.push([host, scope, root]); }
320
+ };
321
+
322
+ const cwd = process.cwd();
323
+ for (const { host, projectMarker } of HOSTS) {
324
+ if (isDir(path.join(cwd, projectMarker))) add(host, 'project', cwd);
325
+ }
326
+ for (const { host, configDir } of HOSTS) {
327
+ try { add(host, 'global', configDir()); } catch { /* a missing host dir is not fatal */ }
328
+ }
329
+ for (const [host, scope, root] of registeredTargets()) add(host, scope, root);
330
+ return out;
331
+ }
332
+
333
+ // ---- what this machine installed (`_harness_setup._installed_defaults`) --------------------
334
+
335
+ /**
336
+ * `_harness_setup._installed_defaults` — all five keys, as of P5i.
337
+ *
338
+ * It carried only `theme` and `emit` for four phases because those are the two `status`
339
+ * prints and the two `doctor` reads, and the port's keep-vs-delete rule is "part of an
340
+ * asserted partition", not "the Python has it". `setup` is the caller the debt was recorded
341
+ * against: `collectSetupLines` pre-selects the posture, mode and footprint picker at
342
+ * `inst.posture` / `inst.mode` / `inst.footprint`, so a three-key answer would have silently
343
+ * pre-selected the CONFIGURED default over the DEPLOYED one on three of its five questions.
344
+ * Nothing in `status` or `doctor` moves: both read the keys they already read.
345
+ */
346
+ export function installedDefaults() {
347
+ const found = { theme: null, posture: null, mode: null, emit: null, footprint: null };
348
+ const candidates = [];
349
+ for (const { host, configDir } of HOSTS) {
350
+ try { candidates.push([configDir(), `${host}-global`]); } catch { /* as the Python */ }
351
+ }
352
+ // One cwd-relative bundle candidate — `ROOT / "Harness"` and `ROOT.parent / "Harness"`
353
+ // left with the Python's, see `_harness_setup._installed_defaults` for the argument.
354
+ candidates.push([path.join(process.cwd(), 'Harness'), null]);
355
+ for (const [base, knownEmit] of candidates) {
356
+ if (found.emit === null) {
357
+ const em = path.join(base, '.geneseed-emit');
358
+ if (isFile(em)) {
359
+ found.emit = (readMaybe(em) ?? '').trim() || null;
360
+ } else if (isFile(path.join(base, GLOBAL_MANIFEST))) {
361
+ // A manifest with no emit marker: a known config dir names its own host; a bundle
362
+ // tells Claude from OpenCode by shape.
363
+ found.emit = knownEmit
364
+ ?? (manifestIsClaude(base) ? 'claude-global' : 'opencode-global');
365
+ }
366
+ }
367
+ if (found.theme === null) found.theme = themeOfDir(base) ?? null;
368
+ if (found.posture === null) found.posture = postureOfDir(base) ?? null;
369
+ if (found.mode === null) found.mode = modeOfDir(base) ?? null;
370
+ // `found["footprint"] is None` in the Python, so only the FIRST marker on the walk
371
+ // speaks — and `footprintOfDir` never answers null, which is why a boolean stood in for
372
+ // the value while nothing read it.
373
+ if (found.footprint === null && isFile(path.join(base, '.geneseed-footprint'))) {
374
+ found.footprint = footprintOfDir(base);
375
+ }
376
+ }
377
+ return found;
378
+ }