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 +72 -7
- package/hooks/README.md +93 -0
- package/hooks/hooks-cursor.json +11 -0
- package/hooks/hooks.json +16 -0
- package/index.js +112 -6
- package/package.json +2 -1
- package/rules/preferences.md +7 -8
- package/scripts/check.py +45 -0
- package/scripts/dispatch_guard.py +317 -0
- package/scripts/dispatch_log.py +240 -0
- package/scripts/usage_scan.py +455 -0
- package/skills/review-usage/SKILL.md +97 -0
- package/skills/review-usage/agents/openai.yaml +5 -0
- package/skills/review-usage/reference/sources.md +80 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# leos-agent
|
|
2
2
|
|
|
3
|
-
Leo's portable agent operating policy, version **10.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
package/hooks/README.md
ADDED
|
@@ -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.
|
package/hooks/hooks.json
ADDED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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.
|
|
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/",
|
package/rules/preferences.md
CHANGED
|
@@ -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
|
|
58
|
-
|
|
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
|
-
|
|
66
|
-
inherited fan-out is the policy's
|
|
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,
|