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
@@ -1,9 +1,21 @@
1
1
  /**
2
- * The four HOOK verbs — `context`, `git-gate`, `rule-gate`, `learn` — in Node.
2
+ * The five HOOK verbs — `context`, `git-gate`, `rule-gate`, `tool-gate`, `learn` — in Node.
3
3
  *
4
4
  * These are the commands the emitted `settings.json` actually invokes on a user's machine,
5
- * every session: a SessionStart injector, two PreToolUse gates, and a Stop/SubagentStop
6
- * distiller. Since P5b they are also what the emitted hooks name: `bin/build-driver.mjs` bakes
5
+ * every session: a SessionStart injector, two PreToolUse gates (and `tool-gate`, the two
6
+ * fused for a host that runs ONE command per event), and a Stop/SubagentStop/PreCompact
7
+ * distiller.
8
+ *
9
+ * ONE VERB, TWO DIALECTS. `--host` names the host that will read the verdict. Claude Code
10
+ * (the default) reads `hookSpecificOutput.permissionDecision: "ask"` and shows the user a
11
+ * prompt. GitHub Copilot's `toolCall` hook has no ask tier — its stdout contract is
12
+ * `{"block": true|false}` — so on `--host copilot` the two Laws that admit no judgement call
13
+ * (I, IV) BLOCK and everything that was an ask (process 1, process 5, a gate error) is a
14
+ * warning on stderr, which Copilot logs. The same stance the OpenCode guard plugin takes
15
+ * where its `tool.execute.before` can only allow or throw. `--host bob` is Bob's own
16
+ * protocol: PreToolUse ignores stdout and refuses only on EXIT CODE 2, so the two Laws
17
+ * exit 2 with the reason on stderr and the rest is a stderr line with exit 0; SessionStart
18
+ * context is plain stdout, as on Claude. Since P5b they are also what the emitted hooks name: `bin/build-driver.mjs` bakes
7
19
  * `<node> <checkout>/bin/geneseed-hook.mjs` into the machine-wide shim, so an install this
8
20
  * driver emits has no Python in its hook path at all — which is what let the interpreter
9
21
  * discovery and its exit-4 refusal be deleted rather than merely bypassed.
@@ -31,7 +43,8 @@
31
43
  * and every file left behind. Absolute rather than compared is what let it survive the deletion
32
44
  * of the implementation it was once compared against.
33
45
  */
34
- import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, realpathSync }
46
+ import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, realpathSync,
47
+ appendFileSync, writeFileSync }
35
48
  from 'node:fs';
36
49
  import path from 'node:path';
37
50
  import { spawnSync } from 'node:child_process';
@@ -43,7 +56,7 @@ import { NO_WINDOW } from '../lib/proc.mjs';
43
56
  // module to find it: the CLI is under a hard transitive `child_process` ban and `learn`
44
57
  // spawns. So the resolver has one owner in `js/hosts/hosts.mjs` rather than a second copy — and
45
58
  // with it went this file's private `opencodeConfigDir`, whose only caller it was.
46
- import { resolveMemoryDir, expanduser } from './hosts.mjs';
59
+ import { resolveMemoryDir, expanduser, sovereignBypass } from './hosts.mjs';
47
60
 
48
61
  const ROOT = path.resolve(import.meta.dirname, '../..');
49
62
 
@@ -72,10 +85,12 @@ const ROOT = path.resolve(import.meta.dirname, '../..');
72
85
  // an adjudicated product decision (`TheTildeUserFormIsADeliberateDivergence`) that the hook
73
86
  // was quietly exempt from because it had a private copy. It now imports the owner. The
74
87
  // refusal throws, so `sovereignBypass` and `cmdContext` below contain it per entry / per
75
- // call: a hook that CRASHES reports success, because these gates exit 0 and signal on
76
- // stdout, so "the gate blew up" and "the gate found nothing" are the same observation to
77
- // Claude. `resolvePath` just below is the THIRD copy of a primitive `js/hosts/hosts.mjs` also
78
- // exports, and is the next item in this series.
88
+ // call. For the two GATES that containment is not enough, and `guardGate` below is the
89
+ // contract: these verbs exit 0 and signal on stdout, so "the gate blew up" and "the gate
90
+ // found nothing" would be the SAME observation to Claude — a crash would be a silent allow.
91
+ // A gate that cannot evaluate a call therefore asks, and says why. `resolvePath` just below
92
+ // is the THIRD copy of a primitive `js/hosts/hosts.mjs` also exports, and is the next item
93
+ // in this series.
79
94
 
80
95
  /**
81
96
  * `Path(p).resolve()` — absolute, symlinks followed, and the filesystem's OWN casing.
@@ -229,7 +244,6 @@ const EXCLUDE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'vendor',
229
244
 
230
245
  const CLAUDE_MARKERS = ['.claude', '.bob'];
231
246
  const GENESEED_MANIFEST = '.geneseed-manifest.json';
232
- const EXCLUDES_FILE = 'excludes.json';
233
247
 
234
248
  /**
235
249
  * `_global_hook_standing_down` — project-bypasses-global.
@@ -252,52 +266,10 @@ export function globalHookStandingDown(hookRoot, cwd) {
252
266
  return false;
253
267
  }
254
268
 
255
- /**
256
- * `sovereign_bypass` — the user's own excludes.json, read on EVERY hook call so an edit
257
- * takes effect without a re-emit. Every failure mode degrades to false: a hook must never
258
- * fail or block on a file the user owns.
259
- */
260
- export function sovereignBypass(root) {
261
- if (!root) return false;
262
- let entries;
263
- try {
264
- const data = JSON.parse(readText(path.join(root, EXCLUDES_FILE)));
265
- entries = Array.isArray(data && data.excludes) ? data.excludes : [];
266
- } catch {
267
- return false;
268
- }
269
- let cwd;
270
- try {
271
- cwd = normcase(resolvePath(process.cwd()));
272
- } catch {
273
- return false;
274
- }
275
- for (const entry of entries) {
276
- const raw = (entry && typeof entry === 'object' ? entry.path : entry) || '';
277
- if (typeof raw !== 'string' || !raw.trim()) continue;
278
- let base;
279
- try {
280
- // `.rstrip("\\/")` AFTER normcase, exactly as the Python orders it, so the separator
281
- // test below cannot be fooled by a trailing slash in the user's file.
282
- //
283
- // `withDiscardableStderr` because `expanduser` writes its refusal at the RAISE SITE
284
- // and this caller catches: the reference prints nothing here, so replaying the
285
- // message would be a stderr divergence on every hook call for one bad entry.
286
- base = withDiscardableStderr(
287
- () => normcase(toPlatformPath(expanduser(raw.trim()))).replace(/[\\/]+$/, ''));
288
- } catch {
289
- // PER ENTRY, and the loop continues. One unusable line in a file the USER hand-edits
290
- // must not decide the whole function — `expanduser` refuses a `~user` form, and the
291
- // reference's `Path.expanduser()` raises RuntimeError on the same input on POSIX.
292
- // Skipping errs toward the gate staying ACTIVE, which is the safe direction: this
293
- // feeds `git-gate`/`rule-gate`, which exit 0 and signal on stdout, so a crash here
294
- // would read as a clean pass while gating nothing.
295
- continue;
296
- }
297
- if (cwd === base || cwd.startsWith(base + path.sep)) return true;
298
- }
299
- return false;
300
- }
269
+ // `sovereignBypass` moved to `./hosts.mjs` so `status` can read it without importing this
270
+ // module's `child_process` binding; re-exported because the tests and the excludes writer's
271
+ // docblock name it here.
272
+ export { sovereignBypass };
301
273
 
302
274
  /** `_disp` — relative to the repo root when it sits under it, else verbatim. */
303
275
  function disp(pathStr, root) {
@@ -469,6 +441,7 @@ export function resolveContextSets(root) {
469
441
  }
470
442
 
471
443
  export function cmdContext(args) {
444
+ setHost(args);
472
445
  // Discovery runs against the project root the hook was launched from — Claude runs
473
446
  // SessionStart hooks with cwd = repo root — not the harness package dir.
474
447
  // The only two `resolvePath` calls in this file whose argument can be a `~user` path the
@@ -523,7 +496,12 @@ export function cmdContext(args) {
523
496
  lines.push('');
524
497
  }
525
498
 
526
- out(`${lines.join('\n')}\n`);
499
+ // Claude Code takes a SessionStart hook's plain stdout as context. Copilot's `sessionStart`
500
+ // wants a JSON document and reads `additionalContext` out of it; plain text there would be
501
+ // parsed as nothing and silently dropped.
502
+ // Bob reads a SessionStart hook's stdout as context exactly as Claude does.
503
+ const text = `${lines.join('\n')}\n`;
504
+ out(HOST === 'copilot' ? `${jsonDumpsCompact({ additionalContext: text })}\n` : text);
527
505
  return 0;
528
506
  }
529
507
 
@@ -537,6 +515,15 @@ export function cmdContext(args) {
537
515
  // one line and a `commit` on the next are two commands, not one.
538
516
  const GIT_GATE_RE = /\bgit\b[^\n]*\b(?:commit|push)\b/;
539
517
 
518
+ // Law IV's boundary half: the git verbs that rewrite or discard history. Law IX (now rigor 5)
519
+ // says a rule in a prompt is a request and a rule at a boundary is a constraint; before this
520
+ // regex the harness enforced one doctrine rule and no law. `checkout --` is listed because it
521
+ // reverts to the INDEX and eats unstaged work; `checkout <branch>` is not. `clean -f` matches
522
+ // any flag cluster carrying `f` (`-fd`, `-xf`) and never `-n`. `push --force*` trips this gate
523
+ // rather than process 5's so the stronger reason is the one the user reads.
524
+ const DESTRUCTIVE_GIT_RE =
525
+ /\bgit\b[^\n]*\b(?:reset\s+--hard|clean\s+-[a-zA-Z]*f|branch\s+-D|checkout\s+--\s|push\s+[^\n]*--force)/;
526
+
540
527
  /** The `permissionDecision: "ask"` document, in Python's compact `json.dumps` spelling. */
541
528
  function askDecision(reason) {
542
529
  return `${jsonDumpsCompact({
@@ -548,21 +535,162 @@ function askDecision(reason) {
548
535
  })}\n`;
549
536
  }
550
537
 
551
- export function cmdGitGate(args) {
552
- if (sovereignBypass(args.root)) return 0;
553
- let command;
538
+ /**
539
+ * The host reading this process's verdict — `--host` on the emitted command, `claude` when
540
+ * absent. Module state rather than a parameter because a hook process answers exactly one
541
+ * call: set once at the verb's entry (`guardGate`, `cmdContext`), read where the output
542
+ * dialect is chosen.
543
+ */
544
+ let HOST = 'claude';
545
+ const setHost = (args) => { HOST = (args && args.host) || 'claude'; };
546
+
547
+ /** Copilot's `toolCall` verdict: `{"block": true}` plus the reason it displays. */
548
+ function blockDecision(reason) {
549
+ return `${jsonDumpsCompact({ block: true, reason })}\n`;
550
+ }
551
+
552
+ /**
553
+ * Bob's PreToolUse protocol: stdout is ignored and EXIT CODE 2 is the one refusal. The
554
+ * single exception to "every gate exits 0" in this file, confined to `--host bob` — on
555
+ * Claude Code a non-zero exit is a broken hook, on Bob it is the verdict.
556
+ */
557
+ const BOB_DENY_EXIT = 2;
558
+
559
+ /**
560
+ * On a host with no ask tier, which rules are worth a hard block. Laws I and IV: a
561
+ * credential in a tracked file and a history-discarding git act are wrong in every context,
562
+ * so refusing them costs nothing. Process 1 and process 5 are the USER's calls — a hard
563
+ * block would make Copilot unable to commit at all — so they become a warning the host
564
+ * logs. `gate-error` is a warning too: a crashed gate that blocked every tool call would be
565
+ * a lockout, not a safeguard, and there is no prompt through which the user could clear it.
566
+ */
567
+ const BLOCK_RULES = new Set(['law-1', 'law-4']);
568
+
569
+ /** Where the gate ledger lives inside an install: the notebook is the agent's own, gitignored space. */
570
+ export const GATE_LEDGER = path.join('notebook', 'gates.jsonl');
571
+
572
+ /**
573
+ * THE GATE LEDGER — one JSON line per ask, nothing per defer.
574
+ *
575
+ * The gates are the only mechanical enforcement the harness has, and until this line
576
+ * nothing recorded whether they ever fired: no way to tell whether Law I has caught a secret
577
+ * this year, or whether the process 5 prompt is signal or noise. `status` counts these by
578
+ * rule. It records the RULE, never the command or the content that tripped it — a ledger
579
+ * that stored the string Law I caught would itself be a place a secret lands. And it records
580
+ * only `asked`: the host never tells a hook how the user answered, so a `decided` column
581
+ * would be a claim the hook cannot make. Written only on the ask path, which is the rare
582
+ * one; the defer path — every other tool call — pays nothing. A ledger that cannot be
583
+ * written must never change the decision, hence the swallow.
584
+ *
585
+ * Capped at the newest MAX_LEDGER_LINES, like the agent lessons and the notes beside it: an
586
+ * install lives for months and `status` only ever counts, so the oldest asks carry no
587
+ * information a trimmed file loses. The re-read happens on the ask path only.
588
+ */
589
+ const MAX_LEDGER_LINES = 1000;
590
+ function ledger(root, verb, rule) {
591
+ if (!root) return;
554
592
  try {
555
- const payload = JSON.parse(readStdin() || '{}');
556
- command = ((payload && payload.tool_input) || {}).command;
557
- } catch {
593
+ const file = path.join(root, GATE_LEDGER);
594
+ appendFileSync(file, `${JSON.stringify({
595
+ ts: new Date().toISOString(), verb, rule, cwd: process.cwd(),
596
+ })}\n`);
597
+ const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean);
598
+ if (lines.length > MAX_LEDGER_LINES) {
599
+ writeFileSync(file, `${lines.slice(-MAX_LEDGER_LINES).join('\n')}\n`);
600
+ }
601
+ } catch { /* see above: the ledger never decides */ }
602
+ }
603
+
604
+ /**
605
+ * Ask, and record that we asked. Every ask site goes through here so none can skip the
606
+ * ledger. On `--host copilot` the ask becomes a block or a warning (see `BLOCK_RULES`); the
607
+ * ledger records the block, never the warning — a warning asked nothing of anyone.
608
+ */
609
+ function ask(args, verb, rule, reason) {
610
+ if (HOST === 'copilot' || HOST === 'bob') {
611
+ if (!BLOCK_RULES.has(rule)) {
612
+ // Neither host has an ask tier; a stderr line is the nudge, and the call goes through.
613
+ err(`[geneseed] ${reason}\n`);
614
+ return 0;
615
+ }
616
+ ledger(args && args.root, verb, rule);
617
+ if (HOST === 'bob') {
618
+ // stdout is not read on Bob's PreToolUse; the reason goes where Bob's hook log looks.
619
+ err(`[geneseed] BLOCKED: ${reason}\n`);
620
+ return BOB_DENY_EXIT;
621
+ }
622
+ out(blockDecision(reason));
558
623
  return 0;
559
624
  }
560
- if (typeof command !== 'string' || !GIT_GATE_RE.test(command)) return 0;
561
- out(askDecision('Geneseed Doctrine process 5 \u2014 every git commit/push needs explicit '
562
- + 'approval'));
625
+ ledger(args && args.root, verb, rule);
626
+ out(askDecision(reason));
563
627
  return 0;
564
628
  }
565
629
 
630
+ /**
631
+ * FAIL CLOSED. Wraps a gate verb so that any throw — a payload that is not JSON, a path
632
+ * primitive refusing a form, a bug — becomes an `ask` carrying the error, never a silent
633
+ * allow. The host reads exit 0 + empty stdout as "the gate deferred", which is exactly what a
634
+ * crashed gate would otherwise look like. A well-formed ABSENCE (empty stdin, `{}`, a null
635
+ * `tool_input`) is still a defer: there is nothing to evaluate, and asking on every
636
+ * hand-run invocation would teach the user to allow-list the gate.
637
+ */
638
+ export function guardGate(fn, verb = 'gate') {
639
+ return (args) => {
640
+ setHost(args);
641
+ try {
642
+ return fn(args);
643
+ } catch (e) {
644
+ const why = e && e.message ? e.message : String(e);
645
+ return ask(args, verb, 'gate-error', `Geneseed gate error — ${why}. The gate could `
646
+ + 'not evaluate this call, so it does not defer.');
647
+ }
648
+ };
649
+ }
650
+
651
+ /**
652
+ * The hook payload on stdin, or null for a well-formed absence. Not-JSON THROWS.
653
+ *
654
+ * Copilot spells the same payload in camelCase — `toolName`/`toolInput` where Claude sends
655
+ * `tool_name`/`tool_input` — and fires `toolCall` both before AND after the tool runs
656
+ * (`phase`). The field names are folded to Claude's so every gate reads one shape; the
657
+ * `after` phase is a well-formed absence, since there is nothing left to gate.
658
+ */
659
+ function readPayload() {
660
+ const raw = readStdin();
661
+ if (!raw.trim()) return null;
662
+ let payload;
663
+ try {
664
+ payload = JSON.parse(raw);
665
+ } catch {
666
+ throw new Error('the hook payload on stdin is not JSON');
667
+ }
668
+ if (!payload || typeof payload !== 'object') return null;
669
+ if (payload.phase === 'after') return null;
670
+ if (payload.tool_input === undefined && payload.toolInput !== undefined) {
671
+ payload.tool_input = payload.toolInput;
672
+ }
673
+ return payload;
674
+ }
675
+
676
+ function gitGate(args) {
677
+ if (sovereignBypass(args.root)) return 0;
678
+ return gitDecide(args, readPayload());
679
+ }
680
+
681
+ function gitDecide(args, payload) {
682
+ const command = ((payload && payload.tool_input) || {}).command;
683
+ if (typeof command !== 'string') return 0;
684
+ if (DESTRUCTIVE_GIT_RE.test(command)) {
685
+ return ask(args, 'git-gate', 'law-4', 'Geneseed Law IV \u2014 a history-rewriting or '
686
+ + 'discarding git act needs confirmation bound to this specific command');
687
+ }
688
+ if (!GIT_GATE_RE.test(command)) return 0;
689
+ return ask(args, 'git-gate', 'process-5', 'Geneseed Doctrine process 5 \u2014 every git '
690
+ + 'commit/push needs explicit approval');
691
+ }
692
+ export const cmdGitGate = guardGate(gitGate, 'git-gate');
693
+
566
694
  // ======================================================================================
567
695
  // rule-gate — Doctrine process 1's tool-boundary backstop
568
696
  // ======================================================================================
@@ -595,23 +723,57 @@ function ruleGateTarget(p, root) {
595
723
  }
596
724
  }
597
725
 
598
- export function cmdRuleGate(args) {
726
+ // Law I's boundary half. High-precision vendor prefixes only — a generic "looks like entropy"
727
+ // scan would fire on every hash and lockfile, and a gate that cries wolf gets allow-listed.
728
+ // `.env*` is exempt because Law I names it as the place a secret may live.
729
+ const SECRET_RE =
730
+ /\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|sk-ant-[A-Za-z0-9-]{20,}|xox[abprs]-[0-9A-Za-z-]{10,})\b|-----BEGIN [A-Z ]*PRIVATE KEY-----/;
731
+ const DOTENV_RE = /(^|[\\/])\.env(\.[^\\/]*)?$/;
732
+
733
+ function ruleGate(args) {
599
734
  if (sovereignBypass(args.root)) return 0;
600
- let p;
601
- try {
602
- const payload = JSON.parse(readStdin() || '{}');
603
- const ti = (payload && payload.tool_input) || {};
604
- p = ti.file_path || ti.path || '';
605
- } catch {
606
- return 0;
607
- }
735
+ return ruleDecide(args, readPayload());
736
+ }
737
+
738
+ function ruleDecide(args, payload) {
739
+ const ti = (payload && payload.tool_input) || {};
740
+ const p = ti.file_path || ti.path || '';
608
741
  if (typeof p !== 'string' || !p) return 0;
742
+ // Write carries `content`, Edit carries `new_string`; either can plant a credential.
743
+ const body = typeof ti.content === 'string' ? ti.content
744
+ : typeof ti.new_string === 'string' ? ti.new_string : '';
745
+ if (body && !DOTENV_RE.test(p) && SECRET_RE.test(body)) {
746
+ return ask(args, 'rule-gate', 'law-1', `Geneseed Law I — ${p} would carry a `
747
+ + 'credential-shaped string. Secrets live in .env or a secret manager, never in a '
748
+ + 'tracked file.');
749
+ }
609
750
  const target = ruleGateTarget(p, args.root);
610
751
  if (!target) return 0;
611
- out(askDecision(`Geneseed Doctrine process 1 \u2014 writing to ${target}: a standing rule, or a `
612
- + "fact to remember? That choice is the user's. Run the rule skill first."));
613
- return 0;
752
+ return ask(args, 'rule-gate', 'process-1', `Geneseed Doctrine process 1 \u2014 writing to `
753
+ + `${target}: a standing rule, or a fact to remember? That choice is the user's. Run the `
754
+ + 'rule skill first.');
755
+ }
756
+ export const cmdRuleGate = guardGate(ruleGate, 'rule-gate');
757
+
758
+ // ======================================================================================
759
+ // tool-gate — both gates behind one command
760
+ // ======================================================================================
761
+
762
+ /**
763
+ * Copilot's settings carry ONE command per hook event, not a list of matcher groups, so
764
+ * its `toolCall` hook cannot name `git-gate` for Bash and `rule-gate` for Write the way
765
+ * Claude's `PreToolUse` does. This verb is the two fused, dispatched on the payload rather
766
+ * than on a matcher: a `command` field is a shell call and gets the git checks, a path
767
+ * field is a write and gets the rule checks. Nothing here decides anything the two named
768
+ * gates do not — it only removes the need for a matcher the host does not have.
769
+ */
770
+ function toolGate(args) {
771
+ if (sovereignBypass(args.root)) return 0;
772
+ const payload = readPayload();
773
+ const ti = (payload && payload.tool_input) || {};
774
+ return typeof ti.command === 'string' ? gitDecide(args, payload) : ruleDecide(args, payload);
614
775
  }
776
+ export const cmdToolGate = guardGate(toolGate, 'tool-gate');
615
777
 
616
778
  // ======================================================================================
617
779
  // learn — distil notes/transcripts into deduped memory entries
@@ -17,12 +17,66 @@
17
17
  import { realpathSync } from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import os from 'node:os';
20
- import { printErr, withDiscardableStderr, isDir } from '../lib/fs.mjs';
21
- import { toPlatformPath } from '../lib/paths.mjs';
20
+ import { printErr, withDiscardableStderr, isDir, readText } from '../lib/fs.mjs';
21
+ import { toPlatformPath, normcase } from '../lib/paths.mjs';
22
22
 
23
23
  /** `_build_global.GLOBAL_MANIFEST` — the file whose presence means "a global install". */
24
24
  export const GLOBAL_MANIFEST = '.geneseed-manifest.json';
25
25
 
26
+ /** The user's own per-install exclusion list; `js/build/stubs.mjs` seeds it, this file reads it. */
27
+ export const EXCLUDES_FILE = 'excludes.json';
28
+
29
+ /**
30
+ * `sovereign_bypass` — the user's own excludes.json, read on EVERY hook call so an edit
31
+ * takes effect without a re-emit. Every failure mode degrades to false: a hook must never
32
+ * fail or block on a file the user owns.
33
+ *
34
+ * LIVES HERE, NOT IN `hooks.mjs`, because two readers need it and only one of them may pay
35
+ * for the other: the hook (hot path, already imports this module) and `status` (the CLI,
36
+ * under the transitive `child_process` ban that `hooks.mjs`'s `learn` verb breaks). A
37
+ * second copy in `js/inspect/` was the alternative, and a copy is what let the `~user`
38
+ * refusal diverge between the two CLIs once already (see `hooks.mjs`'s header).
39
+ */
40
+ export function sovereignBypass(root) {
41
+ if (!root) return false;
42
+ let entries;
43
+ try {
44
+ const data = JSON.parse(readText(path.join(root, EXCLUDES_FILE)));
45
+ entries = Array.isArray(data && data.excludes) ? data.excludes : [];
46
+ } catch {
47
+ return false;
48
+ }
49
+ let cwd;
50
+ try {
51
+ cwd = normcase(resolvePath(process.cwd()));
52
+ } catch {
53
+ return false;
54
+ }
55
+ for (const entry of entries) {
56
+ const raw = (entry && typeof entry === 'object' ? entry.path : entry) || '';
57
+ if (typeof raw !== 'string' || !raw.trim()) continue;
58
+ let base;
59
+ try {
60
+ // `.rstrip("\\/")` AFTER normcase, exactly as the Python orders it, so the separator
61
+ // test below cannot be fooled by a trailing slash in the user's file.
62
+ //
63
+ // `withDiscardableStderr` because `expanduser` writes its refusal at the RAISE SITE
64
+ // and this caller catches: the reference prints nothing here, so replaying the
65
+ // message would be a stderr divergence on every hook call for one bad entry.
66
+ base = withDiscardableStderr(
67
+ () => normcase(toPlatformPath(expanduser(raw.trim()))).replace(/[\\/]+$/, ''));
68
+ } catch {
69
+ // PER ENTRY, and the loop continues. One unusable line in a file the USER hand-edits
70
+ // must not decide the whole function — `expanduser` refuses a `~user` form, and the
71
+ // reference's `Path.expanduser()` raises RuntimeError on the same input on POSIX.
72
+ // Skipping errs toward the gate staying ACTIVE, which is the safe direction.
73
+ continue;
74
+ }
75
+ if (cwd === base || cwd.startsWith(base + path.sep)) return true;
76
+ }
77
+ return false;
78
+ }
79
+
26
80
  /**
27
81
  * `_build_core.VERSION_MARKER`. Third caller, so it moves here — `js/build/version.mjs` and
28
82
  * `js/inspect/diff.mjs` each had a private const of their own and `js/maintain/uninstall.mjs` would have made
package/js/hosts/link.mjs CHANGED
@@ -24,7 +24,7 @@
24
24
  * The second removes a working feature from every GIT-CHECKOUT user — for whom `link` is
25
25
  * the only thing that puts `geneseed` on PATH, because there is no package manager in that
26
26
  * story — in order to spare npm users a duplicate. It also spends a `web/dist` rebuild on a
27
- * deletion. So: they ship, they port, and `NOT_PORTED_ACTIONS` empties.
27
+ * deletion. So: they ship, and they port.
28
28
  *
29
29
  * The npm duplication is left as it is rather than papered over with a new "you already have
30
30
  * this" refusal: that would be a REDESIGN of the verb, the reference has no such arm, and a
package/js/hosts/mcp.mjs CHANGED
@@ -244,8 +244,10 @@ export function mcpConfigFor(host, scope, root) {
244
244
  : resolvePath(path.join(os.homedir(), '.claude.json'));
245
245
  }
246
246
  if (host === 'bob') {
247
+ // Nested at global scope, as Bob's hooks doc places the global file (and as the emit
248
+ // now writes it); the project file is flat.
247
249
  return scope === 'project' ? path.join(root, '.bob', 'settings.json')
248
- : path.join(bobConfigDir(), 'settings.json');
250
+ : path.join(bobConfigDir(), 'settings', 'settings.json');
249
251
  }
250
252
  if (host === 'copilot') {
251
253
  // The Copilot CLI reads MCP servers from ~/.copilot/mcp-config.json only — no per-repo
@@ -149,6 +149,20 @@ function isReadonly(text) {
149
149
  return text.includes('Read-only');
150
150
  }
151
151
 
152
+ /**
153
+ * A skill only the USER may open. The marker is an HTML comment in the source spec
154
+ * (`<!-- invocation: user -->`, placed under the purpose blockquote), and it renders to
155
+ * Claude Code's `disable-model-invocation: true`: the skill still answers `/name`, but its
156
+ * description leaves the model's always-on catalogue and the model never self-triggers it.
157
+ * Same precedent as `isReadonly` — a phrase in the spec, not a second manifest to keep in
158
+ * step. Which skills carry it is an authoring decision: the teaching skills a human runs on
159
+ * themselves, and the ones gated on an environment the model cannot see (a herdr pane).
160
+ */
161
+ const USER_INVOKED_RE = /<!--\s*invocation:\s*user\s*-->/;
162
+ export function isUserInvokedOnly(text) {
163
+ return USER_INVOKED_RE.test(text);
164
+ }
165
+
152
166
  /** `_build_emit._strip_skill_body_links`. */
153
167
  export function stripSkillBodyLinks(body) {
154
168
  return body.replace(SKILL_BODY_LINK_RE, '$1');
@@ -419,7 +433,10 @@ export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null,
419
433
  kind = 'agent';
420
434
  } else {
421
435
  // Skills are BYTE-IDENTICAL across hosts: name + description, body link-stripped.
436
+ // The user-only key is emitted for every host too — Claude Code and Bob honour it,
437
+ // OpenCode and Copilot ignore an unknown key — so the identity holds.
422
438
  fm = [`name: ${stem}`, `description: ${jsonDumps(descOf(text))}`];
439
+ if (isUserInvokedOnly(text)) fm.push('disable-model-invocation: true');
423
440
  body = stripSkillBodyLinks(body);
424
441
  dest = path.join(skillsDir, stem, 'SKILL.md');
425
442
  kind = 'skill';