geneseed 3.2.0 → 3.3.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 (112) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +22 -12
  3. package/SETUP.md +38 -7
  4. package/SHIPPED.md +6 -5
  5. package/adapters/bob/README.md +40 -10
  6. package/adapters/claude-code/README.md +5 -0
  7. package/adapters/claude-code/settings.json +8 -1
  8. package/adapters/copilot/README.md +50 -24
  9. package/adapters/opencode/README.md +5 -1
  10. package/adapters/opencode/plugins/geneseed-guard.js +40 -6
  11. package/bin/build-driver.mjs +16 -10
  12. package/bin/geneseed-cli.mjs +55 -56
  13. package/bin/geneseed-hook.mjs +21 -14
  14. package/docs/candidate-hosts.md +74 -0
  15. package/docs/extending.md +6 -5
  16. package/docs/token-footprint.md +32 -21
  17. package/docs/web-ui.md +5 -13
  18. package/harness.config.json +1 -1
  19. package/js/README.md +5 -6
  20. package/js/build/emit-claude.mjs +22 -7
  21. package/js/build/render.mjs +37 -19
  22. package/js/build/source.mjs +22 -2
  23. package/js/cli-table.json +84 -0
  24. package/js/hosts/hooks.mjs +240 -78
  25. package/js/hosts/hosts.mjs +56 -2
  26. package/js/hosts/link.mjs +1 -1
  27. package/js/hosts/mcp.mjs +3 -1
  28. package/js/hosts/native.mjs +17 -0
  29. package/js/hosts/settings.mjs +173 -5
  30. package/js/inspect/excludes.mjs +1 -1
  31. package/js/inspect/inventory.mjs +1 -1
  32. package/js/inspect/status.mjs +50 -2
  33. package/js/maintain/uninstall.mjs +38 -11
  34. package/js/maintain/update.mjs +2 -2
  35. package/js/web/actions.mjs +8 -8
  36. package/js/web/api.mjs +5 -10
  37. package/js/web/docs.mjs +9 -31
  38. package/js/web/handler.mjs +20 -30
  39. package/js/web/jobs.mjs +5 -26
  40. package/js/web/routes.mjs +18 -84
  41. package/js/web/server.mjs +3 -3
  42. package/package.json +1 -1
  43. package/registry.json +14 -0
  44. package/src/AGENT.md.tmpl +55 -16
  45. package/src/agents/explorer.md +1 -1
  46. package/src/doctrines/README.md +3 -3
  47. package/src/doctrines/rigor.md +13 -0
  48. package/src/laws/universal.md +98 -20
  49. package/src/ontology/universal.md +38 -13
  50. package/src/skills/_template.md +5 -1
  51. package/src/skills/bruno-collection-generator.md +63 -0
  52. package/src/skills/bruno-test-writer.md +62 -0
  53. package/src/skills/crash-course.md +1 -0
  54. package/src/skills/decode.md +1 -0
  55. package/src/skills/drill.md +1 -0
  56. package/src/skills/feynman.md +1 -0
  57. package/src/skills/forge-mcp.md +1 -1
  58. package/src/skills/herdr.md +1 -0
  59. package/src/skills/learning-path.md +1 -0
  60. package/src/skills/opencode-theme.md +1 -0
  61. package/src/skills/research.md +1 -1
  62. package/themes/_TEMPLATE.json +3 -0
  63. package/themes/biker.json +3 -0
  64. package/themes/commentator.json +3 -0
  65. package/themes/cyberpunk.json +3 -0
  66. package/themes/gamer.json +3 -0
  67. package/themes/imperial.json +3 -0
  68. package/themes/joker.json +3 -0
  69. package/themes/marvin.json +3 -0
  70. package/themes/mean.json +3 -0
  71. package/themes/military.json +3 -0
  72. package/themes/neutral.json +3 -0
  73. package/themes/pirate.json +3 -0
  74. package/themes/sports.json +3 -0
  75. package/themes/verstappen.json +3 -0
  76. package/themes/wizard.json +3 -0
  77. package/web/dist/assets/Activity-BfVcLrda.js +1 -0
  78. package/web/dist/assets/ActivityDetail-CQXccGPl.js +1 -0
  79. package/web/dist/assets/{CatalogRow-DqS4e5XM.js → CatalogRow-DALEQImZ.js} +1 -1
  80. package/web/dist/assets/{Diff-CIrGbiqm.js → Diff-BIjxXx46.js} +1 -1
  81. package/web/dist/assets/{Doctor-CpI53oHY.js → Doctor-CJLbgsrw.js} +1 -1
  82. package/web/dist/assets/FilterInput-CkUCk_lY.js +1 -0
  83. package/web/dist/assets/GreenhouseView-DfC5IILJ.js +2 -0
  84. package/web/dist/assets/{Harness-vRYs11kX.js → Harness-Dm5CCU-v.js} +1 -1
  85. package/web/dist/assets/JournalView-Cpu2eI4s.js +1 -0
  86. package/web/dist/assets/Laws-DDDVFuQz.js +3 -0
  87. package/web/dist/assets/Library-Dao3T9na.js +1 -0
  88. package/web/dist/assets/{Markdown-kgpg9EQM.js → Markdown-C__nuWX5.js} +1 -1
  89. package/web/dist/assets/OperatorHudView-DEpCtAjP.js +2 -0
  90. package/web/dist/assets/{Profile-CHr-PPf6.js → Profile-BlXtUlh2.js} +1 -1
  91. package/web/dist/assets/{Rules-CsC6hfqk.js → Rules-DIre6gxR.js} +1 -1
  92. package/web/dist/assets/Skills-rY13ZPhM.js +1 -0
  93. package/web/dist/assets/{StatusBadge-gRrak0nO.js → StatusBadge-wW_RS_my.js} +1 -1
  94. package/web/dist/assets/{activity-B0-FXZCj.js → activity-DpYPKNrA.js} +1 -1
  95. package/web/dist/assets/{index-DJOhOwwR.js → index-BEZvPvpF.js} +1 -1
  96. package/web/dist/assets/index-DNQE5ydy.js +10 -0
  97. package/web/dist/assets/index-DzdQjvM1.css +1 -0
  98. package/web/dist/assets/{index-Bx3I7cRW.js → index-PmQGRS1X.js} +1 -1
  99. package/web/dist/assets/jobBuckets-DfTUJUxR.js +1 -0
  100. package/web/dist/assets/lawCats-DUS9UTH4.js +1 -0
  101. package/web/dist/index.html +2 -2
  102. package/web/src/pages/Laws.jsx +82 -64
  103. package/js/web/graph.mjs +0 -0
  104. package/web/dist/assets/Activity-BTCG2gMl.js +0 -1
  105. package/web/dist/assets/ActivityDetail-julsmPDB.js +0 -1
  106. package/web/dist/assets/Graph-CS4tA7YS.js +0 -1
  107. package/web/dist/assets/Laws-BjbHJrJt.js +0 -3
  108. package/web/dist/assets/Library-CjC7BAWN.js +0 -1
  109. package/web/dist/assets/Skills-BvatJGfe.js +0 -1
  110. package/web/dist/assets/index-BnRWQbZb.js +0 -12
  111. package/web/dist/assets/index-D0f9tJlA.css +0 -1
  112. package/web/dist/assets/roman-DkYN0LX9.js +0 -1
@@ -60,6 +60,36 @@ const WARN_ONLY = MODE === "warn"
60
60
 
61
61
  function log(msg) { console.error(`[geneseed-guard] ${msg}`) }
62
62
 
63
+ // ---- the gate ledger ------------------------------------------------------------
64
+ // The twin of `ledger()` in js/hosts/hooks.mjs: one JSON line per BLOCK into
65
+ // `<cfg>/notebook/gates.jsonl`, the rule and never the content, so `geneseed status` can
66
+ // count what the boundary actually caught on an OpenCode install too. Until this the
67
+ // recommended install was the one with no gate evidence at all. A warning is not recorded
68
+ // (nothing was refused) and neither is `GENESEED_GUARD=warn`. `notebook/` is never created
69
+ // here — an install without one (or a test importing this file) records nothing. Capped
70
+ // at the newest MAX_LEDGER_LINES like the hook's; never throws, never decides.
71
+ const MAX_LEDGER_LINES = 1000
72
+ async function ledger(rule) {
73
+ try {
74
+ const bases = []
75
+ if (process.env.GENESEED_HARNESS) bases.push(process.env.GENESEED_HARNESS)
76
+ bases.push(path.dirname(PLUGIN_DIR))
77
+ for (const base of bases) {
78
+ const dir = path.join(base, "notebook")
79
+ if (!(await isDir(dir))) continue
80
+ const file = path.join(dir, "gates.jsonl")
81
+ await fs.appendFile(file, `${JSON.stringify({
82
+ ts: new Date().toISOString(), verb: "guard", rule, cwd: process.cwd(),
83
+ })}\n`)
84
+ const lines = (await fs.readFile(file, "utf8")).split("\n").filter(Boolean)
85
+ if (lines.length > MAX_LEDGER_LINES) {
86
+ await fs.writeFile(file, `${lines.slice(-MAX_LEDGER_LINES).join("\n")}\n`)
87
+ }
88
+ return
89
+ }
90
+ } catch { /* the ledger never decides */ }
91
+ }
92
+
63
93
  // Files the agent must not write (secrets / private keys) → BLOCK.
64
94
  const SECRET_RE = [
65
95
  /(^|\/)id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$/i,
@@ -119,6 +149,7 @@ const hasAny = (name, parts) => parts.some((s) => name.includes(s))
119
149
  // per wiki. Mutating anything under one is denied. Same resolution chain as the
120
150
  // context plugin: $GENESEED_WIKI -> $GENESEED_HARNESS/wiki.jsonc -> beside the install.
121
151
  async function isFile(p) { try { return (await fs.stat(p)).isFile() } catch { return false } }
152
+ async function isDir(p) { try { return (await fs.stat(p)).isDirectory() } catch { return false } }
122
153
 
123
154
  // wiki.jsonc is JSONC (the seeded stub carries a commented example): strip // and
124
155
  // /* */ comments plus trailing commas before parsing — string-aware, so quoted
@@ -243,22 +274,25 @@ export const GeneseedGuard = async () => {
243
274
  if (await sovereignBypass(process.cwd())) return
244
275
  const tool = (input?.tool || input?.name || "").toLowerCase()
245
276
  const args = output?.args || input?.args || {}
246
- const deny = (why) => {
277
+ // `rule` is the ledger key, spelled as js/hosts/hooks.mjs spells it (`law-1`,
278
+ // `law-4`, `process-1`) plus `wiki`, so status counts one vocabulary across hosts.
279
+ const deny = async (why, rule) => {
247
280
  if (WARN_ONLY) { log(`WARN (would block): ${why}`); return false }
248
281
  log(`BLOCKED: ${why}`)
282
+ await ledger(rule)
249
283
  throw new Error(`[geneseed-guard] blocked: ${why} — set GENESEED_GUARD=off to allow`)
250
284
  }
251
285
  try {
252
286
  if (hasAny(tool, WRITE_TOOLS)) {
253
287
  const p = pickPath(args)
254
- if (p && SECRET_RE.some((re) => re.test(p))) { deny(`write to secret/key file ${p} (Law I)`); return }
288
+ if (p && SECRET_RE.some((re) => re.test(p))) { await deny(`write to secret/key file ${p} (Law I)`, "law-1"); return }
255
289
  if (p && SECRET_WARN_RE.some((re) => re.test(p))) log(`WARN: writing ${p} — keep secrets out of tracked files (Law I)`)
256
290
  const store = p && ruleStoreTarget(p)
257
291
  if (store && !RULE_STORE_BUMPED.has(p)) {
258
292
  RULE_STORE_BUMPED.add(p)
259
- deny(`writing to ${store} — a standing rule, or a fact to remember? That ` +
293
+ await deny(`writing to ${store} — a standing rule, or a fact to remember? That ` +
260
294
  `choice is the user's (Doctrine process 1). Settle it through the rule skill, ` +
261
- `then re-issue this write`)
295
+ `then re-issue this write`, "process-1")
262
296
  return
263
297
  }
264
298
  }
@@ -268,14 +302,14 @@ export const GeneseedGuard = async () => {
268
302
  const abs = (path.isAbsolute(p) ? p : path.resolve(p)).replace(/\\/g, "/").toLowerCase()
269
303
  const hit = (await protectedPrefixes()).find(
270
304
  (x) => abs.startsWith(x.prefix) || abs === x.prefix.slice(0, -1))
271
- if (hit) { deny(`mutation in protected wiki folder — ${hit.label} (AGENT.md §8)`); return }
305
+ if (hit) { await deny(`mutation in protected wiki folder — ${hit.label} (AGENT.md §8)`, "wiki"); return }
272
306
  }
273
307
  }
274
308
  // NOT else-if: a compound tool name (e.g. exec_and_save) can match both
275
309
  // classes, and the shell check must still run after the write check.
276
310
  if (hasAny(tool, SHELL_TOOLS)) {
277
311
  const c = pickCommand(args)
278
- if (c && SHELL_BLOCK_RE.some((re) => re.test(c))) { deny(`catastrophic command (Law IV): ${c.slice(0, 80)}`); return }
312
+ if (c && SHELL_BLOCK_RE.some((re) => re.test(c))) { await deny(`catastrophic command (Law IV): ${c.slice(0, 80)}`, "law-4"); return }
279
313
  if (c && SHELL_WARN_RE.some((re) => re.test(c))) log(`WARN: irreversible op — confirm intent (Law IV): ${c.slice(0, 80)}`)
280
314
  }
281
315
  } catch (err) {
@@ -42,7 +42,7 @@ import { parseArgs as nodeParseArgs } from 'node:util';
42
42
  import { build, phaseLog } from '../js/build/bundle.mjs';
43
43
  import { emitClaudeRender } from '../js/build/emit-claude.mjs';
44
44
  import { emitOpencodeRender, emitOpencodeGlobalRender } from '../js/build/emit-opencode.mjs';
45
- import { settingsIntegrityCheck } from '../js/hosts/settings.mjs';
45
+ import { copilotIntegrityCheck, settingsIntegrityCheck } from '../js/hosts/settings.mjs';
46
46
  import { writeText, withPlatformNewlines, isFile } from '../js/lib/fs.mjs';
47
47
  import { parseJson, jsonDumpsIndent } from '../js/lib/json.mjs';
48
48
  // P5c moved these out of this file: `bin/geneseed-cli.mjs` needs the same four resolvers to
@@ -848,8 +848,8 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
848
848
  // VERIFY — re-read the settings file just written and match it against the claims just
849
849
  // recorded, so a merge that silently did not stick (a commented file, a mid-flight
850
850
  // external edit, a bug in the merge) is loud now instead of surfacing as hooks that
851
- // quietly never fire. Skipped for Copilot, which writes no settings file at all
852
- // (`_emit_claude_core:827`) — the other half of why Copilot could cross first.
851
+ // quietly never fire. Copilot's settings have their own shape and their own check; a
852
+ // Copilot PROJECT emit records no hooks and so checks nothing.
853
853
  //
854
854
  // On the Python driver this stage runs in Python AFTER a Node child did the wiring, which
855
855
  // makes it a live cross-implementation check on every build. Here both halves are Node, so
@@ -860,15 +860,17 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
860
860
  // SILENT ON SUCCESS, which is its own coverage hazard: a clean emit prints nothing, so
861
861
  // deleting this call is byte-identical in all 259 cells. `test_verify_reports_an_orphaned
862
862
  // _geneseed_hook` plants the fault that makes it speak.
863
+ phaseLog('VERIFY');
863
864
  if (!isCopilot) {
864
- phaseLog('VERIFY');
865
865
  settingsIntegrityCheck(
866
866
  path.join(cfgDir, managed.settings_file || 'settings.json'), managed, 'present');
867
+ } else if (managed.copilot_hooks) {
868
+ copilotIntegrityCheck(path.join(cfgDir, 'settings.json'), managed.copilot_hooks, 'present');
867
869
  }
868
870
  return {
869
871
  nAgents: stats.nAgents,
870
872
  nSkills: stats.nSkills,
871
- nHooks: (managed.settings_hooks || []).length,
873
+ nHooks: (managed.settings_hooks || managed.copilot_hooks || []).length,
872
874
  memStatus,
873
875
  nbStatus,
874
876
  };
@@ -883,7 +885,8 @@ function emitCopilot(cfg, args, out) {
883
885
  });
884
886
  process.stdout.write(`[geneseed] copilot (folder) -> ${root}: AGENTS.md + .github/ `
885
887
  + `(${r.nAgents} agents (.agent.md), ${r.nSkills} skills), ${r.memStatus}, `
886
- + `${r.nbStatus}. No settings.json/hooks (Copilot has none).\n`);
888
+ + `${r.nbStatus}. No hooks at project scope (Copilot reads hooks from `
889
+ + '~/.copilot/settings.json only — the global emit wires them).\n');
887
890
  return cfgDir;
888
891
  }
889
892
 
@@ -891,14 +894,17 @@ function emitCopilot(cfg, args, out) {
891
894
  function emitCopilotGlobal(cfg, args, out) {
892
895
  const cfgDir = args.cfgDir ?? copilotConfigDir();
893
896
  warnCopilotGlobalOverProject();
897
+ // The hook runner pair, as for Claude and Bob: the global emit is the one Copilot scope
898
+ // that wires hooks (`~/.copilot/settings.json`), and the shim it bakes is machine-wide.
899
+ const hookOpts = hookRunnerEntry();
894
900
  const r = emitClaudeCore(cfg, args, {
895
901
  cfgDir, claudeMd: path.join(cfgDir, 'copilot-instructions.md'),
896
- scope: 'global', host: 'copilot', out,
902
+ scope: 'global', host: 'copilot', out, hookOpts,
897
903
  });
898
904
  process.stdout.write(`[geneseed] copilot-global -> ${cfgDir}: ${r.nAgents} agents (.agent.md), `
899
- + `${r.nSkills} skills, copilot-instructions.md, ${r.memStatus}, ${r.nbStatus}. `
900
- + 'No settings.json/hooks (Copilot has no hook mechanism — memory rides the '
901
- + "preamble's instructions); MCP servers go in mcp-config.json.\n");
905
+ + `${r.nSkills} skills, copilot-instructions.md, ${r.memStatus}, ${r.nbStatus}, `
906
+ + `${r.nHooks} hooks (settings.json: sessionStart context, toolCall gate). Memory `
907
+ + "write-back rides the preamble's instructions; MCP servers go in mcp-config.json.\n");
902
908
  return cfgDir;
903
909
  }
904
910
 
@@ -41,32 +41,22 @@
41
41
  * keeps the two tables from ever answering the same verb twice, since the shim bakes only one
42
42
  * of them.
43
43
  *
44
- * P6h MADE THE DISPATCH ASYNCHRONOUS and put `js/web/` on this entry's import graph. Both
45
- * are noted where they happen (`main`'s `await`, and the `web` row below); the consequence
46
- * for the file as a whole is that its transitive imports now reach two modules that spawn,
47
- * which `_ALLOWED_SPAWNS` in `tests/test_hook_cli_parity.py` declares argv by argv.
44
+ * P6h MADE THE DISPATCH ASYNCHRONOUS and put `js/web/` on this entry's import graph — LAZILY,
45
+ * since the slim pass: every command module loads through its verb's thunk, so the graph is
46
+ * reached per verb rather than per invocation. The modules that spawn are still declared argv
47
+ * by argv in `ALLOWED_SPAWNS` (`tests/unit/hook_cli.test.mjs`), whose closure walk follows
48
+ * the dynamic spelling too.
48
49
  */
49
- import { cmdCatalog } from '../js/build/catalog.mjs';
50
+ // THE FOUR EAGER IMPORTS ARE THE PARSER'S OWN, AND NOTHING ELSE LOADS BEFORE DISPATCH.
51
+ // The twenty command modules used to be static imports, which made every invocation pay for
52
+ // all of them: `geneseed status` loaded the web daemon, the TUI, the updater and the whole
53
+ // build driver — 57 modules, ~1 MB, ~40-55 ms — before a byte of its own work. The verb
54
+ // table below now holds one LOADER per row (`() => import(…).then(m => m.cmdX)`), so a verb
55
+ // loads its slice of the tree and no other verb's. The spawn allow-list gate still sees the
56
+ // whole graph: `tests/unit/hook_cli.test.mjs`'s `importClosure` follows both import spellings.
50
57
  import { cliSpec, printHelp } from '../js/ui/cli.mjs';
51
- import { cmdDiff } from '../js/inspect/diff.mjs';
52
- import { cmdDoctor } from '../js/inspect/doctor.mjs';
53
- import { cmdValidate } from '../js/inspect/validate.mjs';
54
- import { parseDriverArgs } from './build-driver.mjs';
55
- import { cmdExclude } from '../js/inspect/excludes.mjs';
56
- import { cmdBuild, cmdPrompt, cmdRebuildAll, cmdTheme } from '../js/build/generate.mjs';
57
- import { cmdMigrate } from '../js/maintain/migrate.mjs';
58
58
  import { parseIntStrict } from '../js/lib/text.mjs';
59
59
  import { printErr } from '../js/lib/fs.mjs';
60
- import { cmdLink, cmdUnlink } from '../js/hosts/link.mjs';
61
- import { cmdMcp } from '../js/hosts/mcp.mjs';
62
- import { cmdMemory } from '../js/maintain/memory.mjs';
63
- import { cmdHome, cmdMenu } from '../js/ui/menu.mjs';
64
- import { cmdTui } from '../js/ui/tui.mjs';
65
- import { cmdSetup } from '../js/maintain/setup.mjs';
66
- import { cmdStatus, cmdVersion } from '../js/inspect/status.mjs';
67
- import { cmdUninstall } from '../js/maintain/uninstall.mjs';
68
- import { cmdBootstrap, cmdSyncSelf, cmdUpgrade } from '../js/maintain/update.mjs';
69
- import { cmdWeb } from '../js/web/server.mjs';
70
60
 
71
61
  /**
72
62
  * The verbs this entry answers — and, since P10c, NOTHING ELSE ABOUT THEM.
@@ -85,30 +75,30 @@ import { cmdWeb } from '../js/web/server.mjs';
85
75
  * `main`'s refusal lists `Object.keys(VERBS)` IN THIS ORDER, which `harness_golden` cells
86
76
  * assert verbatim — the order is the old table's and is not alphabetical.
87
77
  *
88
- * FLATTENED TO `verb: fn` since Task 5: every row was a single-field `{ fn: cmdX }` object,
89
- * the one field this table has left after `positionals`/`options`/`flags`/etc moved to
90
- * `js/cli-table.json` above. `main` reads `VERBS[verb]` directly rather than
91
- * `VERBS[verb].fn` now; the scraping regexes in `tests/unit/{cli_table,hook_cli,docs}.test.mjs`
92
- * match on the KEY and the colon only; the literal string `const VERBS = {` and the key
93
- * order neither changed.
78
+ * FLATTENED TO `verb: fn` since Task 5, AND TO `verb: loader` since the lazy pass: every row
79
+ * is now a thunk that imports the command's module and resolves to its function, so the one
80
+ * field a row holds is still the one thing a data file cannot — where the FUNCTION lives.
81
+ * `main` awaits `VERBS[verb]()` after the parse succeeds; the scraping regexes in
82
+ * `tests/unit/{cli_table,hook_cli,docs}.test.mjs` match on the KEY and the colon only; the
83
+ * literal string `const VERBS = {` and the key order neither changed.
94
84
  */
95
85
  const VERBS = {
96
- exclude: cmdExclude,
97
- status: cmdStatus,
98
- version: cmdVersion,
99
- build: cmdBuild,
100
- prompt: cmdPrompt,
101
- theme: cmdTheme,
102
- diff: cmdDiff,
103
- 'rebuild-all': cmdRebuildAll,
104
- migrate: cmdMigrate,
105
- doctor: cmdDoctor,
106
- uninstall: cmdUninstall,
107
- link: cmdLink,
108
- unlink: cmdUnlink,
109
- setup: cmdSetup,
110
- web: cmdWeb,
111
- upgrade: cmdUpgrade,
86
+ exclude: () => import('../js/inspect/excludes.mjs').then((m) => m.cmdExclude),
87
+ status: () => import('../js/inspect/status.mjs').then((m) => m.cmdStatus),
88
+ version: () => import('../js/inspect/status.mjs').then((m) => m.cmdVersion),
89
+ build: () => import('../js/build/generate.mjs').then((m) => m.cmdBuild),
90
+ prompt: () => import('../js/build/generate.mjs').then((m) => m.cmdPrompt),
91
+ theme: () => import('../js/build/generate.mjs').then((m) => m.cmdTheme),
92
+ diff: () => import('../js/inspect/diff.mjs').then((m) => m.cmdDiff),
93
+ 'rebuild-all': () => import('../js/build/generate.mjs').then((m) => m.cmdRebuildAll),
94
+ migrate: () => import('../js/maintain/migrate.mjs').then((m) => m.cmdMigrate),
95
+ doctor: () => import('../js/inspect/doctor.mjs').then((m) => m.cmdDoctor),
96
+ uninstall: () => import('../js/maintain/uninstall.mjs').then((m) => m.cmdUninstall),
97
+ link: () => import('../js/hosts/link.mjs').then((m) => m.cmdLink),
98
+ unlink: () => import('../js/hosts/link.mjs').then((m) => m.cmdUnlink),
99
+ setup: () => import('../js/maintain/setup.mjs').then((m) => m.cmdSetup),
100
+ web: () => import('../js/web/server.mjs').then((m) => m.cmdWeb),
101
+ upgrade: () => import('../js/maintain/update.mjs').then((m) => m.cmdUpgrade),
112
102
  // `up.add_parser(..., aliases=["update"])`, reproduced in P8c as a ROW OF ITS OWN rather than
113
103
  // as an `aliases` field on the row above — because a field would be a DECLARATION and the
114
104
  // three matrix gates read this table as the DISPATCH (rule 7, and M23 is where it was
@@ -120,18 +110,18 @@ const VERBS = {
120
110
  //
121
111
  // It is NOT what the web console's `update` ACTION needs: that row's argv names `upgrade`,
122
112
  // the subparser, and always did. The two were coupled only in a handoff's note.
123
- update: cmdUpgrade,
113
+ update: () => import('../js/maintain/update.mjs').then((m) => m.cmdUpgrade),
124
114
  // Points at `cmdSyncSelf` rather than at `cmdUpgrade`, and that is not tidiness:
125
115
  // `sync-self` DROPS its `ref` before `upgrade` ever sees it, where `upgrade` both WARNS
126
116
  // about one and re-reads it as a theme — so the two verbs answer `sync-self cyberpunk`
127
117
  // and `sync-self v1.2.3` differently. See `js/maintain/update.mjs`'s `syncSelf` for both halves.
128
- 'sync-self': cmdSyncSelf,
129
- bootstrap: cmdBootstrap,
118
+ 'sync-self': () => import('../js/maintain/update.mjs').then((m) => m.cmdSyncSelf),
119
+ bootstrap: () => import('../js/maintain/update.mjs').then((m) => m.cmdBootstrap),
130
120
  // P7a. Both are DISPATCHERS whose off-TTY arm is the whole of what a cell can reach, and
131
121
  // `menu`'s on-TTY arm falls back rather than opening a panel — `js/ui/menu.mjs`'s header
132
122
  // argues both.
133
- menu: cmdMenu,
134
- home: cmdHome,
123
+ menu: () => import('../js/ui/menu.mjs').then((m) => m.cmdMenu),
124
+ home: () => import('../js/ui/menu.mjs').then((m) => m.cmdHome),
135
125
  // P7b, and it is the twenty-fifth and last. `cmd_tui`'s FIRST arm is `if not sys.stdin.
136
126
  // isatty()`, so off a TTY — which is every cell there is — the verb is one line and an
137
127
  // exit code, and that arm crosses byte for byte. The panel behind it is P7c's;
@@ -139,7 +129,7 @@ const VERBS = {
139
129
  // panel-unavailable line rather than inventing a second full-screen UI, and
140
130
  // `tests/test_tui_boundary.py` asserts that the arm it declares is genuinely unreachable
141
131
  // here rather than merely untested.
142
- tui: cmdTui,
132
+ tui: () => import('../js/ui/tui.mjs').then((m) => m.cmdTui),
143
133
  // THE THREE THAT NEVER HAD A PYTHON ORIGINAL, and they are last for that reason rather
144
134
  // than by alphabet: every row above is the twin of a subparser, and these three are not.
145
135
  // The information behind them existed only behind the web console — the catalog endpoint,
@@ -154,9 +144,9 @@ const VERBS = {
154
144
  // ABSOLUTE — `tests/snapshot/cli_help.test.mjs` splits the two populations by name and refuses a
155
145
  // verb that falls into neither, and each has its own unit gate stating what it does rather
156
146
  // than that it agrees.
157
- catalog: cmdCatalog,
158
- mcp: cmdMcp,
159
- memory: cmdMemory,
147
+ catalog: () => import('../js/build/catalog.mjs').then((m) => m.cmdCatalog),
148
+ mcp: () => import('../js/hosts/mcp.mjs').then((m) => m.cmdMcp),
149
+ memory: () => import('../js/maintain/memory.mjs').then((m) => m.cmdMemory),
160
150
  };
161
151
 
162
152
  function die(code, msg) {
@@ -309,6 +299,11 @@ async function main(argv) {
309
299
  // and it is on THIS binary — not on `bin/build-driver.mjs` — because it runs the doctor, which
310
300
  // starts a process the driver is banned from reaching.
311
301
  if (verb === 'validate') {
302
+ // Lazy for the same reason the table rows are — `parseDriverArgs` alone drags the whole
303
+ // generator driver in, and every OTHER verb was paying for it.
304
+ const [{ cmdValidate }, { parseDriverArgs }] = await Promise.all([
305
+ import('../js/inspect/validate.mjs'), import('./build-driver.mjs'),
306
+ ]);
312
307
  try {
313
308
  return cmdValidate(parseDriverArgs(argv.slice(1)));
314
309
  } catch (e) {
@@ -316,8 +311,8 @@ async function main(argv) {
316
311
  throw e;
317
312
  }
318
313
  }
319
- const fn = VERBS[verb];
320
- if (!fn) {
314
+ const load = VERBS[verb];
315
+ if (!load) {
321
316
  // "and every other harness subcommand is still Python — run the interpreter against the
322
317
  // old harness script" is GONE, and its removal is not cosmetic: with the four hook verbs
323
318
  // named, the two entry points now cover every subcommand there is, so the clause was not
@@ -353,7 +348,11 @@ async function main(argv) {
353
348
  // it `process.exitCode` would be assigned a PENDING PROMISE, which Node coerces to 0
354
349
  // and prints nothing about. `web status` would then report "not running" and exit 0.
355
350
  // Awaiting a number is a no-op for the ten synchronous verbs.
356
- return await fn(parsed.args);
351
+ //
352
+ // The load happens HERE, after the parse succeeded: a bad argument line refuses in a
353
+ // few milliseconds without touching the command's module tree, which is also why the
354
+ // parse errors above cannot depend on anything a loader would have brought in.
355
+ return await (await load())(parsed.args);
357
356
  } catch (e) {
358
357
  // `e.exitCode` is the generator's existing marker for a DELIBERATE refusal that has
359
358
  // already explained itself on stderr — `js/build/emit-claude.mjs` reads the same flag, and it is
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * The hook entry point — a Node twin of `rituals/harness.py` for the four verbs the
4
- * emitted `settings.json` actually invokes.
3
+ * The hook entry point — a Node twin of `rituals/harness.py` for the verbs the emitted
4
+ * `settings.json` actually invokes (four from the reference, plus `tool-gate` for Copilot).
5
5
  *
6
6
  * WHY IT IS A SECOND BINARY AND NOT A SUBCOMMAND OF `bin/build-driver.mjs`. Three reasons,
7
7
  * and each alone would be enough:
@@ -37,16 +37,20 @@
37
37
  * every Geneseed hook returns 0 and signals through stdout — so "did nothing" and "worked"
38
38
  * are the same observation.
39
39
  */
40
- import { cmdContext, cmdGitGate, cmdRuleGate, cmdLearn } from '../js/hosts/hooks.mjs';
41
- import { printHelp } from '../js/ui/cli.mjs';
40
+ import { cmdContext, cmdGitGate, cmdRuleGate, cmdToolGate, cmdLearn } from '../js/hosts/hooks.mjs';
42
41
  // Not a new module on the hot path: `js/hosts/hooks.mjs` above already imports
43
42
  // `js/lib/fs.mjs`, so this edge names a file the process has already loaded.
44
43
  import { printErr } from '../js/lib/fs.mjs';
45
44
 
45
+ // `--host` selects the verdict dialect (see js/hosts/hooks.mjs's header); the emitter bakes
46
+ // it into the command for every host that is not Claude Code, so the hook never has to guess
47
+ // its reader from the payload's shape.
48
+ const HOSTED = { '--root': 'root', '--host': 'host' };
46
49
  const VERBS = {
47
- context: { fn: cmdContext, flags: { '--root': 'root' } },
48
- 'git-gate': { fn: cmdGitGate, flags: { '--root': 'root' } },
49
- 'rule-gate': { fn: cmdRuleGate, flags: { '--root': 'root' } },
50
+ context: { fn: cmdContext, flags: HOSTED },
51
+ 'git-gate': { fn: cmdGitGate, flags: HOSTED },
52
+ 'rule-gate': { fn: cmdRuleGate, flags: HOSTED },
53
+ 'tool-gate': { fn: cmdToolGate, flags: HOSTED },
50
54
  learn: {
51
55
  fn: cmdLearn,
52
56
  flags: { '--memory': 'memory' },
@@ -63,12 +67,12 @@ function die(code, msg) {
63
67
  }
64
68
 
65
69
  /**
66
- * `argparse`'s surface for these four verbs, and only theirs: `--flag VALUE`, the
70
+ * `argparse`'s surface for these verbs, and only theirs: `--flag VALUE`, the
67
71
  * `--flag=VALUE` spelling it also accepts, store_true switches, and one optional
68
72
  * positional.
69
73
  */
70
74
  function parse(spec, argv) {
71
- const args = { root: null, memory: null, consolidate: false, file: null };
75
+ const args = { root: null, host: null, memory: null, consolidate: false, file: null };
72
76
  for (let i = 0; i < argv.length; i += 1) {
73
77
  const tok = argv[i];
74
78
  const eq = tok.indexOf('=');
@@ -92,7 +96,7 @@ function parse(spec, argv) {
92
96
  return { args };
93
97
  }
94
98
 
95
- function main(argv) {
99
+ async function main(argv) {
96
100
  // `geneseed status | head` closes stdout early. Python exits quietly through
97
101
  // BrokenPipeError; the Node equivalent is an EPIPE that would otherwise be an unhandled
98
102
  // 'error' event and a stack trace on a hook path.
@@ -111,16 +115,19 @@ function main(argv) {
111
115
  // The COMMAND is not a table: `geneseed` is this package's `bin` entry for the CLI
112
116
  // (package.json), it answers every non-hook verb, and it survives the deletion of the
113
117
  // interpreter-plus-script invocation this line used to print.
114
- return die(2, `invalid choice: '${verb}'. This entry point carries only the four HOOK `
118
+ return die(2, `invalid choice: '${verb}'. This entry point carries only the HOOK `
115
119
  + `verbs (${Object.keys(VERBS).join(', ')}); every other harness subcommand lives `
116
120
  + 'elsewhere — run `geneseed ' + verb + '`.');
117
121
  }
118
122
  // `<verb> --help`, for the same reason and by the same owner as `bin/geneseed-cli.mjs`:
119
123
  // argparse holds `-h` at the parser, so this entry's `parse` calls it an unrecognized
120
124
  // argument. The four hook verbs are four of the reference's 26 and had the same gap.
121
- // `js/ui/cli.mjs` reads the CLI table lazily, inside its functions, so importing it costs this
122
- // entry a module parse and no file read on the hook path.
125
+ // `js/ui/cli.mjs` is imported HERE, not at the top: a static import cost every tool call
126
+ // of every session a module parse (~3 ms of an ~12 ms controllable budget) for a branch
127
+ // only a human at a terminal ever takes. The hook path never awaits it, so `main` staying
128
+ // async-shaped costs the hot verbs nothing.
123
129
  if (argv.slice(1).some((t) => t === '-h' || t === '--help')) {
130
+ const { printHelp } = await import('../js/ui/cli.mjs');
124
131
  const rc = printHelp('geneseed-hook', verb);
125
132
  if (rc !== null) return rc;
126
133
  }
@@ -129,4 +136,4 @@ function main(argv) {
129
136
  return spec.fn(parsed.args);
130
137
  }
131
138
 
132
- process.exitCode = main(process.argv.slice(2));
139
+ main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
@@ -0,0 +1,74 @@
1
+ # Candidate hosts — what Codex, Cursor and Gemini CLI actually offer
2
+
3
+ *2026-09-05. Item 6 of the host-gap review. A docs check, not a design: each tool's
4
+ own reference was read for the three surfaces Geneseed emits — an instructions
5
+ carrier, skills, and hooks — so that "add host X" can be costed without re-reading
6
+ the docs. Nothing here is implemented. Sources: the Codex CLI repo
7
+ (`codex-rs/hooks`, `codex-rs/core/src/agents_md.rs`), `cursor.com/docs/{rules,skills,hooks}`,
8
+ `geminicli.com/docs/{hooks,cli/gemini-md,cli/tutorials/skills-getting-started}`.*
9
+
10
+ ## The finding that changes the cost
11
+
12
+ **`.agents/skills/` is a cross-tool convention.** Cursor loads skills from
13
+ `.agents/skills/` and `~/.agents/skills/` (and, for compatibility, from `.claude/skills/`
14
+ and `.codex/skills/`); Codex from `~/.codex/skills` (`$CODEX_HOME/skills`) and
15
+ `.agents/skills`; Gemini CLI from `.gemini/skills` **or the `.agents/skills` alias**. One
16
+ `skills/` emit into `.agents/` therefore reaches all three, byte-identical to what every
17
+ current host gets. Skills are the largest surface by file count and they cost nothing new.
18
+
19
+ ## Per-host contracts
20
+
21
+ | Surface | Codex CLI | Cursor | Gemini CLI |
22
+ | --- | --- | --- | --- |
23
+ | **Instructions** | `AGENTS.md` at the repo root (and nested), plus a global `~/.codex/AGENTS.md` that no size cap touches | `AGENTS.md` at the root and in subdirectories, combined; also `.cursor/rules/*.mdc` | `GEMINI.md` by default; `context.fileName` in `settings.json` accepts a list, e.g. `["AGENTS.md", "GEMINI.md"]` |
24
+ | **Skills** | `~/.codex/skills`, `.agents/skills` — `SKILL.md` + frontmatter | `.cursor/skills`, `.agents/skills`, `~/.cursor/skills`, `~/.agents/skills`, plus `.claude/skills` and `.codex/skills` for compatibility | `.gemini/skills` or `.agents/skills`; `/skills list`, `/skills reload` |
25
+ | **Hook config** | `hooks.json` in a config layer's folder, or a `[hooks]` table in `config.toml` (same schema, capitalised event names) | `.cursor/hooks.json` (project) and `~/.cursor/hooks.json` (user); plugins may ship `hooks/hooks.json` | `settings.json` `hooks` object (project `.gemini/`, user `~/.gemini/`) |
26
+ | **Hook shape** | **Claude-shaped**: event → `[{matcher, hooks: [{type: "command", command, timeout}]}]`; 11 events incl. `PreToolUse`, `PostToolUse`, `PermissionRequest`, `PreCompact`, `PostCompact`, `SessionStart`, `SessionEnd`, `UserPromptSubmit`, `SubagentStart`, `SubagentStop`, `Stop` | event → `[{command, matcher?}]`; events incl. `sessionStart`, `preToolUse`, `beforeShellExecution`, `beforeReadFile`, `afterFileEdit`, `stop`, `sessionEnd` | **Claude-shaped groups, Gemini names**: `SessionStart`, `BeforeTool`, `AfterTool`, `BeforeAgent`, `AfterAgent`, `BeforeModel`, `AfterModel`, `BeforeToolSelection`, `PreCompact`, `SessionEnd`; matchers are regexes over tool names |
27
+ | **Gate payload** | `tool_name` / `tool_input` (Claude's field names) | `tool_name` / `tool_input`, `cwd`, `tool_use_id` | `tool_name` / `tool_input`, `session_id`, `transcript_path`, `cwd`, `hook_event_name` |
28
+ | **Gate verdict** | `hookSpecificOutput.permissionDecision`: **`allow` / `deny` / `ask`** — the ask tier exists | `permission: "allow" \| "deny"` + `user_message` / `agent_message`; **no ask** | `decision: "deny"` + `reason`, or exit code 2 with stderr as the reason; `systemMessage` shows the user a line; **no ask** |
29
+ | **Context injection** | `SessionStart` → `additionalContext` (Claude form) | `sessionStart` → `additional_context` (snake_case) | `SessionStart` → `hookSpecificOutput.additionalContext`; plain stdout is not read |
30
+ | **Learn point** | `Stop` / `SubagentStop` / `PreCompact`, transcript on stdin as on Claude | `stop` (payload not checked for a transcript path) | `AfterAgent` (turn end) and `PreCompact`; `transcript_path` is in the base schema |
31
+ | **Tool names for matchers** | Claude's (`Bash`, `Write`, `Edit`) | `Shell`, `Read`, `Write`, MCP, `Task` (generic `preToolUse`) | `run_shell_command` (`command`), `write_file` (`file_path`, `content`), `replace` (`file_path`, `old_string`, `new_string`) |
32
+ | **Subagents** | `SubagentStart`/`SubagentStop` exist; custom agents not checked | Plugins carry `agents/` | Local and remote subagents (`/agents list`, `enable`, `disable`) |
33
+
34
+ ## What each would cost, against what exists
35
+
36
+ - **Codex CLI — cheapest, and the only one with an ask tier.** Its hook schema is
37
+ Claude's with `hooks.json`/`config.toml` as the carrier, its payload field names are
38
+ Claude's, and `permissionDecision: "ask"` exists. The Claude engine with `host: "codex"`
39
+ covers instructions (`AGENTS.md`), skills (`.agents/skills`) and the four hook verbs
40
+ unchanged; the only new code is the carrier writer (a `hooks.json` merge or a TOML
41
+ table) and the config-dir resolver (`~/.codex`, `$CODEX_HOME`). Roughly the Copilot
42
+ port minus the dialect work.
43
+
44
+ - **Gemini CLI — Claude's groups, Gemini's names.** The hook groups are Claude's
45
+ `event → [{matcher, hooks}]` and the payload fields are Claude's, but the events are
46
+ `BeforeTool`/`AfterAgent` and the verdict is `decision: "deny"` or a `systemMessage`.
47
+ Bob Shell shares Gemini's *config* shape (`context.fileName`, `tools.allowed:
48
+ ["run_shell_command(git)"]`), but Bob's documented *hook* protocol is its own —
49
+ Claude's event names, refusal by exit code 2; see `adapters/bob/README.md` — so the two
50
+ are not one dialect. Instructions need one settings key (`context.fileName` to include
51
+ `AGENTS.md`) or a `GEMINI.md` carrier; skills go to `.agents/skills`. No ask tier: Laws
52
+ I/IV deny, the consent rules become a `systemMessage`.
53
+
54
+ - **Cursor — a third verdict dialect.** `AGENTS.md` and `.agents/skills` are free; hooks
55
+ are `hooks.json` with a `{command}` list per event and a `permission: allow|deny`
56
+ verdict, `additional_context` in snake_case for `sessionStart`. Same block-or-warn stance
57
+ as Copilot, one more output spelling in `js/hosts/hooks.mjs`, one more carrier writer.
58
+ Cursor also reads `.claude/skills/`, so a Claude project install already gives a Cursor
59
+ user the skills.
60
+
61
+ ## Recommendation
62
+
63
+ If a fifth host is wanted, Codex first: it reuses the most and it is the only candidate
64
+ where the gates can *ask* rather than block. Before any of them, a `--emit agents` for
65
+ the shared `.agents/skills/` folder would give Codex, Cursor and Gemini users the whole
66
+ skill catalogue for the price of one directory name, with no host-specific code at all.
67
+ A Gemini host would be the fourth verdict spelling in `js/hosts/hooks.mjs` (after Claude's
68
+ ask, Copilot's block, Bob's exit code 2), plus a resolver and a carrier.
69
+
70
+ ## Not verified
71
+
72
+ None of the three CLIs is installed on the reviewing machine. Every row above is from the
73
+ tool's own documentation as indexed on 2026-09-05; the "learn point" column for Cursor and
74
+ the Codex custom-agent surface were not checked further than noted.
package/docs/extending.md CHANGED
@@ -215,11 +215,12 @@ a defect.
215
215
 
216
216
  Steps 1 and 9 of §2c, plus `LAW_META` if the Principle line moved. Two cautions:
217
217
 
218
- - `--footprint` defaults to **lean**, and lean keeps only the heading plus the first sentence
219
- (`terseBlocks` in `js/build/render.mjs`). A clause added to the second paragraph is invisible in the
220
- default build. Amend the first sentence, or the amendment does not exist for most installs. The
221
- same is true of a doctrine rule; it is **not** true of the ontology, which ships whole at both
222
- footprints.
218
+ - `--footprint` defaults to **lean**, and a law's lean text is the authored `LEAN:else` half of
219
+ its block in `src/laws/universal.md` (`resolveLean` in `js/build/render.mjs`). Amend **both**
220
+ halves, or the amendment does not exist for most installs. A doctrine rule is different: it is
221
+ still machine-cut to its heading plus first sentence (`terseBlocks`), so a clause in its second
222
+ paragraph is invisible in the default build. The on-disk `laws/` and `ontology/` copies always
223
+ render at full, whatever the footprint.
223
224
 
224
225
  ### 2c — Adding an INVARIANT
225
226
 
@@ -4,10 +4,11 @@ What a deployed harness costs in context-window tokens, per host. Token counts a
4
4
  chars/4 estimates and shift a little with theme and version.
5
5
 
6
6
  **Treat every number below as a floor, not a reading.** They were measured on a
7
- `--footprint full` build of the neutral theme when the tree carried 41 skills and 17
8
- agents; it carries more skills than that now, and nothing re-measures this page
9
- automatically. For a live figure on your own install, ask the agent for the
10
- `token-report` skill — that is what it is for.
7
+ `--footprint lean` build (the default) of the neutral theme when the tree carried
8
+ 47 skills and 17 agents, and nothing re-measures this page automatically. For a
9
+ live figure on your own install, ask the agent for the `token-report` skill —
10
+ that is what it is for. A `--footprint full` build runs roughly 5k tokens
11
+ heavier on the root file.
11
12
 
12
13
  ## Always-on cost per session
13
14
 
@@ -15,40 +16,50 @@ The context every host injects at session start, before you type anything:
15
16
 
16
17
  | Component | Claude Code | OpenCode | Bob | Copilot |
17
18
  |---|---|---|---|---|
18
- | Root instruction file | ~10.7k (`CLAUDE.md`) | ~10.6k (`AGENT.md`) | ~10.7k (`AGENTS.md`) | ~10.7k (`AGENTS.md`) |
19
- | Skill metadata (name + description) | ~1.6k | ~1.6k | ~1.6k | ~1.6k |
20
- | Agent metadata | ~0.45k | ~0.55k | ~0.45k | ~0.45k |
19
+ | Root instruction file | ~6.4k (`CLAUDE.md`) | ~6.4k (`AGENT.md`) | ~8.3k (`AGENTS.md`) | ~8.3k (`AGENTS.md`) |
20
+ | Skill metadata (name + description) | ~1.7k | ~1.7k | ~1.7k | ~1.7k |
21
+ | Agent metadata | ~0.5k | ~0.55k | ~0.5k | ~0.5k |
21
22
  | Eager memory/notebook injection | ~1.2k (SessionStart hook) | ~1.2k (context plugin) | ~1.2k (SessionStart hook) | — (no hooks; read on demand) |
22
- | **Total** | **~14k** | **~14k** | **~14k** | **~12.8k** |
23
+ | **Total** | **~9.8k** | **~9.9k** | **~11.7k** | **~10.5k** |
23
24
 
24
- The emits are at parity by design: ~14k tokens, about 7% of a 200k
25
- window (Copilot runs slightly lighter because it has no hook mechanism — the
26
- memory/notebook indexes load when the agent reads them, not eagerly). The
25
+ The emits are at parity by design: ~10–12k tokens, about 5–6% of a 200k
26
+ window. Bob and Copilot carry the §3/§4 catalogue tables inline because their
27
+ hosts expose no native skill/agent inventory; Claude Code and OpenCode ship a
28
+ pointer to the host's own inventory instead, ~1.9k tokens lighter. Copilot also
29
+ runs without eager injection — it has no hook mechanism, so the
30
+ memory/notebook indexes load when the agent reads them, not eagerly. The
27
31
  eager-injection path is budget-capped identically everywhere
28
32
  (16 KB per file, 48 KB total ≈ 12k tokens ceiling), so growing Memory degrades
29
33
  every host the same way instead of one silently falling behind.
30
34
 
31
35
  ## Where the tokens go
32
36
 
33
- - **The root file is ~76% of the bill**, and the constitution inside it is the
37
+ - **The root file is ~65% of the bill**, and the constitution inside it is the
34
38
  bulk of that — no longer one section but three: the Ontology, the nine Rules,
35
39
  and the doctrine packs this install built in. **Two** levers move it, not one.
36
- The [footprint dial](#/docs/footprint) (`lean`, the default, keeps each Rule
37
- and each doctrine rule as its heading plus first line, and ships the Ontology
38
- *whole*; `full` inlines every rationale). And the
40
+ The [footprint dial](#/docs/footprint) (`lean`, the default, swaps each Rule,
41
+ the Ontology and the longest prose sections — the preamble, §6 Notebook, §8
42
+ Wiki — for their hand-written condensations, and cuts each doctrine rule to its
43
+ heading plus first line; `full` inlines every rationale). And the
39
44
  [doctrine packs](#/docs/setup-choices) — `geneseed-build --doctrines craft,rigor`
40
45
  drops whole blocks of §2 at build time, which removes far more than the
41
46
  footprint dial trims. Anything else is noise.
42
47
 
43
- The three-tier split grew the root file: measured on `neutral`, `AGENT.md`
44
- went from 31,457 bytes to 36,882 at `lean` (+17.2%) and 53,378 to 54,810 at
45
- `full` (+2.7%). The lean growth is the larger one *by design* — the Ontology
46
- is prose, and truncating four flowing sections to four orphan sentences would
47
- have shipped a worldview nobody could read.
48
+ The lean condensations are authored, not generated. An earlier design shipped
49
+ the Ontology *whole* under `lean` on the grounds that truncating four flowing
50
+ sections to four orphan sentences would ship a worldview nobody could read —
51
+ the `LEAN:` blocks answer that by hand-writing the short form instead
52
+ (measured on `neutral`, the root file at `lean` went from 29,966 bytes to
53
+ 25,614 with them).
48
54
  - **Skill bodies (~53k) and agent bodies (~10k) are lazy** on every host —
49
55
  loaded only when invoked. A typical skill costs ≤3k per invocation; the
50
56
  heaviest (`react-view-transitions`, ~17.5k with its reference files) loads
51
57
  its references progressively.
58
+ - **User-only skills leave the catalogue.** A skill whose source carries
59
+ `<!-- invocation: user -->` renders `disable-model-invocation: true`; Claude
60
+ Code and Bob then drop its description from the always-on skill metadata and
61
+ only `/name` opens it. Seven skills carry it today (the five teaching drills,
62
+ `herdr`, `opencode-theme`), roughly a seventh of the ~1.7k skill-metadata line.
52
63
  - Plugin JavaScript (OpenCode, ~26k on disk) runs in the host runtime and
53
64
  never enters the context window.
54
65
 
@@ -73,7 +84,7 @@ token counter also includes, none of which Geneseed controls:
73
84
  per-repo install, a personal `~/.claude/CLAUDE.md`, or MCP servers each add
74
85
  their own share.
75
86
 
76
- So a ~34k session on a work repo is normal arithmetic: ~14k harness +
87
+ So a ~30k session on a work repo is normal arithmetic: ~10k harness +
77
88
  ~10k repo docs + ~5–10k host overhead. Set `GENESEED_DEBUG=1` (OpenCode) to
78
89
  log exactly which files the context plugin injected, or run the SessionStart
79
90
  command by hand to see the hook's payload.