geneseed 3.20.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.
- package/CHANGELOG.md +35 -0
- package/README.md +9 -1
- package/SHIPPED.md +1 -1
- package/adapters/bob/README.md +31 -12
- package/adapters/claude-code/README.md +24 -17
- package/adapters/claude-code/settings.json +4 -6
- package/adapters/openclaude/README.md +13 -5
- package/adapters/opencode/plugins/geneseed-guard.js +291 -174
- package/bin/geneseed-hook.mjs +36 -5
- package/docs/README.md +3 -1
- package/docs/candidate-hosts.md +0 -8
- package/docs/concepts/enforcement-table.md +66 -0
- package/docs/concepts/hook-gates.md +2 -2
- package/docs/concepts/hooks.md +6 -6
- package/docs/concepts/host-matrix.md +4 -3
- package/docs/concepts/loop-branch.md +6 -1
- package/docs/concepts/rules.md +1 -1
- package/docs/design-history.md +59 -0
- package/docs/extending.md +11 -3
- package/docs/guides/install-opencode.md +1 -1
- package/docs/guides/loop-running.md +1 -1
- package/docs/reference/env-context.md +3 -2
- package/docs/reference/environment.md +2 -1
- package/docs/reference/plugin-guard.md +3 -3
- package/docs/reference/troubleshoot-hooks.md +34 -0
- package/docs/understand/design-choices-engineering.md +29 -0
- package/docs/understand/design-choices-gates.md +23 -0
- package/docs/understand/design-choices-hosts.md +21 -0
- package/docs/understand/design-choices-install.md +19 -0
- package/docs/understand/design-choices-shape.md +21 -0
- package/docs/understand/design-choices.md +34 -0
- package/docs/understand/enforced-vs-asked.md +5 -4
- package/docs/understand/machine-bob.md +1 -1
- package/docs/understand/machine-opencode.md +2 -2
- package/harness.config.json +1 -1
- package/js/build/driver.mjs +42 -16
- package/js/build/emit-claude.mjs +24 -8
- package/js/build/emit-opencode.mjs +33 -15
- package/js/build/generate.mjs +38 -9
- package/js/build/source.mjs +39 -0
- package/js/cli-table.json +6 -6
- package/js/hosts/gitref.mjs +3 -3
- package/js/hosts/hooks-context.mjs +11 -30
- package/js/hosts/hooks-learn.mjs +89 -8
- package/js/hosts/hooks.mjs +212 -18
- package/js/hosts/hosts.mjs +217 -13
- package/js/hosts/installs.mjs +259 -46
- package/js/hosts/native.mjs +48 -12
- package/js/hosts/opencode.mjs +47 -17
- package/js/hosts/settings.mjs +366 -42
- package/js/hosts/shim.mjs +37 -0
- package/js/inspect/checks-authoring.mjs +81 -1
- package/js/inspect/checks-constitution.mjs +2 -1
- package/js/inspect/checks-repo.mjs +68 -4
- package/js/inspect/doctor.mjs +40 -9
- package/js/inspect/scan.mjs +5 -4
- package/js/inspect/status.mjs +62 -12
- package/js/lib/text.mjs +23 -0
- package/js/loop/catalog.mjs +7 -2
- package/js/maintain/migrate.mjs +42 -5
- package/js/maintain/uninstall.mjs +150 -38
- package/js/maintain/update.mjs +22 -2
- package/js/web/actions.mjs +28 -10
- package/js/web/api.mjs +5 -3
- package/js/web/state.mjs +28 -15
- package/package.json +1 -1
- package/src/agents/_template.md +1 -1
- package/src/bricks/_template.md +1 -1
- package/src/doctrines/ops.md +9 -7
- package/src/laws/universal.md +1 -1
- package/src/skills/_template.md +3 -3
- package/src/skills/herdr.md +1 -1
- package/web/dist/assets/{Activity-IbwdHHyx.js → Activity-BCtqxjfy.js} +1 -1
- package/web/dist/assets/{ActivityDetail-B5uivdWI.js → ActivityDetail-Ba3Oo4dE.js} +1 -1
- package/web/dist/assets/{Dashboard-v6WQ37bD.js → Dashboard-DpHza68-.js} +2 -2
- package/web/dist/assets/{Docs-CypIfDBX.js → Docs-Bw_-UK0O.js} +1 -1
- package/web/dist/assets/{GreenhouseView-ClxP0hQG.js → GreenhouseView-DnJ1Ggp1.js} +1 -1
- package/web/dist/assets/{Installs-CPkQeFjO.js → Installs-CWoNKFBk.js} +1 -1
- package/web/dist/assets/{JournalView-BeEInhUf.js → JournalView-g6-bw4c8.js} +1 -1
- package/web/dist/assets/{Laws-Ds6U5xBQ.js → Laws-DkVWR1tb.js} +1 -1
- package/web/dist/assets/{Library-BraUKPyQ.js → Library-CK9HZzjV.js} +1 -1
- package/web/dist/assets/{Loops-DWcy3mwK.js → Loops-BP98SMOH.js} +1 -1
- package/web/dist/assets/{OperatorHudView-CQyrJQ9m.js → OperatorHudView-DI_n12M9.js} +1 -1
- package/web/dist/assets/{Personal-YWqs6qFL.js → Personal-Bk89f13a.js} +1 -1
- package/web/dist/assets/{Tabs-EhzEC-Xh.js → Tabs-3WuYIE1b.js} +1 -1
- package/web/dist/assets/{drawable-CEKPNV7X.js → drawable-oCUeq9e5.js} +1 -1
- package/web/dist/assets/{index-CPi22b7y.js → index-CiXwChvf.js} +2 -2
- package/web/dist/index.html +1 -1
package/js/hosts/gitref.mjs
CHANGED
|
@@ -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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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,
|
|
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.
|
|
@@ -169,29 +169,8 @@ export function sessionFiles(hookRoot) {
|
|
|
169
169
|
return found;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* `_global_hook_standing_down` — project-bypasses-global.
|
|
177
|
-
*
|
|
178
|
-
* A GLOBAL install's hook passes its own dir as `--root`; when a Geneseed PROJECT install
|
|
179
|
-
* of the SAME host sits at or above cwd, the project's hook injects and this one must not
|
|
180
|
-
* double up.
|
|
181
|
-
*/
|
|
182
|
-
export function globalHookStandingDown(hookRoot, cwd) {
|
|
183
|
-
const marker = path.basename(hookRoot);
|
|
184
|
-
if (!CLAUDE_MARKERS.includes(marker)) return false;
|
|
185
|
-
for (const d of selfAndParents(cwd)) {
|
|
186
|
-
const cand = path.join(d, marker);
|
|
187
|
-
if (isFile(path.join(cand, GENESEED_MANIFEST))) {
|
|
188
|
-
// Path equality, which is case-folded on Windows — `~/.claude` and `~/.Claude` are
|
|
189
|
-
// the same install there and two different ones on Linux.
|
|
190
|
-
return normcase(resolvePath(cand)) !== normcase(resolvePath(hookRoot));
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
return false;
|
|
194
|
-
}
|
|
172
|
+
// Moved to `hosts.mjs` so the gates and `learn` share it; re-exported for this verb's callers.
|
|
173
|
+
export { globalHookStandingDown };
|
|
195
174
|
|
|
196
175
|
/** `_disp` — relative to the repo root when it sits under it, else verbatim. */
|
|
197
176
|
function disp(pathStr, root) {
|
|
@@ -412,8 +391,9 @@ function lazyLines(entries, root, room) {
|
|
|
412
391
|
|
|
413
392
|
export function cmdContext(args) {
|
|
414
393
|
HOST = (args && args.host) || 'claude';
|
|
415
|
-
// Discovery runs against the project root the
|
|
416
|
-
//
|
|
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.
|
|
417
397
|
// The only two `resolvePath` calls in this file whose argument can be a `~user` path the
|
|
418
398
|
// user typed — `$GENESEED_ROOT` and `--root` — and `resolvePath` expands, so both can now
|
|
419
399
|
// throw. Every other caller either builds its argument by `path.join` from an absolute
|
|
@@ -423,15 +403,16 @@ export function cmdContext(args) {
|
|
|
423
403
|
let hookRoot;
|
|
424
404
|
try {
|
|
425
405
|
({ root, hookRoot } = withDiscardableStderr(() => ({
|
|
426
|
-
root: resolvePath(process.env.GENESEED_ROOT ||
|
|
406
|
+
root: resolvePath(process.env.GENESEED_ROOT || hookProjectDir()),
|
|
427
407
|
hookRoot: args.root ? resolvePath(args.root) : null,
|
|
428
408
|
})));
|
|
429
409
|
} catch {
|
|
430
410
|
return 0;
|
|
431
411
|
}
|
|
432
412
|
if (hookRoot && sovereignBypass(hookRoot)) return 0;
|
|
433
|
-
|
|
434
|
-
|
|
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;
|
|
435
416
|
const session = hookRoot ? sessionFiles(hookRoot) : [];
|
|
436
417
|
// A session file discovery would also pick up (a `user-rules.md` at the repo root of an
|
|
437
418
|
// install whose harness dir IS the repo root) is injected once, in the session block.
|
package/js/hosts/hooks-learn.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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
|
-
|
|
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,
|
|
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');
|
package/js/hosts/hooks.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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 {
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
428
|
-
* `
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
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
|
}
|