geneseed 3.1.3 → 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 (150) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/README.md +24 -14
  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 +143 -89
  12. package/bin/geneseed-cli.mjs +63 -106
  13. package/bin/geneseed-hook.mjs +26 -16
  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 -15
  20. package/js/build/emit-claude.mjs +26 -12
  21. package/js/build/emit-common.mjs +63 -114
  22. package/js/build/emit-opencode.mjs +74 -66
  23. package/js/build/generate.mjs +4 -6
  24. package/js/build/render.mjs +50 -32
  25. package/js/build/source.mjs +29 -8
  26. package/js/build/stubs.mjs +25 -33
  27. package/js/build/themes.mjs +1 -1
  28. package/js/build/version.mjs +1 -3
  29. package/js/cli-table.json +84 -0
  30. package/js/hosts/hooks.mjs +243 -81
  31. package/js/hosts/hosts.mjs +57 -5
  32. package/js/hosts/installs.mjs +35 -28
  33. package/js/hosts/link.mjs +2 -2
  34. package/js/hosts/mcp.mjs +3 -1
  35. package/js/hosts/native.mjs +54 -19
  36. package/js/hosts/opencode.mjs +39 -28
  37. package/js/hosts/settings.mjs +206 -115
  38. package/js/inspect/checks-authoring.mjs +5 -4
  39. package/js/inspect/checks-build.mjs +1 -1
  40. package/js/inspect/diff.mjs +1 -3
  41. package/js/inspect/doctor.mjs +59 -44
  42. package/js/inspect/excludes.mjs +5 -11
  43. package/js/inspect/inventory.mjs +38 -56
  44. package/js/inspect/registry.mjs +4 -7
  45. package/js/inspect/scan.mjs +8 -15
  46. package/js/inspect/status.mjs +51 -3
  47. package/js/lib/fs.mjs +34 -0
  48. package/js/lib/json.mjs +28 -1
  49. package/js/lib/paths.mjs +0 -10
  50. package/js/lib/udiff.mjs +11 -16
  51. package/js/maintain/memory.mjs +2 -4
  52. package/js/maintain/migrate.mjs +3 -3
  53. package/js/maintain/uninstall.mjs +127 -88
  54. package/js/maintain/update.mjs +4 -4
  55. package/js/ui/anim.mjs +1 -1
  56. package/js/ui/menu.mjs +1 -1
  57. package/js/ui/tui.mjs +3 -20
  58. package/js/web/actions.mjs +230 -217
  59. package/js/web/activity.mjs +71 -89
  60. package/js/web/api.mjs +130 -226
  61. package/js/web/daemon.mjs +92 -110
  62. package/js/web/docs.mjs +59 -126
  63. package/js/web/handler.mjs +94 -123
  64. package/js/web/jobs.mjs +116 -178
  65. package/js/web/routes.mjs +47 -192
  66. package/js/web/server.mjs +47 -82
  67. package/package.json +1 -1
  68. package/registry.json +14 -0
  69. package/src/AGENT.md.tmpl +55 -16
  70. package/src/agents/explorer.md +2 -1
  71. package/src/doctrines/README.md +3 -3
  72. package/src/doctrines/process.md +4 -2
  73. package/src/doctrines/rigor.md +13 -0
  74. package/src/laws/universal.md +133 -43
  75. package/src/ontology/universal.md +38 -13
  76. package/src/skills/_template.md +5 -1
  77. package/src/skills/bruno-collection-generator.md +63 -0
  78. package/src/skills/bruno-test-writer.md +62 -0
  79. package/src/skills/clarify.md +1 -1
  80. package/src/skills/crash-course.md +1 -0
  81. package/src/skills/decode.md +1 -0
  82. package/src/skills/drill.md +1 -0
  83. package/src/skills/feynman.md +1 -0
  84. package/src/skills/forge-mcp.md +2 -1
  85. package/src/skills/fresh-eyes.md +1 -1
  86. package/src/skills/herdr.md +1 -0
  87. package/src/skills/ingest.md +2 -1
  88. package/src/skills/learning-path.md +1 -0
  89. package/src/skills/opencode-theme.md +1 -0
  90. package/src/skills/parallel-agents.md +1 -1
  91. package/src/skills/ponytail.md +2 -2
  92. package/src/skills/research.md +4 -2
  93. package/themes/_TEMPLATE.json +5 -0
  94. package/themes/biker.json +5 -0
  95. package/themes/commentator.json +5 -0
  96. package/themes/cyberpunk.json +5 -0
  97. package/themes/gamer.json +5 -0
  98. package/themes/imperial.json +5 -0
  99. package/themes/joker.json +5 -0
  100. package/themes/marvin.json +5 -0
  101. package/themes/mean.json +5 -0
  102. package/themes/military.json +5 -0
  103. package/themes/neutral.json +5 -0
  104. package/themes/pirate.json +5 -0
  105. package/themes/sports.json +5 -0
  106. package/themes/verstappen.json +5 -0
  107. package/themes/wizard.json +5 -0
  108. package/web/dist/assets/Activity-BfVcLrda.js +1 -0
  109. package/web/dist/assets/ActivityDetail-CQXccGPl.js +1 -0
  110. package/web/dist/assets/CatalogRow-DALEQImZ.js +1 -0
  111. package/web/dist/assets/Diff-BIjxXx46.js +5 -0
  112. package/web/dist/assets/Doctor-CJLbgsrw.js +1 -0
  113. package/web/dist/assets/FilterInput-CkUCk_lY.js +1 -0
  114. package/web/dist/assets/GreenhouseView-DfC5IILJ.js +2 -0
  115. package/web/dist/assets/Harness-Dm5CCU-v.js +1 -0
  116. package/web/dist/assets/JournalView-Cpu2eI4s.js +1 -0
  117. package/web/dist/assets/Laws-DDDVFuQz.js +3 -0
  118. package/web/dist/assets/Library-Dao3T9na.js +1 -0
  119. package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-C__nuWX5.js} +15 -15
  120. package/web/dist/assets/OperatorHudView-DEpCtAjP.js +2 -0
  121. package/web/dist/assets/Profile-BlXtUlh2.js +1 -0
  122. package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-DIre6gxR.js} +1 -1
  123. package/web/dist/assets/Skills-rY13ZPhM.js +1 -0
  124. package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-wW_RS_my.js} +1 -1
  125. package/web/dist/assets/{activity-Cc0n2jwx.js → activity-DpYPKNrA.js} +1 -1
  126. package/web/dist/assets/index-BEZvPvpF.js +1 -0
  127. package/web/dist/assets/index-DNQE5ydy.js +10 -0
  128. package/web/dist/assets/index-DzdQjvM1.css +1 -0
  129. package/web/dist/assets/index-PmQGRS1X.js +2 -0
  130. package/web/dist/assets/jobBuckets-DfTUJUxR.js +1 -0
  131. package/web/dist/assets/lawCats-DUS9UTH4.js +1 -0
  132. package/web/dist/index.html +2 -2
  133. package/web/src/pages/Laws.jsx +119 -130
  134. package/js/web/graph.mjs +0 -0
  135. package/web/dist/assets/Activity-B49fE_ug.js +0 -1
  136. package/web/dist/assets/ActivityDetail-abt7hzD2.js +0 -1
  137. package/web/dist/assets/Diff-D3_bVkih.js +0 -5
  138. package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
  139. package/web/dist/assets/Graph-DvYMtlvl.js +0 -1
  140. package/web/dist/assets/Harness-BIWei7N5.js +0 -1
  141. package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
  142. package/web/dist/assets/Library-MjbOkHIW.js +0 -1
  143. package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
  144. package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
  145. package/web/dist/assets/Skills-DArA6IHS.js +0 -1
  146. package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
  147. package/web/dist/assets/index-DC3-GlCY.js +0 -1
  148. package/web/dist/assets/index-DP035wPa.js +0 -12
  149. package/web/dist/assets/index-dHi-7M-F.js +0 -2
  150. 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,11 +43,11 @@
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';
38
- import { fileURLToPath } from 'node:url';
39
51
  import { readText, writeText, printOut, printErr, withDiscardableStderr } from '../lib/fs.mjs';
40
52
  import { jsonDumpsCompact } from '../lib/json.mjs';
41
53
  import { normcase, comparePaths, toPlatformPath } from '../lib/paths.mjs';
@@ -44,9 +56,9 @@ import { NO_WINDOW } from '../lib/proc.mjs';
44
56
  // module to find it: the CLI is under a hard transitive `child_process` ban and `learn`
45
57
  // spawns. So the resolver has one owner in `js/hosts/hosts.mjs` rather than a second copy — and
46
58
  // with it went this file's private `opencodeConfigDir`, whose only caller it was.
47
- import { resolveMemoryDir, expanduser } from './hosts.mjs';
59
+ import { resolveMemoryDir, expanduser, sovereignBypass } from './hosts.mjs';
48
60
 
49
- const ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
61
+ const ROOT = path.resolve(import.meta.dirname, '../..');
50
62
 
51
63
  // ---- Python path and text primitives -------------------------------------------------
52
64
  // Small, and each one is here because a JS-idiomatic equivalent differs observably.
@@ -73,10 +85,12 @@ const ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.ur
73
85
  // an adjudicated product decision (`TheTildeUserFormIsADeliberateDivergence`) that the hook
74
86
  // was quietly exempt from because it had a private copy. It now imports the owner. The
75
87
  // refusal throws, so `sovereignBypass` and `cmdContext` below contain it per entry / per
76
- // call: a hook that CRASHES reports success, because these gates exit 0 and signal on
77
- // stdout, so "the gate blew up" and "the gate found nothing" are the same observation to
78
- // Claude. `resolvePath` just below is the THIRD copy of a primitive `js/hosts/hosts.mjs` also
79
- // 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.
80
94
 
81
95
  /**
82
96
  * `Path(p).resolve()` — absolute, symlinks followed, and the filesystem's OWN casing.
@@ -101,6 +115,7 @@ function resolvePath(p) {
101
115
  }
102
116
  }
103
117
 
118
+ // NOT imported from `js/lib/fs.mjs` (the owner everywhere else) — hot path, no new import cost.
104
119
  const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
105
120
  const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
106
121
 
@@ -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
  // ======================================================================================
@@ -575,7 +703,7 @@ export function cmdGitGate(args) {
575
703
  * Geneseed's own coinage and match anywhere; any other markdown matches only inside THIS
576
704
  * install's `<root>/memory/`.
577
705
  */
578
- export function ruleGateTarget(p, root) {
706
+ function ruleGateTarget(p, root) {
579
707
  if (typeof p !== 'string') return null;
580
708
  // Split on BOTH separators rather than taking a basename: a payload carrying a Windows
581
709
  // path reaches a POSIX host with its backslashes intact, and `path.basename` there
@@ -595,23 +723,57 @@ export 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
@@ -14,17 +14,69 @@
14
14
  * changed resolver rather than catch it — but `tests/unit/node_driver.test.mjs`'s relocation
15
15
  * table, which asserts each host's global target absolutely.
16
16
  */
17
- import { realpathSync, statSync } from 'node:fs';
17
+ import { realpathSync } from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import os from 'node:os';
20
- import { printErr, withDiscardableStderr } from '../lib/fs.mjs';
21
- import { toPlatformPath } from '../lib/paths.mjs';
22
-
23
- const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
20
+ import { printErr, withDiscardableStderr, isDir, readText } from '../lib/fs.mjs';
21
+ import { toPlatformPath, normcase } from '../lib/paths.mjs';
24
22
 
25
23
  /** `_build_global.GLOBAL_MANIFEST` — the file whose presence means "a global install". */
26
24
  export const GLOBAL_MANIFEST = '.geneseed-manifest.json';
27
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
+
28
80
  /**
29
81
  * `_build_core.VERSION_MARKER`. Third caller, so it moves here — `js/build/version.mjs` and
30
82
  * `js/inspect/diff.mjs` each had a private const of their own and `js/maintain/uninstall.mjs` would have made
@@ -32,19 +32,16 @@
32
32
  * web cells recorded it. Both candidates are gone (the argument is in the Python), and the
33
33
  * claim is now true rather than aspirational.
34
34
  */
35
- import { existsSync, readdirSync, statSync } from 'node:fs';
35
+ import { existsSync, readdirSync } from 'node:fs';
36
36
  import path from 'node:path';
37
37
 
38
38
  import { CONFIG, PACK_ORDER, THEMES, discoverNames } from '../build/source.mjs';
39
39
  import { GLOBAL_MANIFEST, HOSTS, resolvePath } from './hosts.mjs';
40
40
  import { registryRoots } from '../inspect/registry.mjs';
41
- import { printErr, readText } from '../lib/fs.mjs';
41
+ import { printErr, readText, isFile, isDir } from '../lib/fs.mjs';
42
42
  import { formatRepr } from '../lib/json.mjs';
43
43
  import { comparePaths } from '../lib/paths.mjs';
44
44
 
45
- const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
46
- const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
47
-
48
45
  /**
49
46
  * `Path.read_text(encoding="utf-8")`, or null where Python raises OSError.
50
47
  *
@@ -150,6 +147,22 @@ function themeFromAgent(agentMd) {
150
147
  const CARRIERS = ['AGENT.md', 'CLAUDE.md', path.join('rules', 'geneseed.md'),
151
148
  'copilot-instructions.md', 'AGENTS.md'];
152
149
 
150
+ /**
151
+ * The scan `themeOfDir`, `leadOfDir`, `doctrinesOfDir` and `excludedRulesOfDir` each wrote
152
+ * out: try each carrier in order, first answer wins. `probe(carrierPath)` returns
153
+ * `undefined` to mean "keep looking" and anything else — including `null` or `[]`, both
154
+ * legitimate stop values for the two register readers below — to mean "stop, this is the
155
+ * answer". `fallback` is what every caller's own trailing `return …;` supplied once the
156
+ * whole list was exhausted with no answer.
157
+ */
158
+ function firstCarrier(d, probe, fallback = null) {
159
+ for (const carrier of CARRIERS) {
160
+ const result = probe(path.join(d, carrier));
161
+ if (result !== undefined) return result;
162
+ }
163
+ return fallback;
164
+ }
165
+
153
166
  /** `_harness_setup._theme_of_dir` — the marker, else the sigil in one of five carriers. */
154
167
  export function themeOfDir(d) {
155
168
  const marker = path.join(d, '.geneseed-theme');
@@ -157,11 +170,7 @@ export function themeOfDir(d) {
157
170
  const name = (readMaybe(marker) ?? '').trim();
158
171
  if (name) return name;
159
172
  }
160
- for (const carrier of CARRIERS) {
161
- const found = themeFromAgent(path.join(d, carrier));
162
- if (found) return found;
163
- }
164
- return null;
173
+ return firstCarrier(d, (carrierPath) => themeFromAgent(carrierPath) || undefined);
165
174
  }
166
175
 
167
176
  /** `_harness_setup.FOOTPRINTS`. */
@@ -209,14 +218,14 @@ export function capitalize(s) {
209
218
  }
210
219
 
211
220
  function leadOfDir(d, names) {
212
- for (const carrier of CARRIERS) {
213
- const text = readMaybe(path.join(d, carrier));
214
- if (text === null) continue;
221
+ return firstCarrier(d, (carrierPath) => {
222
+ const text = readMaybe(carrierPath);
223
+ if (text === null) return undefined;
215
224
  for (const name of names) {
216
225
  if (text.includes(`**${capitalize(name)}**`)) return name;
217
226
  }
218
- }
219
- return null;
227
+ return undefined;
228
+ });
220
229
  }
221
230
 
222
231
  export const postureOfDir = (d) => leadOfDir(d, discoverNames('postures', 'peer'));
@@ -257,17 +266,16 @@ const ACTIVE_PACKS_RE = /^Active packs:[ \t]*(.+?)[ \t]*$/m;
257
266
  * exactly the empty field a fold leaves behind, which is what makes this catch it.
258
267
  */
259
268
  export function doctrinesOfDir(d) {
260
- for (const carrier of CARRIERS) {
261
- const text = readMaybe(path.join(d, carrier));
262
- if (text === null) continue;
269
+ return firstCarrier(d, (carrierPath) => {
270
+ const text = readMaybe(carrierPath);
271
+ if (text === null) return undefined;
263
272
  const m = ACTIVE_PACKS_RE.exec(text);
264
- if (!m) continue;
273
+ if (!m) return undefined;
265
274
  if (m[1] === 'none') return [];
266
275
  const named = m[1].split(',').map((s) => s.trim());
267
276
  if (!named.every((n) => PACK_ORDER.includes(n))) return null;
268
- return PACK_ORDER.filter((p) => named.includes(p));
269
- }
270
- return null;
277
+ return PACK_ORDER.filter((pk) => named.includes(pk));
278
+ });
271
279
  }
272
280
 
273
281
  /**
@@ -314,11 +322,11 @@ const EXCLUDED_RULES_RE = /^Excluded rules:[ \t]*(.+?)[ \t]*$/m;
314
322
  * take away rules nobody named.
315
323
  */
316
324
  export function excludedRulesOfDir(d) {
317
- for (const carrier of CARRIERS) {
318
- const text = readMaybe(path.join(d, carrier));
319
- if (text === null) continue;
325
+ return firstCarrier(d, (carrierPath) => {
326
+ const text = readMaybe(carrierPath);
327
+ if (text === null) return undefined;
320
328
  const m = EXCLUDED_RULES_RE.exec(text);
321
- if (!m) continue;
329
+ if (!m) return undefined;
322
330
  if (m[1] === 'none') return [];
323
331
  const named = m[1].split(',').map((s) => s.trim());
324
332
  // `<pack> <n>` on the page, `<pack>.<n>` in the code — the same asymmetry the rendered
@@ -329,8 +337,7 @@ export function excludedRulesOfDir(d) {
329
337
  ? `${parts[0]}.${Number(parts[1])}` : null;
330
338
  });
331
339
  return ids.every(Boolean) ? [...new Set(ids)].sort() : [];
332
- }
333
- return [];
340
+ }, []);
334
341
  }
335
342
 
336
343
  // ---- what host a deployed dir belongs to (`_harness_mcp`) ---------------------------------
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
@@ -86,7 +86,7 @@ import { toPlatformPath } from '../lib/paths.mjs';
86
86
  const IS_WIN = process.platform === 'win32';
87
87
 
88
88
  /** `_harness_lifecycle._win_bin_dir`. */
89
- export function winBinDir() {
89
+ function winBinDir() {
90
90
  // `os.environ.get(...) or str(Path.home())` — an EMPTY LOCALAPPDATA falls back too,
91
91
  // which `??` would not do.
92
92
  const base = process.env.LOCALAPPDATA || os.homedir();
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