geneseed 3.19.0 → 4.0.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 (109) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +9 -1
  3. package/SHIPPED.md +1 -1
  4. package/adapters/bob/README.md +31 -12
  5. package/adapters/claude-code/README.md +24 -17
  6. package/adapters/claude-code/settings.json +4 -6
  7. package/adapters/openclaude/README.md +13 -5
  8. package/adapters/opencode/plugins/geneseed-guard.js +291 -174
  9. package/bin/geneseed-hook.mjs +36 -5
  10. package/docs/README.md +3 -1
  11. package/docs/candidate-hosts.md +0 -8
  12. package/docs/concepts/enforcement-table.md +66 -0
  13. package/docs/concepts/hook-gates.md +2 -2
  14. package/docs/concepts/hooks.md +6 -6
  15. package/docs/concepts/host-matrix.md +4 -3
  16. package/docs/concepts/loop-branch.md +6 -1
  17. package/docs/concepts/loops.md +4 -3
  18. package/docs/concepts/rules.md +1 -1
  19. package/docs/design-history.md +59 -0
  20. package/docs/extending.md +11 -3
  21. package/docs/guides/install-opencode.md +1 -1
  22. package/docs/guides/loop-running.md +1 -1
  23. package/docs/reference/env-context.md +3 -2
  24. package/docs/reference/environment.md +2 -1
  25. package/docs/reference/plugin-guard.md +3 -3
  26. package/docs/reference/troubleshoot-hooks.md +34 -0
  27. package/docs/understand/design-choices-engineering.md +29 -0
  28. package/docs/understand/design-choices-gates.md +23 -0
  29. package/docs/understand/design-choices-hosts.md +21 -0
  30. package/docs/understand/design-choices-install.md +19 -0
  31. package/docs/understand/design-choices-shape.md +21 -0
  32. package/docs/understand/design-choices.md +34 -0
  33. package/docs/understand/enforced-vs-asked.md +5 -4
  34. package/docs/understand/machine-bob.md +1 -1
  35. package/docs/understand/machine-opencode.md +2 -2
  36. package/harness.config.json +1 -1
  37. package/js/build/driver.mjs +42 -16
  38. package/js/build/emit-claude.mjs +24 -8
  39. package/js/build/emit-opencode.mjs +33 -15
  40. package/js/build/generate.mjs +38 -9
  41. package/js/build/source.mjs +39 -0
  42. package/js/build/stubs.mjs +2 -1
  43. package/js/cli-table.json +6 -6
  44. package/js/hosts/gitref.mjs +3 -3
  45. package/js/hosts/hooks-context.mjs +13 -31
  46. package/js/hosts/hooks-learn.mjs +89 -8
  47. package/js/hosts/hooks.mjs +212 -18
  48. package/js/hosts/hosts.mjs +217 -13
  49. package/js/hosts/installs.mjs +259 -46
  50. package/js/hosts/native.mjs +48 -12
  51. package/js/hosts/opencode.mjs +47 -17
  52. package/js/hosts/settings.mjs +366 -42
  53. package/js/hosts/shim.mjs +37 -0
  54. package/js/inspect/checks-authoring.mjs +81 -1
  55. package/js/inspect/checks-constitution.mjs +2 -1
  56. package/js/inspect/checks-repo.mjs +68 -4
  57. package/js/inspect/doctor.mjs +40 -9
  58. package/js/inspect/scan.mjs +5 -4
  59. package/js/inspect/status.mjs +62 -12
  60. package/js/lib/text.mjs +23 -0
  61. package/js/loop/catalog.mjs +7 -2
  62. package/js/loop/state.mjs +2 -2
  63. package/js/maintain/migrate.mjs +42 -5
  64. package/js/maintain/uninstall.mjs +150 -38
  65. package/js/maintain/update.mjs +22 -2
  66. package/js/web/actions.mjs +28 -10
  67. package/js/web/api.mjs +5 -3
  68. package/js/web/catalog.mjs +1 -1
  69. package/js/web/state.mjs +28 -15
  70. package/package.json +1 -1
  71. package/src/agents/_template.md +1 -1
  72. package/src/bricks/_template.md +1 -1
  73. package/src/bricks/apply.md +1 -1
  74. package/src/bricks/test.md +1 -1
  75. package/src/doctrines/ops.md +9 -7
  76. package/src/doctrines/process.md +3 -3
  77. package/src/laws/universal.md +1 -1
  78. package/src/loops/api-endpoint.json +1 -1
  79. package/src/loops/bugfix.json +1 -1
  80. package/src/loops/ci-repair.json +1 -1
  81. package/src/loops/deps-upgrade.json +1 -1
  82. package/src/loops/enforce-architecture-rule.json +1 -1
  83. package/src/loops/feature.json +1 -1
  84. package/src/loops/fix-flaky-tests.json +1 -1
  85. package/src/loops/legacy-refactor.json +1 -1
  86. package/src/loops/legacy-tests.json +1 -1
  87. package/src/loops/refactor.json +1 -1
  88. package/src/loops/remove-dead-code.json +1 -1
  89. package/src/loops/spec-first-feature.json +1 -1
  90. package/src/loops/tdd.json +1 -1
  91. package/src/loops/update-contract.json +1 -1
  92. package/src/skills/_template.md +3 -3
  93. package/src/skills/herdr.md +1 -1
  94. package/web/dist/assets/{Activity-IbwdHHyx.js → Activity-BCtqxjfy.js} +1 -1
  95. package/web/dist/assets/{ActivityDetail-B5uivdWI.js → ActivityDetail-Ba3Oo4dE.js} +1 -1
  96. package/web/dist/assets/{Dashboard-v6WQ37bD.js → Dashboard-DpHza68-.js} +2 -2
  97. package/web/dist/assets/{Docs-CypIfDBX.js → Docs-Bw_-UK0O.js} +1 -1
  98. package/web/dist/assets/{GreenhouseView-ClxP0hQG.js → GreenhouseView-DnJ1Ggp1.js} +1 -1
  99. package/web/dist/assets/{Installs-CPkQeFjO.js → Installs-CWoNKFBk.js} +1 -1
  100. package/web/dist/assets/{JournalView-BeEInhUf.js → JournalView-g6-bw4c8.js} +1 -1
  101. package/web/dist/assets/{Laws-Ds6U5xBQ.js → Laws-DkVWR1tb.js} +1 -1
  102. package/web/dist/assets/{Library-BraUKPyQ.js → Library-CK9HZzjV.js} +1 -1
  103. package/web/dist/assets/{Loops-DWcy3mwK.js → Loops-BP98SMOH.js} +1 -1
  104. package/web/dist/assets/{OperatorHudView-CQyrJQ9m.js → OperatorHudView-DI_n12M9.js} +1 -1
  105. package/web/dist/assets/{Personal-YWqs6qFL.js → Personal-Bk89f13a.js} +1 -1
  106. package/web/dist/assets/{Tabs-EhzEC-Xh.js → Tabs-3WuYIE1b.js} +1 -1
  107. package/web/dist/assets/{drawable-CEKPNV7X.js → drawable-oCUeq9e5.js} +1 -1
  108. package/web/dist/assets/{index-CPi22b7y.js → index-CiXwChvf.js} +2 -2
  109. package/web/dist/index.html +1 -1
package/js/cli-table.json CHANGED
@@ -312,7 +312,7 @@
312
312
  ],
313
313
  "dest": "root",
314
314
  "metavar": null,
315
- "help": "the install's own dir (set by the emitted hook). A GLOBAL install's hook stands down when a Geneseed project install of the same host is at/above cwd (project-bypasses-global); GENESEED_STACK_GLOBAL=1 disables.",
315
+ "help": "the install's own dir (set by the emitted hook). A GLOBAL install's context, git-gate and learn hooks stand down when a live Geneseed project install of the same host, wiring that hook, is in the session's project dir (no walk up; project-bypasses-global); GENESEED_STACK_GLOBAL=1 disables.",
316
316
  "choices": null,
317
317
  "default": null,
318
318
  "required": false,
@@ -327,7 +327,7 @@
327
327
  ],
328
328
  "dest": "host",
329
329
  "metavar": null,
330
- "help": "the host that reads the verdict (set by the emitted hook): 'claude' (default) asks; 'bob' has no ask tier, so Laws I/IV exit 2 and everything else is a stderr warning",
330
+ "help": "the host that reads the verdict (set by the emitted hook): 'claude' (default) and 'openclaude' ask (OpenClaude speaks Claude's dialect verbatim); 'bob' has no ask tier, so Laws I/IV, rigor-5 and ops-2 exit 2 and everything else is a stderr warning",
331
331
  "choices": null,
332
332
  "default": null,
333
333
  "required": false,
@@ -518,7 +518,7 @@
518
518
  },
519
519
  {
520
520
  "name": "git-gate",
521
- "help": "PreToolUse hook: ASK before a history-discarding git act (Deletion Is Deliberate) and before every git commit/push (Consent Before Push backstop)",
521
+ "help": "PreToolUse hook: ASK before a history-discarding git act (Deletion Is Deliberate), a recursive scan of a whole filesystem (Commands Must Return) and every git commit/push (Consent Before Push backstop)",
522
522
  "description": "",
523
523
  "positionals": [],
524
524
  "options": [
@@ -543,7 +543,7 @@
543
543
  ],
544
544
  "dest": "host",
545
545
  "metavar": null,
546
- "help": "the host that reads the verdict (set by the emitted hook): 'claude' (default) asks; 'bob' has no ask tier, so Laws I/IV exit 2 and everything else is a stderr warning",
546
+ "help": "the host that reads the verdict (set by the emitted hook): 'claude' (default) and 'openclaude' ask (OpenClaude speaks Claude's dialect verbatim); 'bob' has no ask tier, so Laws I/IV, rigor-5 and ops-2 exit 2 and everything else is a stderr warning",
547
547
  "choices": null,
548
548
  "default": null,
549
549
  "required": false,
@@ -1067,7 +1067,7 @@
1067
1067
  ],
1068
1068
  "dest": "host",
1069
1069
  "metavar": null,
1070
- "help": "the host that reads the verdict (set by the emitted hook): 'claude' (default) asks; 'bob' has no ask tier, so Laws I/IV exit 2 and everything else is a stderr warning",
1070
+ "help": "the host that reads the verdict (set by the emitted hook): 'claude' (default) and 'openclaude' ask (OpenClaude speaks Claude's dialect verbatim); 'bob' has no ask tier, so Laws I/IV, rigor-5 and ops-2 exit 2 and everything else is a stderr warning",
1071
1071
  "choices": null,
1072
1072
  "default": null,
1073
1073
  "required": false,
@@ -1329,7 +1329,7 @@
1329
1329
  ],
1330
1330
  "dest": "host",
1331
1331
  "metavar": null,
1332
- "help": "the host that reads the verdict (set by the emitted hook): 'claude' (default) asks; 'bob' has no ask tier, so Laws I/IV exit 2 and everything else is a stderr warning",
1332
+ "help": "the host that reads the verdict (set by the emitted hook): 'claude' (default) and 'openclaude' ask (OpenClaude speaks Claude's dialect verbatim); 'bob' has no ask tier, so Laws I/IV, rigor-5 and ops-2 exit 2 and everything else is a stderr warning",
1333
1333
  "choices": null,
1334
1334
  "default": null,
1335
1335
  "required": false,
@@ -12,9 +12,9 @@
12
12
  * `child_process` ban like every other closure that feeds `bin/geneseed-cli.mjs` — may ever
13
13
  * import. A module that is fs/path only is importable from anywhere.
14
14
  *
15
- * The OpenCode guard plugin (`adapters/opencode/plugins/geneseed-guard.js`) keeps its own
16
- * standalone copy — it cannot import from `js/` at all — kept in step by hand, commented as
17
- * this module's twin.
15
+ * The OpenCode guard plugin once kept a standalone twin of this for its `permission.ask`
16
+ * loop exemption; upstream never calls that hook, so the twin was deleted (OpenCode verdict
17
+ * I-2). This module has no copy to keep in step.
18
18
  */
19
19
  import { existsSync, statSync, lstatSync, readFileSync, openSync, readSync, closeSync } from 'node:fs';
20
20
  import path from 'node:path';
@@ -9,8 +9,8 @@ import { createHash } from 'node:crypto';
9
9
  import { readText, printOut as out, printErr as err, withDiscardableStderr } from '../lib/fs.mjs';
10
10
  import { normcase, toPlatformPath } from '../lib/paths.mjs';
11
11
  import { relPosix } from '../lib/text.mjs';
12
- import { resolvePath, sovereignBypass } from './hosts.mjs';
13
- import { isFile, isDir, selfAndParents, sortPaths, listDir } from './hooks-prims.mjs';
12
+ import { globalHookStandingDown, hookProjectDir, resolvePath, sovereignBypass } from './hosts.mjs';
13
+ import { isFile, isDir, sortPaths, listDir } from './hooks-prims.mjs';
14
14
 
15
15
  /**
16
16
  * `str(OSError)` — `[Errno N] strerror: 'filename'`, with the filename REPR'd.
@@ -128,7 +128,8 @@ const cost = (s) => s.length + (s.match(/\n/g) || []).length + 2;
128
128
  const SESSION_FILES = ['user-rules.md', 'PROFILE.md', 'memory/MEMORY.md', 'anamnesis/MEMORY.md',
129
129
  'notebook/NOTEBOOK.md', 'geneseed-wiki.jsonc'];
130
130
  // The wiki manifest's earlier name, read when the new one is absent (an install not yet re-emitted,
131
- // or a rename that failed). One release; `wiki.json`, older still, is the build's to rename.
131
+ // or a rename that failed). Kept until a missed rename is reported somewhere (status), since
132
+ // without it such an install loses its wiki in silence; `wiki.json`, older still, is the build's to rename.
132
133
  const LEGACY_WIKI = 'wiki.jsonc';
133
134
  // SHA-256 of each seed body in `js/build/stubs.mjs` (`SESSION_SEEDS`), CRLF folded. A file still
134
135
  // byte-identical to its seed says nothing, so it is skipped rather than injected as noise.
@@ -168,29 +169,8 @@ export function sessionFiles(hookRoot) {
168
169
  return found;
169
170
  }
170
171
 
171
- const CLAUDE_MARKERS = ['.claude', '.bob', '.openclaude'];
172
- const GENESEED_MANIFEST = '.geneseed-manifest.json';
173
-
174
- /**
175
- * `_global_hook_standing_down` — project-bypasses-global.
176
- *
177
- * A GLOBAL install's hook passes its own dir as `--root`; when a Geneseed PROJECT install
178
- * of the SAME host sits at or above cwd, the project's hook injects and this one must not
179
- * double up.
180
- */
181
- export function globalHookStandingDown(hookRoot, cwd) {
182
- const marker = path.basename(hookRoot);
183
- if (!CLAUDE_MARKERS.includes(marker)) return false;
184
- for (const d of selfAndParents(cwd)) {
185
- const cand = path.join(d, marker);
186
- if (isFile(path.join(cand, GENESEED_MANIFEST))) {
187
- // Path equality, which is case-folded on Windows — `~/.claude` and `~/.Claude` are
188
- // the same install there and two different ones on Linux.
189
- return normcase(resolvePath(cand)) !== normcase(resolvePath(hookRoot));
190
- }
191
- }
192
- return false;
193
- }
172
+ // Moved to `hosts.mjs` so the gates and `learn` share it; re-exported for this verb's callers.
173
+ export { globalHookStandingDown };
194
174
 
195
175
  /** `_disp` — relative to the repo root when it sits under it, else verbatim. */
196
176
  function disp(pathStr, root) {
@@ -411,8 +391,9 @@ function lazyLines(entries, root, room) {
411
391
 
412
392
  export function cmdContext(args) {
413
393
  HOST = (args && args.host) || 'claude';
414
- // Discovery runs against the project root the hook was launched from — Claude runs
415
- // SessionStart hooks with cwd = repo root — not the harness package dir.
394
+ // Discovery (and the stand-down) runs against the SESSION's project root, not the harness
395
+ // package dir: `$CLAUDE_PROJECT_DIR` when the host sets it (`hookProjectDir`), because
396
+ // SessionStart also fires on resume/compact/clear, after the agent may have `cd`'d away.
416
397
  // The only two `resolvePath` calls in this file whose argument can be a `~user` path the
417
398
  // user typed — `$GENESEED_ROOT` and `--root` — and `resolvePath` expands, so both can now
418
399
  // throw. Every other caller either builds its argument by `path.join` from an absolute
@@ -422,15 +403,16 @@ export function cmdContext(args) {
422
403
  let hookRoot;
423
404
  try {
424
405
  ({ root, hookRoot } = withDiscardableStderr(() => ({
425
- root: resolvePath(process.env.GENESEED_ROOT || process.cwd()),
406
+ root: resolvePath(process.env.GENESEED_ROOT || hookProjectDir()),
426
407
  hookRoot: args.root ? resolvePath(args.root) : null,
427
408
  })));
428
409
  } catch {
429
410
  return 0;
430
411
  }
431
412
  if (hookRoot && sovereignBypass(hookRoot)) return 0;
432
- if (hookRoot && !process.env.GENESEED_STACK_GLOBAL
433
- && globalHookStandingDown(hookRoot, root)) return 0;
413
+ // The RAW `args.host`, not `HOST`: `HOST` defaults to claude, which would key an older
414
+ // OpenClaude emit's `~/.openclaude` (no `--host`) on `.claude` instead of its folder name.
415
+ if (globalHookStandingDown(hookRoot, root, args.host, 'context')) return 0;
434
416
  const session = hookRoot ? sessionFiles(hookRoot) : [];
435
417
  // A session file discovery would also pick up (a `user-rules.md` at the repo root of an
436
418
  // install whose harness dir IS the repo root) is injected once, in the session block.
@@ -9,7 +9,9 @@ import { spawnSync } from 'node:child_process';
9
9
  import { readText, printOut as out, printErr as err } from '../lib/fs.mjs';
10
10
  import { toPlatformPath } from '../lib/paths.mjs';
11
11
  import { NO_WINDOW } from '../lib/proc.mjs';
12
- import { resolveMemoryDir, sovereignBypass } from './hosts.mjs';
12
+ import {
13
+ globalHookStandingDown, hookProjectDir, resolveMemoryDir, sovereignBypass,
14
+ } from './hosts.mjs';
13
15
  import { readStdin, splitLines, splitWords } from './hooks-prims.mjs';
14
16
  import {
15
17
  existingSlugs, writeMemories, consolidateMemory, appendAgentLesson,
@@ -116,10 +118,27 @@ function flattenTranscript(p) {
116
118
  }
117
119
 
118
120
  /**
119
- * `_read_notes` — a lifecycle-hook payload with a `transcript_path` is read and flattened
120
- * (this is what makes wiring `learn` to a Stop hook work with no redirection); anything
121
- * else is used as-is. `meta` is that payload as `hookMeta` parsed it — passed in by
122
- * `cmdLearn`, which needs it again, so the raw text is parsed once.
121
+ * `_read_notes` — a lifecycle-hook payload naming a transcript is read and flattened (this is
122
+ * what makes wiring `learn` to a Stop hook work with no redirection); anything else is used
123
+ * as-is. `meta` is that payload as `hookMeta` parsed it — passed in by `cmdLearn`, which needs
124
+ * it again, so the raw text is parsed once.
125
+ *
126
+ * B10 (Claude verdict, host-compat-2026-10): on SubagentStop, `transcript_path` is the MAIN
127
+ * session's transcript — the subagent's own turn lives in `agent_transcript_path` instead, in
128
+ * a nested `subagents/` folder (docs `hooks.md`). Flattening the parent's file distils a lesson
129
+ * about the wrong conversation, so the agent's own path wins whenever both are given; Stop/
130
+ * PreCompact never send `agent_transcript_path`, so they fall through to `transcript_path`
131
+ * unchanged.
132
+ *
133
+ * `last_assistant_message` (Stop and SubagentStop) is the host's own copy of the final turn. It
134
+ * is never preferred over the transcript — `learn` distils from the whole exchange, and
135
+ * `last_assistant_message` is Claude's reply alone, with the user's side of the turn gone, so
136
+ * using it as the default would trade lesson quality for a read it does not need to save. It is
137
+ * used in two cases only, both strictly additive: as the whole of `notes` when the transcript
138
+ * file cannot be read at all (missing, or the host has not flushed it yet); and APPENDED when
139
+ * the file DID read but the write lagged behind the live conversation enough to miss the final
140
+ * turn (docs `hooks.md`) — detected by the flattened text not already ending in it, so a
141
+ * transcript that already has the final turn never gets a duplicate.
123
142
  *
124
143
  * EXPORTED FOR THE UNIT TIER, and the rule this follows is written down once here for it and
125
144
  * for `./memory-files.mjs`'s `existingSlugs` and `writeMemories`.
@@ -134,8 +153,14 @@ function flattenTranscript(p) {
134
153
  */
135
154
  export function readNotes(raw, meta = hookMeta(raw)) {
136
155
  if (!raw.trim()) return '';
137
- const tp = meta.transcript_path;
138
- return tp ? flattenTranscript(tp) : raw;
156
+ const tp = meta.agent_transcript_path || meta.transcript_path;
157
+ if (!tp) return raw;
158
+ const flat = flattenTranscript(tp);
159
+ const lastMsg = typeof meta.last_assistant_message === 'string'
160
+ ? meta.last_assistant_message.trim() : '';
161
+ if (!flat) return lastMsg || flat;
162
+ if (lastMsg && !flat.endsWith(lastMsg)) return `${flat}\n\nassistant: ${lastMsg}`;
163
+ return flat;
139
164
  }
140
165
 
141
166
  function buildLearnPrompt(notes, existing) {
@@ -163,8 +188,15 @@ function runLlm(llm, prompt) {
163
188
  // `_harness_learn.py:85` runs this through the wrapped `run(..., capture_output=True)`,
164
189
  // which folds in `CREATE_NO_WINDOW`; a model CLI invoked from the windowless daemon
165
190
  // otherwise pops a console for as long as it takes to answer.
191
+ //
192
+ // `GENESEED_LEARN_CHILD=1` (Claude B2): `$GENESEED_LLM="claude -p"` with hooks not disabled
193
+ // starts a child Claude session that loads this SAME install's Stop hook, which re-runs
194
+ // `learn` — one recursion per nested session, stopped only by the host's 600 s timeout
195
+ // killing the outermost call. The marker rides in the child's own environment (inherited by
196
+ // every grandchild too), and `cmdLearn`'s first line stands down the instant it sees it.
166
197
  const proc = spawnSync(argv[0], [...argv.slice(1), prompt],
167
- { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...NO_WINDOW });
198
+ { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
199
+ env: { ...process.env, GENESEED_LEARN_CHILD: '1' }, ...NO_WINDOW });
168
200
  // Universal newlines, because Python reads the pipe in TEXT mode: a model CLI that is
169
201
  // itself a Python script emits CRLF on Windows, `subprocess` folds it to `\n`, and
170
202
  // `write_text` expands it again on the way into the memory file. Node hands back the raw
@@ -198,6 +230,9 @@ function learnAgentLesson(meta, notes, args) {
198
230
  if (!memDir) return 0;
199
231
  const prompt = [agentLessonPrompt(), '', 'NOTES:', notes].join('\n');
200
232
  const llm = process.env.GENESEED_LLM;
233
+ // Unreachable through the hook path: `cmdLearn` already returned above when `$GENESEED_LLM`
234
+ // was unset, because a SubagentStop payload always names its own event. Left in as the same
235
+ // defensive no-op `resolveMemoryDir`'s check above is, should this ever be called directly.
201
236
  if (!llm) {
202
237
  err('[learn] $GENESEED_LLM unset \u2014 printing agent-lesson prompt.\n\n');
203
238
  out(`${prompt}\n`);
@@ -217,6 +252,11 @@ function learnAgentLesson(meta, notes, args) {
217
252
  }
218
253
 
219
254
  export function cmdLearn(args) {
255
+ // B2: stand straight down inside a model-CLI child `runLlm` just spawned (see its comment).
256
+ // Checked before anything else — a child never gets as far as reading its own stdin, let
257
+ // alone deciding whether there is a store to write to.
258
+ if (process.env.GENESEED_LEARN_CHILD) return 0;
259
+
220
260
  if (args.consolidate) {
221
261
  const memDir = resolveMemoryDir(args.memory);
222
262
  if (!memDir) {
@@ -234,9 +274,50 @@ export function cmdLearn(args) {
234
274
  // The Stop/SubagentStop hook always passes `--memory <cfg>/memory`; inside an excluded
235
275
  // folder the global install must not learn.
236
276
  if (args.memory && sovereignBypass(path.dirname(toPlatformPath(args.memory)))) return 0;
277
+ // Same install root, and a project install of this host beside it learns on its own Stop: a
278
+ // second LLM call, and the global store learning facts the project owns (Claude B4).
279
+ if (args.memory && globalHookStandingDown(path.dirname(toPlatformPath(args.memory)),
280
+ hookProjectDir(), args.host, 'learn')) return 0;
237
281
 
238
282
  const raw = args.file ? readText(args.file) : readStdin();
239
283
  const meta = hookMeta(raw);
284
+ // A genuine lifecycle-hook call names its own event — `hook_event_name` (Claude/OpenClaude)
285
+ // or `event` (Bob, whose docs give the Stop payload as `{"event":"Stop","session_id":...}`).
286
+ // A MANUAL invocation (plain notes text, a `--file`, or a hand-typed JSON note such as
287
+ // `{"foo": 1}` — `readNotes`'s own rule, kept for it below) carries neither and is unaffected
288
+ // by either check in this block.
289
+ const hookEvent = typeof meta.hook_event_name === 'string' ? meta.hook_event_name
290
+ : (typeof meta.event === 'string' ? meta.event : null);
291
+
292
+ // I2 (Bob): its Stop payload never carries `transcript_path`, so there is never a transcript
293
+ // to distil — and the bare envelope itself is not notes text (that would waste a model call
294
+ // on `{"event":"Stop","session_id":"s"}`). Scoped to `--host bob`: every other host's Stop/
295
+ // PreCompact genuinely carries `transcript_path` (docs `hooks.md`), so a bare envelope there
296
+ // is either a hand-typed note (no event field at all) or a bug upstream, neither of which this
297
+ // should swallow. SubagentStop is excluded too — its own transcript is `agent_transcript_path`,
298
+ // which `readNotes` now reads instead (B10), and it is routed below on the
299
+ // agent-name fields alone. Kept rather than dropping Bob's Stop group (js/hosts/settings.mjs):
300
+ // the group costs nothing once this returns immediately, and if Bob ever starts sending a
301
+ // transcript, learning starts working there with no emit change.
302
+ if (args.host === 'bob' && hookEvent && hookEvent !== 'SubagentStop' && !args.file
303
+ && typeof meta.transcript_path !== 'string') {
304
+ err('[learn] no transcript available (bob) \u2014 nothing to distil.\n');
305
+ return 0;
306
+ }
307
+
308
+ // B3: a real hook call with `$GENESEED_LLM` unset has nothing to send a prompt to — stdout on
309
+ // Stop/SubagentStop/PreCompact reaches only the debug log, never the user (docs `hooks.md`) —
310
+ // so reading and flattening the transcript (up to 16 KB, every turn) just to print it nowhere
311
+ // is pure waste. Checked BEFORE `readNotes`, which is what does that read. A manual
312
+ // invocation keeps printing the prompt on an unset `$GENESEED_LLM` — that is the documented,
313
+ // deliberate behaviour (docs/reference/env-context.md) for someone running `learn` by hand to
314
+ // see what would have been sent, and `hookEvent` being unset is exactly what tells the two
315
+ // apart.
316
+ if (hookEvent && !process.env.GENESEED_LLM) {
317
+ err('[learn] $GENESEED_LLM unset \u2014 nothing to distil.\n');
318
+ return 0;
319
+ }
320
+
240
321
  let notes = readNotes(raw, meta);
241
322
  if (!notes.trim()) {
242
323
  err('[learn] no notes or transcript content \u2014 nothing to distil.\n');
@@ -6,14 +6,17 @@
6
6
  * fused for a host that runs ONE command per event), and a Stop/SubagentStop/PreCompact
7
7
  * distiller.
8
8
  *
9
- * ONE VERB, TWO DIALECTS. `--host` names the host that will read the verdict. Claude Code
9
+ * THREE HOSTS, TWO DIALECTS. `--host` names the host that will read the verdict. Claude Code
10
10
  * (the default) reads `hookSpecificOutput.permissionDecision: "ask"` and shows the user a
11
11
  * prompt. `--host bob` is Bob's own protocol: PreToolUse ignores stdout and refuses only on
12
- * EXIT CODE 2, so the two Laws exit 2 with the reason on stderr and the rest is a stderr line with exit 0; SessionStart
13
- * context is plain stdout, as on Claude. `--host openclaude` speaks Claude's dialect verbatim
14
- * (OpenClaude is a Claude Code fork); the flag only picks which root file counts as native. Since P5b they are also what the emitted hooks name: `bin/build-driver.mjs` bakes
15
- * `<node> <checkout>/bin/geneseed-hook.mjs` into the machine-wide shim, so an install this
16
- * driver emits has no Python in its hook path at all — which is what let the interpreter
12
+ * EXIT CODE 2, so `BLOCK_RULES` (Laws I and IV, plus rigor-5 and ops-2) exit 2 with the reason on stderr
13
+ * and the rest is a stderr line with exit 0; SessionStart context is plain stdout, as on
14
+ * Claude. `--host openclaude` speaks Claude's dialect verbatim (OpenClaude is a Claude Code
15
+ * fork); the flag only picks which root file counts as native for `context`'s discovery.
16
+ *
17
+ * Since P5b, `--host` is also what the emitted hooks themselves name: `bin/build-driver.mjs`
18
+ * bakes `<node> <checkout>/bin/geneseed-hook.mjs` into the machine-wide shim, so an install
19
+ * this driver emits has no Python in its hook path at all — which is what let the interpreter
17
20
  * discovery and its exit-4 refusal be deleted rather than merely bypassed.
18
21
  *
19
22
  * Ported from `rituals/_harness_context.py` and `rituals/_harness_learn.py`, whose shared
@@ -51,7 +54,9 @@ import { readFileSync, appendFileSync, writeFileSync } from 'node:fs';
51
54
  import path from 'node:path';
52
55
  import { printOut as out, printErr as err } from '../lib/fs.mjs';
53
56
  import { normcase } from '../lib/paths.mjs';
54
- import { GATE_LEDGER, resolvePath, sovereignBypass } from './hosts.mjs';
57
+ import {
58
+ GATE_LEDGER, globalHookStandingDown, hookProjectDir, resolvePath, sovereignBypass,
59
+ } from './hosts.mjs';
55
60
  import { currentBranch, gitRootOf, loopLaunched } from './gitref.mjs';
56
61
  import { selfAndParents, readStdin } from './hooks-prims.mjs';
57
62
 
@@ -75,8 +80,32 @@ const GIT_GATE_RE = /\bgit\b[^\n]*\b(?:commit|push)\b/;
75
80
  // A forced push is `--force*`, a `-f` cluster, or a `+refspec` (`push origin +main`) — the last
76
81
  // is a force push with no flag at all. `push --force*` trips this gate rather than process 5's
77
82
  // so the stronger reason is the one the user reads.
83
+ //
84
+ // B5 (claude-code.md / claude-verdict.md) found the long/modern spellings of the same acts
85
+ // still passed: `clean --force` (the long flag, not a `-f` cluster), `branch --delete --force`
86
+ // (the long form of `-D`, EITHER flag order — `--force --delete` is the same act; an un-forced
87
+ // `--delete` refuses on an unmerged branch exactly like `-d`, so it stays OUT; `-D --force`/
88
+ // `--delete -f`/`-d --force`/`-df` are the SAME act again and are a known ceiling this does not
89
+ // chase — add them if a probe ever shows one in real use), `checkout -f`/`--force` and
90
+ // `switch -f`/`--force`/`--discard-changes`
91
+ // (git's own `-h` lists `-f, --force` AND a separate `--discard-changes` for switch — both
92
+ // discard uncommitted work the same way `checkout --` does), `worktree remove --force`/`-f`
93
+ // (can discard an uncommitted worktree), `reflog expire` and `gc --prune=now` (delete the
94
+ // reflog/dangling-commit safety net recovery depends on), and `push :branch`/`push --delete`
95
+ // (deletes a remote branch — the colon form needs a space before the `:` so `push origin
96
+ // HEAD:main`, an ordinary refspec, is not caught).
97
+ //
98
+ // `restore` and `stash drop`/`stash clear` are carved OUT of the shared `\bgit\b[^\n]*\b(…)`
99
+ // wrapper above and anchored to the verb position instead (`git`, an optional `-C <path>`, then
100
+ // the verb immediately): every other arm here needs a flag that is vanishingly unlikely in a
101
+ // commit message or a filename, but "restore" needs no flag at all and "drop"/"clear" are
102
+ // ordinary English, so `git commit -m "restore working behavior"` or `git stash push -m "clear
103
+ // old state"` would otherwise trip Law IV on the MESSAGE TEXT, not the command. Anchoring to
104
+ // the verb position is also what lets `restore` ask for `--staged --worktree` together (that
105
+ // combination DOES discard working-tree changes, unlike `--staged` alone) while still deferring
106
+ // on `--staged` alone.
78
107
  const DESTRUCTIVE_GIT_RE =
79
- /\bgit\b[^\n]*\b(?:reset\b[^\n]*\s--hard\b|clean\b[^\n]*\s-[a-zA-Z]*f|branch\s+-D|checkout\s+--\s|push\b[^\n]*(?:\s--force|\s-[a-zA-Z]*f\b|\s\+\S))/;
108
+ /\bgit\b[^\n]*\b(?:reset\b[^\n]*\s--hard\b|clean\b[^\n]*\s(?:-[a-zA-Z]*f|--force\b)|branch\b[^\n]*\s(?:-D\b|--delete\b[^\n]*--force\b|--force\b[^\n]*--delete\b)|checkout\s+--\s|checkout\b[^\n]*\s(?:-[a-zA-Z]*f\b|--force\b)|switch\b[^\n]*\s(?:-[a-zA-Z]*f\b|--force\b|--discard-changes\b)|worktree\b[^\n]*\bremove\b[^\n]*(?:-[a-zA-Z]*f\b|--force\b)|reflog\b[^\n]*\bexpire\b|gc\b[^\n]*--prune\b|push\b[^\n]*(?:\s--force|\s-[a-zA-Z]*f\b|\s\+\S|\s--delete\b|\s:\S))|\bgit(?:\s+-C\s+\S+)*\s+restore\b(?:(?![^\n]*--staged\b)|(?=[^\n]*--staged\b)(?=[^\n]*--worktree\b))|\bgit(?:\s+-C\s+\S+)*\s+stash\s+(?:drop|clear)\b/;
80
109
 
81
110
  // `loopExempt` — a WHITELIST, not the blacklist this replaced. A false ask costs one prompt; a
82
111
  // false allow publishes. So the exemption holds only when the ENTIRE command is built from
@@ -196,12 +225,16 @@ const BOB_DENY_EXIT = 2;
196
225
  /**
197
226
  * On Bob, which has no ask tier, which rules are worth a hard block. Laws I and IV: a
198
227
  * credential in a tracked file and a history-discarding git act are wrong in every context,
199
- * so refusing them costs nothing. Process 1 and process 5 are the USER's calls — a hard
228
+ * so refusing them costs nothing. rigor-5 (External Gate) joins them: a protected check the
229
+ * agent can edit is not a check, so it is as unconditional as the two Laws — NOT "Laws I/IV
230
+ * only", see bob-code.md B3. Process 1 and process 5 are the USER's calls — a hard
200
231
  * block would make Bob unable to commit at all — so they become a warning the host
201
- * logs. `gate-error` is a warning too: a crashed gate that blocked every tool call would be
232
+ * logs. ops-2 (Commands Must Return, the root-scan check) is a block too: a warning would let a
233
+ * scan of every drive run for hours, and the agent loses nothing by re-targeting a directory.
234
+ * `gate-error` is a warning too: a crashed gate that blocked every tool call would be
202
235
  * a lockout, not a safeguard, and there is no prompt through which the user could clear it.
203
236
  */
204
- const BLOCK_RULES = new Set(['law-1', 'law-4', 'rigor-5']);
237
+ const BLOCK_RULES = new Set(['law-1', 'law-4', 'rigor-5', 'ops-2']);
205
238
 
206
239
  /**
207
240
  * THE GATE LEDGER — one JSON line per ask, nothing per defer.
@@ -295,9 +328,132 @@ function readPayload() {
295
328
 
296
329
  function gitGate(args) {
297
330
  if (sovereignBypass(args.root)) return 0;
331
+ // A project install of this host beside a global one: the project's own git-gate runs too and
332
+ // carries the project's `--no-consent` choice, so the global's verdict would only overrule it
333
+ // (Claude B4). Law IV still runs, in the project's gate.
334
+ if (globalHookStandingDown(args.root, hookProjectDir(), args.host, 'git-gate')) return 0;
298
335
  return gitDecide(args, readPayload());
299
336
  }
300
337
 
338
+ // ======================================================================================
339
+ // root scan — Doctrine ops 2 (Commands Must Return)'s tool-boundary backstop
340
+ // ======================================================================================
341
+
342
+ // On 2026-10-10 a subagent ran `find / -iname "write.ts" … | head -5` in Git Bash, where `/`
343
+ // mounts every drive (/c, /d) and a network share: 3.5 h at 100 % CPU, and the Bash timeout
344
+ // did not stop the orphaned child. `| head` stops nothing — find still visits every directory.
345
+ // Ported from Ritus/guards/root_scan.py (its 9 refuse / 8 pass self-check is in
346
+ // tests/unit/hook_gates.test.mjs), with one tightening: the reference's recursion regex,
347
+ // `-[a-z]*r[a-z]*` under `/i`, read PowerShell's `-Force`/`-Filter`/`-ErrorAction` as a
348
+ // recursion flag, which on Bob and OpenCode is a hard block, not a prompt. So grep takes a short
349
+ // flag cluster carrying `r`/`R` (or `--recursive`), ls/dir an uppercase `R` (their `-r` is
350
+ // reverse), and PowerShell's `-Recurse` or one of its prefix abbreviations. Lives HERE, inside `gitDecide`, rather than in a verb of
351
+ // its own: every `command` payload on every host already routes through this function, and a
352
+ // new verb on an older machine-wide shim would exit 1 — which `onFailure: "block"` turns into
353
+ // every Bash call refused. Runs whether or not the ops pack is built in, like rule-gate's
354
+ // process-1: how a command is run is never wrong to bound. Twin: adapters/opencode/plugins/
355
+ // geneseed-guard.js `rootScan` — keep in step (tests/plugins/guard.test.mjs diffs the constants).
356
+ const SCAN_ALWAYS = new Set(['find', 'du', 'tree', 'rg', 'fd', 'where.exe']);
357
+ const SCAN_GREP_R = new Set(['grep', 'egrep']);
358
+ const SCAN_LS_R = new Set(['ls', 'dir']);
359
+ const SCAN_PS_R = new Set(['get-childitem', 'gci']);
360
+ // `-r` recurses for grep only: for `ls` it is REVERSE (`ls -ltr /` lists one directory), so
361
+ // ls/dir need an UPPERCASE `R` in the cluster, `--recursive`, or (as PowerShell aliases) `-Recurse`.
362
+ const GREP_RECURSE_RE = /^(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)$/;
363
+ const LS_RECURSE_RE = /^(?:-[a-zA-Z]*R[a-zA-Z]*|--recursive)$/;
364
+ const PS_RECURSE_RE = /^-r(?:e(?:c(?:u(?:r(?:s(?:e)?)?)?)?)?)?$/i;
365
+ // A trailing `*` is still the root: the shell expands `du -sh /*` to every drive just the same.
366
+ const FS_ROOT_RE = /^(?:\/|\/[a-z]\/?|\/mnt\/[a-z]\/?|[a-z]:[\\/]?)\*?$/i;
367
+ // The cheap prefilter: a command that names no scanner pays this one test and nothing else.
368
+ const SCAN_WORD_RE = /\b(?:find|du|tree|rg|fd|where\.exe|grep|egrep|ls|dir|get-childitem|gci)\b/i;
369
+ // Non-POSIX tokens, like the reference's `shlex.split(posix=False)`: a quoted run stays inside
370
+ // its word and a backslash is NEVER an escape — a POSIX lexer would eat the one in `C:\ -Recurse`
371
+ // and leave the root unseen.
372
+ const SCAN_TOKEN_RE = /(?:[^\s"']+|"[^"]*"?|'[^']*'?)+/g;
373
+ const SCAN_WRAPPERS = new Set(['sudo', 'command', '\\builtin', 'time', 'nice']);
374
+ // One left-to-right pass: a comment (`#` starting a word, to the end of the line — an apostrophe
375
+ // in it is no quote), a quoted run (kept whole, so its `;`/`|` is data), a separator, or a
376
+ // heredoc opener (`<<TAG`, `<<-TAG`, `<< 'TAG'`, `<<"TAG"` — never the here-string `<<<`).
377
+ // Every alternative consumes at least one character, and none can backtrack past its start.
378
+ const SCAN_SPLIT_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|\|\||&&|[|;\n]|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2/g;
379
+
380
+ /**
381
+ * The command cut at `|`/`;`/`&&`/`||`/newline OUTSIDE quotes, with every heredoc body (the
382
+ * lines after `<<TAG` up to the `TAG` line; `<<-` lets it be tab-indented) dropped: both are data.
383
+ * Without this, `git commit -F - <<'EOF'` — the mandated multi-line commit form — or a quoted
384
+ * `-m "fix; find / loop"` whose text merely names a root scan reads as one, which on Bob and
385
+ * OpenCode is a hard block of the commit. An unterminated heredoc runs to the end, as in bash.
386
+ * Backslash is no escape here either, like `SCAN_TOKEN_RE` (`"C:\"` must close).
387
+ */
388
+ function scanSegments(command) {
389
+ const segs = [];
390
+ const tags = [];
391
+ const re = new RegExp(SCAN_SPLIT_RE);
392
+ let start = 0;
393
+ let m;
394
+ while ((m = re.exec(command))) {
395
+ if (m[3]) { tags.push([m[1], m[3]]); continue; }
396
+ if (m[0][0] === '"' || m[0][0] === "'") continue;
397
+ segs.push(command.slice(start, m.index));
398
+ start = re.lastIndex;
399
+ // A comment is cut OUT of its segment (its text is prose, `# scanning / for refs`); the
400
+ // newline after it, if any, is the next separator.
401
+ if (m[0][0] === '#') continue;
402
+ // A pending heredoc's body starts on the line after its opener; each line is read once.
403
+ while (m[0] === '\n' && tags.length) {
404
+ const end = command.indexOf('\n', start);
405
+ const line = command.slice(start, end < 0 ? command.length : end).replace(/\r$/, '');
406
+ start = end < 0 ? command.length : end + 1;
407
+ if ((tags[0][0] ? line.replace(/^\t+/, '') : line) === tags[0][1]) tags.shift();
408
+ if (end < 0) break;
409
+ }
410
+ re.lastIndex = start;
411
+ }
412
+ segs.push(command.slice(start));
413
+ return segs;
414
+ }
415
+
416
+ /**
417
+ * The first segment (see `scanSegments`) that recursively scans a whole filesystem root, or
418
+ * null. A segment whose PROGRAM is ssh is exempt — its remote command is ssh's argument, and a
419
+ * remote root scan is the remote's business — but only that segment: `ssh h true; find /` still
420
+ * refuses, and so does a `find /` whose comment merely mentions ssh (the reference exempted any
421
+ * command containing "ssh"). Any path deeper than a root passes.
422
+ */
423
+ export function rootScan(command) {
424
+ if (!SCAN_WORD_RE.test(command)) return null;
425
+ for (const seg of scanSegments(command)) {
426
+ // Tokens keep their quotes until the root test, so a quoted `"a:"` can be told from a bare `C:`.
427
+ let toks = (seg.match(SCAN_TOKEN_RE) || []).filter((t) => !/^\w+=/.test(t));
428
+ while (toks.length && SCAN_WRAPPERS.has(unquote(toks[0]))) toks = toks.slice(1);
429
+ if (!toks.length) continue;
430
+ // The program word without its path (either slash) or an `.exe` suffix: `C:\tools\rg.exe`.
431
+ const word = unquote(toks[0]).split(/[\\/]/).pop().toLowerCase();
432
+ const prog = SCAN_ALWAYS.has(word) ? word : word.replace(/\.exe$/, '');
433
+ const always = SCAN_ALWAYS.has(prog);
434
+ const grep = SCAN_GREP_R.has(prog);
435
+ const ls = SCAN_LS_R.has(prog);
436
+ const ps = SCAN_PS_R.has(prog);
437
+ if (!always && !grep && !ls && !ps) continue; // ssh is none of these, so its segment passes
438
+ // `-Path=x` and PowerShell's colon binding `-Path:C:\` read as the path itself.
439
+ const argv = toks.slice(1).map((t) => {
440
+ const m = /^-path[=:](.+)$/i.exec(t);
441
+ return m ? m[1] : t;
442
+ });
443
+ if (!always && !argv.some((a) => (grep && GREP_RECURSE_RE.test(a))
444
+ || (ls && (LS_RECURSE_RE.test(a) || (a.length >= 3 && PS_RECURSE_RE.test(a))))
445
+ || (ps && PS_RECURSE_RE.test(a)))) continue;
446
+ if (argv.some(rootArg)) return seg.trim();
447
+ }
448
+ return null;
449
+ }
450
+ // For ls/dir a PowerShell `-Recurse` abbreviation counts from `-re` up: a bare `-r` there is the
451
+ // POSIX reverse flag (`ls -r /`, `dir -r C:\`). gci/Get-ChildItem keep the full abbreviation set.
452
+ const unquote = (t) => t.replace(/^["']+|["']+$/g, '');
453
+ // A QUOTED token is data — a grep pattern (`grep -rn "/" src`), a path named in a search
454
+ // (`rg "/mnt/c" README.md`) — never a root target; only an unquoted root is one.
455
+ const rootArg = (t) => !/^["']/.test(t) && FS_ROOT_RE.test(t);
456
+
301
457
  function gitDecide(args, payload) {
302
458
  const command = ((payload && payload.tool_input) || {}).command;
303
459
  if (typeof command !== 'string') return 0;
@@ -305,6 +461,14 @@ function gitDecide(args, payload) {
305
461
  return ask(args, 'git-gate', 'law-4', 'Geneseed (Deletion Is Deliberate) \u2014 a history-rewriting or '
306
462
  + 'discarding git act needs confirmation bound to this specific command');
307
463
  }
464
+ // Before `--no-consent`: that flag drops the process pack's rule, not this one.
465
+ const scan = rootScan(command);
466
+ if (scan) {
467
+ return ask(args, 'git-gate', 'ops-2', `Geneseed (Commands Must Return) \u2014 \`${scan.slice(0, 80)}\` `
468
+ + 'recursively scans a whole filesystem root (in Git Bash, / spans every drive and mounted '
469
+ + 'share, for hours; | head stops nothing). Search a specific directory instead: the '
470
+ + 'project, node_modules, ~/.npm, %APPDATA%.');
471
+ }
308
472
  // `--no-consent`: the process pack (or process 5 alone) is off, so the commit/push ask has
309
473
  // no rule behind it — but Law IV above is universal and has already run. Checked before
310
474
  // `loopExempt` so a non-git command, or one with consent off, never pays for the whitelist.
@@ -397,8 +561,14 @@ function ruleDecide(args, payload) {
397
561
  if (typeof p !== 'string' || !p) return 0;
398
562
  // Write carries `content`, Edit `new_string`, NotebookEdit `new_source`, MultiEdit an
399
563
  // `edits[]` of `new_string`s — every one can plant a credential, so every one is scanned.
564
+ // MultiEdit is no longer in Claude Code's own tool table (tools-reference.md) or the
565
+ // settings matcher that routes here (Claude verdict R2); the `edits[]` read stays as a
566
+ // cheap, harmless guard in case that ever changes.
567
+ // `diff` (Bob's `apply_diff`) and `replace`/`search` (`search_and_replace`) are Roo-lineage
568
+ // field names the Bob docs never confirm (bob-verdict.md I1) — accepted when present, never
569
+ // required, same as every field above.
400
570
  const edits = Array.isArray(ti.edits) ? ti.edits.map((e) => e && e.new_string) : [];
401
- const body = [ti.content, ti.new_string, ti.new_source, ...edits]
571
+ const body = [ti.content, ti.new_string, ti.new_source, ti.diff, ti.replace, ti.search, ...edits]
402
572
  .filter((v) => typeof v === 'string').join('\n');
403
573
  if (body && !DOTENV_RE.test(p) && SECRET_RE.test(body)) {
404
574
  return ask(args, 'rule-gate', 'law-1', `Geneseed (Sealed Secrets) — ${p} would carry a `
@@ -424,15 +594,39 @@ export const cmdRuleGate = guardGate(ruleGate, 'rule-gate');
424
594
  // ======================================================================================
425
595
 
426
596
  /**
427
- * Bob's PreToolUse entry carries no matcher over documented tool names, so it cannot name `git-gate` for Bash and `rule-gate` for Write the way Claude's
428
- * `PreToolUse` does. This verb is the two fused, dispatched on the payload rather
429
- * than on a matcher: a `command` field is a shell call and gets the git checks, a path
430
- * field is a write and gets the rule checks. Nothing here decides anything the two named
431
- * gates do not — it only removes the need for a matcher the host does not have.
597
+ * Bob's documented PreToolUse payload (bob-verdict.md I1) is `{event, session_id, tool,
598
+ * input:{...}}` — none of `tool_input`, `tool_name` or `hook_event_name` exist. Normalised
599
+ * HERE, once, at Bob's one entry point (`toolGate` is the only verb its settings.json invokes
600
+ * for PreToolUse), so `gitDecide`/`ruleDecide` keep reading the Claude shape unchanged and
601
+ * never learn Bob exists. A no-op for an already-Claude-shaped payload: every field it sets
602
+ * is only filled in when the Claude-named field is absent.
603
+ */
604
+ function normaliseBobPayload(payload) {
605
+ if (!payload || typeof payload !== 'object') return payload;
606
+ const out = { ...payload };
607
+ if (!out.tool_input && payload.input && typeof payload.input === 'object') {
608
+ out.tool_input = payload.input;
609
+ }
610
+ if (!out.tool_name && typeof payload.tool === 'string') out.tool_name = payload.tool;
611
+ if (!out.hook_event_name && typeof payload.event === 'string') out.hook_event_name = payload.event;
612
+ return out;
613
+ }
614
+
615
+ /**
616
+ * Bob's PreToolUse entry carries no matcher, deliberately (host-compat O1, revisited): Bob's
617
+ * tool list drifts (already stale against the 2.1.0+ changelog — `web_fetch`, the Office/
618
+ * IBM-docs tools) and its "regex on the tool name" dialect is otherwise unverified, so
619
+ * `claudeHookGroups` (settings.mjs) does not build one — an allow-list would fail OPEN on a
620
+ * tool it has not seen yet, and a deny-list needs lookahead the regex dialect may not have.
621
+ * So this verb cannot name `git-gate` for one tool and `rule-gate` for another the way
622
+ * Claude's two separate `PreToolUse` matchers do; it is the two fused, dispatched on the
623
+ * PAYLOAD rather than on a matcher: a `command` field is a shell call and gets the git
624
+ * checks, a path field is a write and gets the rule checks. Nothing here decides anything
625
+ * the two named gates do not — it only removes the need for a matcher the host does not have.
432
626
  */
433
627
  function toolGate(args) {
434
628
  if (sovereignBypass(args.root)) return 0;
435
- const payload = readPayload();
629
+ const payload = normaliseBobPayload(readPayload());
436
630
  const ti = (payload && payload.tool_input) || {};
437
631
  return typeof ti.command === 'string' ? gitDecide(args, payload) : ruleDecide(args, payload);
438
632
  }