leos-agent 10.6.0 → 10.7.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # leos-agent
2
2
 
3
- Leo's portable agent operating policy, version **10.6.0**, installable on Claude
3
+ Leo's portable agent operating policy, version **10.7.0**, installable on Claude
4
4
  Code, Codex, Cursor, Hermes, Pi, and OpenCode through each harness's own plugin
5
5
  system.
6
6
 
@@ -30,6 +30,7 @@ The GitHub ones need `gh`, authenticated.
30
30
  | `watch-review` | Arms a watcher that streams direct review requests into the session for `review-pr` to handle, and re-streams one when its head moves. Never surfaces a pull request someone else has approved. Polling is a shell script (`scripts/watch_review.py`), not a model loop: an idle tick is one `gh` call and zero tokens. | **Claude Code only** — built on its Monitor tool |
31
31
  | `doctor` | Diagnoses this harness's setup, read-only: whether the `<leos-agent>` block is injected and current, what else is loaded into every session (global instruction file, memories, settings, skills), and whether a local checkout passes `scripts/check.py`. Run it with `/doctor`. | every skill-loading harness |
32
32
  | `tune-routing` | Picks the concrete models behind `leo-runner` and `leo-executor` on this machine, writes them to `~/.leos-agent-local/routing.json`, re-renders the install, and proves the choice with one live dispatch — model strings are never checked against a known-model list, so a typo surfaces at dispatch time and nowhere earlier. Run it with `/tune-routing`. | every skill-loading harness |
33
+ | `review-usage` | Reads many sessions across every harness on this machine — a time window, not one session — and reports where the tokens went and how well the policy actually held: routing compliance, guard blocks and whether they were re-dispatched, over- and under-delegation, cache health. The scan is a script, not a prompt, so it costs a second rather than a model's worth of transcript reading. Run it with `/review-usage`. | every skill-loading harness |
33
34
  | `handoff` | Writes this session's context — goal, what landed, what is next, key files, decisions, gotchas — to a markdown document under `~/.leos-agent-local/handoffs/`, so a later session can pick the work up. Pointers, not contents: it names files rather than pasting them. Run it with `/handoff`. | every skill-loading harness |
34
35
  | `handon` | Loads a handoff written earlier — in this harness or a different one — and resumes from it, reporting any drift first when the directory, branch, or HEAD has moved since. Loading never consumes a handoff. Run it with `/handon <name>`. | every skill-loading harness |
35
36
  | `attach-pr` | Attaches the current desktop session to an existing pull request so the app shows its PR card. Creates nothing and pushes nothing. | **Claude Code only** — it drives that app's card |
@@ -113,6 +114,70 @@ Cursor gets its own `~/.cursor/rules/leos-agent-routing.mdc` because its rules
113
114
  come straight out of the plugin directory, and the rest get the rendered line in
114
115
  their global instruction file.
115
116
 
117
+ ## The dispatch guard
118
+
119
+ The payload has always said that a subagent dispatch must name a model. Prose
120
+ alone did not hold: a forgotten dispatch inherits the parent's expensive model
121
+ and pays a cold cache write per child, which is the single most expensive shape
122
+ this policy has. From 10.7.0 that half of the rule is enforced by a hook instead,
123
+ and the prose it replaced came out of the always-loaded payload — enforcement in
124
+ code costs **zero** context per turn, so the guard paid for itself in bytes
125
+ before saving a cent.
126
+
127
+ `scripts/dispatch_guard.py` runs before a subagent dispatch and refuses exactly
128
+ one thing: an agent selected with a brief, **no model named**, on a harness that
129
+ can name one. Three ways to comply, all of them one word:
130
+
131
+ | Instead of | Use | For |
132
+ |---|---|---|
133
+ | a generic agent, no model | `subagent_type: "leo-runner"` | reading, search, tests, logs, codemods, fan-out |
134
+ | a generic agent, no model | `subagent_type: "leo-executor"` | an approved plan, a well-specified change |
135
+ | a generic agent, no model | `model: "<name>"` | investigation and debugging — naming it *is* the stated reason |
136
+
137
+ It never picks a model for you. It cannot force cheap work onto an expensive
138
+ problem, so it cannot cause a quality regression — only an explicit choice. It
139
+ is also deliberately narrow: a false block costs one re-dispatch, while a caught
140
+ inherited fan-out saves the cold prefix of every child, so the margin only holds
141
+ while the rule refuses to make judgment calls.
142
+
143
+ Detection is by **argument shape**, not tool name — only Claude Code's dispatch
144
+ tool is verified, so an unanticipated one degrades to a no-op rather than a
145
+ broken harness. MCP tools are never guarded. Anything that goes wrong inside the
146
+ guard allows the call and records `decision: "error"`, kept distinct from a
147
+ decision to allow, because a guard that dies quietly is worse than no guard.
148
+
149
+ ```
150
+ LEOS_AGENT_DISPATCH_GUARD=on block (default)
151
+ warn record, never block
152
+ off disabled entirely
153
+ verbose also put the over-delegation notice in front of the model
154
+ LEOS_AGENT_DISPATCH_LOG_PROMPTS=1 debug only: keep 200 chars of brief text in the log
155
+ ```
156
+
157
+ **Coverage is honest, not uniform.** Claude Code is verified. Codex, Cursor,
158
+ Hermes and OpenCode are wired with the same policy but their dispatch argument
159
+ shapes are unconfirmed; there the guard is best-effort and no-ops rather than
160
+ misfires. Pi has no hook surface and gets nothing. **Codex hash-pins hooks**, so
161
+ upgrading to 10.7.0 — and every later edit to the guard — needs re-approval
162
+ through `/hooks` there. Until you do, Codex silently enforces nothing; zero Codex
163
+ rows in the report is the symptom.
164
+
165
+ ### What it records
166
+
167
+ `~/.leos-agent-local/dispatch.jsonl`, one line per dispatch, mode `0600`,
168
+ rotated at 1 MiB with one generation kept — bounded at 2 MiB forever.
169
+
170
+ It stores **no prompt text and no paths.** Prompts, sessions and working
171
+ directories are truncated SHA-256. The prompt hash is what makes the report
172
+ meaningful: a blocked brief whose hash comes back naming a tier is a block that
173
+ worked, and one that never returns was abandoned work rather than a saving.
174
+ Delete it whenever you like — nothing depends on its history.
175
+
176
+ ```bash
177
+ python3 scripts/dispatch_log.py report
178
+ python3 scripts/usage_scan.py --since 7d
179
+ ```
180
+
116
181
  ## How it works
117
182
 
118
183
  The payload lives in exactly one file: [`rules/preferences.md`](rules/preferences.md).
@@ -122,7 +187,7 @@ gets it through its global instruction file, written by
122
187
  [`scripts/leo-install.py`](scripts/leo-install.py) into a marker block:
123
188
 
124
189
  ```
125
- <leos-agent version="10.6.0">
190
+ <leos-agent version="10.7.0">
126
191
  ...the payload...
127
192
  </leos-agent>
128
193
  ```
@@ -191,7 +256,7 @@ that it is already installed and changes nothing.
191
256
  Run the installer's uninstall first, while the script is still on disk:
192
257
 
193
258
  ```bash
194
- python3 ~/.claude/plugins/cache/leos-agent/leos-agent/10.6.0/scripts/leo-install.py claude --uninstall
259
+ python3 ~/.claude/plugins/cache/leos-agent/leos-agent/10.7.0/scripts/leo-install.py claude --uninstall
195
260
  ```
196
261
 
197
262
  ```bash
@@ -222,7 +287,7 @@ Then run the `install` skill in a Codex session (`$leos-agent`, then `install`),
222
287
  run the script directly:
223
288
 
224
289
  ```bash
225
- python3 ~/.codex/plugins/cache/leos-agent/leos-agent/10.6.0/scripts/leo-install.py codex
290
+ python3 ~/.codex/plugins/cache/leos-agent/leos-agent/10.7.0/scripts/leo-install.py codex
226
291
  ```
227
292
 
228
293
  This writes `~/.codex/AGENTS.md` and installs two economical agents:
@@ -247,7 +312,7 @@ threads only. Re-adding an already-installed plugin is idempotent.
247
312
  **Uninstall**
248
313
 
249
314
  ```bash
250
- python3 ~/.codex/plugins/cache/leos-agent/leos-agent/10.6.0/scripts/leo-install.py codex --uninstall
315
+ python3 ~/.codex/plugins/cache/leos-agent/leos-agent/10.7.0/scripts/leo-install.py codex --uninstall
251
316
  ```
252
317
 
253
318
  ```bash
@@ -396,7 +461,7 @@ Pinned refs are reconciled, never silently advanced — to move to a new tag,
396
461
  install it explicitly:
397
462
 
398
463
  ```bash
399
- pi install git:github.com/foxhatleo/leos-agent@v10.6.0
464
+ pi install git:github.com/foxhatleo/leos-agent@v10.7.0
400
465
  ```
401
466
 
402
467
  Re-run `/skill:install` afterwards.
@@ -616,7 +681,7 @@ claude plugin uninstall leos-agent@leos-agent && claude plugin install leos-agen
616
681
  ```
617
682
 
618
683
  or replace the cachebuster suffix in the Codex manifest with one in the form
619
- `10.6.0+codex.local-YYYYMMDD-HHMMSS` and re-add. Either way, plugin changes only
684
+ `10.7.0+codex.local-YYYYMMDD-HHMMSS` and re-add. Either way, plugin changes only
620
685
  reach a **new** session or thread.
621
686
 
622
687
  `--check` exits non-zero when a file is out of date, and `--force` replaces a
@@ -0,0 +1,93 @@
1
+ # Hooks
2
+
3
+ Both files carry exactly one hook: the dispatch guard, which refuses a subagent
4
+ dispatch that names no model. See [the README](../README.md#the-dispatch-guard).
5
+
6
+ **v10 shipped these empty on purpose** — policy was enforced through the payload
7
+ and the skills, not by intercepting tool calls. 10.7.0 reversed that for one
8
+ narrow rule, and the reason is worth keeping: the always-loaded budget in
9
+ `scripts/measure_context.py` had 56 bytes of headroom, so the prose could not be
10
+ strengthened, while a hook costs nothing per turn. Moving the mechanical half of
11
+ the routing rule into code let the prose that restated it come *out* of the
12
+ payload. The doctrine still holds for anything a machine cannot check: judgment
13
+ stays in `rules/preferences.md`, where a model can read it.
14
+
15
+ **Hook scripts live in `scripts/`, not here.** `hooks/` was absent from
16
+ `package.json`'s `files` until 10.7.0, so a script placed here reached nobody who
17
+ installed from npm — and it would have failed silently, since the guard fails
18
+ open. `scripts/check.py` now asserts every hook `command` resolves to a file
19
+ inside a shipped directory. These JSON files are pointers.
20
+
21
+ **There are two files because the harnesses disagree on the format.** Claude
22
+ Code and Codex use PascalCase event names and no version key; Cursor uses
23
+ camelCase names wrapped in `{"version": 1, ...}`. A single file cannot satisfy
24
+ both, so each manifest points at its own.
25
+
26
+ | File | Read by | Wired via |
27
+ |---|---|---|
28
+ | `hooks.json` | Claude Code, Codex | auto-discovery — **neither manifest may name it** |
29
+ | `hooks-cursor.json` | Cursor | `.cursor-plugin/plugin.json`, which overrides Cursor's own auto-discovery |
30
+
31
+ Both Claude Code and Codex load `hooks/hooks.json` on their own. Declaring it in
32
+ the manifest as well is a duplicate: Codex's validator rejects the key outright,
33
+ and Claude Code fails the entire plugin at load time with `Duplicate hooks file
34
+ detected` — which `claude plugin validate` does **not** catch, so only a real
35
+ install reveals it. `scripts/check.py` guards both cases.
36
+
37
+ Cursor is the exception, and only because its file has a different name: naming
38
+ it explicitly overrides Cursor's auto-discovery, which is what keeps Cursor from
39
+ trying to read the PascalCase `hooks.json` it cannot parse.
40
+
41
+ Hermes and OpenCode already carry the guard through their own mechanisms, and
42
+ both call into `scripts/dispatch_guard.py` so that one policy has one
43
+ implementation. Hermes hooks are Python callbacks registered from
44
+ `register(ctx)` in `__init__.py` (`pre_tool_call`, `post_tool_call`,
45
+ `on_session_start`, and so on), not JSON. OpenCode's are JavaScript hooks
46
+ returned from the plugin factory in `index.js`. Pi's are extension event
47
+ handlers. All three would be written in code rather than added here.
48
+
49
+ ## Adding one
50
+
51
+ Claude Code and Codex (`hooks.json`):
52
+
53
+ ```json
54
+ {
55
+ "hooks": {
56
+ "PreToolUse": [
57
+ {
58
+ "matcher": "Bash",
59
+ "hooks": [
60
+ {
61
+ "type": "command",
62
+ "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/my-check.py\"",
63
+ "timeout": 10
64
+ }
65
+ ]
66
+ }
67
+ ]
68
+ }
69
+ }
70
+ ```
71
+
72
+ Codex exposes the same directory as `${PLUGIN_ROOT}` and accepts
73
+ `${CLAUDE_PLUGIN_ROOT}` as an alias, so one command string serves both. A hook
74
+ script reads the event JSON on stdin and writes its decision to stdout; exit
75
+ code 2 blocks the call, with stderr as the reason.
76
+
77
+ Cursor (`hooks-cursor.json`) uses the same idea with its own names:
78
+
79
+ ```json
80
+ {
81
+ "version": 1,
82
+ "hooks": {
83
+ "preToolUse": [
84
+ { "command": "python3 ./scripts/my-check.py", "timeout": 10 }
85
+ ]
86
+ }
87
+ }
88
+ ```
89
+
90
+ After editing either file, re-run `python3 scripts/check.py` — it validates
91
+ that both still parse and that Cursor's keeps `version: 1`. Codex additionally
92
+ hash-pins hooks for trust, so a changed hook must be re-approved through
93
+ `/hooks` there.
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": 1,
3
+ "hooks": {
4
+ "preToolUse": [
5
+ {
6
+ "command": "python3 ./scripts/dispatch_guard.py",
7
+ "timeout": 10
8
+ }
9
+ ]
10
+ }
11
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "[Tt]ask|[Aa]gent|[Ss]ubagent|[Dd]ispatch|[Dd]elegate|[Ss]pawn",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/dispatch_guard.py\"",
10
+ "timeout": 10
11
+ }
12
+ ]
13
+ }
14
+ ]
15
+ }
16
+ }
package/index.js CHANGED
@@ -1,12 +1,118 @@
1
1
  /**
2
2
  * OpenCode plugin entry point for leos-agent.
3
3
  *
4
- * Deliberately does nothing at runtime. OpenCode's plugin API registers tools
5
- * and hooks, not skills or commands, and the payload is installed to disk on
6
- * demand rather than written at session start. This export exists so that
7
- * `opencode plugin leos-agent -g` resolves and loads cleanly; the real work
8
- * happens in scripts/leo-install.py, which the installed `install` skill runs.
4
+ * One hook: refuse a subagent dispatch that names no model, so a fan-out cannot
5
+ * silently inherit the parent's expensive model. The decision itself lives in
6
+ * scripts/dispatch_guard.py -- five harnesses share exactly one policy, and only
7
+ * the event shape differs per harness.
8
+ *
9
+ * OpenCode has no per-tool matcher, so the prefilter is here in JS: without it
10
+ * every Read, Grep and Bash would pay a python3 spawn, which would be a worse
11
+ * regression than the one being fixed. The keys below are duplicated from
12
+ * dispatch_guard.py and pinned equal by tests/test_dispatch_guard.py.
13
+ *
14
+ * Everything else in this plugin is installed to disk on demand by
15
+ * scripts/leo-install.py, which the installed `install` skill runs.
9
16
  */
10
- export const LeosAgent = async () => ({});
17
+ import { spawn } from 'node:child_process';
18
+ import { appendFileSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { join } from 'node:path';
21
+
22
+ const AGENT_KEYS = ['subagent_type', 'subagentType', 'agent_type', 'agentType', 'agent', 'subagent', 'profile'];
23
+ const PROMPT_KEYS = ['prompt', 'brief', 'instructions', 'task', 'message', 'input'];
24
+ const GUARD_TIMEOUT_MS = 10_000;
25
+
26
+ const dataRoot = () => process.env.LEOS_AGENT_LOCAL_PATH || join(homedir(), '.leos-agent-local');
27
+
28
+ /** A breadcrumb that cannot be written must not itself break the session. */
29
+ function breadcrumb(message) {
30
+ try {
31
+ appendFileSync(join(dataRoot(), 'dispatch-guard.log'), `${new Date().toISOString()} ${message}\n`, { mode: 0o600 });
32
+ } catch {
33
+ /* nothing left to do; the guard has already failed open */
34
+ }
35
+ }
36
+
37
+ const hasKey = (args, keys) => keys.some((k) => typeof args[k] === 'string' && args[k].trim());
38
+
39
+ /**
40
+ * Run the shared guard. Resolves to a block reason, or null to allow.
41
+ * Every infrastructure failure -- missing python3, spawn error, timeout, a
42
+ * wedged interpreter -- resolves null: the harm guarded against here is money,
43
+ * and a guard that fails closed would wedge every dispatch in every session.
44
+ */
45
+ function runGuard(root, event) {
46
+ return new Promise((resolve) => {
47
+ let child;
48
+ try {
49
+ child = spawn('python3', [join(root, 'scripts', 'dispatch_guard.py')], {
50
+ stdio: ['pipe', 'ignore', 'pipe'],
51
+ env: { ...process.env, LEOS_AGENT_HARNESS: 'opencode' },
52
+ });
53
+ } catch (err) {
54
+ breadcrumb(`spawn failed: ${err && err.message}`);
55
+ return resolve(null);
56
+ }
57
+
58
+ let stderr = '';
59
+ let settled = false;
60
+ const finish = (value) => {
61
+ if (settled) return;
62
+ settled = true;
63
+ clearTimeout(timer);
64
+ resolve(value);
65
+ };
66
+ const timer = setTimeout(() => {
67
+ breadcrumb('guard timed out; allowing');
68
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
69
+ finish(null);
70
+ }, GUARD_TIMEOUT_MS);
71
+
72
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
73
+ // EPIPE on either pipe means the child exited early. That is an allow, not
74
+ // an unhandled error event that would take the session down with it.
75
+ child.stderr.on('error', () => {});
76
+ child.stdin.on('error', () => {});
77
+ child.on('error', (err) => {
78
+ breadcrumb(`guard error: ${err && err.message}`);
79
+ finish(null);
80
+ });
81
+ child.on('close', (code) => finish(code === 2 ? (stderr.trim() || '[leo routing] blocked') : null));
82
+
83
+ try {
84
+ child.stdin.end(JSON.stringify(event));
85
+ } catch (err) {
86
+ breadcrumb(`stdin write failed: ${err && err.message}`);
87
+ finish(null);
88
+ }
89
+ });
90
+ }
91
+
92
+ export const LeosAgent = async (ctx) => {
93
+ // The factory's ctx is the only place the session directory is available;
94
+ // tool.execute.before receives just {tool, sessionID, callID}.
95
+ const directory = (ctx && (ctx.directory || ctx.worktree)) || process.cwd();
96
+ const root = process.env.LEOS_AGENT_ROOT || process.env.PLUGIN_ROOT || new URL('.', import.meta.url).pathname;
97
+
98
+ return {
99
+ 'tool.execute.before': async (input, output) => {
100
+ const args = output && output.args;
101
+ if (!args || typeof args !== 'object') return;
102
+ const tool = String((input && input.tool) || '');
103
+ if (tool.startsWith('mcp__')) return;
104
+ if (!hasKey(args, AGENT_KEYS) || !hasKey(args, PROMPT_KEYS)) return;
105
+
106
+ const reason = await runGuard(root, {
107
+ tool_name: tool,
108
+ tool_input: args,
109
+ session_id: (input && input.sessionID) || '',
110
+ cwd: directory,
111
+ });
112
+ // OpenCode has no deny return value; throwing is how a hook refuses.
113
+ if (reason) throw new Error(reason);
114
+ },
115
+ };
116
+ };
11
117
 
12
118
  export default LeosAgent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leos-agent",
3
- "version": "10.6.0",
3
+ "version": "10.7.0",
4
4
  "description": "Leo's portable agent operating policy: orchestrator main thread, subagent-first execution, cost-tiered model routing.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -28,6 +28,7 @@
28
28
  "files": [
29
29
  "commands-claude/",
30
30
  "commands/",
31
+ "hooks/",
31
32
  "index.js",
32
33
  "payload/",
33
34
  "rules/",
@@ -48,22 +48,21 @@ turns and took a third of a day's subagent spend.
48
48
 
49
49
  ## Model routing
50
50
 
51
- Every subagent dispatch MUST name an explicit model or profile. The harness
52
- inherits the parent model when you say nothing, so a dispatch with no model and
53
- no stated reason for inheriting is a bug, not a default.
54
-
55
51
  - Reading, search, tests, logs, codemods, and every fan-out → **leo-runner**.
56
52
  - An approved plan or a well-specified code change → **leo-executor**.
57
- - Investigation, debugging, adjudication, orchestration inherit the current
58
- model, and say that inheriting is intended.
53
+ - Investigation, debugging, adjudication → name the inherited model outright.
54
+
55
+ Floor and ceiling: a lone brief naming one file should have been inline; work
56
+ whose every file you would not want to read should have been fanned out.
59
57
 
60
58
  <!-- leos-agent:routing -->
61
59
  On Claude Code pass `subagent_type: "leo-runner"` or `"leo-executor"`; on Codex
62
60
  the installed profiles carry the models. Elsewhere use the current model.
63
61
  <!-- /leos-agent:routing -->
64
62
 
65
- Never upgrade a cheaper session; report when routing cannot be applied. Wide
66
- inherited fan-out is the policy's most expensive shape.
63
+ A dispatch naming no model is refused, not defaulted.
64
+ Never upgrade a cheaper session; wide inherited fan-out is the policy's
65
+ most expensive shape.
67
66
 
68
67
  ## Caching
69
68
 
package/scripts/check.py CHANGED
@@ -147,6 +147,51 @@ def main():
147
147
  check(data.get("version") == 1, "hooks/hooks-cursor.json: Cursor requires version 1")
148
148
  check(isinstance(data.get("hooks"), dict), "hooks/hooks-cursor.json: needs a top-level `hooks` object")
149
149
 
150
+ # 6b. The dispatch guard must actually be wired, and reachable once shipped.
151
+ # A matcher is not decoration: without one the guard spawns a python3 per
152
+ # Read and per Grep, which costs more than the routing it enforces. And a
153
+ # command pointing outside package.json's `files` runs fine from a git
154
+ # checkout and silently does nothing for anyone who installed from npm --
155
+ # the failure that put this script in scripts/ rather than hooks/.
156
+ shipped = tuple(entry for entry in json.loads((ROOT / "package.json").read_text(encoding="utf-8"))["files"] if not entry.startswith("!"))
157
+ pre = (json.loads(shared_hooks.read_text(encoding="utf-8")).get("hooks") or {}).get("PreToolUse") or []
158
+ check(bool(pre), "hooks/hooks.json: no PreToolUse entry (the dispatch guard is not wired)")
159
+ cursor_pre = (json.loads(cursor_hooks.read_text(encoding="utf-8")).get("hooks") or {}).get("preToolUse") or []
160
+ check(bool(cursor_pre), "hooks/hooks-cursor.json: no preToolUse entry (Cursor gets no guard)")
161
+ for entry in pre:
162
+ check(bool(entry.get("matcher")), "hooks/hooks.json: PreToolUse needs a matcher, or it spawns on every tool call")
163
+ commands = [h.get("command", "") for entry in pre for h in entry.get("hooks") or []]
164
+ commands += [entry.get("command", "") for entry in cursor_pre]
165
+ for timeout in [h.get("timeout") for entry in pre for h in entry.get("hooks") or []] + [e.get("timeout") for e in cursor_pre]:
166
+ check(timeout is not None and timeout <= 10, f"hook timeout {timeout!r} exceeds the 10s a harness will wait")
167
+ for command in commands:
168
+ match = re.search(r"(?:\$\{[A-Z_]+\}|\./)?/?((?:scripts|hooks)/[\w./-]+\.py)", command)
169
+ check(match is not None, f"hooks: cannot find a script path in command {command!r}")
170
+ if match:
171
+ target = match.group(1)
172
+ check((ROOT / target).is_file(), f"hooks: command points at {target}, which does not exist")
173
+ check(any(target.startswith(entry) for entry in shipped), f"hooks: {target} is outside package.json files; npm installs would not get it")
174
+
175
+ # The guard's own modules must import cleanly: a hook that cannot even load
176
+ # fails open on every dispatch, silently, which is the one failure mode that
177
+ # looks exactly like everything working.
178
+ for name in ("dispatch_guard", "dispatch_log", "usage_scan"):
179
+ path = ROOT / "scripts" / f"{name}.py"
180
+ check(path.is_file(), f"scripts/{name}.py is missing")
181
+ if path.is_file():
182
+ try:
183
+ spec = importlib.util.spec_from_file_location(f"check_{name}", path)
184
+ module = importlib.util.module_from_spec(spec)
185
+ spec.loader.exec_module(module)
186
+ except Exception as exc:
187
+ check(False, f"scripts/{name}.py does not import: {type(exc).__name__}: {exc}")
188
+
189
+ # The one sentence the payload must keep: the guard refuses a dispatch that
190
+ # names no model, and a model that does not know that wastes a turn finding
191
+ # out. Prose elsewhere may be trimmed; this line pays for itself.
192
+ payload_text = (ROOT / "rules" / "preferences.md").read_text(encoding="utf-8")
193
+ check("refused, not defaulted" in payload_text, "rules/preferences.md: lost the line telling the model a modelless dispatch is refused")
194
+
150
195
  # Payload files copied by the installer must carry the provenance string, or
151
196
  # it will mistake its own installed copy for a stranger's file and refuse to
152
197
  # upgrade or remove it. The list is derived from the installer's own copy sets,