leos-agent 10.2.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 +174 -30
- 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 +36 -32
- package/scripts/check.py +122 -1
- package/scripts/dispatch_guard.py +317 -0
- package/scripts/dispatch_log.py +240 -0
- package/scripts/ghreview.py +12 -0
- package/scripts/handoff.py +44 -21
- package/scripts/leo-install.py +146 -25
- package/scripts/measure_context.py +43 -0
- package/scripts/routing.py +420 -0
- package/scripts/state.py +8 -3
- package/scripts/usage_scan.py +455 -0
- package/scripts/watch_review.py +143 -44
- package/skills/doctor/SKILL.md +24 -6
- package/skills/handoff/SKILL.md +16 -6
- package/skills/handon/SKILL.md +26 -9
- package/skills/install/SKILL.md +1 -1
- package/skills/review-pr/SKILL.md +4 -4
- package/skills/review-pr/reference/procedure.md +18 -15
- 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/skills/tune-routing/SKILL.md +129 -0
- package/skills/tune-routing/agents/openai.yaml +5 -0
- package/skills/tune-routing/reference/harnesses.md +63 -0
- package/skills-claude/attach-pr/SKILL.md +14 -4
- package/skills-claude/watch-review/SKILL.md +74 -25
package/README.md
CHANGED
|
@@ -1,28 +1,36 @@
|
|
|
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
|
|
|
7
7
|
The policy it carries is short: **the main thread is an orchestrator.**
|
|
8
|
-
Investigation, brainstorming, debugging,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
Investigation, brainstorming, debugging, and mechanical work all run in briefed
|
|
9
|
+
subagents, so the main thread never fills up with the files, retries, and logs
|
|
10
|
+
that produced an answer — only the answer. A single command you can filter at
|
|
11
|
+
the shell stays inline. Work runs at one of two named tiers: **standard**, the
|
|
12
|
+
model you are already using, for thinking and judging; **economical**, two
|
|
13
|
+
named agent profiles — `leo-runner` for narrow search, reading, testing, and
|
|
14
|
+
mechanical work, `leo-executor` for well-specified implementation — shipped as
|
|
15
|
+
first-class agent definitions on both Claude Code (`agents/`, Haiku and Sonnet)
|
|
16
|
+
and Codex (installed TOML profiles), so the cheaper model is baked into the
|
|
17
|
+
agent type rather than chosen per dispatch. Every other harness inherits unless
|
|
18
|
+
a machine-local [routing config](#per-machine-model-routing) names models for it.
|
|
15
19
|
|
|
16
20
|
## What it ships
|
|
17
21
|
|
|
18
|
-
Beyond the preferences payload:
|
|
19
|
-
|
|
22
|
+
Beyond the preferences payload: the two economical-tier agent definitions
|
|
23
|
+
(`agents/` for Claude Code, `payload/codex-agents/` for Codex), a setup
|
|
24
|
+
diagnostic, a routing tuner, a session handoff pair, and three GitHub skills.
|
|
25
|
+
The GitHub ones need `gh`, authenticated.
|
|
20
26
|
|
|
21
27
|
| Skill | What it does | Where |
|
|
22
28
|
|---|---|---|
|
|
23
29
|
| `review-pr` | Reviews a pull request and stages inline comments as a **pending** review — visible only to you until you submit or discard on GitHub. Never submits. Resolves the originating ticket (Linear, Jira, GitHub issue) from the PR's title, body, or branch when one is named, and adds a spec lens that checks the diff against it. | every skill-loading harness |
|
|
24
|
-
| `watch-review` | Arms a watcher that streams
|
|
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 |
|
|
25
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
|
+
| `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 |
|
|
26
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 |
|
|
27
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 |
|
|
28
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 |
|
|
@@ -31,14 +39,144 @@ The Claude-only pair live in `skills-claude/` and `commands-claude/`, listed in
|
|
|
31
39
|
`.claude-plugin/plugin.json` and nowhere else. Hermes receives the preferences
|
|
32
40
|
payload but no skills — it has no skill loader.
|
|
33
41
|
|
|
34
|
-
The watcher records
|
|
35
|
-
(override with `$LEOS_AGENT_LOCAL_PATH`), so
|
|
36
|
-
|
|
42
|
+
The watcher records the **head commit** it reviewed each pull request at, under
|
|
43
|
+
`~/.leos-agent-local/` (override with `$LEOS_AGENT_LOCAL_PATH`), so a pull
|
|
44
|
+
request comes back when someone pushes to it and stays quiet otherwise;
|
|
45
|
+
`watch_review.py forget <n>` puts one back in play at its current head. Two
|
|
46
|
+
gates keep a continuous watch from being expensive: a pull request another user
|
|
47
|
+
has already approved is never surfaced at all, and a new head must hold still
|
|
48
|
+
for `--settle` seconds (default 120) before it is emitted, so a burst of pushes
|
|
49
|
+
costs one review rather than one per commit.
|
|
50
|
+
|
|
51
|
+
Handoffs live in the same place, at
|
|
52
|
+
`~/.leos-agent-local/handoffs/<name>.md` — a fixed path that needs no plugin
|
|
53
|
+
root, so `/handon` reads one with a single `cat` rather than going looking for
|
|
54
|
+
it. Nothing there is ever pruned automatically: `handoff.py list [--all]` shows
|
|
55
|
+
what exists and `handoff.py rm <name>` is the only way one goes away. The
|
|
56
|
+
directory is deliberately outside the plugin, so upgrading or reinstalling can
|
|
57
|
+
never take state with it.
|
|
58
|
+
|
|
59
|
+
## Per-machine model routing
|
|
60
|
+
|
|
61
|
+
The economical tier only ever had teeth on Claude Code and Codex, because those
|
|
62
|
+
are the two harnesses whose model names the payload could hardcode. Everywhere
|
|
63
|
+
else, every fan-out ran at the current model — the most expensive shape the
|
|
64
|
+
policy has. Which models a harness offers varies by machine and by what an IT
|
|
65
|
+
department allows, so the mapping is machine-local config rather than something
|
|
66
|
+
the plugin can ship:
|
|
67
|
+
|
|
68
|
+
```jsonc
|
|
69
|
+
// ~/.leos-agent-local/routing.json (override the directory with $LEOS_AGENT_LOCAL_PATH)
|
|
70
|
+
{
|
|
71
|
+
"cursor": {"runner": "grok-code-fast-1", "executor": "claude-sonnet-4.6"},
|
|
72
|
+
"opencode": {"runner": "anthropic/claude-haiku-4-5"},
|
|
73
|
+
"codex": {"runner": {"model": "gpt-5.6-luna", "effort": "low"}}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Keys are harness names; each holds `runner` and/or `executor`, independently —
|
|
78
|
+
configuring only `runner` is the common case, since it is the fan-out that
|
|
79
|
+
costs. A bare string is shorthand for `{"model": ...}`. Model strings are
|
|
80
|
+
free-form and never checked against a known-model list: whatever the harness
|
|
81
|
+
accepts goes in verbatim. A misspelled *key*, though, is a hard error, because a
|
|
82
|
+
typo that silently left a harness on the expensive model is the one failure this
|
|
83
|
+
is here to prevent.
|
|
84
|
+
|
|
85
|
+
**Nothing reads it at run time.** `leo-install.py` renders the result into the
|
|
86
|
+
`<leos-agent>` block it already writes, so a session pays nothing to know its own
|
|
87
|
+
routing — no config read, no extra turn. It costs *less* than before: each
|
|
88
|
+
machine now carries only its own harness's dispatch line instead of all of them,
|
|
89
|
+
which took the installed payload from 4497 bytes to 4315–4344 depending on the
|
|
90
|
+
harness. `scripts/measure_context.py` prints the per-harness figure and fails if
|
|
91
|
+
an unconfigured harness ever grows past the old one.
|
|
92
|
+
|
|
93
|
+
Edit the file — by hand, or with `routing.py set --harness <h> --runner
|
|
94
|
+
<model>`, which [`/tune-routing`](#what-it-ships) drives end to end — then
|
|
95
|
+
re-run the installer to re-render; `leo-install.py <harness> --check` reports
|
|
96
|
+
"out of date" until you do, and `/doctor` surfaces it. Installing is
|
|
97
|
+
idempotent: same config, same version, same bytes, so a second run reports
|
|
98
|
+
`unchanged`.
|
|
99
|
+
|
|
100
|
+
**The config is yours, never the installer's.** `leo-install.py` only ever reads
|
|
101
|
+
it, and never creates, migrates, rewrites, or removes it, including under
|
|
102
|
+
`--uninstall`; it lives outside the plugin so an upgrade cannot take it. The one
|
|
103
|
+
thing that writes it is `routing.py set` / `unset`, run because you asked: it
|
|
104
|
+
creates the file if it is missing, replaces the one role you named, validates
|
|
105
|
+
the whole document before writing, and leaves every other harness's entry —
|
|
106
|
+
including the bare-string shorthand — exactly as you wrote it. `routing.py show`
|
|
107
|
+
prints what is configured; with no file at all, every harness uses its shipped
|
|
108
|
+
default and behaviour is exactly what it was before this existed.
|
|
109
|
+
|
|
110
|
+
Delivery differs by harness only in the last mile: Claude Code gets a `model:`
|
|
111
|
+
override alongside `subagent_type:` (the plugin-owned `agents/*.md` are never
|
|
112
|
+
rewritten), Codex gets the models substituted into its installed profile TOMLs,
|
|
113
|
+
Cursor gets its own `~/.cursor/rules/leos-agent-routing.mdc` because its rules
|
|
114
|
+
come straight out of the plugin directory, and the rest get the rendered line in
|
|
115
|
+
their global instruction file.
|
|
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
|
+
```
|
|
37
156
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
```
|
|
42
180
|
|
|
43
181
|
## How it works
|
|
44
182
|
|
|
@@ -49,7 +187,7 @@ gets it through its global instruction file, written by
|
|
|
49
187
|
[`scripts/leo-install.py`](scripts/leo-install.py) into a marker block:
|
|
50
188
|
|
|
51
189
|
```
|
|
52
|
-
<leos-agent version="10.
|
|
190
|
+
<leos-agent version="10.7.0">
|
|
53
191
|
...the payload...
|
|
54
192
|
</leos-agent>
|
|
55
193
|
```
|
|
@@ -67,7 +205,7 @@ exactly the content it exists to protect.
|
|
|
67
205
|
|
|
68
206
|
| Harness | Global file the installer writes |
|
|
69
207
|
|---|---|
|
|
70
|
-
| Claude Code | `~/.claude/CLAUDE.md` |
|
|
208
|
+
| Claude Code | `~/.claude/CLAUDE.md` (the `leo-runner` / `leo-executor` agents need no installer step — the plugin's `agents/` directory delivers them) |
|
|
71
209
|
| Codex | `~/.codex/AGENTS.md` (plus `~/.codex/agents/leo-runner.toml` and `leo-executor.toml`) |
|
|
72
210
|
| Cursor | none — the plugin's always-apply rule delivers it |
|
|
73
211
|
| Hermes | `~/.hermes/SOUL.md` (edited only if it already exists) |
|
|
@@ -118,7 +256,7 @@ that it is already installed and changes nothing.
|
|
|
118
256
|
Run the installer's uninstall first, while the script is still on disk:
|
|
119
257
|
|
|
120
258
|
```bash
|
|
121
|
-
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
|
|
122
260
|
```
|
|
123
261
|
|
|
124
262
|
```bash
|
|
@@ -149,7 +287,7 @@ Then run the `install` skill in a Codex session (`$leos-agent`, then `install`),
|
|
|
149
287
|
run the script directly:
|
|
150
288
|
|
|
151
289
|
```bash
|
|
152
|
-
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
|
|
153
291
|
```
|
|
154
292
|
|
|
155
293
|
This writes `~/.codex/AGENTS.md` and installs two economical agents:
|
|
@@ -174,7 +312,7 @@ threads only. Re-adding an already-installed plugin is idempotent.
|
|
|
174
312
|
**Uninstall**
|
|
175
313
|
|
|
176
314
|
```bash
|
|
177
|
-
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
|
|
178
316
|
```
|
|
179
317
|
|
|
180
318
|
```bash
|
|
@@ -290,8 +428,9 @@ Remove the `leos-agent` entry from `plugins.enabled`, and delete the clone if
|
|
|
290
428
|
you made one. Your own `SOUL.md` content is left intact — only the block goes.
|
|
291
429
|
|
|
292
430
|
**Note on model routing:** Hermes applies a single `delegation.model` to every
|
|
293
|
-
child of a `delegate_task` call, so it cannot vary the model per spawn.
|
|
294
|
-
|
|
431
|
+
child of a `delegate_task` call, so it cannot vary the model per spawn. A
|
|
432
|
+
routing config still renders a stanza for it, and the stanza says to inherit and
|
|
433
|
+
say so where a per-spawn model is not available.
|
|
295
434
|
|
|
296
435
|
---
|
|
297
436
|
|
|
@@ -322,7 +461,7 @@ Pinned refs are reconciled, never silently advanced — to move to a new tag,
|
|
|
322
461
|
install it explicitly:
|
|
323
462
|
|
|
324
463
|
```bash
|
|
325
|
-
pi install git:github.com/foxhatleo/leos-agent@v10.
|
|
464
|
+
pi install git:github.com/foxhatleo/leos-agent@v10.7.0
|
|
326
465
|
```
|
|
327
466
|
|
|
328
467
|
Re-run `/skill:install` afterwards.
|
|
@@ -361,7 +500,10 @@ python3 ~/.cache/opencode/packages/leos-agent@latest/node_modules/leos-agent/scr
|
|
|
361
500
|
|
|
362
501
|
That writes `~/.config/opencode/AGENTS.md` and copies the skills and commands into
|
|
363
502
|
`~/.config/opencode/skills/` and `~/.config/opencode/commands/`. From then on
|
|
364
|
-
`/leo-install` works inside OpenCode.
|
|
503
|
+
`/leo-install` works inside OpenCode. The copies are installed with the plugin
|
|
504
|
+
root already resolved to an absolute path — OpenCode sets no resolution env var,
|
|
505
|
+
and the copies live apart from the scripts they invoke — so re-run the installer
|
|
506
|
+
after clearing or moving the package cache to point them at the new location.
|
|
365
507
|
|
|
366
508
|
**Upgrade**
|
|
367
509
|
|
|
@@ -462,8 +604,10 @@ is the worked example: the main thread loads a 3.2 KB contract, the reviewer
|
|
|
462
604
|
subagent reads `reference/procedure.md`, and the lens sub-subagents read
|
|
463
605
|
`reference/lenses.md` that the reviewer itself never loads. Before the split the
|
|
464
606
|
main thread and the reviewer each loaded the same 21 KB file, and every turn
|
|
465
|
-
after that re-read it.
|
|
466
|
-
|
|
607
|
+
after that re-read it. `tune-routing` does the same with its per-harness model
|
|
608
|
+
discovery, in `reference/harnesses.md`, which only a run that actually tunes
|
|
609
|
+
ever loads. Split a file out only when some run genuinely does not read it;
|
|
610
|
+
moving prose around costs the same tokens.
|
|
467
611
|
|
|
468
612
|
**Invocation split.** A skill is either *user-invoked* — reached by typing its
|
|
469
613
|
slash command, and carrying `disable-model-invocation: true` — or *deliberately
|
|
@@ -537,7 +681,7 @@ claude plugin uninstall leos-agent@leos-agent && claude plugin install leos-agen
|
|
|
537
681
|
```
|
|
538
682
|
|
|
539
683
|
or replace the cachebuster suffix in the Codex manifest with one in the form
|
|
540
|
-
`10.
|
|
684
|
+
`10.7.0+codex.local-YYYYMMDD-HHMMSS` and re-add. Either way, plugin changes only
|
|
541
685
|
reach a **new** session or thread.
|
|
542
686
|
|
|
543
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/",
|