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.
- package/CHANGELOG.md +110 -0
- package/README.md +22 -12
- package/SETUP.md +38 -7
- package/SHIPPED.md +6 -5
- package/adapters/bob/README.md +40 -10
- package/adapters/claude-code/README.md +5 -0
- package/adapters/claude-code/settings.json +8 -1
- package/adapters/copilot/README.md +50 -24
- package/adapters/opencode/README.md +5 -1
- package/adapters/opencode/plugins/geneseed-guard.js +40 -6
- package/bin/build-driver.mjs +16 -10
- package/bin/geneseed-cli.mjs +55 -56
- package/bin/geneseed-hook.mjs +21 -14
- package/docs/candidate-hosts.md +74 -0
- package/docs/extending.md +6 -5
- package/docs/token-footprint.md +32 -21
- package/docs/web-ui.md +5 -13
- package/harness.config.json +1 -1
- package/js/README.md +5 -6
- package/js/build/emit-claude.mjs +22 -7
- package/js/build/render.mjs +37 -19
- package/js/build/source.mjs +22 -2
- package/js/cli-table.json +84 -0
- package/js/hosts/hooks.mjs +240 -78
- package/js/hosts/hosts.mjs +56 -2
- package/js/hosts/link.mjs +1 -1
- package/js/hosts/mcp.mjs +3 -1
- package/js/hosts/native.mjs +17 -0
- package/js/hosts/settings.mjs +173 -5
- package/js/inspect/excludes.mjs +1 -1
- package/js/inspect/inventory.mjs +1 -1
- package/js/inspect/status.mjs +50 -2
- package/js/maintain/uninstall.mjs +38 -11
- package/js/maintain/update.mjs +2 -2
- package/js/web/actions.mjs +8 -8
- package/js/web/api.mjs +5 -10
- package/js/web/docs.mjs +9 -31
- package/js/web/handler.mjs +20 -30
- package/js/web/jobs.mjs +5 -26
- package/js/web/routes.mjs +18 -84
- package/js/web/server.mjs +3 -3
- package/package.json +1 -1
- package/registry.json +14 -0
- package/src/AGENT.md.tmpl +55 -16
- package/src/agents/explorer.md +1 -1
- package/src/doctrines/README.md +3 -3
- package/src/doctrines/rigor.md +13 -0
- package/src/laws/universal.md +98 -20
- package/src/ontology/universal.md +38 -13
- package/src/skills/_template.md +5 -1
- package/src/skills/bruno-collection-generator.md +63 -0
- package/src/skills/bruno-test-writer.md +62 -0
- package/src/skills/crash-course.md +1 -0
- package/src/skills/decode.md +1 -0
- package/src/skills/drill.md +1 -0
- package/src/skills/feynman.md +1 -0
- package/src/skills/forge-mcp.md +1 -1
- package/src/skills/herdr.md +1 -0
- package/src/skills/learning-path.md +1 -0
- package/src/skills/opencode-theme.md +1 -0
- package/src/skills/research.md +1 -1
- package/themes/_TEMPLATE.json +3 -0
- package/themes/biker.json +3 -0
- package/themes/commentator.json +3 -0
- package/themes/cyberpunk.json +3 -0
- package/themes/gamer.json +3 -0
- package/themes/imperial.json +3 -0
- package/themes/joker.json +3 -0
- package/themes/marvin.json +3 -0
- package/themes/mean.json +3 -0
- package/themes/military.json +3 -0
- package/themes/neutral.json +3 -0
- package/themes/pirate.json +3 -0
- package/themes/sports.json +3 -0
- package/themes/verstappen.json +3 -0
- package/themes/wizard.json +3 -0
- package/web/dist/assets/Activity-BfVcLrda.js +1 -0
- package/web/dist/assets/ActivityDetail-CQXccGPl.js +1 -0
- package/web/dist/assets/{CatalogRow-DqS4e5XM.js → CatalogRow-DALEQImZ.js} +1 -1
- package/web/dist/assets/{Diff-CIrGbiqm.js → Diff-BIjxXx46.js} +1 -1
- package/web/dist/assets/{Doctor-CpI53oHY.js → Doctor-CJLbgsrw.js} +1 -1
- package/web/dist/assets/FilterInput-CkUCk_lY.js +1 -0
- package/web/dist/assets/GreenhouseView-DfC5IILJ.js +2 -0
- package/web/dist/assets/{Harness-vRYs11kX.js → Harness-Dm5CCU-v.js} +1 -1
- package/web/dist/assets/JournalView-Cpu2eI4s.js +1 -0
- package/web/dist/assets/Laws-DDDVFuQz.js +3 -0
- package/web/dist/assets/Library-Dao3T9na.js +1 -0
- package/web/dist/assets/{Markdown-kgpg9EQM.js → Markdown-C__nuWX5.js} +1 -1
- package/web/dist/assets/OperatorHudView-DEpCtAjP.js +2 -0
- package/web/dist/assets/{Profile-CHr-PPf6.js → Profile-BlXtUlh2.js} +1 -1
- package/web/dist/assets/{Rules-CsC6hfqk.js → Rules-DIre6gxR.js} +1 -1
- package/web/dist/assets/Skills-rY13ZPhM.js +1 -0
- package/web/dist/assets/{StatusBadge-gRrak0nO.js → StatusBadge-wW_RS_my.js} +1 -1
- package/web/dist/assets/{activity-B0-FXZCj.js → activity-DpYPKNrA.js} +1 -1
- package/web/dist/assets/{index-DJOhOwwR.js → index-BEZvPvpF.js} +1 -1
- package/web/dist/assets/index-DNQE5ydy.js +10 -0
- package/web/dist/assets/index-DzdQjvM1.css +1 -0
- package/web/dist/assets/{index-Bx3I7cRW.js → index-PmQGRS1X.js} +1 -1
- package/web/dist/assets/jobBuckets-DfTUJUxR.js +1 -0
- package/web/dist/assets/lawCats-DUS9UTH4.js +1 -0
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +82 -64
- package/js/web/graph.mjs +0 -0
- package/web/dist/assets/Activity-BTCG2gMl.js +0 -1
- package/web/dist/assets/ActivityDetail-julsmPDB.js +0 -1
- package/web/dist/assets/Graph-CS4tA7YS.js +0 -1
- package/web/dist/assets/Laws-BjbHJrJt.js +0 -3
- package/web/dist/assets/Library-CjC7BAWN.js +0 -1
- package/web/dist/assets/Skills-BvatJGfe.js +0 -1
- package/web/dist/assets/index-BnRWQbZb.js +0 -12
- package/web/dist/assets/index-D0f9tJlA.css +0 -1
- 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
|
-
|
|
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)
|
|
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)
|
|
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)}
|
|
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) {
|
package/bin/build-driver.mjs
CHANGED
|
@@ -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.
|
|
852
|
-
//
|
|
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
|
|
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
|
-
+
|
|
901
|
-
+ "preamble's instructions
|
|
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
|
|
package/bin/geneseed-cli.mjs
CHANGED
|
@@ -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
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
-
|
|
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
|
|
89
|
-
* the
|
|
90
|
-
*
|
|
91
|
-
* `VERBS[verb]
|
|
92
|
-
* match on the KEY and the colon only; the
|
|
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
|
|
320
|
-
if (!
|
|
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
|
-
|
|
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
|
package/bin/geneseed-hook.mjs
CHANGED
|
@@ -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
|
|
4
|
-
*
|
|
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:
|
|
48
|
-
'git-gate': { fn: cmdGitGate, flags:
|
|
49
|
-
'rule-gate': { fn: cmdRuleGate, flags:
|
|
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
|
|
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
|
|
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`
|
|
122
|
-
//
|
|
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
|
-
|
|
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
|
|
219
|
-
(`
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
package/docs/token-footprint.md
CHANGED
|
@@ -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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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 | ~
|
|
19
|
-
| Skill metadata (name + description) | ~1.
|
|
20
|
-
| Agent metadata | ~0.
|
|
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** | **~
|
|
23
|
+
| **Total** | **~9.8k** | **~9.9k** | **~11.7k** | **~10.5k** |
|
|
23
24
|
|
|
24
|
-
The emits are at parity by design: ~
|
|
25
|
-
window
|
|
26
|
-
|
|
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 ~
|
|
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,
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 ~
|
|
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.
|