geneseed 3.13.0 → 3.15.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 (131) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +23 -9
  4. package/adapters/opencode/plugins/geneseed-guard.js +16 -4
  5. package/adapters/opencode/plugins/geneseed-learn.js +45 -10
  6. package/adapters/opencode/plugins/geneseed-notify.js +37 -19
  7. package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
  8. package/adapters/opencode/workflows/_runtime.js +16 -3
  9. package/bin/geneseed-cli.mjs +32 -34
  10. package/bin/geneseed-hook.mjs +28 -18
  11. package/docs/extending.md +5 -5
  12. package/harness.config.json +1 -1
  13. package/js/README.md +28 -15
  14. package/js/build/args.mjs +375 -0
  15. package/js/build/bundle.mjs +10 -10
  16. package/js/build/driver.mjs +145 -560
  17. package/js/build/emit-claude.mjs +5 -6
  18. package/js/build/emit-common.mjs +2 -14
  19. package/js/build/emit-opencode.mjs +12 -8
  20. package/js/build/generate.mjs +21 -14
  21. package/js/build/render.mjs +6 -10
  22. package/js/build/source.mjs +9 -17
  23. package/js/build/stubs.mjs +6 -5
  24. package/js/build/version.mjs +21 -2
  25. package/js/hosts/hooks-context.mjs +432 -0
  26. package/js/hosts/hooks-learn.mjs +275 -0
  27. package/js/hosts/hooks-prims.mjs +108 -0
  28. package/js/hosts/hooks.mjs +50 -977
  29. package/js/hosts/hosts.mjs +31 -25
  30. package/js/hosts/installs.mjs +38 -20
  31. package/js/hosts/link.mjs +75 -26
  32. package/js/hosts/mcp.mjs +12 -48
  33. package/js/hosts/memory-files.mjs +190 -0
  34. package/js/hosts/native.mjs +106 -48
  35. package/js/hosts/opencode.mjs +44 -36
  36. package/js/hosts/settings.mjs +135 -414
  37. package/js/hosts/shim.mjs +375 -0
  38. package/js/inspect/checks-authoring.mjs +21 -828
  39. package/js/inspect/checks-build.mjs +4 -4
  40. package/js/inspect/checks-constitution.mjs +493 -0
  41. package/js/inspect/checks-counts.mjs +242 -0
  42. package/js/inspect/checks-repo.mjs +17 -8
  43. package/js/inspect/diff.mjs +9 -7
  44. package/js/inspect/doctor.mjs +2 -2
  45. package/js/inspect/excludes.mjs +10 -5
  46. package/js/inspect/inventory.mjs +4 -4
  47. package/js/inspect/scan.mjs +1 -1
  48. package/js/inspect/status.mjs +19 -22
  49. package/js/inspect/validate.mjs +50 -37
  50. package/js/lib/fs.mjs +12 -11
  51. package/js/lib/json.mjs +4 -4
  52. package/js/lib/paths.mjs +27 -33
  53. package/js/lib/proc.mjs +3 -3
  54. package/js/lib/prompt.mjs +132 -0
  55. package/js/lib/text.mjs +18 -4
  56. package/js/lib/udiff.mjs +3 -2
  57. package/js/loop/catalog.mjs +5 -3
  58. package/js/loop/cli.mjs +26 -12
  59. package/js/loop/graph.mjs +1 -1
  60. package/js/loop/registry.mjs +10 -15
  61. package/js/loop/score.mjs +1 -1
  62. package/js/loop/state.mjs +35 -11
  63. package/js/maintain/memory.mjs +6 -7
  64. package/js/maintain/migrate.mjs +22 -33
  65. package/js/maintain/setup.mjs +9 -131
  66. package/js/maintain/uninstall.mjs +57 -97
  67. package/js/maintain/update.mjs +115 -34
  68. package/js/ui/anim.mjs +8 -12
  69. package/js/ui/cli.mjs +37 -43
  70. package/js/ui/menu.mjs +7 -13
  71. package/js/ui/tui.mjs +14 -123
  72. package/js/web/actions.mjs +109 -403
  73. package/js/web/activity.mjs +1 -1
  74. package/js/web/api.mjs +41 -725
  75. package/js/web/catalog.mjs +527 -0
  76. package/js/web/daemon.mjs +21 -3
  77. package/js/web/docs.mjs +29 -72
  78. package/js/web/handler.mjs +51 -27
  79. package/js/web/jobs.mjs +24 -2
  80. package/js/web/routes.mjs +61 -6
  81. package/js/web/server.mjs +4 -4
  82. package/js/web/state.mjs +138 -0
  83. package/js/web/user-files.mjs +339 -0
  84. package/package.json +1 -1
  85. package/web/dist/assets/Activity-kpWxd70F.js +1 -0
  86. package/web/dist/assets/ActivityDetail-Ccr942VL.js +1 -0
  87. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  88. package/web/dist/assets/Docs-BHyaggFy.js +5 -0
  89. package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
  90. package/web/dist/assets/GreenhouseView-Sc_UDMOE.js +2 -0
  91. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  92. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  93. package/web/dist/assets/Laws-S9fq1r9T.js +3 -0
  94. package/web/dist/assets/Library-MBxadRzn.js +1 -0
  95. package/web/dist/assets/Loops-Da_JhlzM.js +2 -0
  96. package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
  97. package/web/dist/assets/OperatorHudView--TkvDybH.js +2 -0
  98. package/web/dist/assets/Personal-CTXKHVbV.js +1 -0
  99. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  100. package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
  101. package/web/dist/assets/api-CxgfYu18.js +1 -0
  102. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  103. package/web/dist/assets/index-Ctp-4poP.css +1 -0
  104. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  105. package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
  106. package/web/dist/assets/sections-GalMS0Zv.js +1 -0
  107. package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
  108. package/web/dist/index.html +5 -6
  109. package/web/src/pages/Laws.jsx +0 -1
  110. package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
  111. package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
  112. package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
  113. package/web/dist/assets/Docs-BzccCllG.js +0 -5
  114. package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
  115. package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
  116. package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
  117. package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
  118. package/web/dist/assets/Laws-BANxq_I_.js +0 -3
  119. package/web/dist/assets/Library-DW4x0fbh.js +0 -1
  120. package/web/dist/assets/Loops-DEBibbBP.js +0 -2
  121. package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
  122. package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
  123. package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
  124. package/web/dist/assets/activity-CObqyxkL.js +0 -1
  125. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  126. package/web/dist/assets/drawable-C_x5xncq.js +0 -1
  127. package/web/dist/assets/index-CMPgezFm.js +0 -2
  128. package/web/dist/assets/index-DMsT4Aa6.css +0 -1
  129. package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  130. package/web/dist/assets/sections-Dg23Hel5.js +0 -1
  131. package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
@@ -8,7 +8,8 @@
8
8
  * specific.
9
9
  *
10
10
  * * GROWING `bin/geneseed-hook.mjs` weakens the gate the design now rests on.
11
- * `test_the_entry_carries_exactly_the_verbs_the_emitter_wires` asserts EQUALITY between
11
+ * 'the hook entry carries exactly the verbs the emitter wires' (`tests/unit/hook_cli.test.mjs`)
12
+ * asserts EQUALITY between
12
13
  * that file's table and the hooks `js/hosts/settings.mjs` wires, and P5b made it load-bearing:
13
14
  * the shim is machine-wide (`~/.geneseed/bin/geneseed-hook[.cmd]`, no per-install
14
15
  * component) and last-writer-wins, so a Node emit owns the hooks of installs Python
@@ -23,23 +24,24 @@
23
24
  * no-op and a success are one observation.
24
25
  * * `bin/build-driver.mjs` IS `build.py`'s `main()` — a different Python program from
25
26
  * `rituals/harness.py`, not a different half of the same one. It parses generator FLAGS
26
- * with a partition asserted over them (`test_the_node_driver_classifies_every_emit`),
27
- * `tests/golden.py` drives it 259 times per run, and it is under a hard `child_process`
27
+ * with a partition asserted over them ('the driver classifies every emit',
28
+ * `tests/unit/node_driver.test.mjs`),
29
+ * `tests/golden.mjs` drives it over every emit configuration, and it is under a hard `child_process`
28
30
  * ban that is half the proof it is not a passthrough. `exclude` spawns nothing and would
29
31
  * not have broken that ban — but `web`, `upgrade` and `setup` will, and the ban should
30
32
  * outlive them rather than be dismantled by the first verb that does not need it.
31
33
  *
32
34
  * What this costs is what the P5b handoff predicted: a second acceptance matrix. It is paid
33
- * in `tests/harness_golden.py`, which now carries a `bin` per cell and takes `--new-cli`
34
- * beside `--new` — and it would have been paid identically by the `bin/build-driver.mjs` option,
35
+ * in `tests/golden.mjs --cli` (`tests/helpers/matrix/cli.<platform>.json`), which carries a
36
+ * `bin` per cell — and it would have been paid identically by the `bin/build-driver.mjs` option,
35
37
  * so it is not a discriminator between the two.
36
38
  *
37
39
  * THE VERB SET REFUSES THE REST BY NAME, exactly as the hook entry does. It carries 22 of the
38
40
  * 26 names the reference's parser answered to (25 subparsers, plus the alias) — the other
39
41
  * four are the hook verbs — and, since the three rows at the end of the table below, three
40
- * that the reference never had at all. `test_the_two_entry_points_carry_disjoint_verb_sets`
41
- * keeps the two tables from ever answering the same verb twice, since the shim bakes only one
42
- * of them.
42
+ * that the reference never had at all. 'the two entry points carry disjoint verb sets'
43
+ * (`tests/unit/hook_cli.test.mjs`) keeps the two tables from ever answering the same verb
44
+ * twice, since the shim bakes only one of them.
43
45
  *
44
46
  * P6h MADE THE DISPATCH ASYNCHRONOUS and put `js/web/` on this entry's import graph — LAZILY,
45
47
  * since the slim pass: every command module loads through its verb's thunk, so the graph is
@@ -58,7 +60,7 @@
58
60
  import '../js/lib/node-floor.mjs';
59
61
  import { cliSpec, printHelp, printVerbList } from '../js/ui/cli.mjs';
60
62
  import { parseIntStrict } from '../js/lib/text.mjs';
61
- import { printErr } from '../js/lib/fs.mjs';
63
+ import { printErr, printOut } from '../js/lib/fs.mjs';
62
64
 
63
65
  /**
64
66
  * The verbs this entry answers — and, since P10c, NOTHING ELSE ABOUT THEM.
@@ -73,9 +75,9 @@ import { printErr } from '../js/lib/fs.mjs';
73
75
  * hold: the FUNCTION.
74
76
  *
75
77
  * IT IS STILL A TABLE and still the DISPATCH rather than a declaration beside one.
76
- * `tests/test_hook_cli_parity.py` scrapes `const VERBS = {` for its three matrix gates, and
77
- * `main`'s refusal lists `Object.keys(VERBS)` IN THIS ORDER, which `harness_golden` cells
78
- * assert verbatim — the order is the old table's and is not alphabetical.
78
+ * `tests/unit/hook_cli.test.mjs` scrapes `const VERBS = {` for its matrix gates, and
79
+ * `main`'s refusal lists `Object.keys(VERBS)` IN THIS ORDER (no cell asserts the list
80
+ * verbatim any more) — the order is the old table's and is not alphabetical.
79
81
  *
80
82
  * FLATTENED TO `verb: fn` since Task 5, AND TO `verb: loader` since the lazy pass: every row
81
83
  * is now a thunk that imports the command's module and resolves to its function, so the one
@@ -104,9 +106,9 @@ const VERBS = {
104
106
  // `up.add_parser(..., aliases=["update"])`, reproduced in P8c as a ROW OF ITS OWN rather than
105
107
  // as an `aliases` field on the row above — because a field would be a DECLARATION and the
106
108
  // three matrix gates read this table as the DISPATCH (rule 7, and M23 is where it was
107
- // learned). As a key it is a real verb: `test_every_entry_verb_is_a_real_harness_subcommand`
108
- // reads argparse's aliases out of `harness.py` and finds it, and
109
- // `test_the_matrix_covers_every_verb_it_claims` demands the `update/` cell group that proves
109
+ // learned). As a key it is a real verb: `tests/unit/hook_cli.test.mjs`'s 'every entry verb
110
+ // is a command the driver table dispatches' finds it in `js/cli-table.json`, and 'the matrix
111
+ // covers every verb each entry claims' demands the `update/` cell group that proves
110
112
  // it reaches `cmdUpgrade` — including the theme re-read, which is what separates it from
111
113
  // `sync-self`.
112
114
  //
@@ -129,8 +131,8 @@ const VERBS = {
129
131
  // exit code, and that arm crosses byte for byte. The panel behind it is P7c's;
130
132
  // `js/ui/tui.mjs`'s header argues why this entry falls back to `cmd_tui`'s own
131
133
  // panel-unavailable line rather than inventing a second full-screen UI, and
132
- // `tests/test_tui_boundary.py` asserts that the arm it declares is genuinely unreachable
133
- // here rather than merely untested.
134
+ // `tests/unit/no_panel.test.mjs` asserts that no panel lies behind it — the module paints
135
+ // nothing and refuses on a TTY too — rather than leaving that merely untested.
134
136
  tui: () => import('../js/ui/tui.mjs').then((m) => m.cmdTui),
135
137
  // THE THREE THAT NEVER HAD A PYTHON ORIGINAL, and they are last for that reason rather
136
138
  // than by alphabet: every row above is the twin of a subparser, and these three are not.
@@ -143,9 +145,9 @@ const VERBS = {
143
145
  // this change rather than after it. The recorded help corpus was rendered from a live
144
146
  // argparse object, and there was no subparser to render these from; the acceptance matrix
145
147
  // is a comparison of two implementations, and there is only one. So their gates are
146
- // ABSOLUTE — `tests/snapshot/cli_help.test.mjs` splits the two populations by name and refuses a
147
- // verb that falls into neither, and each has its own unit gate stating what it does rather
148
- // than that it agrees.
148
+ // ABSOLUTE — `tests/unit/hook_cli.test.mjs`'s `NATIVE` list splits the two populations by
149
+ // name and refuses a verb that falls into neither, and each has its own unit gate stating
150
+ // what it does rather than that it agrees.
149
151
  catalog: () => import('../js/build/catalog.mjs').then((m) => m.cmdCatalog),
150
152
  mcp: () => import('../js/hosts/mcp.mjs').then((m) => m.cmdMcp),
151
153
  memory: () => import('../js/maintain/memory.mjs').then((m) => m.cmdMemory),
@@ -173,7 +175,7 @@ function die(code, msg) {
173
175
  *
174
176
  * The WORDING of an argparse failure is deliberately not reproduced — argparse prints a
175
177
  * usage block computed from the whole parser tree, and P5a set the precedent for the hook
176
- * entry: state the fault plainly, gate it absolutely in `test_hook_cli_parity.py`, and keep
178
+ * entry: state the fault plainly, gate it absolutely in `tests/unit/hook_cli.test.mjs`, and keep
177
179
  * it out of the compared matrix. What IS compared is every error the command's own body
178
180
  * raises, `exclude`'s missing-path refusal among them.
179
181
  */
@@ -302,7 +304,7 @@ async function main(argv) {
302
304
  // that binary is never reached, and its import graph is paid on every tool call.
303
305
  if (process.env.GENESEED_NO_SHIM_CHECK !== '1' && verb !== 'help'
304
306
  && !argv.some((t) => t === '-h' || t === '--help')) {
305
- const { shimDead } = await import('../js/hosts/settings.mjs');
307
+ const { shimDead } = await import('../js/hosts/shim.mjs');
306
308
  const dead = shimDead();
307
309
  if (dead.length) {
308
310
  printErr(`[geneseed] ⚠ hooks are off on this machine: the hook shim points at `
@@ -311,25 +313,21 @@ async function main(argv) {
311
313
  }
312
314
  }
313
315
  // `validate` — the generator's `--validate-only`, and the one verb this entry answers that is NOT
314
- // in the table above. It is dispatched here, before the lookup, for a reason that is a
315
- // property of the gates rather than a preference:
316
- //
317
- // * `VERBS` is asserted to be the twin of `rituals/harness.py`'s SUBPARSERS
318
- // (`test_every_entry_verb_is_a_real_harness_subcommand`), and `--validate-only` is a
319
- // GENERATOR flag — `harness.py` has never had a `validate` subcommand to be a twin of;
320
- // * `test_the_matrix_covers_every_verb_it_claims` demands a recorded cell group per table
321
- // row, and a cell has an `lf` half this host cannot produce.
322
- //
323
- // So a row would have meant amending two gates and recording a corpus half blind. It takes
324
- // the generator's own parser (see `parseDriverArgs`) because its flags are the generator's,
316
+ // in the table above. It is dispatched here, before the lookup, because its flags are the
317
+ // GENERATOR's, not a table row's: it takes the generator's own parser (see `parseDriverArgs`),
325
318
  // and it is on THIS binary — not on `bin/build-driver.mjs` — because it runs the doctor, which
326
319
  // starts a process the driver is banned from reaching.
327
320
  if (verb === 'validate') {
328
321
  // Lazy for the same reason the table rows are — `parseDriverArgs` alone drags the whole
329
322
  // generator driver in, and every OTHER verb was paying for it.
330
- const [{ cmdValidate }, { parseDriverArgs }] = await Promise.all([
323
+ const [{ cmdValidate, VALIDATE_USAGE }, { parseDriverArgs }] = await Promise.all([
331
324
  import('../js/inspect/validate.mjs'), import('../js/build/driver.mjs'),
332
325
  ]);
326
+ // Before the borrowed parser, whose own `-h` prints the GENERATOR's usage.
327
+ if (argv.slice(1).some((t) => t === '-h' || t === '--help')) {
328
+ printOut(VALIDATE_USAGE);
329
+ return 0;
330
+ }
333
331
  try {
334
332
  return cmdValidate(parseDriverArgs(argv.slice(1)));
335
333
  } catch (e) {
@@ -7,7 +7,8 @@
7
7
  * and each alone would be enough:
8
8
  *
9
9
  * * The generator driver is under a hard `child_process` ban
10
- * (`test_the_driver_imports_no_child_process_module`), which is half of the proof that
10
+ * (`tests/unit/hook_cli.test.mjs`'s 'the generator driver still reaches no child-process
11
+ * module'), which is half of the proof that
11
12
  * it is a real implementation rather than a passthrough to `build.py`. `learn` MUST
12
13
  * spawn — the whole verb is "hand these notes to whatever `$GENESEED_LLM` names".
13
14
  * Putting it in that file means either breaking the gate or weakening it.
@@ -15,7 +16,8 @@
15
16
  * the Python side; a Node twin needs its own, invoked as `<node> <this> context
16
17
  * --root "<cfg>"`.
17
18
  * * `bin/build-driver.mjs` parses generator FLAGS (`--emit`, `--theme`, `--footprint`), and
18
- * `test_the_node_driver_classifies_every_emit` asserts a partition over them. Verbs
19
+ * 'the driver classifies every emit' (`tests/unit/node_driver.test.mjs`) asserts a
20
+ * partition over them. Verbs
19
21
  * are a different shape and would sit awkwardly inside that.
20
22
  *
21
23
  * WHAT BAKES THIS FILE, SINCE P5b. `bin/build-driver.mjs` writes `<node> <checkout>/bin/
@@ -27,9 +29,10 @@
27
29
  * component — so the last driver to emit anything on a machine owns every install's hooks,
28
30
  * including installs the other driver wrote. That is safe exactly while the two entry
29
31
  * points answer the same verbs identically, which is not a hope:
30
- * `test_the_entry_carries_exactly_the_verbs_the_emitter_wires` pins the verb set to the
31
- * hooks the emitter wires, and `tests/harness_golden.py` runs this binary as a process
32
- * against the Python one on stdout, stderr, exit code and every file written.
32
+ * `tests/unit/hook_cli.test.mjs`'s "the hook entry carries exactly the verbs the emitter wires"
33
+ * pins the verb set to the hooks the emitter wires, and `node tests/golden.mjs --cli` runs
34
+ * this binary as a process with absolute assertions on stdout, stderr, exit code and every file
35
+ * written.
33
36
  *
34
37
  * The verb set is deliberately SMALL and refuses the rest by name. `harness.py` has 24
35
38
  * subparsers (25 invocable names — `update` is an alias of `upgrade`); a hook entry that
@@ -39,22 +42,29 @@
39
42
  */
40
43
  // FIRST, before anything it could fail to run: an old Node gets one sentence, not a stack trace.
41
44
  import '../js/lib/node-floor.mjs';
42
- import { cmdContext, cmdGitGate, cmdRuleGate, cmdToolGate, cmdLearn } from '../js/hosts/hooks.mjs';
43
- // Not a new module on the hot path: `js/hosts/hooks.mjs` above already imports
44
- // `js/lib/fs.mjs`, so this edge names a file the process has already loaded.
45
+ // Not a new module on the hot path: every verb's module imports `js/lib/fs.mjs` too, so this
46
+ // edge only loads early a file the process would load anyway.
45
47
  import { printErr } from '../js/lib/fs.mjs';
46
48
 
47
49
  // `--host` selects the verdict dialect (see js/hosts/hooks.mjs's header); the emitter bakes
48
50
  // it into the command for every host that is not Claude Code, so the hook never has to guess
49
51
  // its reader from the payload's shape.
52
+ //
53
+ // `load` is a DYNAMIC import per verb, so a process parses only its own verb's module: the gates
54
+ // run on every tool call and must not pay for `context`'s discovery or `learn`'s spawn. The
55
+ // specifiers stay literal strings — `tests/unit/hook_cli.test.mjs`'s import walk reads them.
50
56
  const HOSTED = { '--root': 'root', '--host': 'host' };
57
+ const gates = () => import('../js/hosts/hooks.mjs');
51
58
  const VERBS = {
52
- context: { fn: cmdContext, flags: HOSTED },
53
- 'git-gate': { fn: cmdGitGate, flags: HOSTED, switches: { '--no-consent': 'noConsent' } },
54
- 'rule-gate': { fn: cmdRuleGate, flags: HOSTED },
55
- 'tool-gate': { fn: cmdToolGate, flags: HOSTED },
59
+ context: { load: () => import('../js/hosts/hooks-context.mjs'), fn: 'cmdContext', flags: HOSTED },
60
+ 'git-gate': {
61
+ load: gates, fn: 'cmdGitGate', flags: HOSTED, switches: { '--no-consent': 'noConsent' },
62
+ },
63
+ 'rule-gate': { load: gates, fn: 'cmdRuleGate', flags: HOSTED },
64
+ 'tool-gate': { load: gates, fn: 'cmdToolGate', flags: HOSTED },
56
65
  learn: {
57
- fn: cmdLearn,
66
+ load: () => import('../js/hosts/hooks-learn.mjs'),
67
+ fn: 'cmdLearn',
58
68
  flags: { '--memory': 'memory' },
59
69
  switches: { '--consolidate': 'consolidate' },
60
70
  positional: 'file',
@@ -62,7 +72,7 @@ const VERBS = {
62
72
  };
63
73
 
64
74
  function die(code, msg) {
65
- // CRLF on Windows, for the same reason `js/hosts/hooks.mjs`'s funnels translate: argparse
75
+ // CRLF on Windows, for the same reason the hook verbs' funnels translate: argparse
66
76
  // writes this line through `sys.stderr`, which does. `printErr` owns the `\n`-to-`os.linesep` rule.
67
77
  printErr(`geneseed-hook: error: ${msg}\n`);
68
78
  return code;
@@ -115,7 +125,8 @@ async function main(argv) {
115
125
  if (!spec) {
116
126
  // "elsewhere", not a file name: naming `bin/geneseed-cli.mjs` here would mean this entry
117
127
  // carrying a copy of its sibling's verb table — the one thing
118
- // `test_the_two_entry_points_carry_disjoint_verb_sets` exists to keep from happening.
128
+ // 'the two entry points carry disjoint verb sets' (`tests/unit/hook_cli.test.mjs`) exists to
129
+ // keep from happening.
119
130
  // The COMMAND is not a table: `geneseed` is this package's `bin` entry for the CLI
120
131
  // (package.json), it answers every non-hook verb, and it survives the deletion of the
121
132
  // interpreter-plus-script invocation this line used to print.
@@ -128,8 +139,7 @@ async function main(argv) {
128
139
  // argument. The four hook verbs are four of the reference's 26 and had the same gap.
129
140
  // `js/ui/cli.mjs` is imported HERE, not at the top: a static import cost every tool call
130
141
  // of every session a module parse (~3 ms of an ~12 ms controllable budget) for a branch
131
- // only a human at a terminal ever takes. The hook path never awaits it, so `main` staying
132
- // async-shaped costs the hot verbs nothing.
142
+ // only a human at a terminal ever takes.
133
143
  if (argv.slice(1).some((t) => t === '-h' || t === '--help')) {
134
144
  const { printHelp } = await import('../js/ui/cli.mjs');
135
145
  const rc = printHelp('geneseed-hook', verb);
@@ -137,7 +147,7 @@ async function main(argv) {
137
147
  }
138
148
  const parsed = parse(spec, argv.slice(1));
139
149
  if (parsed.error) return die(2, parsed.error);
140
- return spec.fn(parsed.args);
150
+ return (await spec.load())[spec.fn](parsed.args);
141
151
  }
142
152
 
143
153
  main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
package/docs/extending.md CHANGED
@@ -336,7 +336,7 @@ render — no citation moves, because none names a number. Then:
336
336
  3. Its `LEX_<ID>`/`DOC_<ID>` key in all fifteen theme files (the equality arm names each).
337
337
  4. The number-keyed tables, which the doctor checks against the canon: `LAW_CLASS`
338
338
  (`js/inspect/inventory.mjs`), `LAW_META`/`DOCTRINE_META` (their id pins fail on every shifted
339
- row), and — only if a hook-gated rule moved — `HOOK_PINNED` in `checks-authoring.mjs` names the
339
+ row), and — only if a hook-gated rule moved — `HOOK_PINNED` in `checks-constitution.mjs` names the
340
340
  ledger keys, `CONSENT_RULE` and `HOOK_GATED` to move with it. `--exclude-rules` addresses are
341
341
  `<pack>.<n>` too, and an install that excluded a shifted doctrine rule excludes its neighbour
342
342
  after a re-emit: prefer appending in a pack, and say so in the CHANGELOG when you cannot.
@@ -391,9 +391,10 @@ Two traps worth knowing before you write the spec:
391
391
  re-opens bash on all three hosts (OpenCode gates it to `ask`), and `<!-- webfetch: allow -->`
392
392
  re-opens web fetch — the researcher is the one spec that carries it.
393
393
  - **The console has a fourth, ungated copy of the skill taxonomy.** `SKILL_CATS` in
394
- `web/src/pages/Skills.jsx:19` mirrors `SKILL_CLASS` with nothing comparing them (contrast
394
+ `web/src/lib/lawCats.js` mirrors `SKILL_CLASS` with nothing comparing them (contrast
395
395
  `LAW_META`, which *is* cross-checked). Inventing a new *category* means editing that file —
396
- which drags in the `web/dist` rebuild — and an unknown category renders as Build with no warning.
396
+ which drags in the `web/dist` rebuild — and an unknown category lands under Personal in the
397
+ Library (`splitSkills` in `web/src/pages/Library.jsx`) with no warning.
397
398
 
398
399
 
399
400
  ---
@@ -462,8 +463,7 @@ throws). The same file is what GitHub shows, so write for both
462
463
  passage for several hosts lists them, `<!--harness:claude,openclaude-->`, labelled
463
464
  `*(Claude Code and OpenClaude only)*` (names in the marker's order, joined by *and*).
464
465
  - No `{N_*}` token in `understand/` or `guides/` — GitHub would show it raw.
465
- - `kind: "markdown"` pulls from a repo file via `source:`, traversal-guarded, optionally sliced to
466
- one section with `anchor:` + `slice: true`.
466
+ - `kind: "markdown"` pulls from a repo file via `source:`, traversal-guarded.
467
467
 
468
468
  Inline `<!-- harness:claude -->` blocks strip per host and **fail open** on an unbalanced marker —
469
469
  the page renders, wrong, with nothing red.
@@ -2,5 +2,5 @@
2
2
  "_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
3
3
  "theme": "neutral",
4
4
  "product": "Geneseed",
5
- "version": "3.13.0"
5
+ "version": "3.15.0"
6
6
  }
package/js/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `js/` — the module map
2
2
 
3
- Sixty-one modules in eight folders. This page is the address book: **what each one owns**, and
3
+ Sixty-six modules in eight folders. This page is the address book: **what each one owns**, and
4
4
  **which file to open first** for a given task. It is not documentation of behaviour — every module
5
5
  has a docblock for that, and the docblock is the thing to read before editing.
6
6
 
@@ -46,7 +46,8 @@ as a per-host install at project or global scope.
46
46
  | `emit-common.mjs` | Constants, tree walkers, and the three writers BOTH global emits share. Decides no host |
47
47
  | `stubs.mjs` | Write-once seed files: context, wiki, rules, excludes, profile, `.gitignore`, memory/notebook indexes |
48
48
  | `version.mjs` | The release marker: fingerprint the sources, read/compare/write an install's version, warn on downgrade |
49
- | `driver.mjs` | The generator driver: `--emit`/`--theme`/`--footprint` flag parsing, the nine emit targets, per-host emit orchestration, the prune and the manifest. `bin/build-driver.mjs` is only its entry |
49
+ | `driver.mjs` | The generator driver: the nine emit targets, per-host emit orchestration, the prune and the manifest. `bin/build-driver.mjs` is only its entry |
50
+ | `args.mjs` | The generator's flag surface: `harness.config.json` defaults, the `--emit`/`--theme`/`--footprint` parse tables and choices, `-h`, and `die` (a throw, so `rebuild-all` survives one bad install) |
50
51
  | `generate.mjs` | CLI verbs `build`, `prompt`, `theme`, `rebuild-all` — the thin face over `driver.mjs` |
51
52
  | `catalog.mjs` | `geneseed catalog` — prints the shipped roster. Classifies nothing itself |
52
53
  | `themes.mjs` | Maintainer-only `--sync-themes`: inserts `_TEMPLATE.json`'s missing keys into the committed themes |
@@ -67,19 +68,25 @@ user co-owns, and the hook verbs those emitted configs then run.
67
68
 
68
69
  | module | owns |
69
70
  |---|---|
70
- | `hosts.mjs` | The four host config dirs (opencode/claude/bob/openclaude), plus `resolvePath`/`expanduser` |
71
- | `hooks.mjs` | The verbs the emitted hooks run every session: `context`, `git-gate`, `rule-gate`, `tool-gate` (the two gates fused, for Bob), `learn`. `--host` picks the verdict dialect |
71
+ | `hosts.mjs` | The four host config dirs (opencode/claude/bob/openclaude), their `family` (`CLAUDE_STYLE`), plus `resolvePath`/`expanduser` |
72
+ | `hooks.mjs` | The gate verbs, run on every tool call: `git-gate`, `rule-gate`, `tool-gate` (the two gates fused, for Bob), plus `ask`/the gate ledger/`guardGate`. `--host` picks the verdict dialect |
73
+ | `hooks-context.mjs` | The `context` verb — SessionStart: session files plus the repo's context docs |
74
+ | `hooks-learn.mjs` | The `learn` verb — Stop/SubagentStop/PreCompact distiller; the hook path's ONE spawn (`$GENESEED_LLM`) |
75
+ | `hooks-prims.mjs` | The Python path/text primitives the hook verbs share (`splitLines`, `readStdin`, a never-throwing `isFile`) |
76
+ | `memory-files.mjs` | The memory store's file format — `frontmatter`, `MEMORY.md` index writes, agent lessons, consolidate. No spawn: the CLI and web console import it |
72
77
  | `gitref.mjs` | fs/path-only: `gitRootOf`, `gitDirOf`, `currentBranch`, `loopLaunched` — read straight off `.git`, never spawn git. Shared by `hooks.mjs`'s git-gate and `js/loop/cli.mjs`'s commit-message writer |
73
- | `settings.mjs` | Merges into user-owned `settings.json`/`opencode.json`; owns the hook shim and the managed blocks |
78
+ | `settings.mjs` | Merges into user-owned `settings.json`/`opencode.json` (one `loadJsonObject` reader for all of them) and the managed blocks |
79
+ | `shim.mjs` | The machine-wide hook shim: its path, body, liveness (`shimDead`, read on every CLI run), `hookPrefix`/`hookRunnerEntry`. Kept cheap to import |
74
80
  | `native.mjs` | Capability specs → host-native subagents and skills. The impure half of the emit |
75
81
  | `opencode.mjs` | OpenCode-only extras: colour themes, primary agent, `/commands`, plugins, overrides stub |
76
82
  | `installs.mjs` | Detects deployed installs and reads back their theme, footprint, posture, mode and loop trust preset |
77
83
  | `mcp.mjs` | MCP server presets, the per-host `mcpServers` config read/write, and toggle state |
78
84
  | `link.mjs` | `geneseed link`/`unlink` — the PATH shim, and the only Windows USER-Path registry edit |
79
85
 
80
- **Before editing:** `hooks.mjs` **is the hook path** — the verdict travels as JSON on stdout and
86
+ **Before editing:** `hooks*.mjs` **are the hook path** — the verdict travels as JSON on stdout and
81
87
  every arm exits 0, so one stray printed byte turns a blocking gate silently permissive, and anything
82
- imported here is paid on every tool call. `hosts.mjs` is shared by the generator, the CLI and the
88
+ `hooks.mjs` or `hooks-prims.mjs` imports is paid on every tool call. `bin/geneseed-hook.mjs` loads
89
+ one verb's module per process, so keep context and learn code out of the gate module. `hosts.mjs` is shared by the generator, the CLI and the
83
90
  hook path, so it must stay free of `child_process`. `installs.mjs` reads through `readMaybe`, which
84
91
  folds CRLF — a bare `readFileSync` here is a bug.
85
92
 
@@ -92,8 +99,10 @@ Everything that inspects an already-built or already-deployed harness and report
92
99
  | `doctor.mjs` | The `geneseed doctor` **driver** only: picks themes, runs the checks, prints the verdict |
93
100
  | `checks-build.mjs` | Gates over what the build PRODUCED: links, tokens, theme parity, OpenCode colours, bundle drift |
94
101
  | `checks-repo.mjs` | Gates over the repo's own records: `registry.json`, committed secrets, the hook shim, vendored pins |
95
- | `checks-authoring.mjs` | Self-consistency: law/doctrine metadata, constitution numbering, the counts README and the web quote |
96
- | `scan.mjs` | The shared walk/sort/strip/test primitives all three `checks-*` use. Owns no judgement itself |
102
+ | `checks-authoring.mjs` | `authoringProblems`, the aggregate self-consistency gate: spec purpose lines, the learn-prompt literal, `node --check` over the plugins (the doctor's one spawn), then the groups below |
103
+ | `checks-constitution.mjs` | The constitution: rule declarations and citations, LEAN blocks, `HOOK_PINNED`, theme vocabulary, the console's `LAW_META`/`DOCTRINE_META` |
104
+ | `checks-counts.mjs` | The hand-written counts: AGENT.md tables, README badges and prose, `SHIPPED.md`, held against `src/` |
105
+ | `scan.mjs` | The shared walk/sort/strip/test primitives every `checks-*` uses. Owns no judgement itself |
97
106
  | `validate.mjs` | The `--validate-only` verb: renders a sandbox, scans it for tokens and dead links, own exit contract |
98
107
  | `inventory.mjs` | The one catalog walk and its taxonomy tables, read by status, the TUI and the web console |
99
108
  | `status.mjs` | `geneseed status` and `version` — the panel data, counts, accent colour, version verdict |
@@ -120,6 +129,7 @@ into files users have committed, so change the test that states it in the same c
120
129
  | `text.mjs` | String primitives: `codePointLength`, `WHITESPACE`, `parseIntStrict`, `percentDecode` |
121
130
  | `udiff.mjs` | `geneseed diff`'s own difflib clone — the alignment IS the printed hunks — plus `splitLines` |
122
131
  | `proc.mjs` | One constant: the `windowsHide` flag every spawn in `js/` must reuse |
132
+ | `prompt.mjs` | The line-mode stdin readers — `ask`, `promptLine`, `confirm`, `askChoice` — read a byte at a time so they stop where Enter stops. Shared by `setup`, `uninstall` and `web` without dragging in the wizard |
123
133
  | `node-floor.mjs` | The first import of all three binaries: below Node 22.3, one sentence on stderr and exit 1 (0 in the hook) instead of a stack trace |
124
134
 
125
135
  **Before editing:** parse with `parseJson`, never `JSON.parse` — the latter collapses int `1` and
@@ -193,10 +203,13 @@ the deployed harness as JSON.
193
203
  | module | owns |
194
204
  |---|---|
195
205
  | `server.mjs` | `serve()`/`cmdWeb`: UI-build precheck, socket bind, token mint, daemon record wiring |
196
- | `routes.mjs` | Route DECLARATION only: the `POST_ROUTES` table and the ported/unported/declined sets |
197
- | `handler.mjs` | One function per request: dispatch, gzip, Host/CSRF guards, static SPA fallback |
198
- | `api.mjs` | `WebState` plus every GET read endpoint; owns `STATE_ROUTES`, `PREFIX_ROUTES`, `NotFound` |
199
- | `actions.mjs` | The mutating endpoints — rules, profile, memory, MCP, excludes — plus install/deploy argv |
206
+ | `routes.mjs` | The route tables — `STATE_ROUTES`, `PREFIX_ROUTES`, `POST_ROUTES` (+ its 409 column) — and the inline lists |
207
+ | `handler.mjs` | One function per request: dispatch, gzip, Host/CSRF guards and body cap (before the body is read), static SPA fallback |
208
+ | `state.mjs` | `webState` — the resolved install view every endpoint reads, with its cached inventory and doctor |
209
+ | `catalog.mjs` | The Library read model: inventory, constitution tiers, memory/notebook/wiki/config items, `apiCatalog`/`apiItem`, `NotFound` |
210
+ | `api.mjs` | The dashboard and settings reads: overview, recent, themes, setup, doctor, diff, installs, excludes, loops |
211
+ | `user-files.mjs` | The fingerprint-guarded editors for `user-rules.md` (incl. memory promotion) and `PROFILE.md`, read and write |
212
+ | `actions.mjs` | The other mutating endpoints — memory delete, MCP, excludes, installs, view, restore — plus install/deploy argv |
200
213
  | `daemon.mjs` | Detached web start/stop/status/restart, the `.geneseed-web.json` record, and `openUrl` |
201
214
  | `jobs.mjs` | `JobManager` and the action→argv table. The only module here that spawns job children |
202
215
  | `docs.mjs` | The Docs tab: the `docs/<folder>/` page registry, `?harness=` filtering, link rewriting, the six page kinds |
@@ -220,7 +233,7 @@ and inline lists are held against a surface list written out in
220
233
  | a doctor check | `js/inspect/checks-{build,repo,authoring}.mjs` | register with `ran(...)` in `doctor.mjs`, then a PLANTED FAULT in `tests/unit/harness.test.mjs` |
221
234
  | a web endpoint | GET → `js/web/api.mjs`; POST → `js/web/actions.mjs` | declare it in `js/web/routes.mjs`; the partition test probes the real handler |
222
235
  | a CLI verb | `js/cli-table.json` — the table IS the parser | the `VERBS` row in `bin/geneseed-cli.mjs`, the row count in `cli_table.test.mjs`, `NATIVE` in **two** test files |
223
- | a hook verb | `js/hosts/hooks.mjs` | `claudeHookGroups` in `settings.mjs`, `VERBS` in `bin/geneseed-hook.mjs`. Keep the import graph tiny |
236
+ | a hook verb | `js/hosts/hooks.mjs` (a gate) or its own `js/hosts/hooks-<verb>.mjs` | `claudeHookGroups` in `settings.mjs`, the `VERBS` row (with its `load`) in `bin/geneseed-hook.mjs`. Keep the import graph tiny |
224
237
  | a console doc page | `docs/<understand|guides|concepts|reference>/<id>.md` — the stem is the id | `docs/extending.md` §4c. Only `kind: "concept"` gets `{N_*}` substitution |
225
238
  | an OpenCode plugin | `adapters/opencode/plugins/geneseed-<name>.js` | `docs/extending.md` §4a — the `geneseed-` prefix is mechanically load-bearing |
226
- | to change a `js/lib/` primitive | `tests/fixtures/pure_probe.mjs` **first** | the docblock says which Node default it deliberately departs from — change that sentence too, or the next reader restores the default |
239
+ | to change a `js/lib/` primitive | `tests/unit/lib_primitives.test.mjs` **first** | the docblock says which Node default it deliberately departs from — change that sentence too, or the next reader restores the default |