geneseed 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (337) hide show
  1. package/CHANGELOG.md +938 -0
  2. package/DESIGN.md +1423 -0
  3. package/LICENSE +21 -0
  4. package/QUICKSTART.md +48 -0
  5. package/README.md +336 -0
  6. package/SETUP.md +1027 -0
  7. package/SHIPPED.md +70 -0
  8. package/adapters/bob/README.md +100 -0
  9. package/adapters/claude-code/README.md +101 -0
  10. package/adapters/claude-code/settings.json +47 -0
  11. package/adapters/claude-code/skills/geneseed/SKILL.md +72 -0
  12. package/adapters/copilot/README.md +76 -0
  13. package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +434 -0
  14. package/adapters/opencode/HOW-OPENCODE-LOADS.md +158 -0
  15. package/adapters/opencode/README.md +582 -0
  16. package/adapters/opencode/agents/orchestrator.md +25 -0
  17. package/adapters/opencode/opencode.json +44 -0
  18. package/adapters/opencode/plugins/geneseed-activity.js +430 -0
  19. package/adapters/opencode/plugins/geneseed-context.js +876 -0
  20. package/adapters/opencode/plugins/geneseed-guard.js +290 -0
  21. package/adapters/opencode/plugins/geneseed-learn.js +396 -0
  22. package/adapters/opencode/plugins/geneseed-notify.js +142 -0
  23. package/adapters/opencode/plugins/geneseed-ponytail.js +148 -0
  24. package/adapters/opencode/plugins/geneseed-workflow.js +183 -0
  25. package/adapters/opencode/workflows/_runtime.js +277 -0
  26. package/adapters/opencode/workflows/council.js +64 -0
  27. package/adapters/opencode/workflows/dispatch.js +89 -0
  28. package/adapters/opencode/workflows/research-plan-implement.js +43 -0
  29. package/adapters/opencode/workflows/review.js +57 -0
  30. package/bin/geneseed-cli.mjs +412 -0
  31. package/bin/geneseed-hook.mjs +129 -0
  32. package/bin/geneseed.mjs +1086 -0
  33. package/docs/declined.md +149 -0
  34. package/docs/limits.md +270 -0
  35. package/docs/opencode-plugin-setup.md +64 -0
  36. package/docs/token-footprint.md +68 -0
  37. package/docs/web/_groups.json +45 -0
  38. package/docs/web/about.md +6 -0
  39. package/docs/web/adapters-claude-code.md +8 -0
  40. package/docs/web/adapters-opencode-loads.md +8 -0
  41. package/docs/web/adapters-opencode-spec.md +8 -0
  42. package/docs/web/adapters-opencode.md +8 -0
  43. package/docs/web/agents.md +8 -0
  44. package/docs/web/authoring.md +11 -0
  45. package/docs/web/autostart.md +9 -0
  46. package/docs/web/claude-hooks.md +36 -0
  47. package/docs/web/cli.md +6 -0
  48. package/docs/web/collaboration.md +27 -0
  49. package/docs/web/configure-wiki.md +9 -0
  50. package/docs/web/design.md +7 -0
  51. package/docs/web/env-knobs.md +9 -0
  52. package/docs/web/exclusions.md +26 -0
  53. package/docs/web/first-session.md +28 -0
  54. package/docs/web/footprint.md +31 -0
  55. package/docs/web/foreman-mode.md +33 -0
  56. package/docs/web/glossary.md +6 -0
  57. package/docs/web/harnesses.md +28 -0
  58. package/docs/web/headless.md +9 -0
  59. package/docs/web/ingest-docs.md +9 -0
  60. package/docs/web/install-paths.md +9 -0
  61. package/docs/web/install-quick.md +35 -0
  62. package/docs/web/lsp-overview.md +37 -0
  63. package/docs/web/mcp-claude-code.md +10 -0
  64. package/docs/web/mcp-filesystem.md +9 -0
  65. package/docs/web/mcp-gitlab.md +9 -0
  66. package/docs/web/mcp-markitdown.md +9 -0
  67. package/docs/web/mcp-overview.md +34 -0
  68. package/docs/web/mcp-trouble.md +9 -0
  69. package/docs/web/mcp-verify.md +9 -0
  70. package/docs/web/memory.md +7 -0
  71. package/docs/web/migrate.md +39 -0
  72. package/docs/web/model.md +20 -0
  73. package/docs/web/notebook.md +7 -0
  74. package/docs/web/pipelines.md +24 -0
  75. package/docs/web/plugin-activity.md +23 -0
  76. package/docs/web/plugin-context.md +26 -0
  77. package/docs/web/plugin-guard.md +24 -0
  78. package/docs/web/plugin-learn.md +27 -0
  79. package/docs/web/plugin-notify.md +24 -0
  80. package/docs/web/plugin-ponytail.md +23 -0
  81. package/docs/web/plugin-setup.md +7 -0
  82. package/docs/web/plugin-workflow.md +24 -0
  83. package/docs/web/plugins.md +17 -0
  84. package/docs/web/project-context.md +9 -0
  85. package/docs/web/rules.md +8 -0
  86. package/docs/web/run-anywhere.md +9 -0
  87. package/docs/web/self-improve.md +8 -0
  88. package/docs/web/setup-choices.md +39 -0
  89. package/docs/web/skills.md +8 -0
  90. package/docs/web/themes-author.md +8 -0
  91. package/docs/web/themes.md +9 -0
  92. package/docs/web/token-footprint.md +7 -0
  93. package/docs/web/trouble.md +38 -0
  94. package/docs/web/upgrade.md +9 -0
  95. package/docs/web/verify.md +30 -0
  96. package/docs/web/wiki.md +7 -0
  97. package/docs/web/worktree.md +10 -0
  98. package/docs/web-ui.md +144 -0
  99. package/docs/wiki.md +43 -0
  100. package/geneseed +39 -0
  101. package/geneseed.cmd +34 -0
  102. package/harness.config.json +6 -0
  103. package/js/anim.mjs +326 -0
  104. package/js/catalog.mjs +112 -0
  105. package/js/checkout.mjs +88 -0
  106. package/js/cli-table.json +1034 -0
  107. package/js/cli.mjs +498 -0
  108. package/js/diff.mjs +384 -0
  109. package/js/doctor.mjs +1364 -0
  110. package/js/emit.mjs +1373 -0
  111. package/js/excludes.mjs +271 -0
  112. package/js/generate.mjs +379 -0
  113. package/js/hooks.mjs +1026 -0
  114. package/js/hosts.mjs +253 -0
  115. package/js/installs.mjs +378 -0
  116. package/js/inventory.mjs +175 -0
  117. package/js/lib/fs.mjs +913 -0
  118. package/js/lib/proc.mjs +28 -0
  119. package/js/lib/udiff.mjs +275 -0
  120. package/js/link.mjs +286 -0
  121. package/js/mcp.mjs +335 -0
  122. package/js/memory.mjs +108 -0
  123. package/js/menu.mjs +118 -0
  124. package/js/migrate.mjs +251 -0
  125. package/js/native.mjs +426 -0
  126. package/js/opencode.mjs +325 -0
  127. package/js/registry.mjs +110 -0
  128. package/js/render.mjs +282 -0
  129. package/js/settings.mjs +924 -0
  130. package/js/setup.mjs +492 -0
  131. package/js/status.mjs +281 -0
  132. package/js/themes.mjs +195 -0
  133. package/js/tui.mjs +444 -0
  134. package/js/uninstall.mjs +1137 -0
  135. package/js/update.mjs +910 -0
  136. package/js/web/actions.mjs +868 -0
  137. package/js/web/activity.mjs +284 -0
  138. package/js/web/api.mjs +873 -0
  139. package/js/web/docs.mjs +531 -0
  140. package/js/web/graph.mjs +0 -0
  141. package/js/web/jobs.mjs +480 -0
  142. package/js/web/server.mjs +1135 -0
  143. package/package.json +54 -0
  144. package/registry.json +474 -0
  145. package/src/AGENT.md.tmpl +390 -0
  146. package/src/agents/_template.md +76 -0
  147. package/src/agents/advocate.md +36 -0
  148. package/src/agents/architect.md +55 -0
  149. package/src/agents/developer.md +66 -0
  150. package/src/agents/docs.md +54 -0
  151. package/src/agents/empiricist.md +39 -0
  152. package/src/agents/explorer.md +54 -0
  153. package/src/agents/framer.md +36 -0
  154. package/src/agents/historian.md +51 -0
  155. package/src/agents/operator.md +37 -0
  156. package/src/agents/pragmatist.md +37 -0
  157. package/src/agents/reviewer.md +52 -0
  158. package/src/agents/security.md +57 -0
  159. package/src/agents/skeptic.md +48 -0
  160. package/src/agents/steward.md +36 -0
  161. package/src/agents/tester.md +54 -0
  162. package/src/agents/user-advocate.md +36 -0
  163. package/src/agents/visionary.md +36 -0
  164. package/src/laws/universal.md +387 -0
  165. package/src/memory/README.md +109 -0
  166. package/src/memory/gitignore +6 -0
  167. package/src/modes/README.md +26 -0
  168. package/src/modes/direct.md +2 -0
  169. package/src/modes/foreman.md +19 -0
  170. package/src/notebook/README.md +39 -0
  171. package/src/notebook/gitignore +7 -0
  172. package/src/postures/README.md +31 -0
  173. package/src/postures/artisan.md +14 -0
  174. package/src/postures/assistant.md +12 -0
  175. package/src/postures/expert.md +11 -0
  176. package/src/postures/mentor.md +11 -0
  177. package/src/postures/peer.md +12 -0
  178. package/src/skills/_template.md +47 -0
  179. package/src/skills/brainstorm.md +28 -0
  180. package/src/skills/clarify.md +28 -0
  181. package/src/skills/codebase-design.md +44 -0
  182. package/src/skills/commit.md +48 -0
  183. package/src/skills/council.md +34 -0
  184. package/src/skills/crash-course.md +28 -0
  185. package/src/skills/daydream/.claude-plugin/plugin.json +9 -0
  186. package/src/skills/daydream/SKILL.md +74 -0
  187. package/src/skills/daydream/VENDOR.md +25 -0
  188. package/src/skills/daydream/critic-prompt.md +38 -0
  189. package/src/skills/daydream/instructions.md +422 -0
  190. package/src/skills/daydream/synthesizer-prompt.md +41 -0
  191. package/src/skills/debug.md +37 -0
  192. package/src/skills/decode.md +28 -0
  193. package/src/skills/develop.md +29 -0
  194. package/src/skills/document-project.md +79 -0
  195. package/src/skills/domain-modeling.md +38 -0
  196. package/src/skills/drill.md +28 -0
  197. package/src/skills/feynman.md +26 -0
  198. package/src/skills/forge-mcp.md +48 -0
  199. package/src/skills/fresh-eyes.md +29 -0
  200. package/src/skills/frontend-design.md +58 -0
  201. package/src/skills/gap-detector.md +27 -0
  202. package/src/skills/geneseed-code-review.md +33 -0
  203. package/src/skills/geneseed.md +29 -0
  204. package/src/skills/git-archaeology.md +47 -0
  205. package/src/skills/git-rescue.md +68 -0
  206. package/src/skills/handoff.md +29 -0
  207. package/src/skills/herdr.md +164 -0
  208. package/src/skills/ingest.md +49 -0
  209. package/src/skills/learning-path.md +26 -0
  210. package/src/skills/migrate.md +45 -0
  211. package/src/skills/opencode-theme.md +52 -0
  212. package/src/skills/parallel-agents.md +28 -0
  213. package/src/skills/pipeline.md +52 -0
  214. package/src/skills/plan.md +45 -0
  215. package/src/skills/ponytail.md +29 -0
  216. package/src/skills/profile.md +58 -0
  217. package/src/skills/prose.md +42 -0
  218. package/src/skills/prototype.md +44 -0
  219. package/src/skills/react-view-transitions/AGENTS.md +955 -0
  220. package/src/skills/react-view-transitions/README.md +42 -0
  221. package/src/skills/react-view-transitions/SKILL.md +320 -0
  222. package/src/skills/react-view-transitions/VENDOR.md +16 -0
  223. package/src/skills/react-view-transitions/metadata.json +12 -0
  224. package/src/skills/react-view-transitions/references/css-recipes.md +242 -0
  225. package/src/skills/react-view-transitions/references/implementation.md +182 -0
  226. package/src/skills/react-view-transitions/references/nextjs.md +176 -0
  227. package/src/skills/react-view-transitions/references/patterns.md +262 -0
  228. package/src/skills/refactor.md +32 -0
  229. package/src/skills/release.md +43 -0
  230. package/src/skills/repo-map.md +36 -0
  231. package/src/skills/research.md +42 -0
  232. package/src/skills/review-response.md +36 -0
  233. package/src/skills/roast-me.md +28 -0
  234. package/src/skills/rule.md +100 -0
  235. package/src/skills/ship.md +49 -0
  236. package/src/skills/tdd.md +31 -0
  237. package/src/skills/tickets.md +44 -0
  238. package/src/skills/token-report/SKILL.md +76 -0
  239. package/src/skills/token-report/VENDOR.md +23 -0
  240. package/src/skills/token-report/scripts/token_report.mjs +1301 -0
  241. package/src/skills/wayfinder.md +49 -0
  242. package/src/skills/wiki.md +68 -0
  243. package/src/skills/workflow.md +38 -0
  244. package/themes/README.md +69 -0
  245. package/themes/_TEMPLATE.json +147 -0
  246. package/themes/biker.json +147 -0
  247. package/themes/commentator.json +147 -0
  248. package/themes/cyberpunk.json +147 -0
  249. package/themes/gamer.json +147 -0
  250. package/themes/imperial.json +147 -0
  251. package/themes/joker.json +147 -0
  252. package/themes/marvin.json +147 -0
  253. package/themes/mean.json +147 -0
  254. package/themes/military.json +147 -0
  255. package/themes/neutral.json +147 -0
  256. package/themes/opencode/README.md +58 -0
  257. package/themes/opencode/catppuccin.json +27 -0
  258. package/themes/opencode/dracula.json +27 -0
  259. package/themes/opencode/everforest.json +27 -0
  260. package/themes/opencode/gruvbox.json +27 -0
  261. package/themes/opencode/kanagawa.json +27 -0
  262. package/themes/opencode/neon.json +27 -0
  263. package/themes/opencode/nord.json +27 -0
  264. package/themes/opencode/rosepine.json +27 -0
  265. package/themes/opencode/tokyonight.json +27 -0
  266. package/themes/pirate.json +147 -0
  267. package/themes/sports.json +147 -0
  268. package/themes/verstappen.json +147 -0
  269. package/themes/wizard.json +147 -0
  270. package/web/dist/assets/About-DX1-VTBP.js +1 -0
  271. package/web/dist/assets/Activity-Dp9NaXOn.js +1 -0
  272. package/web/dist/assets/ActivityDetail-HpOBtrXS.js +1 -0
  273. package/web/dist/assets/Diff-Ct4G3Se8.js +5 -0
  274. package/web/dist/assets/Doctor-DGjkZFuA.js +1 -0
  275. package/web/dist/assets/Graph-b8o-S6kv.js +1 -0
  276. package/web/dist/assets/Harnesses-gr3-aHBF.js +1 -0
  277. package/web/dist/assets/Laws-DTwF5tTC.js +1 -0
  278. package/web/dist/assets/Library-BEZscyaQ.js +1 -0
  279. package/web/dist/assets/Markdown-k_LSLBGq.js +1 -0
  280. package/web/dist/assets/Profile-DydukTxP.js +1 -0
  281. package/web/dist/assets/Rules-CIRG4Ldt.js +1 -0
  282. package/web/dist/assets/Skills-Buoi1IBr.js +1 -0
  283. package/web/dist/assets/StatusBadge-CE_AJttl.js +1 -0
  284. package/web/dist/assets/Themes-CI4o3gJC.js +2 -0
  285. package/web/dist/assets/activity-BCW7e6Cy.js +1 -0
  286. package/web/dist/assets/audiowide-latin-400-normal-6GFCX7ni.woff2 +0 -0
  287. package/web/dist/assets/geist-mono-latin-400-normal-DKaoCDn5.woff2 +0 -0
  288. package/web/dist/assets/geist-mono-latin-500-normal-C3sF8Y1B.woff2 +0 -0
  289. package/web/dist/assets/geist-mono-latin-600-normal-BHzjB6_C.woff2 +0 -0
  290. package/web/dist/assets/hanken-grotesk-latin-400-italic-Bserb8X5.woff2 +0 -0
  291. package/web/dist/assets/hanken-grotesk-latin-400-normal-BG6hkEXj.woff2 +0 -0
  292. package/web/dist/assets/hanken-grotesk-latin-500-italic-BGfwwhek.woff2 +0 -0
  293. package/web/dist/assets/hanken-grotesk-latin-500-normal-DrDcrrxK.woff2 +0 -0
  294. package/web/dist/assets/hanken-grotesk-latin-600-normal-CIXX6EOa.woff2 +0 -0
  295. package/web/dist/assets/hanken-grotesk-latin-700-normal-CeQ8H3UY.woff2 +0 -0
  296. package/web/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  297. package/web/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  298. package/web/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  299. package/web/dist/assets/ibm-plex-serif-latin-400-normal-BIGslYFI.woff2 +0 -0
  300. package/web/dist/assets/ibm-plex-serif-latin-500-normal-REKAfBVG.woff2 +0 -0
  301. package/web/dist/assets/ibm-plex-serif-latin-600-normal-DAxitmSu.woff2 +0 -0
  302. package/web/dist/assets/index-B1PIx7RM.js +1 -0
  303. package/web/dist/assets/index-BnTQUSUz.js +12 -0
  304. package/web/dist/assets/index-Ck2iCdaC.js +2 -0
  305. package/web/dist/assets/index-DUCsDv7z.css +1 -0
  306. package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  307. package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  308. package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  309. package/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  310. package/web/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  311. package/web/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  312. package/web/dist/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
  313. package/web/dist/assets/marked.esm-JKvyS0Pp.js +46 -0
  314. package/web/dist/assets/oswald-latin-400-normal-BAFo8sP8.woff2 +0 -0
  315. package/web/dist/assets/oswald-latin-500-normal-B-eyT0Dw.woff2 +0 -0
  316. package/web/dist/assets/oswald-latin-600-normal-plFmSr5g.woff2 +0 -0
  317. package/web/dist/assets/oswald-latin-700-normal-vy6hmKSr.woff2 +0 -0
  318. package/web/dist/assets/poppins-latin-400-normal-cpxAROuN.woff2 +0 -0
  319. package/web/dist/assets/poppins-latin-500-normal-C8OXljZJ.woff2 +0 -0
  320. package/web/dist/assets/poppins-latin-600-normal-zEkxB9Mr.woff2 +0 -0
  321. package/web/dist/assets/poppins-latin-700-normal-Qrb0O0WB.woff2 +0 -0
  322. package/web/dist/assets/react-DOpiTn5v.js +32 -0
  323. package/web/dist/assets/roman-DkYN0LX9.js +1 -0
  324. package/web/dist/assets/space-grotesk-latin-400-normal-CJ-V5oYT.woff2 +0 -0
  325. package/web/dist/assets/space-grotesk-latin-500-normal-lFbtlQH6.woff2 +0 -0
  326. package/web/dist/assets/space-grotesk-latin-600-normal-DjKNqYRj.woff2 +0 -0
  327. package/web/dist/assets/space-grotesk-latin-700-normal-RjhwGPKo.woff2 +0 -0
  328. package/web/dist/assets/space-mono-latin-400-italic-YylcN9Ay.woff2 +0 -0
  329. package/web/dist/assets/space-mono-latin-400-normal-Rg4St2Dn.woff2 +0 -0
  330. package/web/dist/assets/space-mono-latin-700-italic-vNvENeTh.woff2 +0 -0
  331. package/web/dist/assets/space-mono-latin-700-normal-mWgeinG7.woff2 +0 -0
  332. package/web/dist/icon-maskable.svg +10 -0
  333. package/web/dist/icon.svg +8 -0
  334. package/web/dist/index.html +21 -0
  335. package/web/dist/manifest.webmanifest +14 -0
  336. package/web/dist/sw.js +40 -0
  337. package/web/src/pages/Laws.jsx +230 -0
@@ -0,0 +1,876 @@
1
+ // Geneseed — OpenCode project-context plugin (v2, convention-glob).
2
+ //
3
+ // Enforces Law XVIII ("Load the Project Context") by INJECTION, not instruction:
4
+ // on `session.created` it puts the repo's documentation in context before your
5
+ // first turn, rather than trusting the agent to read it. v2 removes the need for a
6
+ // committed `context.json` — it AUTO-DISCOVERS the current repo's docs by
7
+ // convention (so the harness can live entirely in the global config dir, with zero
8
+ // per-repo files), while still honouring an explicit manifest when one exists.
9
+ //
10
+ // Source resolution (first match wins):
11
+ // 1. $GENESEED_CONTEXT explicit manifest path -> declarative mode
12
+ // 2. <repo>/.harness/context.json per-repo override -> declarative mode
13
+ // 3. <repo>/context.json legacy per-repo manifest -> declarative mode
14
+ // 4. (none) -> AUTO-DISCOVERY (convention glob)
15
+ // A manifest may set "extend": true to run auto-discovery first, then add/override.
16
+ //
17
+ // Eager docs are injected in full (budget-capped, oversized ones demoted to lazy);
18
+ // lazy docs are only listed (path + first heading) to be read on demand. Output
19
+ // mirrors the harness's own `context` verb. Every error is swallowed — it never blocks
20
+ // a session. See adapters/opencode/GLOBAL-HARNESS-SPEC.md.
21
+ //
22
+ // The block also carries two best-effort self-orientation lines:
23
+ // - COMMANDS: the repo's runnable targets (Makefile / package.json scripts /
24
+ // justfile / Taskfile), so the agent invokes real entry points, not guesses.
25
+ // - MODEL: the model the session is using (read from the transcript, else
26
+ // $GENESEED_MODEL), so the agent reasons within its real limits. The model line
27
+ // is added per request, outside the cached block; unknown -> omitted.
28
+ //
29
+ // MACHINE WIKI (AGENT.md §7): the same block also carries the user's own knowledge
30
+ // base(s) — typically an Obsidian vault — declared once per machine in `wiki.jsonc`
31
+ // ($GENESEED_WIKI -> $GENESEED_HARNESS/wiki.jsonc -> beside this plugin's install).
32
+ // Each wiki's eager entries inject in full and lazy entries list, drawing on the
33
+ // SAME budgets as the project context; its conventions/inbox/protected metadata is
34
+ // surfaced so the agent knows the house rules before writing. No wiki.jsonc, or an
35
+ // empty `wikis` list, costs nothing.
36
+ //
37
+ // DELIVERY (invisible by default): the context rides into each request's message
38
+ // array via `experimental.chat.messages.transform` — nothing shows in the
39
+ // conversation, and it survives compaction inherently (re-sent per request). The
40
+ // hook is experimental; on a build that lacks it the plugin notices (a request
41
+ // completes without the hook ever firing) and FALLS BACK to the classic visible
42
+ // delivery — a `session.created` noReply message — so no build is left without
43
+ // context. GENESEED_CONTEXT_VISIBLE=1 forces the visible block up front (legacy
44
+ // GENESEED_CONTEXT_TRANSFORM=0/off does the same; =1 matches the default).
45
+ //
46
+ // Quiet: by default it logs NOTHING (OpenCode shows a plugin's stderr as red text).
47
+ // GENESEED_DEBUG=1 re-enables discovery/inject logs. GENESEED_CONTEXT_INJECT=off
48
+ // disables injection entirely — no PROJECT CONTEXT in any form — leaving project
49
+ // context to the AGENT.md Law (soft, agent-discipline) instead of injection.
50
+ //
51
+ // Install: copy into ~/.config/opencode/plugins/ (global) — `build --emit
52
+ // opencode-global` and `--emit opencode` both place it for you. Keep ONE copy:
53
+ // OpenCode dedups plugins by npm name+version only, so two local copies both load.
54
+
55
+ import { promises as fs } from "node:fs"
56
+ import * as path from "node:path"
57
+ import { fileURLToPath } from "node:url"
58
+ import { homedir } from "node:os"
59
+
60
+ const MARKER = "<!-- geneseed-context:v2 -->"
61
+ const PLUGIN_DIR = path.dirname(fileURLToPath(import.meta.url))
62
+
63
+ // Sovereign-repo bypass — twin of the harness's own `sovereignBypass()`.
64
+ // <cfg>/excludes.json (user-owned, managed by `harness exclude`) lists folders where
65
+ // this GLOBAL install goes dormant. Any error degrades to "not excluded".
66
+ const norm = (p) => {
67
+ // Expand a leading "~" / "~/…" to the home dir — a hand-edited excludes.json entry
68
+ // like "~/vault" must resolve the same way here as it does for the Claude/Bob
69
+ // hook guard.
70
+ let raw = String(p)
71
+ if (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\")) {
72
+ raw = raw === "~" ? homedir() : path.join(homedir(), raw.slice(2))
73
+ }
74
+ let s = path.resolve(raw)
75
+ return process.platform === "win32" ? s.toLowerCase() : s
76
+ }
77
+ async function sovereignBypass(cwd) {
78
+ try {
79
+ const cfg = path.dirname(PLUGIN_DIR) // plugins/ sits directly in <cfg>
80
+ const raw = await fs.readFile(path.join(cfg, "excludes.json"), "utf8")
81
+ const entries = (JSON.parse(raw).excludes) || []
82
+ const here = norm(cwd || process.cwd())
83
+ for (const e of entries) {
84
+ const p = typeof e === "string" ? e : e && e.path
85
+ if (typeof p !== "string" || !p.trim()) continue
86
+ const base = norm(p.trim()).replace(/[\\/]+$/, "")
87
+ if (here === base || here.startsWith(base + path.sep)) return true
88
+ }
89
+ } catch { /* degrade to active */ }
90
+ return false
91
+ }
92
+
93
+ // ---- tunable budgets (env-overridable) -------------------------------------
94
+ // Garbage in the env var must fall back to the default, not poison the budget as
95
+ // NaN — every `>` comparison against NaN is false, which silently DISABLES the
96
+ // demotion checks and injects oversized docs unbounded on every request.
97
+ const envNum = (name, dflt) => {
98
+ const n = Number(process.env[name])
99
+ return Number.isFinite(n) && n > 0 ? n : dflt
100
+ }
101
+ const EAGER_FILE_KB = envNum("GENESEED_EAGER_FILE_KB", 16)
102
+ const EAGER_TOTAL_KB = envNum("GENESEED_EAGER_TOTAL_KB", 48)
103
+ const MAX_FILES_SCANNED = 2000
104
+ const MAX_DEPTH = 6
105
+ // Bounds on lazy-listing cost: read at most this many headings per session, and only
106
+ // the head of each file (enough for an H1) rather than the whole thing — a large
107
+ // docs/ tree must not cost one full-file read per entry on every session start.
108
+ const LAZY_HEADING_LIMIT = envNum("GENESEED_LAZY_HEADINGS", 64)
109
+ const HEADING_SLICE_BYTES = 4096
110
+
111
+ // Quiet by default — OpenCode surfaces a plugin's stderr in the UI (red text). Set
112
+ // GENESEED_DEBUG=1 to see discovery/inject logs. Set GENESEED_CONTEXT_INJECT=off to
113
+ // disable injection entirely and lean on the AGENT.md project-context Law instead
114
+ // (no PROJECT CONTEXT delivered at all; enforcement becomes soft/agent-discipline).
115
+ const DEBUG = !!process.env.GENESEED_DEBUG
116
+ const INJECT_OFF = ["off", "0", "false", "no"].includes(
117
+ (process.env.GENESEED_CONTEXT_INJECT || "on").toLowerCase())
118
+ // Delivery — INVISIBLE by default: the context is prepended to each request's message
119
+ // array via `experimental.chat.messages.transform`, so no PROJECT CONTEXT block shows
120
+ // in the conversation and compaction survival is inherent (re-sent per request).
121
+ // GENESEED_CONTEXT_VISIBLE=1 forces the classic visible session.created message
122
+ // instead; legacy GENESEED_CONTEXT_TRANSFORM=0/off is honoured the same way (=1, the
123
+ // old opt-in, now matches the default). The transform hook is experimental — when a
124
+ // build lacks it the event handler detects that and engages the visible fallback, so
125
+ // no build is left without context.
126
+ const VISIBLE =
127
+ ["1", "on", "true", "yes"].includes(
128
+ (process.env.GENESEED_CONTEXT_VISIBLE || "").toLowerCase()) ||
129
+ ["0", "off", "false", "no"].includes(
130
+ (process.env.GENESEED_CONTEXT_TRANSFORM || "").toLowerCase())
131
+ const TRANSFORM = !VISIBLE
132
+ // Fallback state (module-level — OpenCode keeps ONE copy of the plugin loaded):
133
+ // `transformSeen` flips the first time the experimental hook fires; if a request
134
+ // completes (session.idle) while it is still false, the build lacks the hook and
135
+ // `fallbackVisible` switches delivery to the classic visible injection.
136
+ let transformSeen = false
137
+ let fallbackVisible = false
138
+ function log(msg) { if (DEBUG) console.error(`[geneseed-context] ${msg}`) }
139
+
140
+ // ---- convention --------------------------------------------------------------
141
+ // Root-level files injected in full. Agent-directed rules + canonical entry docs.
142
+ const EAGER_ROOT = new Set([
143
+ "AGENTS.md", "AGENT.md", "CLAUDE.md", ".cursorrules",
144
+ "README.md", "CONTRIBUTING.md", "user-rules.md", "PROFILE.md",
145
+ ])
146
+ // Doc trees walked recursively; everything found is lazy (listed, not injected).
147
+ const LAZY_DIRS = ["docs", "doc", "documentation", "architecture", "adr", "ADR"]
148
+ // Never descend into these.
149
+ const EXCLUDE_DIRS = new Set([
150
+ "node_modules", ".git", "dist", "build", "vendor", ".next", "target",
151
+ ".venv", "__pycache__", ".opencode", ".harness",
152
+ ])
153
+
154
+ async function isFile(p) { try { return (await fs.stat(p)).isFile() } catch { return false } }
155
+ async function isDir(p) { try { return (await fs.stat(p)).isDirectory() } catch { return false } }
156
+
157
+ async function readJson(p) {
158
+ try { return JSON.parse(await fs.readFile(p, "utf8")) } catch { return null }
159
+ }
160
+
161
+ // Strip JSONC niceties — // and /* */ comments plus trailing commas — string-aware,
162
+ // so a "https://…" or "C:/Users/…" inside quotes is untouched. wiki.jsonc is seeded
163
+ // with a commented example, so its readers must tolerate comments.
164
+ function stripJsonc(text) {
165
+ let out = "", inStr = false, esc = false
166
+ for (let i = 0; i < text.length; i++) {
167
+ const c = text[i]
168
+ if (inStr) {
169
+ out += c
170
+ if (esc) esc = false
171
+ else if (c === "\\") esc = true
172
+ else if (c === '"') inStr = false
173
+ continue
174
+ }
175
+ if (c === '"') { inStr = true; out += c; continue }
176
+ if (c === "/" && text[i + 1] === "/") { while (i < text.length && text[i] !== "\n") i++; out += "\n"; continue }
177
+ if (c === "/" && text[i + 1] === "*") { i += 2; while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++; i++; continue }
178
+ out += c
179
+ }
180
+ let res = ""
181
+ inStr = false; esc = false
182
+ for (let i = 0; i < out.length; i++) {
183
+ const c = out[i]
184
+ if (inStr) {
185
+ res += c
186
+ if (esc) esc = false
187
+ else if (c === "\\") esc = true
188
+ else if (c === '"') inStr = false
189
+ continue
190
+ }
191
+ if (c === '"') { inStr = true; res += c; continue }
192
+ if (c === ",") {
193
+ let j = i + 1
194
+ while (j < out.length && /\s/.test(out[j])) j++
195
+ if (out[j] === "]" || out[j] === "}") continue
196
+ }
197
+ res += c
198
+ }
199
+ return res
200
+ }
201
+
202
+ async function readJsonc(p) {
203
+ try { return JSON.parse(stripJsonc(await fs.readFile(p, "utf8"))) } catch { return null }
204
+ }
205
+
206
+ // Minimal glob -> RegExp: ** spans path separators, * does not.
207
+ function globToRegExp(glob) {
208
+ let re = ""
209
+ for (let i = 0; i < glob.length; i++) {
210
+ const c = glob[i]
211
+ if (c === "*") {
212
+ if (glob[i + 1] === "*") { re += ".*"; i++ } else { re += "[^/]*" }
213
+ } else if ("\\^$+?.()|[]{}".includes(c)) {
214
+ re += "\\" + c
215
+ } else {
216
+ re += c
217
+ }
218
+ }
219
+ return new RegExp("^" + re + "$")
220
+ }
221
+
222
+ // First H1 / heading line of a markdown file, for the lazy listing.
223
+ function firstHeading(text) {
224
+ for (const line of text.split("\n")) {
225
+ const s = line.trim()
226
+ if (s.startsWith("#")) return s.replace(/^#+\s*/, "").trim()
227
+ }
228
+ return ""
229
+ }
230
+
231
+ // Read only the head of a file (enough to find an H1) instead of the whole thing —
232
+ // bounds per-file cost when listing a large docs/ tree.
233
+ async function readHeadSlice(p, max = HEADING_SLICE_BYTES) {
234
+ let fh
235
+ try {
236
+ fh = await fs.open(p, "r")
237
+ const buf = Buffer.alloc(max)
238
+ const { bytesRead } = await fh.read(buf, 0, max, 0)
239
+ return buf.subarray(0, bytesRead).toString("utf8")
240
+ } catch {
241
+ return ""
242
+ } finally {
243
+ if (fh) await fh.close().catch(() => {})
244
+ }
245
+ }
246
+
247
+ function kb(bytes) { return (bytes / 1024).toFixed(0) }
248
+
249
+ function repoRoot(ctx) {
250
+ return ctx?.worktree || ctx?.directory || process.cwd()
251
+ }
252
+
253
+ // ---- model self-awareness (best-effort) --------------------------------------
254
+ // Surface the model the session is actually using so the agent reasons within its
255
+ // real limits (awesome-opencode "Model Announcer"). Sources, first hit wins: an
256
+ // assistant message's info.providerID/modelID in the outgoing transcript — the same
257
+ // fields the learn plugin reads — then $GENESEED_MODEL ("provider/model"). Unknown ->
258
+ // no line (graceful: the first turn often has no assistant message yet). It is added
259
+ // per request, OUTSIDE the cached block, so one cache serves every model.
260
+ function modelFromMessages(messages) {
261
+ if (!Array.isArray(messages)) return null
262
+ let found = null
263
+ for (const m of messages) {
264
+ const info = m?.info ?? {}
265
+ if (info.role !== "assistant") continue
266
+ const providerID = info.providerID ?? info.provider
267
+ const modelID = info.modelID ?? info.model
268
+ if (providerID && modelID) found = `${providerID}/${modelID}` // last wins (latest turn)
269
+ }
270
+ return found
271
+ }
272
+ function envModelStr() {
273
+ const v = process.env.GENESEED_MODEL
274
+ return v && v.includes("/") ? v : null
275
+ }
276
+ // Insert the model line just after the MARKER (first line) of an already-rendered
277
+ // block, leaving the model-agnostic cache untouched.
278
+ function withModel(text, model) {
279
+ if (!model || !text) return text
280
+ const nl = text.indexOf("\n")
281
+ if (nl === -1) return text
282
+ const line = `current model: ${model} — reason within this model's real limits`
283
+ return `${text.slice(0, nl + 1)}${line}\n${text.slice(nl + 1)}`
284
+ }
285
+
286
+ // ---- command discovery -------------------------------------------------------
287
+ // List the project's runnable command targets (awesome-opencode "Command Inject")
288
+ // so the agent invokes the repo's real entry points instead of guessing — Makefile
289
+ // targets, package.json scripts (with the right runner per lockfile), justfile
290
+ // recipes, Taskfile tasks. Root-level only, best-effort parse, capped. Each group is
291
+ // one rendered line: "make — build, test, lint".
292
+ const COMMANDS_CAP = 40
293
+ async function discoverCommands(root) {
294
+ const groups = []
295
+ const readSafe = async (p) => { try { return await fs.readFile(p, "utf8") } catch { return null } }
296
+
297
+ for (const name of ["Makefile", "makefile", "GNUmakefile"]) {
298
+ const text = await readSafe(path.join(root, name))
299
+ if (text == null) continue
300
+ const targets = []
301
+ for (const line of text.split("\n")) {
302
+ const m = /^([a-zA-Z0-9][\w.-]*)\s*:(?!=)/.exec(line) // target: (not := assignment)
303
+ if (m && !m[1].startsWith(".") && !line.includes("%") && !targets.includes(m[1])) targets.push(m[1])
304
+ if (targets.length >= COMMANDS_CAP) break
305
+ }
306
+ if (targets.length) groups.push(`make — ${targets.join(", ")}`)
307
+ break
308
+ }
309
+
310
+ const pkgText = await readSafe(path.join(root, "package.json"))
311
+ if (pkgText) {
312
+ try {
313
+ const pkg = JSON.parse(pkgText)
314
+ const scripts = pkg && typeof pkg.scripts === "object" ? Object.keys(pkg.scripts) : []
315
+ if (scripts.length) {
316
+ let mgr = "npm run"
317
+ if (await isFile(path.join(root, "bun.lockb"))) mgr = "bun run"
318
+ else if (await isFile(path.join(root, "pnpm-lock.yaml"))) mgr = "pnpm"
319
+ else if (await isFile(path.join(root, "yarn.lock"))) mgr = "yarn"
320
+ groups.push(`${mgr} — ${scripts.slice(0, COMMANDS_CAP).join(", ")}`)
321
+ }
322
+ } catch {}
323
+ }
324
+
325
+ for (const name of ["justfile", "Justfile", ".justfile"]) {
326
+ const text = await readSafe(path.join(root, name))
327
+ if (text == null) continue
328
+ const recipes = []
329
+ for (const line of text.split("\n")) {
330
+ const m = /^([a-zA-Z0-9][\w-]*)(?:[ \t][^\n:]*)?:(?!=)/.exec(line) // recipe name [args]:
331
+ if (m && !recipes.includes(m[1])) recipes.push(m[1])
332
+ if (recipes.length >= COMMANDS_CAP) break
333
+ }
334
+ if (recipes.length) groups.push(`just — ${recipes.join(", ")}`)
335
+ break
336
+ }
337
+
338
+ for (const name of ["Taskfile.yml", "Taskfile.yaml"]) {
339
+ const text = await readSafe(path.join(root, name))
340
+ if (text == null) continue
341
+ const tasks = []
342
+ let inTasks = false
343
+ for (const line of text.split("\n")) {
344
+ if (/^tasks:\s*$/.test(line)) { inTasks = true; continue }
345
+ if (inTasks) {
346
+ if (/^\S/.test(line)) break // dedented to a new top-level key
347
+ const m = /^\s{2}([a-zA-Z0-9][\w:.-]*):/.exec(line) // two-space-indented task key
348
+ if (m && !tasks.includes(m[1])) tasks.push(m[1])
349
+ }
350
+ if (tasks.length >= COMMANDS_CAP) break
351
+ }
352
+ if (tasks.length) groups.push(`task — ${tasks.join(", ")}`)
353
+ break
354
+ }
355
+
356
+ return groups
357
+ }
358
+
359
+ // ---- discovery ---------------------------------------------------------------
360
+ async function walkMd(dir, root, depth, acc) {
361
+ if (depth > MAX_DEPTH || acc.length >= MAX_FILES_SCANNED) return
362
+ let entries
363
+ try { entries = await fs.readdir(dir, { withFileTypes: true }) } catch { return }
364
+ for (const e of entries) {
365
+ if (acc.length >= MAX_FILES_SCANNED) return
366
+ const full = path.join(dir, e.name)
367
+ if (e.isDirectory()) {
368
+ if (EXCLUDE_DIRS.has(e.name)) continue
369
+ await walkMd(full, root, depth + 1, acc)
370
+ } else if (e.isFile() && e.name.toLowerCase().endsWith(".md")) {
371
+ acc.push(full)
372
+ }
373
+ }
374
+ }
375
+
376
+ // Targeted convention discovery — never walks the whole tree (skips node_modules
377
+ // etc.). Returns { eager:[{rel,abs}], lazy:[{rel,abs}] }, deduped, eager wins.
378
+ async function discover(root) {
379
+ const eager = new Map() // abs -> {rel, abs}
380
+ const lazy = new Map()
381
+ const add = (map, abs) => {
382
+ const rel = path.relative(root, abs).split(path.sep).join("/")
383
+ map.set(abs, { rel, abs })
384
+ }
385
+
386
+ // 1. Root-level files.
387
+ let rootEntries
388
+ try { rootEntries = await fs.readdir(root, { withFileTypes: true }) } catch { rootEntries = [] }
389
+ for (const e of rootEntries) {
390
+ if (!e.isFile()) continue
391
+ const abs = path.join(root, e.name)
392
+ if (EAGER_ROOT.has(e.name)) add(eager, abs)
393
+ else if (e.name.toLowerCase().endsWith(".md")) add(lazy, abs) // misc root .md
394
+ }
395
+
396
+ // 2. Doc trees (recursive) -> lazy.
397
+ for (const d of LAZY_DIRS) {
398
+ const dir = path.join(root, d)
399
+ if (await isDir(dir)) {
400
+ const acc = []
401
+ await walkMd(dir, root, 1, acc)
402
+ for (const abs of acc) if (!eager.has(abs)) add(lazy, abs)
403
+ }
404
+ }
405
+
406
+ // 3. Monorepo package entry docs -> lazy.
407
+ for (const group of ["packages", "apps"]) {
408
+ const base = path.join(root, group)
409
+ if (!(await isDir(base))) continue
410
+ let pkgs
411
+ try { pkgs = await fs.readdir(base, { withFileTypes: true }) } catch { continue }
412
+ for (const p of pkgs) {
413
+ if (!p.isDirectory() || EXCLUDE_DIRS.has(p.name)) continue
414
+ const readme = path.join(base, p.name, "README.md")
415
+ if (await isFile(readme) && !eager.has(readme)) add(lazy, readme)
416
+ }
417
+ }
418
+
419
+ const sortByRel = (a, b) => a.rel.localeCompare(b.rel)
420
+ return {
421
+ eager: [...eager.values()].sort(sortByRel),
422
+ lazy: [...lazy.values()].filter((x) => !eager.has(x.abs)).sort(sortByRel),
423
+ }
424
+ }
425
+
426
+ // ---- declarative manifest (override / extend) --------------------------------
427
+ // Returns { eager, lazy } from a manifest. Each entry: { path, load, description }
428
+ // where load is eager | lazy | exclude and path may be absolute, repo-relative, or
429
+ // a glob (a glob only reclassifies files already discovered). With "extend": true,
430
+ // auto-discovery runs first and entries add to / override / exclude its results;
431
+ // otherwise the manifest is the complete list (legacy semantics).
432
+ async function fromManifest(manifestFile, root) {
433
+ const data = await readJson(manifestFile)
434
+ const entries = Array.isArray(data?.context) ? data.context : []
435
+ const recs = new Map() // abs -> { rel, abs, load, desc }
436
+
437
+ const put = (abs, load, desc) => {
438
+ const rel = path.relative(root, abs).split(path.sep).join("/")
439
+ const prev = recs.get(abs)
440
+ recs.set(abs, { rel, abs, load, desc: desc || prev?.desc || "" })
441
+ }
442
+
443
+ if (data?.extend) {
444
+ const d = await discover(root)
445
+ for (const x of d.eager) put(x.abs, "eager", "")
446
+ for (const x of d.lazy) put(x.abs, "lazy", "")
447
+ }
448
+
449
+ for (const entry of entries) {
450
+ const raw = entry?.path ?? ""
451
+ const load = entry?.load ?? "eager"
452
+ const desc = entry?.description ?? ""
453
+ if (!raw) continue
454
+ if (raw.includes("*")) {
455
+ const re = globToRegExp(raw)
456
+ for (const [abs, x] of recs) if (re.test(x.rel)) put(abs, load, desc)
457
+ continue // a glob reclassifies discovered files; it does not add new ones
458
+ }
459
+ // Relative entry paths resolve against the REPO ROOT (matches the context.json
460
+ // stub's documented "relative to the repo root" — important when the manifest
461
+ // lives in .harness/, whose dir is not the root).
462
+ const abs = path.isAbsolute(raw) ? raw : path.resolve(root, raw)
463
+ put(abs, load, desc)
464
+ }
465
+
466
+ const eager = [], lazy = []
467
+ for (const x of recs.values()) {
468
+ if (x.load === "exclude") continue
469
+ ;(x.load === "lazy" ? lazy : eager).push(x)
470
+ }
471
+ const byRel = (a, b) => a.rel.localeCompare(b.rel)
472
+ return { eager: eager.sort(byRel), lazy: lazy.sort(byRel) }
473
+ }
474
+
475
+ async function resolveSource(root) {
476
+ const explicit = process.env.GENESEED_CONTEXT
477
+ if (explicit && (await isFile(explicit))) return { mode: "manifest", file: explicit }
478
+ for (const rel of [path.join(".harness", "context.json"), "context.json"]) {
479
+ const p = path.join(root, rel)
480
+ if (await isFile(p)) return { mode: "manifest", file: p }
481
+ }
482
+ return { mode: "discover" }
483
+ }
484
+
485
+ // ---- machine wiki (wiki.jsonc) --------------------------------------------------
486
+ // The user's own knowledge base(s) — typically an Obsidian vault — declared once per
487
+ // machine, not per repo (AGENT.md §7). Same injection mechanics as project context,
488
+ // different scope. Resolution (first match wins, mirroring the learn plugin):
489
+ // 1. $GENESEED_WIKI explicit manifest path
490
+ // 2. $GENESEED_HARNESS/wiki.jsonc pinned install dir
491
+ // 3. <plugin dir>/../wiki.jsonc auto-locate: beside the installed AGENT.md
492
+ async function resolveWikiFile() {
493
+ const explicit = process.env.GENESEED_WIKI
494
+ if (explicit && (await isFile(explicit))) return explicit
495
+ const bases = []
496
+ if (process.env.GENESEED_HARNESS) bases.push(process.env.GENESEED_HARNESS)
497
+ bases.push(path.resolve(PLUGIN_DIR, ".."))
498
+ for (const base of bases) {
499
+ // wiki.json is the legacy name from earlier seeds — still honoured.
500
+ for (const name of ["wiki.jsonc", "wiki.json"]) {
501
+ const p = path.join(base, name)
502
+ if (await isFile(p)) return p
503
+ }
504
+ }
505
+ return null
506
+ }
507
+
508
+ // Walk a vault folder for .md notes — like the project walk, but dot-folders are
509
+ // skipped too (.obsidian, .trash — a vault keeps its config and trash inside).
510
+ // Shares the global MAX_DEPTH / MAX_FILES_SCANNED bounds.
511
+ async function walkWikiDir(dir, depth, acc) {
512
+ if (depth > MAX_DEPTH || acc.length >= MAX_FILES_SCANNED) return
513
+ let entries
514
+ try { entries = await fs.readdir(dir, { withFileTypes: true }) } catch { return }
515
+ for (const e of entries) {
516
+ if (acc.length >= MAX_FILES_SCANNED) return
517
+ const full = path.join(dir, e.name)
518
+ if (e.isDirectory()) {
519
+ if (EXCLUDE_DIRS.has(e.name) || e.name.startsWith(".")) continue
520
+ await walkWikiDir(full, depth + 1, acc)
521
+ } else if (e.isFile() && e.name.toLowerCase().endsWith(".md")) {
522
+ acc.push(full)
523
+ }
524
+ }
525
+ }
526
+
527
+ // How many lazy notes one wiki may LIST per session — a `.` (whole-vault) folder
528
+ // entry on a large vault would otherwise inject thousands of listing lines. Beyond
529
+ // the cap the listing truncates with a visible count and the agent explores the
530
+ // folders on demand instead.
531
+ const WIKI_LAZY_LIMIT = Number(process.env.GENESEED_WIKI_LAZY_LIMIT || 200)
532
+
533
+ // Parse wiki.jsonc into renderable wikis: [{ name, root, desc, conventions, inbox,
534
+ // protected, eager:[{rel,abs,desc}], lazy:[...], truncated }]. Entry paths resolve
535
+ // against the wiki's own root and may name a single note OR a folder — a folder
536
+ // applies its load mode to every note beneath it (`.` covers the whole vault).
537
+ // Folders are processed first so a specific file entry always overrides the mode
538
+ // its folder gave it, regardless of manifest order; within each class, later
539
+ // entries win; `load: "exclude"` prunes. A missing root skips that wiki (never
540
+ // blocks the session).
541
+ async function wikiSets() {
542
+ const file = await resolveWikiFile()
543
+ if (!file) return []
544
+ const data = await readJsonc(file) // wiki.jsonc is JSONC: stub ships commented
545
+ const wikis = Array.isArray(data?.wikis) ? data.wikis : []
546
+ const out = []
547
+ for (const w of wikis) {
548
+ if (!w?.path) continue
549
+ if (!(await isDir(w.path))) { log(`wiki '${w.name ?? w.path}': root not found, skipped`); continue }
550
+ const name = w.name || path.basename(w.path)
551
+
552
+ const recs = new Map() // abs -> { rel, abs, load, desc }
553
+ const put = (abs, load, desc) => {
554
+ let rel = path.relative(w.path, abs).split(path.sep).join("/")
555
+ if (rel.startsWith("..")) rel = abs.split(path.sep).join("/") // outside the root
556
+ const prev = recs.get(abs)
557
+ recs.set(abs, { rel: `${name}/${rel}`, abs, load, desc: desc || prev?.desc || "" })
558
+ }
559
+
560
+ const resolved = []
561
+ for (const e of Array.isArray(w.entries) ? w.entries : []) {
562
+ if (!e?.path) continue
563
+ const abs = path.isAbsolute(e.path) ? e.path : path.resolve(w.path, e.path)
564
+ const load = e.load === "lazy" || e.load === "exclude" ? e.load : "eager"
565
+ resolved.push({ abs, load, desc: e.description || "", dir: await isDir(abs) })
566
+ }
567
+ for (const folderPhase of [true, false]) { // folders first, then files
568
+ for (const e of resolved) {
569
+ if (e.dir !== folderPhase) continue
570
+ if (e.dir) {
571
+ const acc = []
572
+ await walkWikiDir(e.abs, 1, acc)
573
+ for (const f of acc) e.load === "exclude" ? recs.delete(f) : put(f, e.load, "")
574
+ } else {
575
+ e.load === "exclude" ? recs.delete(e.abs) : put(e.abs, e.load, e.desc)
576
+ }
577
+ }
578
+ }
579
+
580
+ const byRel = (a, b) => a.rel.localeCompare(b.rel)
581
+ const eager = [], lazy = []
582
+ for (const r of recs.values()) (r.load === "lazy" ? lazy : eager).push(r)
583
+ eager.sort(byRel)
584
+ lazy.sort(byRel)
585
+ let truncated = 0
586
+ if (lazy.length > WIKI_LAZY_LIMIT) {
587
+ truncated = lazy.length - WIKI_LAZY_LIMIT
588
+ lazy.length = WIKI_LAZY_LIMIT
589
+ log(`wiki '${name}': lazy listing truncated (${truncated} beyond the ${WIKI_LAZY_LIMIT} cap)`)
590
+ }
591
+ out.push({
592
+ name, root: w.path, desc: w.description || "",
593
+ conventions: w.conventions || "", inbox: w.inbox || "",
594
+ protected: Array.isArray(w.protected) ? w.protected : [],
595
+ eager, lazy, truncated,
596
+ })
597
+ }
598
+ return out
599
+ }
600
+
601
+ // ---- injection block ---------------------------------------------------------
602
+ // Render one eager+lazy set into `out`. The budget state (eager bytes, heading
603
+ // reads) is SHARED across segments — project context and every wiki draw from the
604
+ // same caps, so the whole block stays bounded no matter how many wikis are declared.
605
+ async function renderSet(out, { eager, lazy }, state) {
606
+ const perFile = EAGER_FILE_KB * 1024
607
+ const total = EAGER_TOTAL_KB * 1024
608
+ const demoted = []
609
+
610
+ for (const e of eager) {
611
+ let text
612
+ try { text = await fs.readFile(e.abs, "utf8") } catch (err) {
613
+ out.push(`----- ${e.rel}${e.desc ? ` — ${e.desc}` : ""} -----`)
614
+ out.push(`[context] MISSING eager file: ${err?.message ?? err}`, "")
615
+ continue
616
+ }
617
+ const size = Buffer.byteLength(text, "utf8")
618
+ if (size > perFile) {
619
+ demoted.push(`[demoted: ${e.rel} exceeded ${EAGER_FILE_KB} KB — read on demand]`)
620
+ log(`demoted ${e.rel} -> lazy (${kb(size)} KB > ${EAGER_FILE_KB} KB cap)`)
621
+ continue
622
+ }
623
+ if (state.spent + size > total) {
624
+ demoted.push(`[demoted: ${e.rel} — eager budget (${EAGER_TOTAL_KB} KB) reached]`)
625
+ continue
626
+ }
627
+ out.push(`----- ${e.rel}${e.desc ? ` — ${e.desc}` : ""} -----`)
628
+ out.push(text.replace(/\n+$/, ""), "")
629
+ state.spent += size
630
+ state.injected++
631
+ }
632
+
633
+ const lazyLines = []
634
+ for (const l of lazy) {
635
+ let head = l.desc
636
+ // Only crack open files we have no description for, and cap how many — bounded by
637
+ // a head-slice read so a big docs/ tree stays cheap on every session start.
638
+ if (!head && state.headingsRead < LAZY_HEADING_LIMIT) {
639
+ head = firstHeading(await readHeadSlice(l.abs))
640
+ state.headingsRead++
641
+ }
642
+ lazyLines.push(` - ${l.rel}${head ? ` — ${head}` : ""}`)
643
+ }
644
+ if (lazyLines.length || demoted.length) {
645
+ out.push("--- Lazy entries (load only when the task needs them) ---")
646
+ out.push(...lazyLines)
647
+ if (demoted.length) out.push(...demoted.map((d) => ` ${d}`))
648
+ out.push("")
649
+ }
650
+ state.lazy += lazyLines.length
651
+ }
652
+
653
+ async function buildBlock(sets, wikis = [], commands = []) {
654
+ const state = { spent: 0, injected: 0, lazy: 0, headingsRead: 0 }
655
+
656
+ const proj = []
657
+ await renderSet(proj, sets, state)
658
+
659
+ if (commands.length) {
660
+ proj.push("--- Project commands (runnable targets discovered in the repo root) ---")
661
+ for (const g of commands) proj.push(` ${g}`)
662
+ proj.push("")
663
+ }
664
+
665
+ const wik = []
666
+ for (const w of wikis) {
667
+ wik.push(`--- wiki: ${w.name}${w.desc ? ` — ${w.desc}` : ""} (root: ${w.root}) ---`)
668
+ if (w.conventions) wik.push(` conventions: ${w.conventions} (read before your first write there)`)
669
+ if (w.inbox) wik.push(` inbox: ${w.inbox} (drop notes you cannot confidently file)`)
670
+ if (w.protected.length) wik.push(` protected (never write): ${w.protected.join(", ")}`)
671
+ wik.push("")
672
+ await renderSet(wik, { eager: w.eager, lazy: w.lazy }, state)
673
+ if (w.truncated) wik.push(` [+${w.truncated} more notes in this wiki — explore its folders on demand]`, "")
674
+ }
675
+
676
+ if (!proj.length && !wik.length) return null
677
+ const out = [MARKER]
678
+ if (proj.length) out.push("=== PROJECT CONTEXT — binding for this repo per Law XVIII ===", "", ...proj)
679
+ if (wik.length) out.push("=== MACHINE WIKI — the user's knowledge base, binding per AGENT.md §7 ===", "", ...wik)
680
+ return { text: out.join("\n"), injected: state.injected, lazy: state.lazy, kb: kb(state.spent) }
681
+ }
682
+
683
+ // Resolve the eager/lazy sets (manifest or auto-discovery) plus the machine wikis,
684
+ // and render the injection block — shared by the session.created injection and the
685
+ // compaction hook (the transform path reuses it via cachedBlockText).
686
+ async function resolveBlock(root) {
687
+ const src = await resolveSource(root)
688
+ const sets = src.mode === "manifest"
689
+ ? await fromManifest(src.file, root)
690
+ : await discover(root)
691
+ const wikis = await wikiSets()
692
+ const commands = await discoverCommands(root)
693
+ return { block: await buildBlock(sets, wikis, commands), src }
694
+ }
695
+
696
+ // Per-process cache of the rendered block text for the transform path (root is fixed
697
+ // per plugin instance). Recomputed at most once per TTL so doc edits are picked up.
698
+ let _blockCache = { text: null, at: 0 }
699
+ const BLOCK_TTL_MS = 30000
700
+ async function cachedBlockText(root) {
701
+ const now = Date.now()
702
+ if (_blockCache.text !== null && now - _blockCache.at < BLOCK_TTL_MS) return _blockCache.text
703
+ let text = ""
704
+ try { const { block } = await resolveBlock(root); text = block?.text || "" } catch {}
705
+ _blockCache = { text, at: now }
706
+ return text
707
+ }
708
+
709
+ // ---- cross-instance idempotency ----------------------------------------------
710
+ async function alreadyInjected(client, sid) {
711
+ try {
712
+ const msgs = await client.session.messages({ path: { id: sid } })
713
+ const arr = Array.isArray(msgs) ? msgs : (msgs?.data ?? [])
714
+ for (const m of arr) {
715
+ const parts = m?.parts ?? m?.info?.parts ?? []
716
+ for (const p of parts) {
717
+ if (typeof p?.text === "string" && p.text.includes(MARKER)) return true
718
+ }
719
+ }
720
+ } catch {}
721
+ return false
722
+ }
723
+
724
+ export const GeneseedContext = async (ctx) => {
725
+ const { client } = ctx
726
+ const done = new Set()
727
+ const root = repoRoot(ctx)
728
+
729
+ // Visible delivery — post the block as a noReply session message. Used when
730
+ // GENESEED_CONTEXT_VISIBLE forces it, and by the transform fallback below.
731
+ async function injectVisible(sid) {
732
+ if (!sid || done.has(sid)) return
733
+ try {
734
+ // Don't pollute the learn plugin's throwaway distil sessions, and don't
735
+ // re-inject into native subagent child sessions (they inherit the parent's
736
+ // context). The sid is only marked done AFTER the guards pass — marking it
737
+ // earlier would permanently skip a session when a transient session.get
738
+ // failure let it through the title check.
739
+ let title = "", parent
740
+ try {
741
+ const info = await client.session.get({ path: { id: sid } })
742
+ const meta = info?.data ?? info
743
+ title = meta?.title ?? ""
744
+ parent = meta?.parentID ?? meta?.parentId
745
+ } catch {}
746
+ if (parent) { log("skipped: child/subagent session"); return }
747
+ if (title.startsWith("geneseed-")) { log("skipped: geneseed-* session"); return }
748
+
749
+ if (await alreadyInjected(client, sid)) { log("skipped: already injected (marker present)"); return }
750
+ done.add(sid)
751
+
752
+ const { block, src } = await resolveBlock(root)
753
+ if (!block) { log(`no docs discovered in ${root}`); return }
754
+
755
+ // Best-effort model line: read the session transcript, else $GENESEED_MODEL.
756
+ let model = null
757
+ try {
758
+ const msgs = await client.session.messages({ path: { id: sid } })
759
+ model = modelFromMessages(Array.isArray(msgs) ? msgs : (msgs?.data ?? []))
760
+ } catch {}
761
+ const text = withModel(block.text, model || envModelStr())
762
+
763
+ await client.session.prompt({
764
+ path: { id: sid },
765
+ body: { noReply: true, parts: [{ type: "text", text }] },
766
+ })
767
+ const via = src.mode === "manifest" ? `manifest ${src.file}` : `auto-discovery [${root}]`
768
+ log(`injected: ${block.injected} eager (${block.kb} KB), ${block.lazy} lazy listed — via ${via}`)
769
+ } catch (err) {
770
+ log(`error: ${err?.message ?? err}`)
771
+ }
772
+ }
773
+
774
+ // Session-scope filter for the INVISIBLE delivery, mirroring injectVisible's
775
+ // guards: child/subagent sessions inherit the parent's context, and geneseed-*
776
+ // sessions (learn distils, geneseed-wf:* children) must stay clean — without this
777
+ // the transform prepends the full context block to every request of every session
778
+ // in-process. Cached per sid (the transform runs on EVERY request); a transient
779
+ // session.get failure is NOT cached so the next request re-checks.
780
+ const sessionSkip = new Map()
781
+ async function skipSession(sid) {
782
+ if (!sid) return false
783
+ const hit = sessionSkip.get(sid)
784
+ if (hit !== undefined) return hit
785
+ try {
786
+ const info = await client.session.get({ path: { id: sid } })
787
+ const meta = info?.data ?? info
788
+ const skip = Boolean(meta?.parentID ?? meta?.parentId) ||
789
+ String(meta?.title ?? "").startsWith("geneseed-")
790
+ sessionSkip.set(sid, skip)
791
+ return skip
792
+ } catch {
793
+ return false
794
+ }
795
+ }
796
+
797
+ return {
798
+ event: async ({ event }) => {
799
+ if (!event || INJECT_OFF) return
800
+ if (await sovereignBypass(root)) return
801
+ const sid =
802
+ event.properties?.sessionID ?? event.payload?.sessionID ??
803
+ event.properties?.info?.id ?? event.payload?.info?.id
804
+ if (event.type === "session.created") {
805
+ if (TRANSFORM && !fallbackVisible) return // invisible delivery handles it
806
+ await injectVisible(sid)
807
+ return
808
+ }
809
+ // Transform fallback: the session went idle (a request completed) yet the
810
+ // experimental hook never fired in this process — this build lacks it. Engage
811
+ // the visible delivery so context still arrives, injecting into THIS session
812
+ // right away (one turn late) rather than waiting for the next session.
813
+ if (event.type === "session.idle" && TRANSFORM && !transformSeen && !fallbackVisible) {
814
+ fallbackVisible = true
815
+ log("transform hook absent on this build — falling back to visible injection")
816
+ await injectVisible(sid)
817
+ }
818
+ },
819
+
820
+ // Survive compaction. The visible injection above is a conversation message, so
821
+ // OpenCode summarises it away when a long session compacts (the AGENT.md rules
822
+ // persist — they load via opencode.json `instructions`, not the conversation).
823
+ // Re-push the eager docs into the compaction context so the project context —
824
+ // Law XVIII — outlives the summary. Only needed when delivery is visible
825
+ // (forced or fallback); the transform re-sends per request. Experimental
826
+ // OpenCode hook; if it is absent in a build this key is simply never called.
827
+ "experimental.session.compacting": async (_input, output) => {
828
+ if (INJECT_OFF || (TRANSFORM && !fallbackVisible)) return
829
+ if (await sovereignBypass(root)) return
830
+ try {
831
+ const { block } = await resolveBlock(root)
832
+ if (block && output && Array.isArray(output.context)) {
833
+ output.context.push(block.text)
834
+ log(`compaction: re-pushed ${block.injected} eager doc(s)`)
835
+ }
836
+ } catch (err) {
837
+ log(`compaction error: ${err?.message ?? err}`)
838
+ }
839
+ },
840
+
841
+ // Invisible delivery (the default). Prepend the context to each request's
842
+ // outgoing message array — no persisted/visible session message, and it survives
843
+ // compaction because it is re-sent every request. Experimental OpenCode hook;
844
+ // never called on a build that lacks it — the session.idle fallback above
845
+ // detects that and takes over with the visible delivery.
846
+ "experimental.chat.messages.transform": async (_input, output) => {
847
+ transformSeen = true // the hook exists on this build
848
+ if (INJECT_OFF || !TRANSFORM || fallbackVisible) return
849
+ if (await sovereignBypass(root)) return
850
+ try {
851
+ if (!output || !Array.isArray(output.messages) || !output.messages.length) return
852
+ const sid = output.messages[0]?.info?.sessionID
853
+ if (await skipSession(sid)) return
854
+ // Already present this request? (idempotent across plugin copies / retries.)
855
+ const present = output.messages.some((m) =>
856
+ (m?.parts ?? []).some((p) => typeof p?.text === "string" && p.text.includes(MARKER)))
857
+ if (present) return
858
+ const cached = await cachedBlockText(root)
859
+ if (!cached) return
860
+ // Model line is request-specific (kept out of the cache): prefer the live
861
+ // transcript on this request, fall back to $GENESEED_MODEL.
862
+ const text = withModel(cached, modelFromMessages(output.messages) || envModelStr())
863
+ const stamp = Date.now()
864
+ output.messages.unshift({
865
+ info: { id: `geneseed-context-${stamp}`, role: "user", sessionID: sid, time: { created: stamp } },
866
+ parts: [{ id: `geneseed-context-part-${stamp}`, type: "text", text }],
867
+ })
868
+ log("transform: injected context invisibly")
869
+ } catch (err) {
870
+ log(`transform error: ${err?.message ?? err}`)
871
+ }
872
+ },
873
+ }
874
+ }
875
+
876
+ export default GeneseedContext