geneseed 3.12.0 → 3.14.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 (240) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +2 -2
  3. package/SHIPPED.md +1 -1
  4. package/adapters/bob/README.md +1 -1
  5. package/adapters/claude-code/README.md +1 -1
  6. package/adapters/openclaude/README.md +1 -1
  7. package/adapters/opencode/README.md +3 -3
  8. package/adapters/opencode/plugins/geneseed-learn.js +10 -1
  9. package/bin/geneseed-cli.mjs +10 -14
  10. package/bin/geneseed-hook.mjs +22 -15
  11. package/docs/README.md +41 -22
  12. package/docs/concepts/agents.md +3 -1
  13. package/docs/concepts/collaboration.md +3 -1
  14. package/docs/concepts/exclusions.md +4 -2
  15. package/docs/concepts/footprint.md +3 -1
  16. package/docs/concepts/foreman-mode.md +3 -1
  17. package/docs/concepts/hook-gates.md +61 -0
  18. package/docs/concepts/hooks.md +8 -56
  19. package/docs/concepts/host-matrix.md +38 -0
  20. package/docs/concepts/hosts.md +6 -32
  21. package/docs/concepts/loop-branch.md +66 -0
  22. package/docs/concepts/loop-bricks.md +48 -0
  23. package/docs/concepts/loop-contracts.md +33 -0
  24. package/docs/concepts/loop-gates.md +30 -0
  25. package/docs/concepts/loop-notes.md +43 -0
  26. package/docs/concepts/loop-risk.md +36 -0
  27. package/docs/concepts/loop-templates.md +50 -0
  28. package/docs/concepts/loops.md +24 -116
  29. package/docs/concepts/lsp.md +2 -0
  30. package/docs/concepts/memory.md +3 -1
  31. package/docs/concepts/notebook.md +3 -1
  32. package/docs/concepts/rules.md +4 -2
  33. package/docs/concepts/skills.md +3 -1
  34. package/docs/concepts/themes.md +3 -1
  35. package/docs/extending.md +28 -13
  36. package/docs/guides/autostart.md +3 -1
  37. package/docs/guides/choose-your-setup.md +5 -80
  38. package/docs/guides/headless.md +5 -3
  39. package/docs/guides/install-agent-md.md +37 -0
  40. package/docs/guides/install-bob.md +20 -0
  41. package/docs/guides/install-claude-code.md +22 -0
  42. package/docs/guides/install-clone.md +34 -0
  43. package/docs/guides/install-openclaude.md +25 -0
  44. package/docs/guides/install-opencode.md +35 -0
  45. package/docs/guides/install-scripted.md +21 -0
  46. package/docs/guides/install.md +9 -118
  47. package/docs/guides/loop-finish.md +54 -0
  48. package/docs/guides/loop-running.md +66 -0
  49. package/docs/guides/loop-which-template.md +35 -0
  50. package/docs/guides/mcp-filesystem.md +41 -0
  51. package/docs/guides/mcp-gitlab.md +66 -0
  52. package/docs/guides/mcp-markitdown.md +36 -0
  53. package/docs/guides/mcp.md +8 -137
  54. package/docs/guides/migrate.md +3 -1
  55. package/docs/guides/project-context.md +4 -2
  56. package/docs/guides/read-non-markdown.md +17 -0
  57. package/docs/guides/run-a-loop.md +64 -0
  58. package/docs/guides/run-anywhere.md +2 -0
  59. package/docs/guides/setup-doctrines.md +55 -0
  60. package/docs/guides/setup-footprint.md +48 -0
  61. package/docs/guides/uninstall.md +3 -1
  62. package/docs/guides/upgrade.md +3 -1
  63. package/docs/guides/verify.md +3 -1
  64. package/docs/guides/wiki.md +3 -1
  65. package/docs/guides/worktree.md +3 -1
  66. package/docs/reference/about.md +3 -1
  67. package/docs/reference/cli.md +2 -0
  68. package/docs/reference/env-context.md +40 -0
  69. package/docs/reference/environment.md +10 -59
  70. package/docs/reference/glossary.md +3 -1
  71. package/docs/reference/loop-actions.md +20 -0
  72. package/docs/reference/opencode-plugins.md +14 -118
  73. package/docs/reference/plugin-activity.md +20 -0
  74. package/docs/reference/plugin-context.md +27 -0
  75. package/docs/reference/plugin-guard.md +27 -0
  76. package/docs/reference/plugin-learn.md +27 -0
  77. package/docs/reference/plugin-notify.md +20 -0
  78. package/docs/reference/plugin-ponytail.md +19 -0
  79. package/docs/reference/plugin-workflow.md +25 -0
  80. package/docs/reference/troubleshoot-doctor.md +50 -0
  81. package/docs/reference/troubleshoot-hooks.md +99 -0
  82. package/docs/reference/troubleshoot-install.md +108 -0
  83. package/docs/reference/troubleshoot-tools.md +56 -0
  84. package/docs/reference/troubleshoot-update.md +23 -0
  85. package/docs/reference/troubleshoot-web.md +66 -0
  86. package/docs/reference/troubleshooting.md +13 -337
  87. package/docs/understand/a-day.md +3 -1
  88. package/docs/understand/enforced-vs-asked.md +3 -1
  89. package/docs/understand/harness.md +2 -0
  90. package/docs/understand/machine-bob.md +17 -0
  91. package/docs/understand/machine-claude.md +35 -0
  92. package/docs/understand/machine-opencode.md +36 -0
  93. package/docs/understand/on-your-machine.md +12 -55
  94. package/docs/understand/take-it-out.md +13 -0
  95. package/docs/web-ui.md +35 -15
  96. package/harness.config.json +1 -1
  97. package/js/README.md +30 -13
  98. package/js/build/args.mjs +374 -0
  99. package/js/build/driver.mjs +137 -553
  100. package/js/build/emit-claude.mjs +2 -4
  101. package/js/build/generate.mjs +6 -3
  102. package/js/build/render.mjs +5 -9
  103. package/js/build/source.mjs +3 -11
  104. package/js/cli-table.json +27 -1
  105. package/js/hosts/hooks-context.mjs +431 -0
  106. package/js/hosts/hooks-learn.mjs +275 -0
  107. package/js/hosts/hooks-prims.mjs +108 -0
  108. package/js/hosts/hooks.mjs +50 -977
  109. package/js/hosts/hosts.mjs +12 -4
  110. package/js/hosts/installs.mjs +5 -6
  111. package/js/hosts/mcp.mjs +13 -49
  112. package/js/hosts/memory-files.mjs +181 -0
  113. package/js/hosts/native.mjs +43 -5
  114. package/js/hosts/settings.mjs +135 -414
  115. package/js/hosts/shim.mjs +373 -0
  116. package/js/inspect/checks-authoring.mjs +21 -828
  117. package/js/inspect/checks-build.mjs +32 -4
  118. package/js/inspect/checks-constitution.mjs +493 -0
  119. package/js/inspect/checks-counts.mjs +242 -0
  120. package/js/inspect/checks-repo.mjs +17 -8
  121. package/js/inspect/diff.mjs +4 -1
  122. package/js/inspect/doctor.mjs +2 -2
  123. package/js/inspect/inventory.mjs +4 -4
  124. package/js/inspect/scan.mjs +1 -1
  125. package/js/inspect/status.mjs +1 -1
  126. package/js/inspect/validate.mjs +48 -35
  127. package/js/loop/catalog.mjs +17 -4
  128. package/js/loop/cli.mjs +52 -16
  129. package/js/loop/graph.mjs +19 -1
  130. package/js/loop/registry.mjs +191 -0
  131. package/js/loop/score.mjs +46 -7
  132. package/js/loop/state.mjs +157 -19
  133. package/js/maintain/memory.mjs +1 -1
  134. package/js/maintain/migrate.mjs +18 -30
  135. package/js/maintain/setup.mjs +1 -1
  136. package/js/maintain/uninstall.mjs +47 -88
  137. package/js/ui/cli.mjs +12 -12
  138. package/js/web/actions.mjs +39 -4
  139. package/js/web/api.mjs +34 -21
  140. package/js/web/docs.mjs +87 -78
  141. package/js/web/routes.mjs +5 -1
  142. package/package.json +1 -1
  143. package/src/agents/explorer.md +2 -2
  144. package/src/agents/researcher.md +3 -3
  145. package/src/bricks/README.md +3 -1
  146. package/src/bricks/adr-challenge.md +20 -0
  147. package/src/bricks/adr-draft.md +15 -0
  148. package/src/bricks/characterize.md +17 -0
  149. package/src/bricks/ci-fix.md +2 -1
  150. package/src/bricks/ci-triage.md +20 -0
  151. package/src/bricks/compat-check.md +16 -0
  152. package/src/bricks/done-check.md +16 -0
  153. package/src/bricks/fitness-define.md +22 -0
  154. package/src/bricks/flake-check.md +17 -0
  155. package/src/bricks/flake-repro.md +18 -0
  156. package/src/bricks/identify.md +6 -3
  157. package/src/bricks/migration-plan.md +21 -0
  158. package/src/bricks/mutation-check.md +18 -0
  159. package/src/bricks/plan.md +6 -6
  160. package/src/bricks/red-test.md +16 -0
  161. package/src/bricks/review.md +5 -3
  162. package/src/bricks/spec.md +18 -0
  163. package/src/bricks/upgrade-scout.md +16 -0
  164. package/src/loops/api-endpoint.json +31 -0
  165. package/src/loops/architecture-decision.json +22 -0
  166. package/src/loops/bugfix.json +1 -0
  167. package/src/loops/ci-repair.json +19 -0
  168. package/src/loops/deps-upgrade.json +29 -0
  169. package/src/loops/enforce-architecture-rule.json +28 -0
  170. package/src/loops/feature.json +8 -5
  171. package/src/loops/fix-flaky-tests.json +30 -0
  172. package/src/loops/legacy-refactor.json +26 -0
  173. package/src/loops/legacy-tests.json +29 -0
  174. package/src/loops/refactor.json +5 -1
  175. package/src/loops/remove-dead-code.json +30 -0
  176. package/src/loops/spec-first-feature.json +29 -0
  177. package/src/loops/tdd.json +25 -0
  178. package/src/loops/update-contract.json +33 -0
  179. package/src/skills/brick-forge.md +3 -1
  180. package/src/skills/bruno-collection-generator.md +3 -3
  181. package/src/skills/bruno-test-writer.md +3 -3
  182. package/src/skills/ci-fix.md +3 -3
  183. package/src/skills/consolidate-memory.md +4 -4
  184. package/src/skills/council.md +1 -1
  185. package/src/skills/fresh-eyes.md +1 -1
  186. package/src/skills/git-archaeology.md +1 -1
  187. package/src/skills/git-rescue.md +4 -3
  188. package/src/skills/handoff.md +1 -1
  189. package/src/skills/herdr.md +1 -1
  190. package/src/skills/learn-mode.md +2 -2
  191. package/src/skills/loop.md +22 -10
  192. package/src/skills/opencode-theme.md +3 -3
  193. package/src/skills/ponytail.md +1 -1
  194. package/src/skills/profile.md +3 -4
  195. package/src/skills/prose.md +2 -3
  196. package/src/skills/quiz.md +1 -1
  197. package/src/skills/react-view-transitions/SKILL.md +1 -1
  198. package/src/skills/react-view-transitions/VENDOR.md +8 -3
  199. package/src/skills/research.md +3 -3
  200. package/src/skills/review-response.md +2 -2
  201. package/src/skills/rule.md +3 -3
  202. package/src/skills/security-audit.md +4 -4
  203. package/src/skills/teach.md +1 -1
  204. package/src/skills/wiki.md +3 -3
  205. package/src/skills/workflow.md +4 -4
  206. package/src/skills/worktree.md +2 -2
  207. package/web/dist/assets/Activity-CjRqZnr-.js +1 -0
  208. package/web/dist/assets/ActivityDetail-OQTxKBj2.js +1 -0
  209. package/web/dist/assets/Dashboard-J-xCe_Gk.js +2 -0
  210. package/web/dist/assets/Docs-CvOZ8akL.js +5 -0
  211. package/web/dist/assets/{GreenhouseView-C6ibc8IE.js → GreenhouseView-6KDjX8da.js} +1 -1
  212. package/web/dist/assets/{Installs-DRYjYnxs.js → Installs-MWuhNjSm.js} +2 -2
  213. package/web/dist/assets/JournalView-C8MrOz2c.js +1 -0
  214. package/web/dist/assets/Laws-Du0-Zyp2.js +3 -0
  215. package/web/dist/assets/Library-DOkO0TtB.js +1 -0
  216. package/web/dist/assets/Loops-Dv_aLByJ.js +2 -0
  217. package/web/dist/assets/{Markdown-qjM2_ofS.js → Markdown-X2a3qvOh.js} +14 -14
  218. package/web/dist/assets/{OperatorHudView-Bs7paWmh.js → OperatorHudView-BPP1dtLz.js} +1 -1
  219. package/web/dist/assets/{Personal-BY5n4gTw.js → Personal-26VPYkK0.js} +1 -1
  220. package/web/dist/assets/Tabs-BzKuFskD.js +1 -0
  221. package/web/dist/assets/{usePoll-O0sJEw1Y.js → activity-Dr7ZMBI3.js} +1 -1
  222. package/web/dist/assets/{api-CyMRbU2x.js → api-D_ixdyEY.js} +1 -1
  223. package/web/dist/assets/drawable-zVVDbsyw.js +1 -0
  224. package/web/dist/assets/index-Ctp-4poP.css +1 -0
  225. package/web/dist/assets/index-KHt3wE_K.js +2 -0
  226. package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
  227. package/web/dist/index.html +3 -3
  228. package/web/src/pages/Laws.jsx +0 -1
  229. package/web/dist/assets/Activity-GaaZWXTY.js +0 -1
  230. package/web/dist/assets/ActivityDetail-4t7d45Du.js +0 -1
  231. package/web/dist/assets/Dashboard-CYn7Tp16.js +0 -2
  232. package/web/dist/assets/Docs-BeFKLg2T.js +0 -5
  233. package/web/dist/assets/JournalView-1HbU1mfg.js +0 -1
  234. package/web/dist/assets/Laws-DFoG8JWB.js +0 -3
  235. package/web/dist/assets/Library-DDjgNwz_.js +0 -1
  236. package/web/dist/assets/Loops-D9VEoO0F.js +0 -1
  237. package/web/dist/assets/Tabs-ho-EYD8i.js +0 -1
  238. package/web/dist/assets/drawable-CLS9drMb.js +0 -1
  239. package/web/dist/assets/index-CX_hXMi5.css +0 -1
  240. package/web/dist/assets/index-Cjr7XCA3.js +0 -2
@@ -40,7 +40,7 @@ import { existsSync, mkdirSync } from 'node:fs';
40
40
  * Six emits route through here — claude, bob and openclaude at both scopes — so this one
41
41
  * job is what puts two thirds of the matrix on the seam.
42
42
  *
43
- * `claudeMdText` is the payload's one item that is not a file this process wrote. WIRE
43
+ * `claudeMdText` is the one product of this render that is not a file it writes. WIRE
44
44
  * needs the managed block's text, and that text is a RENDER (`prefixedAgentText`
45
45
  * re-renders AGENT.md with every store dir prefixed), so it is computed here and merged
46
46
  * there. `emitOpencodeRender` needed nothing of the kind — its wired file is derived from
@@ -174,8 +174,6 @@ export function emitClaudeRender(cfg, job) {
174
174
  stats: { nAgents, nSkills },
175
175
  memStatus,
176
176
  nbStatus,
177
- hasAgentText: agentText !== null,
178
- claudeMdText,
179
177
  managed,
180
178
  };
181
179
  }
@@ -264,7 +262,7 @@ function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRu
264
262
  // travels from `cfg` rather than being re-read off the deployment: this is the emit that
265
263
  // DECIDES the install's packs, so the marker on disk is still the previous build's.
266
264
  const [, managedHooks] = mergeClaudeSettings(
267
- settingsPath, scope, oldSf === settingsName ? get(old, 'settings_hooks') : null, hookOpts,
265
+ settingsPath, oldSf === settingsName ? get(old, 'settings_hooks') : null, hookOpts,
268
266
  doctrines, excludeRules, host, cfgDir,
269
267
  );
270
268
  managed.settings_hooks = managedHooks;
@@ -171,7 +171,7 @@ export function setupBuildArgs(theme, emit, out = null, root = null, footprint =
171
171
  * A pre-marker install still has a (host, scope), which is what the walk found it by, so it
172
172
  * is rebuilt in its own mode rather than defaulted to OpenCode.
173
173
  */
174
- export const DEFAULT_EMIT = new Map([
174
+ const DEFAULT_EMIT = new Map([
175
175
  ['opencode global', 'opencode-global'], ['opencode project', 'opencode'],
176
176
  ['claude global', 'claude-global'], ['claude project', 'claude'],
177
177
  ['bob global', 'bob-global'], ['bob project', 'bob'],
@@ -483,7 +483,10 @@ export function cmdTheme(args) {
483
483
  written.push(dest);
484
484
  }
485
485
  printOut(`[theme] wrote ${written.map((p) => path.basename(p)).join(', ')} to ${destDir}\n`);
486
- printOut(`[theme] select in OpenCode with: /theme ${full}`
487
- + (args.solidOnly ? '' : ` (or /theme ${full}-transparent)`) + '\n');
486
+ // The hint names what was WRITTEN: `--transparent-only` writes no `${full}.json`, and a
487
+ // hint selecting it would point OpenCode at a theme that does not exist.
488
+ const [first, ...rest] = written.map((p) => path.basename(p, '.json'));
489
+ printOut(`[theme] select in OpenCode with: /theme ${first}`
490
+ + rest.map((n) => ` (or /theme ${n})`).join('') + '\n');
488
491
  return 0;
489
492
  }
@@ -34,7 +34,7 @@ import { readText } from '../lib/fs.mjs';
34
34
  import { parseJson } from '../lib/json.mjs';
35
35
  import { normcase, comparePaths } from '../lib/paths.mjs';
36
36
  // `js/build/source.mjs` imports nothing but node builtins, so this direction cannot cycle.
37
- import { PACK_ORDER, readPackText, resolveRuleIds } from './source.mjs';
37
+ import { PACK_ORDER, readPackText, resolveRuleIds, ruleCanon } from './source.mjs';
38
38
  import { DEFAULT_PRESET } from '../loop/score.mjs';
39
39
 
40
40
  /** Document STRUCTURE is theme-INDEPENDENT — mirrors `_build_render.STRUCTURE`. */
@@ -86,7 +86,7 @@ const CATALOG_BLOCK_RE =
86
86
  // truncation — the objection `docs/token-footprint.md` raised against terse-ing the
87
87
  // Ontology ("four orphan sentences") is answered by authoring the short form, not
88
88
  // generating it. Same marker grammar as CATALOG so the two stay learnable as one idea.
89
- const LEAN_BLOCK_RE =
89
+ export const LEAN_BLOCK_RE =
90
90
  /[ \t]*<!-- LEAN:begin -->\n(?<full>[\s\S]*?)[ \t]*<!-- LEAN:else -->\n(?<lean>[\s\S]*?)[ \t]*<!-- LEAN:end -->\n/g;
91
91
 
92
92
  /**
@@ -184,7 +184,7 @@ function resolveLean(text, footprint) {
184
184
  * match is a corner both engines implement, and the leading '' Python produces when the
185
185
  * text starts with a heading is load-bearing (it becomes `blocks[0]`, the preamble).
186
186
  */
187
- function splitAtLawHeadings(text) {
187
+ export function splitAtLawHeadings(text) {
188
188
  const out = [];
189
189
  let start = 0;
190
190
  for (let i = 0; i < text.length; i++) {
@@ -310,12 +310,8 @@ function activeDoctrines(cfg) {
310
310
  // interpret. Dropped here rather than in `doctrinesBody` so `DOCTRINES_LIST` — the
311
311
  // `Active packs:` marker a later reader parses back — agrees with what was rendered.
312
312
  const excluded = new Set(cfg.excludeRules ?? []);
313
- const survives = (pack) => {
314
- const ids = [...readPackText(path.join(dir, `${pack}.md`))
315
- .matchAll(/^### \{\{DOCTRINE\}\} ([a-z]+) (\d+)\b/gm)]
316
- .map((m) => `${m[1]}.${Number(m[2])}`);
317
- return ids.some((id) => !excluded.has(id));
318
- };
313
+ const { rules } = ruleCanon(cfg.src);
314
+ const survives = (pack) => rules.some((r) => r.pack === pack && !excluded.has(`${pack}.${r.n}`));
319
315
  return PACK_ORDER.filter((p) => wanted.includes(p)).filter(survives);
320
316
  }
321
317
 
@@ -222,7 +222,8 @@ export const PACK_ORDER = ['craft', 'rigor', 'ops', 'process', 'comms'];
222
222
  * The ONE enumerator, for the same arithmetic reason `PACK_ORDER` is one array: the CLI
223
223
  * flag, the console's trust boundary and the doctor all have to close `--exclude-rules`
224
224
  * against the SAME set, and a second copy is a set that drifts by one rule the day a pack
225
- * grows. Read from the UNRENDERED source (`### {{DOCTRINE}} <pack> <n>`) rather than from a
225
+ * grows. Read off `ruleCanon` — the same declarations, in the same order, that the render
226
+ * numbers its headings from and the doctor validates citations against — rather than from a
226
227
  * built tree, because an address is a property of the source and the rendered heading is
227
228
  * themed — fourteen spellings of a thing that must compare equal to itself.
228
229
  *
@@ -232,16 +233,7 @@ export const PACK_ORDER = ['craft', 'rigor', 'ops', 'process', 'comms'];
232
233
  * were ever out of order, and disagree in the direction that reads as correct.
233
234
  */
234
235
  export function knownRuleIds() {
235
- const out = [];
236
- for (const pack of PACK_ORDER) {
237
- const file = path.join(SRC, 'doctrines', `${pack}.md`);
238
- let text = '';
239
- try { text = readPackText(file); } catch { continue; }
240
- for (const m of text.matchAll(/^### \{\{DOCTRINE\}\} ([a-z]+) (\d+)\b/gm)) {
241
- if (m[1] === pack) out.push(`${pack}.${Number(m[2])}`);
242
- }
243
- }
244
- return out;
236
+ return ruleCanon(SRC).rules.filter((r) => r.kind === 'DOCTRINE').map((r) => `${r.pack}.${r.n}`);
245
237
  }
246
238
 
247
239
  /**
package/js/cli-table.json CHANGED
@@ -734,6 +734,19 @@
734
734
  "type": null,
735
735
  "hidden": false
736
736
  },
737
+ {
738
+ "names": ["--contracts"],
739
+ "dest": "contracts",
740
+ "metavar": null,
741
+ "help": "comma-separated contract globs appended to the template's own (init)",
742
+ "choices": null,
743
+ "default": null,
744
+ "required": false,
745
+ "nargs": null,
746
+ "is_flag": false,
747
+ "type": null,
748
+ "hidden": false
749
+ },
737
750
  {
738
751
  "names": ["--declared"],
739
752
  "dest": "declared",
@@ -846,7 +859,20 @@
846
859
  "names": ["--note"],
847
860
  "dest": "note",
848
861
  "metavar": null,
849
- "help": "a note attached to the verdict, written into LOOP.md",
862
+ "help": "a note attached to a record or a decide verdict, written into LOOP.md so it survives the unit",
863
+ "choices": null,
864
+ "default": null,
865
+ "required": false,
866
+ "nargs": null,
867
+ "is_flag": false,
868
+ "type": null,
869
+ "hidden": false
870
+ },
871
+ {
872
+ "names": ["--note-file"],
873
+ "dest": "note_file",
874
+ "metavar": null,
875
+ "help": "record's note read from this file instead of the command line (CRLF folded, trimmed) — for a multi-line note or one that mentions git commit/push",
850
876
  "choices": null,
851
877
  "default": null,
852
878
  "required": false,
@@ -0,0 +1,431 @@
1
+ /**
2
+ * `context` — the SessionStart verb: inject the harness's session files and the repo's context
3
+ * docs. Runs once per session, so it is loaded only when the hook entry dispatches it; see
4
+ * `js/hosts/hooks.mjs`'s header for the contract every hook verb holds.
5
+ */
6
+ import { readFileSync } from 'node:fs';
7
+ import path from 'node:path';
8
+ import { createHash } from 'node:crypto';
9
+ import { readText, printOut as out, printErr as err, withDiscardableStderr } from '../lib/fs.mjs';
10
+ import { normcase, toPlatformPath } from '../lib/paths.mjs';
11
+ import { resolvePath, sovereignBypass } from './hosts.mjs';
12
+ import { isFile, isDir, selfAndParents, sortPaths, listDir } from './hooks-prims.mjs';
13
+
14
+ /**
15
+ * `str(OSError)` — `[Errno N] strerror: 'filename'`, with the filename REPR'd.
16
+ *
17
+ * This is a compared string, not a log line: `cmd_context` prints it verbatim when an
18
+ * eager entry is missing, so `ENOENT: no such file or directory, open '…'` (Node's
19
+ * wording, Node's quoting, no errno) is a whole-line difference in that cell. Only the
20
+ * codes a verb here can actually raise are mapped; anything else falls back to Node's
21
+ * message, which is at least honest about being different.
22
+ */
23
+ const _ERRNO = {
24
+ ENOENT: [2, 'No such file or directory'],
25
+ EACCES: [13, 'Permission denied'],
26
+ EISDIR: [21, 'Is a directory'],
27
+ ENOTDIR: [20, 'Not a directory'],
28
+ EPERM: [1, 'Operation not permitted'],
29
+ };
30
+
31
+ function asOsError(e, filename) {
32
+ const hit = _ERRNO[e && e.code];
33
+ if (!hit) return String((e && e.message) || e);
34
+ // Python reprs the filename, so a Windows path comes out with its backslashes doubled.
35
+ return `[Errno ${hit[0]}] ${hit[1]}: ${JSON.stringify(filename).replace(/^"|"$/g, "'")
36
+ .replace(/\\"/g, '"')}`;
37
+ }
38
+
39
+ /**
40
+ * `fnmatch.fnmatch(name, pattern)` — case-folded on Windows on BOTH sides, and `*` matches
41
+ * a separator (it is shell-glob semantics, not path-glob: `docs/*.md` really does select
42
+ * `docs/nested/deep.md`, in both implementations).
43
+ */
44
+ function fnmatch(name, pattern) {
45
+ return fnTranslate(normcase(pattern)).test(normcase(name));
46
+ }
47
+
48
+ function fnTranslate(pat) {
49
+ let out = '';
50
+ for (let i = 0; i < pat.length; i += 1) {
51
+ const c = pat[i];
52
+ if (c === '*') { out += '[\\s\\S]*'; continue; }
53
+ if (c === '?') { out += '[\\s\\S]'; continue; }
54
+ if (c === '[') {
55
+ let j = i + 1;
56
+ if (pat[j] === '!') j += 1;
57
+ if (pat[j] === ']') j += 1;
58
+ while (j < pat.length && pat[j] !== ']') j += 1;
59
+ // An unterminated `[` is a LITERAL bracket in Python's translate, not an error.
60
+ if (j >= pat.length) { out += '\\['; continue; }
61
+ let body = pat.slice(i + 1, j).replaceAll('\\', '\\\\');
62
+ if (body.startsWith('!')) body = `^${body.slice(1)}`;
63
+ out += `[${body}]`;
64
+ i = j;
65
+ continue;
66
+ }
67
+ out += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
68
+ }
69
+ return new RegExp(`^(?:${out})$`);
70
+ }
71
+
72
+ /**
73
+ * The host whose own root file counts as native — `--host` on the emitted command, `claude`
74
+ * when absent. Module state for the reason `./hooks.mjs`'s gates keep theirs: a hook process
75
+ * answers exactly one call, so it is set once at `cmdContext`'s entry.
76
+ */
77
+ let HOST = 'claude';
78
+
79
+ // Kept in step with adapters/opencode/plugins/geneseed-context.js, same as the Python.
80
+ const EAGER_ROOT = ['AGENTS.md', 'AGENT.md', 'CLAUDE.md', '.cursorrules',
81
+ 'README.md', 'CONTRIBUTING.md', 'user-rules.md', 'PROFILE.md'];
82
+ const LAZY_DIRS = ['docs', 'doc', 'documentation', 'architecture', 'adr', 'ADR'];
83
+ const EXCLUDE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'vendor', '.next',
84
+ 'target', '.venv', '__pycache__', '.opencode', '.harness']);
85
+
86
+ // The root instruction file a host already loads by itself. Injecting it again pays the
87
+ // whole harness twice per session — and was doing exactly that until 2026-09: every hooked
88
+ // host had its own root in EAGER_ROOT. Other tools' roots stay eager (a repo carrying a
89
+ // hand-written AGENTS.md is worth showing Claude Code); only the host's OWN is dropped.
90
+ // The OpenCode context plugin carries the same rule for AGENT.md/AGENTS.md/CLAUDE.md.
91
+ // OpenClaude's root file is AGENTS.md, or CLAUDE.md when AGENTS.md is absent — resolved
92
+ // per repo in `discoverContext`, since which one it loads depends on what is on disk.
93
+ const NATIVE_ROOT = {
94
+ claude: ['CLAUDE.md'], bob: ['AGENTS.md'],
95
+ openclaude: ['AGENTS.md', 'CLAUDE.md'],
96
+ };
97
+
98
+ // Eager injection budget: a root README of 40k chars was going out whole, every session and
99
+ // every compaction, and cost more than the harness itself. Per-file cut at the last line
100
+ // break under the cap; files that would push the total over the budget are listed lazy.
101
+ const EAGER_FILE_BYTES = 16 * 1024;
102
+ const EAGER_TOTAL_BYTES = 48 * 1024;
103
+
104
+ // The files the root instruction file names for session start, relative to the HARNESS dir
105
+ // (`--root`: `.claude/`, `.bob/`, a global config dir) — not to the repo root discovery walks,
106
+ // which is why none of them reached a hooked host before 2026-10. Injected here so the read does
107
+ // not depend on the model remembering it. `context.json` is not in the list: it is a manifest,
108
+ // honoured by `resolveContextSets` (the harness dir is its last candidate). The OpenCode
109
+ // context plugin carries the same list.
110
+ const SESSION_FILES = ['user-rules.md', 'PROFILE.md', 'memory/MEMORY.md', 'anamnesis/MEMORY.md',
111
+ 'notebook/NOTEBOOK.md', 'wiki.jsonc'];
112
+ // SHA-256 of each seed body in `js/build/stubs.mjs` (`SESSION_SEEDS`), CRLF folded. A file still
113
+ // byte-identical to its seed says nothing, so it is skipped rather than injected as noise.
114
+ // Hashes, not an import: stubs.mjs pulls in the build's writers, and this module loads on every
115
+ // tool call. tests/unit/claude.test.mjs gates this set, and the plugin's copy, against the seeds.
116
+ const SEED_SHA256 = new Set([
117
+ '5c2e92fb1acde041e02d9ebf158460d8ff7a07cbc2b447859631604610130721', // user-rules.md
118
+ '29e012c3c4349ea62a9082cbd04bb25599a0ec280d62c33f6e10742809471816', // PROFILE.md
119
+ 'cccc917c34e6b990e821620bdb4739090151885ab71b6e85046f30a3b71fa5e8', // wiki.jsonc
120
+ '99c786049c260f6baf7aec68a5c0f59907861afe9b867da009440613bdddb907', // MEMORY.md
121
+ '9acb5c9d9cb5dac572104480f05b48cc144e676869bd60e86d8a15b1f6308184', // NOTEBOOK.md
122
+ ]);
123
+ export { SESSION_FILES, SEED_SHA256 };
124
+
125
+ /**
126
+ * The session files present under `hookRoot` and changed from their seed, as `{ rel, abs, text }`
127
+ * (text CRLF-folded, trailing newlines trimmed). `$GENESEED_WIKI` overrides the wiki declaration,
128
+ * as it does on OpenCode. A missing or unreadable file is simply absent — this is best-effort
129
+ * context, never a gate.
130
+ */
131
+ export function sessionFiles(hookRoot) {
132
+ const found = [];
133
+ for (const rel of SESSION_FILES) {
134
+ let abs = path.join(hookRoot, rel);
135
+ if (rel === 'wiki.jsonc' && process.env.GENESEED_WIKI && isFile(process.env.GENESEED_WIKI)) {
136
+ abs = process.env.GENESEED_WIKI;
137
+ }
138
+ if (!isFile(abs)) continue;
139
+ let text;
140
+ try { text = readFileSync(abs, 'utf8').replace(/\r\n/g, '\n'); } catch { continue; }
141
+ if (SEED_SHA256.has(createHash('sha256').update(text).digest('hex'))) continue;
142
+ found.push({ rel, abs, text: text.replace(/\n+$/, '') });
143
+ }
144
+ return found;
145
+ }
146
+
147
+ const CLAUDE_MARKERS = ['.claude', '.bob', '.openclaude'];
148
+ const GENESEED_MANIFEST = '.geneseed-manifest.json';
149
+
150
+ /**
151
+ * `_global_hook_standing_down` — project-bypasses-global.
152
+ *
153
+ * A GLOBAL install's hook passes its own dir as `--root`; when a Geneseed PROJECT install
154
+ * of the SAME host sits at or above cwd, the project's hook injects and this one must not
155
+ * double up.
156
+ */
157
+ export function globalHookStandingDown(hookRoot, cwd) {
158
+ const marker = path.basename(hookRoot);
159
+ if (!CLAUDE_MARKERS.includes(marker)) return false;
160
+ for (const d of selfAndParents(cwd)) {
161
+ const cand = path.join(d, marker);
162
+ if (isFile(path.join(cand, GENESEED_MANIFEST))) {
163
+ // Path equality, which is case-folded on Windows — `~/.claude` and `~/.Claude` are
164
+ // the same install there and two different ones on Linux.
165
+ return normcase(resolvePath(cand)) !== normcase(resolvePath(hookRoot));
166
+ }
167
+ }
168
+ return false;
169
+ }
170
+
171
+ /** `_disp` — relative to the repo root when it sits under it, else verbatim. */
172
+ function disp(pathStr, root) {
173
+ // `os.path.relpath` raises ValueError across drives and the Python falls back to the
174
+ // path as given; `path.relative` silently returns an absolute path instead, which would
175
+ // print a DIFFERENT string rather than the same one.
176
+ if (path.parse(path.resolve(pathStr)).root.toLowerCase()
177
+ !== path.parse(path.resolve(root)).root.toLowerCase()) {
178
+ return pathStr;
179
+ }
180
+ return path.relative(root, pathStr).split(path.sep).join('/');
181
+ }
182
+
183
+
184
+ function rglobMd(dir, acc = []) {
185
+ for (const name of listDir(dir)) {
186
+ const full = path.join(dir, name);
187
+ if (isDir(full)) rglobMd(full, acc);
188
+ else if (isFile(full) && path.extname(name).toLowerCase() === '.md') acc.push(full);
189
+ }
190
+ return acc;
191
+ }
192
+
193
+ /**
194
+ * `_discover_context` — the no-manifest path, mirroring the OpenCode context plugin.
195
+ * Root entry docs are eager; other root markdown, the doc trees and monorepo package
196
+ * READMEs are lazy.
197
+ */
198
+ export function discoverContext(root, host = HOST) {
199
+ const eager = new Map();
200
+ const lazy = new Map();
201
+ const native = host === 'openclaude' && isFile(path.join(root, 'AGENTS.md'))
202
+ ? ['AGENTS.md'] : NATIVE_ROOT[host] || [];
203
+ for (const full of sortPaths(listDir(root).map((n) => path.join(root, n)))) {
204
+ if (!isFile(full)) continue;
205
+ const name = path.basename(full);
206
+ if (native.includes(name)) continue;
207
+ if (EAGER_ROOT.includes(name)) eager.set(full, null);
208
+ else if (path.extname(name).toLowerCase() === '.md') lazy.set(full, null);
209
+ }
210
+ for (const d of LAZY_DIRS) {
211
+ const sub = path.join(root, d);
212
+ if (!isDir(sub)) continue;
213
+ for (const md of sortPaths(rglobMd(sub))) {
214
+ const parts = path.relative(root, md).split(/[\\/]/);
215
+ if (parts.some((part) => EXCLUDE_DIRS.has(part))) continue;
216
+ if (!eager.has(md) && !lazy.has(md)) lazy.set(md, null);
217
+ }
218
+ }
219
+ for (const group of ['packages', 'apps']) {
220
+ const base = path.join(root, group);
221
+ if (!isDir(base)) continue;
222
+ for (const pkg of sortPaths(listDir(base).map((n) => path.join(base, n)))) {
223
+ if (!isDir(pkg) || EXCLUDE_DIRS.has(path.basename(pkg))) continue;
224
+ const readme = path.join(pkg, 'README.md');
225
+ if (isFile(readme) && !eager.has(readme) && !lazy.has(readme)) lazy.set(readme, null);
226
+ }
227
+ }
228
+ return [
229
+ [...eager.keys()].map((p) => ({ path: p, description: '' })),
230
+ [...lazy.keys()].filter((p) => !eager.has(p)).map((p) => ({ path: p, description: '' })),
231
+ ];
232
+ }
233
+
234
+ /**
235
+ * `_resolve_context_sets` — an explicit manifest wins, `"extend": true` layers it on top
236
+ * of discovery, and an empty stub falls through to pure discovery.
237
+ *
238
+ * `recs` is a Map, not an object: JS reorders integer-like keys on a plain object, and
239
+ * these keys are absolute paths whose iteration order becomes the printed order.
240
+ */
241
+ export function resolveContextSets(root, hookRoot = null) {
242
+ let manifest = null;
243
+ const env = process.env.GENESEED_CONTEXT;
244
+ if (env && isFile(env)) {
245
+ // `Path(env)`, and the label is printed — so the separators are folded exactly the
246
+ // way `str(Path(...))` folds them, or the source line differs by every slash in it.
247
+ manifest = toPlatformPath(env);
248
+ } else {
249
+ // The harness dir last: a repo's own manifest outranks the one seeded beside the install.
250
+ for (const cand of [path.join(root, '.harness', 'context.json'),
251
+ path.join(root, 'context.json'), ...(hookRoot ? [path.join(hookRoot, 'context.json')] : [])]) {
252
+ if (isFile(cand)) { manifest = cand; break; }
253
+ }
254
+ }
255
+
256
+ if (manifest === null) {
257
+ const [e, l] = discoverContext(root);
258
+ return [e, l, `auto-discovery [${root}]`];
259
+ }
260
+
261
+ let raw;
262
+ try {
263
+ raw = readText(manifest);
264
+ } catch (e) {
265
+ err(`[context] could not read ${manifest}: ${asOsError(e, manifest)}\n`);
266
+ return [[], [], String(manifest)];
267
+ }
268
+ let entries;
269
+ let extend;
270
+ try {
271
+ let data = JSON.parse(raw);
272
+ // Valid JSON of the wrong SHAPE, guarded on both sides — see the Python original. JS
273
+ // would not have thrown here (`[].context` is merely undefined), which is exactly why
274
+ // the guard is written out rather than left to fall out of the language: without it,
275
+ // the two CLIs disagree about a file a user really does hand-edit.
276
+ data = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
277
+ entries = data.context || [];
278
+ if (!Array.isArray(entries)) entries = [];
279
+ extend = Boolean(data.extend);
280
+ } catch {
281
+ // See the Python original for why the decoder's own message is not quoted: the two
282
+ // engines disagree on the wording and on the offset, and V8 supplies no offset at all
283
+ // for the commonest shapes. Reporting the file is what the user needs; reporting
284
+ // WHICH decoder found it is what makes two implementations impossible.
285
+ err(`[context] ${manifest} is not valid JSON — no context was loaded `
286
+ + '(fix the syntax, then re-run).\n');
287
+ return [[], [], String(manifest)];
288
+ }
289
+
290
+ if (!entries.length && !extend) {
291
+ const [e, l] = discoverContext(root);
292
+ return [e, l, `auto-discovery [${root}] (empty ${path.basename(manifest)})`];
293
+ }
294
+
295
+ const recs = new Map();
296
+ const put = (pathStr, load, desc) => {
297
+ const prev = recs.get(pathStr);
298
+ recs.set(pathStr, { path: pathStr, load, description: desc || (prev ? prev.description : '') });
299
+ };
300
+
301
+ if (extend) {
302
+ const [de, dl] = discoverContext(root);
303
+ for (const x of de) put(x.path, 'eager', '');
304
+ for (const x of dl) put(x.path, 'lazy', '');
305
+ }
306
+
307
+ for (const entry of entries) {
308
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
309
+ const raw = typeof entry.path === 'string' ? entry.path.trim() : '';
310
+ if (!raw) continue;
311
+ const load = entry.load === undefined ? 'eager' : entry.load;
312
+ const desc = entry.description === undefined ? '' : entry.description;
313
+ if (raw.includes('*')) {
314
+ // A glob RECLASSIFIES files already known; it never pulls in new ones.
315
+ for (const [pathStr, rec] of [...recs.entries()]) {
316
+ if (fnmatch(disp(pathStr, root), raw)) put(pathStr, load, desc || rec.description);
317
+ }
318
+ continue;
319
+ }
320
+ const abs = path.isAbsolute(raw) ? raw : resolvePath(path.join(root, raw));
321
+ put(abs, load, desc);
322
+ }
323
+
324
+ const eager = [];
325
+ const lazy = [];
326
+ for (const r of recs.values()) {
327
+ (r.load === 'eager' ? eager : lazy).push({ path: r.path, description: r.description });
328
+ }
329
+ return [eager, lazy, String(manifest)];
330
+ }
331
+
332
+ /**
333
+ * Cut `text` at the last line break under EAGER_FILE_BYTES and say where the rest is — one rule
334
+ * for a session file and an eager doc alike. `where` finishes the sentence.
335
+ */
336
+ function capEager(text, where) {
337
+ if (text.length <= EAGER_FILE_BYTES) return text;
338
+ const nl = text.lastIndexOf('\n', EAGER_FILE_BYTES);
339
+ return `${text.slice(0, nl > 0 ? nl : EAGER_FILE_BYTES)}\n`
340
+ + `[context] truncated at ${EAGER_FILE_BYTES / 1024} KB — read ${where}`;
341
+ }
342
+
343
+ export function cmdContext(args) {
344
+ HOST = (args && args.host) || 'claude';
345
+ // Discovery runs against the project root the hook was launched from — Claude runs
346
+ // SessionStart hooks with cwd = repo root — not the harness package dir.
347
+ // The only two `resolvePath` calls in this file whose argument can be a `~user` path the
348
+ // user typed — `$GENESEED_ROOT` and `--root` — and `resolvePath` expands, so both can now
349
+ // throw. Every other caller either builds its argument by `path.join` from an absolute
350
+ // root (so no leading tilde survives) or already sits inside a `try`. Refused input
351
+ // injects nothing, which is the same degrade as "nothing to load" below.
352
+ let root;
353
+ let hookRoot;
354
+ try {
355
+ ({ root, hookRoot } = withDiscardableStderr(() => ({
356
+ root: resolvePath(process.env.GENESEED_ROOT || process.cwd()),
357
+ hookRoot: args.root ? resolvePath(args.root) : null,
358
+ })));
359
+ } catch {
360
+ return 0;
361
+ }
362
+ if (hookRoot && sovereignBypass(hookRoot)) return 0;
363
+ if (hookRoot && !process.env.GENESEED_STACK_GLOBAL
364
+ && globalHookStandingDown(hookRoot, root)) return 0;
365
+ const session = hookRoot ? sessionFiles(hookRoot) : [];
366
+ // A session file discovery would also pick up (a `user-rules.md` at the repo root of an
367
+ // install whose harness dir IS the repo root) is injected once, in the session block.
368
+ const seen = new Set(session.map((s) => normcase(path.resolve(s.abs))));
369
+ const fresh = (e) => !seen.has(normcase(path.resolve(path.isAbsolute(e.path)
370
+ ? e.path : path.join(root, e.path))));
371
+ let [eager, lazy, source] = resolveContextSets(root, hookRoot);
372
+ eager = eager.filter(fresh);
373
+ lazy = lazy.filter(fresh);
374
+ if (!session.length && !eager.length && !lazy.length) {
375
+ err(`[context] nothing to load for ${root} `
376
+ + '(no docs discovered, no manifest entries).\n');
377
+ return 0;
378
+ }
379
+
380
+ const lines = [];
381
+ let spent = 0;
382
+ if (session.length) {
383
+ lines.push('=== SESSION FILES \u2014 your harness files, injected at session start '
384
+ + '(untouched seeds skipped) ===', '');
385
+ for (const s of session) {
386
+ const text = capEager(s.text, `${s.abs} on demand`);
387
+ spent += text.length;
388
+ lines.push(`----- ${s.rel} -----`, text, '');
389
+ }
390
+ }
391
+ if (eager.length || lazy.length) {
392
+ lines.push(`=== PROJECT CONTEXT \u2014 binding for this repo (via ${source}) ===`, '');
393
+ }
394
+ const demoted = [];
395
+ for (const entry of eager) {
396
+ const p = entry.path === undefined ? '' : entry.path;
397
+ const desc = entry.description === undefined ? '' : entry.description;
398
+ const target = path.isAbsolute(p) ? p : path.join(root, p);
399
+ let text;
400
+ try {
401
+ text = readText(target).replace(/\n+$/, '');
402
+ } catch (e) {
403
+ lines.push(`----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`,
404
+ `[context] MISSING eager file: ${asOsError(e, target)}`, '');
405
+ continue;
406
+ }
407
+ text = capEager(text, `${disp(p, root)} on demand for the rest`);
408
+ if (spent + text.length > EAGER_TOTAL_BYTES) {
409
+ demoted.push(entry);
410
+ continue;
411
+ }
412
+ spent += text.length;
413
+ lines.push(`----- ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''} -----`, text, '');
414
+ }
415
+
416
+ if (lazy.length || demoted.length) {
417
+ lines.push('--- Lazy entries (load only when the task needs them) ---');
418
+ for (const entry of [...lazy, ...demoted]) {
419
+ const p = entry.path === undefined ? '' : entry.path;
420
+ const desc = entry.description === undefined ? '' : entry.description;
421
+ const over = demoted.includes(entry)
422
+ ? ` (eager, but over the ${EAGER_TOTAL_BYTES / 1024} KB session budget \u2014 read on demand)` : '';
423
+ lines.push(` - ${disp(p, root)}${desc ? ` \u2014 ${desc}` : ''}${over}`);
424
+ }
425
+ lines.push('');
426
+ }
427
+
428
+ // Claude Code takes a SessionStart hook's plain stdout as context; Bob reads it the same way.
429
+ out(`${lines.join('\n')}\n`);
430
+ return 0;
431
+ }