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/rules/preferences.md
CHANGED
|
@@ -7,58 +7,62 @@ alwaysApply: true
|
|
|
7
7
|
## The main thread is an orchestrator
|
|
8
8
|
|
|
9
9
|
Keep the main thread minimal: understand the request, decide the approach,
|
|
10
|
-
dispatch subagents, integrate what they return, report to Leo.
|
|
11
|
-
in subagents.
|
|
10
|
+
dispatch subagents, integrate what they return, report to Leo.
|
|
12
11
|
|
|
13
|
-
Delegate work
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
with it.
|
|
12
|
+
Delegate work that floods your context to reach one answer: many files read,
|
|
13
|
+
several attempts before it lands, open-ended search — investigation, code
|
|
14
|
+
search, debugging. Only the conclusion comes back.
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
and
|
|
21
|
-
break-even — one known file or a one-line edit never clears it.
|
|
16
|
+
A single command you can filter at the shell runs inline: a `grep` or `tail`
|
|
17
|
+
pipe costs nothing, so noisy output is never the trigger. Test runs, linters,
|
|
18
|
+
and builds are inline by default.
|
|
22
19
|
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
Below that bar, delegating costs more than it saves. Your context is cached; a
|
|
21
|
+
subagent starts cold and pays a full cache write on its system prompt and brief
|
|
22
|
+
— roughly $3 at Opus prices, $1 at Sonnet. That write is the break-even;
|
|
23
|
+
one known file or a one-line edit never clears it.
|
|
24
|
+
|
|
25
|
+
Never delegate to avoid thinking. As a subagent, do not delegate at all — do
|
|
26
|
+
the work yourself.
|
|
25
27
|
|
|
26
28
|
## Briefing a subagent
|
|
27
29
|
|
|
28
30
|
Spawn with clean context: on Codex pass `fork_turns="none"`; elsewhere request
|
|
29
|
-
a fresh child
|
|
30
|
-
prevented. Write the brief to stand alone:
|
|
31
|
+
a fresh child. Write the brief to stand alone:
|
|
31
32
|
|
|
32
33
|
- State the goal and what "done" looks like.
|
|
33
|
-
- Name the files, paths, symbols, and commands
|
|
34
|
+
- Name the files, paths, symbols, and commands to start from.
|
|
34
35
|
- Include settled decisions, so it does not relitigate them.
|
|
35
|
-
-
|
|
36
|
-
|
|
36
|
+
- Grant only the skills and tools it needs; extra schemas invite wandering.
|
|
37
|
+
- Say it does the work itself and spawns nothing further; it sees only the
|
|
38
|
+
brief.
|
|
37
39
|
- Say what to return: the finding, the diff, the verdict — not a transcript.
|
|
38
40
|
|
|
39
41
|
Prefer several narrow subagents over one broad one, run independent ones
|
|
40
42
|
concurrently, and ask for uncertainty explicitly.
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
Cap the scope: a brief that could plausibly run past ~50 turns gets split. A
|
|
45
|
+
subagent's own context grows turn over turn, so one broad brief re-creates the
|
|
46
|
+
expensive-prefix problem inside the child — a single measured agent ran 231
|
|
47
|
+
turns and took a third of a day's subagent spend.
|
|
45
48
|
|
|
46
|
-
|
|
49
|
+
## Model routing
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
session; report when routing cannot be applied.
|
|
51
|
+
- Reading, search, tests, logs, codemods, and every fan-out → **leo-runner**.
|
|
52
|
+
- An approved plan or a well-specified code change → **leo-executor**.
|
|
53
|
+
- Investigation, debugging, adjudication → name the inherited model outright.
|
|
52
54
|
|
|
53
|
-
|
|
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.
|
|
54
57
|
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
<!-- leos-agent:routing -->
|
|
59
|
+
On Claude Code pass `subagent_type: "leo-runner"` or `"leo-executor"`; on Codex
|
|
60
|
+
the installed profiles carry the models. Elsewhere use the current model.
|
|
61
|
+
<!-- /leos-agent:routing -->
|
|
57
62
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
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.
|
|
62
66
|
|
|
63
67
|
## Caching
|
|
64
68
|
|
package/scripts/check.py
CHANGED
|
@@ -110,7 +110,9 @@ def main():
|
|
|
110
110
|
# 5. Skills and commands exist and carry the portable frontmatter subset.
|
|
111
111
|
skills = sorted((ROOT / "skills").glob("*/SKILL.md"))
|
|
112
112
|
check(len(skills) >= 1, "skills/: no SKILL.md found; the plugin must ship at least one skill")
|
|
113
|
-
|
|
113
|
+
# skills-claude/ ships through .claude-plugin/plugin.json on the same terms, so
|
|
114
|
+
# it is validated on the same terms -- an unguarded tree is where conventions rot.
|
|
115
|
+
for skill in skills + sorted((ROOT / "skills-claude").glob("*/SKILL.md")):
|
|
114
116
|
text = skill.read_text(encoding="utf-8")
|
|
115
117
|
check(text.startswith("---\n"), f"{skill.relative_to(ROOT)}: missing frontmatter")
|
|
116
118
|
fm = text.split("---", 2)[1] if text.count("---") >= 2 else ""
|
|
@@ -145,6 +147,51 @@ def main():
|
|
|
145
147
|
check(data.get("version") == 1, "hooks/hooks-cursor.json: Cursor requires version 1")
|
|
146
148
|
check(isinstance(data.get("hooks"), dict), "hooks/hooks-cursor.json: needs a top-level `hooks` object")
|
|
147
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
|
+
|
|
148
195
|
# Payload files copied by the installer must carry the provenance string, or
|
|
149
196
|
# it will mistake its own installed copy for a stranger's file and refuse to
|
|
150
197
|
# upgrade or remove it. The list is derived from the installer's own copy sets,
|
|
@@ -161,6 +208,80 @@ def main():
|
|
|
161
208
|
if path.is_file():
|
|
162
209
|
check(installer.PROVENANCE in path.read_text(encoding="utf-8"), f"{rel}: must contain {installer.PROVENANCE!r} so the installer recognises its own copy")
|
|
163
210
|
|
|
211
|
+
# 5a-agents. Claude Code auto-discovers agents/ at the plugin root. The set
|
|
212
|
+
# must stay in lockstep with the Codex profiles (same names, one policy), and
|
|
213
|
+
# each definition needs the frontmatter Claude reads — a missing model field
|
|
214
|
+
# would silently inherit the parent model, which is the failure this tier
|
|
215
|
+
# exists to prevent.
|
|
216
|
+
claude_agents = sorted((ROOT / "agents").glob("*.md"))
|
|
217
|
+
check(
|
|
218
|
+
sorted(p.stem for p in claude_agents) == sorted(installer.CODEX_AGENTS),
|
|
219
|
+
f"agents/: expected exactly the Claude twins of {installer.CODEX_AGENTS}, found {[p.stem for p in claude_agents]}",
|
|
220
|
+
)
|
|
221
|
+
for agent in claude_agents:
|
|
222
|
+
rel = agent.relative_to(ROOT)
|
|
223
|
+
text = agent.read_text(encoding="utf-8")
|
|
224
|
+
check(text.startswith("---\n"), f"{rel}: missing frontmatter")
|
|
225
|
+
fm = text.split("---", 2)[1] if text.count("---") >= 2 else ""
|
|
226
|
+
name_match = re.search(r"^name:\s*(\S+)", fm, re.MULTILINE)
|
|
227
|
+
check(name_match is not None and name_match.group(1) == agent.stem, f"{rel}: frontmatter name must be {agent.stem!r}")
|
|
228
|
+
check(re.search(r"^description:", fm, re.MULTILINE) is not None, f"{rel}: needs description")
|
|
229
|
+
check(re.search(r"^model:\s*\S", fm, re.MULTILINE) is not None, f"{rel}: needs an explicit model")
|
|
230
|
+
check(re.search(r"^tools:\s*\S", fm, re.MULTILINE) is not None, f"{rel}: needs an explicit tools allowlist")
|
|
231
|
+
|
|
232
|
+
# 5a-routing. The routing region is what makes the economical tier
|
|
233
|
+
# configurable per machine. Rendering must be total (every harness gets a
|
|
234
|
+
# stanza), deterministic (or a second install would not report "unchanged"),
|
|
235
|
+
# and cheaper than the multi-harness prose it replaced.
|
|
236
|
+
prefs_body = installer.payload_body(ROOT)
|
|
237
|
+
for marker in (installer.ROUTING_OPEN, installer.ROUTING_CLOSE):
|
|
238
|
+
check(prefs_body.count(marker) == 1, f"rules/preferences.md: expected exactly one {marker}")
|
|
239
|
+
check(
|
|
240
|
+
prefs_body.find(installer.ROUTING_OPEN) < prefs_body.find(installer.ROUTING_CLOSE),
|
|
241
|
+
"rules/preferences.md: the routing region's closer precedes its opener",
|
|
242
|
+
)
|
|
243
|
+
for harness in installer.HARNESSES:
|
|
244
|
+
rendered = installer.payload_body(ROOT, harness, {})
|
|
245
|
+
check(bool(installer.routing.stanza(harness, {}).strip()), f"routing: {harness} renders an empty stanza")
|
|
246
|
+
check(
|
|
247
|
+
installer.ROUTING_OPEN not in rendered and installer.ROUTING_CLOSE not in rendered,
|
|
248
|
+
f"routing: {harness}'s rendered payload still carries the region markers",
|
|
249
|
+
)
|
|
250
|
+
check(
|
|
251
|
+
rendered == installer.payload_body(ROOT, harness, {}),
|
|
252
|
+
f"routing: rendering {harness} twice is not byte-identical",
|
|
253
|
+
)
|
|
254
|
+
check(
|
|
255
|
+
len(rendered.encode("utf-8")) < len(prefs_body.encode("utf-8")),
|
|
256
|
+
f"routing: {harness}'s rendered payload is not smaller than the unrendered file",
|
|
257
|
+
)
|
|
258
|
+
# The rule is only ever installed when cursor routing is configured, so the
|
|
259
|
+
# provenance requirement is checked on a configured render.
|
|
260
|
+
configured_cursor = {"cursor": {"runner": {"model": "example-model", "effort": None}}}
|
|
261
|
+
check(
|
|
262
|
+
installer.PROVENANCE in installer.cursor_routing_rule("cursor", configured_cursor),
|
|
263
|
+
f"routing: the Cursor rule must contain {installer.PROVENANCE!r} so the installer recognises its own copy",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# 5c. Plugin-root references. Skill and command text points at plugin files
|
|
267
|
+
# through the <plugin-root> placeholder, and the OpenCode installer bakes the
|
|
268
|
+
# absolute root into its copies — so every referenced path must actually
|
|
269
|
+
# exist, or an install ships a command that can only fail.
|
|
270
|
+
for base in ("skills", "skills-claude", "commands", "commands-claude"):
|
|
271
|
+
for doc in sorted((ROOT / base).rglob("*.md")):
|
|
272
|
+
text = doc.read_text(encoding="utf-8")
|
|
273
|
+
for ref in sorted({r.rstrip(".") for r in re.findall(r"<plugin-root>/([\w./-]+)", text)}):
|
|
274
|
+
check((ROOT / ref).exists(), f"{doc.relative_to(ROOT)}: <plugin-root>/{ref} does not exist")
|
|
275
|
+
|
|
276
|
+
# The OpenCode copy of the install skill is renamed to leo-install by a
|
|
277
|
+
# targeted regex in the installer; if the source name ever changes, that
|
|
278
|
+
# regex would silently no-op and ship a dir/name mismatch.
|
|
279
|
+
install_fm = (ROOT / "skills" / "install" / "SKILL.md").read_text(encoding="utf-8").split("---", 2)[1]
|
|
280
|
+
check(
|
|
281
|
+
re.search(r"(?m)^name:\s*install\s*$", install_fm) is not None,
|
|
282
|
+
"skills/install/SKILL.md: frontmatter name must stay 'install' — leo-install.py's OpenCode rename keys on it",
|
|
283
|
+
)
|
|
284
|
+
|
|
164
285
|
# 5b. Invocation split: a skill is either user-invoked (and hidden from the
|
|
165
286
|
# model's always-loaded skill listing) or deliberately model-invocable. Claude
|
|
166
287
|
# reads the SKILL.md flag; Codex reads the sibling agents/openai.yaml policy.
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""dispatch_guard: refuse a subagent dispatch that names no model.
|
|
3
|
+
|
|
4
|
+
WHY THIS EXISTS IN CODE RATHER THAN PROSE. The policy already said every dispatch
|
|
5
|
+
must name a model, and the policy was forgotten. Prose enforcement also costs
|
|
6
|
+
always-loaded bytes on every turn of every session, and the budget in
|
|
7
|
+
measure_context.py is nearly spent -- so the rule that a machine can check moved
|
|
8
|
+
into a hook, which costs nothing, and the payload kept only the judgment a hook
|
|
9
|
+
cannot make. Moving it out of the payload was what paid for the move.
|
|
10
|
+
|
|
11
|
+
WHAT IT REFUSES, AND WHAT IT DELIBERATELY DOES NOT. A dispatch that selects an
|
|
12
|
+
agent, carries a brief, names no model, and runs on a harness that can express
|
|
13
|
+
one per spawn is refused -- because on that path the harness silently inherits
|
|
14
|
+
the parent's expensive model, which is the failure this file exists to prevent.
|
|
15
|
+
Everything else is allowed. The guard NEVER picks a model: it cannot force a
|
|
16
|
+
cheap tier onto work that needed an expensive one, so it cannot cause a quality
|
|
17
|
+
regression, only an explicit choice. That is also why the block stays narrow. A
|
|
18
|
+
false block costs one re-dispatch turn; a caught inherited fan-out saves the cold
|
|
19
|
+
prefix of every child it would have spawned. The margin is wide precisely because
|
|
20
|
+
the rule refuses to make judgment calls, and every widening spends it.
|
|
21
|
+
|
|
22
|
+
SHAPE-BASED, NOT NAME-BASED. Only Claude Code's dispatch tool is verified; the
|
|
23
|
+
argument shapes on Codex, Cursor, Hermes and OpenCode are not. So the decision
|
|
24
|
+
turns on the arguments -- an agent-selection field plus a brief -- and an
|
|
25
|
+
unanticipated tool degrades to a no-op rather than a broken harness. The hook
|
|
26
|
+
manifests still carry a name matcher, but only as a cheap prefilter: without one,
|
|
27
|
+
every Read and Grep would pay a python3 spawn.
|
|
28
|
+
|
|
29
|
+
FAILS OPEN, LOUDLY. The harm here is money, not data loss. A guard that fails
|
|
30
|
+
closed on a broken interpreter wedges every dispatch on every harness, which is
|
|
31
|
+
far worse than the miss it prevents -- and on Claude Code a timed-out hook is
|
|
32
|
+
non-blocking anyway, so fail-closed is not even expressible there. Every internal
|
|
33
|
+
error allows the call and leaves a breadcrumb with decision "error", kept
|
|
34
|
+
rigorously distinct from a decision to allow.
|
|
35
|
+
|
|
36
|
+
LEOS_AGENT_DISPATCH_GUARD on (default) | warn (log, never block) | off | verbose
|
|
37
|
+
LEOS_AGENT_HARNESS overrides harness detection
|
|
38
|
+
|
|
39
|
+
Exit codes: 0 allow, 2 block (reason on stderr).
|
|
40
|
+
"""
|
|
41
|
+
import collections
|
|
42
|
+
import json
|
|
43
|
+
import os
|
|
44
|
+
import re
|
|
45
|
+
import sys
|
|
46
|
+
import time
|
|
47
|
+
|
|
48
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
49
|
+
|
|
50
|
+
Dispatch = collections.namedtuple(
|
|
51
|
+
"Dispatch",
|
|
52
|
+
"tool agent model prompt_bytes prompt_lines path_count prompt_hash prompt_head",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# The agent-selection field is mandatory, and that is the whole trick. A rule
|
|
56
|
+
# keyed on "has a prompt" would refuse unrelated tools -- spawn_task takes
|
|
57
|
+
# {prompt, title, tldr} and nothing else -- and "has a model" is weaker still.
|
|
58
|
+
# Only naming an agent means "I am choosing who runs this".
|
|
59
|
+
AGENT_KEYS = ("subagent_type", "subagentType", "agent_type", "agentType", "agent", "subagent", "profile")
|
|
60
|
+
PROMPT_KEYS = ("prompt", "brief", "instructions", "task", "message", "input")
|
|
61
|
+
MODEL_KEYS = ("model", "model_id", "modelId", "model_name")
|
|
62
|
+
|
|
63
|
+
TOOL_KEYS = ("tool_name", "toolName", "tool", "name")
|
|
64
|
+
INPUT_KEYS = ("tool_input", "toolInput", "arguments", "args", "input", "params", "parameters")
|
|
65
|
+
|
|
66
|
+
# Third-party MCP tools are not routed by this policy and never will be, and
|
|
67
|
+
# their argument namespaces are outside our control forever. One prefix test
|
|
68
|
+
# removes the entire class of false positives they would otherwise create.
|
|
69
|
+
SKIP_PREFIXES = ("mcp__",)
|
|
70
|
+
|
|
71
|
+
# Seeded empty on purpose: populate from field reports, not from guesses.
|
|
72
|
+
SKIP_TOOLS = frozenset()
|
|
73
|
+
|
|
74
|
+
ALLOW, BLOCK = "allow", "block"
|
|
75
|
+
|
|
76
|
+
# Path-ish tokens in a brief. Scanning is capped at the first 8 KiB -- the
|
|
77
|
+
# feature does not improve past that and a hot path should not read a novel.
|
|
78
|
+
PATH_SCAN_BYTES = 8192
|
|
79
|
+
PATH_RE = re.compile(r"[\w.-]+/[\w./-]+|[\w-]+\.[A-Za-z]{1,5}\b")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _first_str(source, keys):
|
|
83
|
+
for key in keys:
|
|
84
|
+
value = source.get(key)
|
|
85
|
+
if isinstance(value, str) and value.strip():
|
|
86
|
+
return value
|
|
87
|
+
return ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _first_dict(source, keys):
|
|
91
|
+
for key in keys:
|
|
92
|
+
value = source.get(key)
|
|
93
|
+
if isinstance(value, dict):
|
|
94
|
+
return value
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def harness(event):
|
|
99
|
+
"""Which harness this event came from. Best effort, and logged either way.
|
|
100
|
+
|
|
101
|
+
The env var is the override the in-process adapters set. Otherwise the
|
|
102
|
+
transcript path is the only hint a command hook gets; when it says nothing,
|
|
103
|
+
assume Claude Code, the one harness whose dispatch tool is verified.
|
|
104
|
+
"""
|
|
105
|
+
named = os.environ.get("LEOS_AGENT_HARNESS", "").strip().lower()
|
|
106
|
+
if named:
|
|
107
|
+
return named
|
|
108
|
+
hint = ""
|
|
109
|
+
if isinstance(event, dict):
|
|
110
|
+
hint = _first_str(event, ("transcript_path", "transcriptPath", "cwd"))
|
|
111
|
+
for name in ("codex", "cursor", "hermes", "opencode"):
|
|
112
|
+
if "/." + name in hint or "/" + name + "/" in hint:
|
|
113
|
+
return name
|
|
114
|
+
return "claude"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def normalize(event, _harness=None):
|
|
118
|
+
"""event -> Dispatch, or None when this is not a subagent dispatch.
|
|
119
|
+
|
|
120
|
+
Never raises. Five harnesses' envelopes are unverified, and a KeyError here
|
|
121
|
+
would turn a routing guard into an outage.
|
|
122
|
+
"""
|
|
123
|
+
if not isinstance(event, dict):
|
|
124
|
+
return None
|
|
125
|
+
tool = _first_str(event, TOOL_KEYS)
|
|
126
|
+
if tool in SKIP_TOOLS or any(tool.startswith(p) for p in SKIP_PREFIXES):
|
|
127
|
+
return None
|
|
128
|
+
args = _first_dict(event, INPUT_KEYS)
|
|
129
|
+
if args is None:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
agent = _first_str(args, AGENT_KEYS)
|
|
133
|
+
prompt = _first_str(args, PROMPT_KEYS)
|
|
134
|
+
if not agent or not prompt:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
from dispatch_log import digest # local: a no-op call must not pay for this
|
|
138
|
+
|
|
139
|
+
head = prompt[:PATH_SCAN_BYTES]
|
|
140
|
+
return Dispatch(
|
|
141
|
+
tool=tool or "-",
|
|
142
|
+
agent=agent,
|
|
143
|
+
model=_first_str(args, MODEL_KEYS) or None,
|
|
144
|
+
prompt_bytes=len(prompt.encode("utf-8", "replace")),
|
|
145
|
+
prompt_lines=prompt.count("\n") + 1,
|
|
146
|
+
path_count=len(set(PATH_RE.findall(head))),
|
|
147
|
+
prompt_hash=digest(prompt),
|
|
148
|
+
prompt_head=prompt[:200],
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def triviality(dispatch):
|
|
153
|
+
"""0..3. A features-only score; it never changes the exit code.
|
|
154
|
+
|
|
155
|
+
Say plainly what this can and cannot see: a trivial spawn has small *work*,
|
|
156
|
+
and tool input only shows the *brief*. The two failure modes happen to
|
|
157
|
+
collapse -- a short brief is either work too small to deserve a cold context
|
|
158
|
+
or work that is under-briefed, and the policy forbids both -- but roughly one
|
|
159
|
+
in six legitimate runner dispatches will still score here. That rate is fine
|
|
160
|
+
for a log line and disqualifying for a block, which is why this is only ever
|
|
161
|
+
a log line.
|
|
162
|
+
"""
|
|
163
|
+
if dispatch is None:
|
|
164
|
+
return 0
|
|
165
|
+
score = 0
|
|
166
|
+
if dispatch.prompt_bytes < 220:
|
|
167
|
+
score += 2
|
|
168
|
+
elif dispatch.prompt_bytes < 450:
|
|
169
|
+
score += 1
|
|
170
|
+
if dispatch.path_count == 1:
|
|
171
|
+
score += 1
|
|
172
|
+
if dispatch.prompt_lines == 1:
|
|
173
|
+
score += 1
|
|
174
|
+
return min(score, 3)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def routable(name):
|
|
178
|
+
"""Can this harness name a model per spawn?
|
|
179
|
+
|
|
180
|
+
Claude Code's dispatch tool takes one, and any harness Leo has configured in
|
|
181
|
+
routing.json has a model to name. Everywhere else the payload itself says to
|
|
182
|
+
inherit and say so -- blocking there would demand something the harness
|
|
183
|
+
cannot do, a false positive by construction.
|
|
184
|
+
"""
|
|
185
|
+
if name == "claude":
|
|
186
|
+
return True
|
|
187
|
+
try:
|
|
188
|
+
import routing
|
|
189
|
+
return name in routing.load()
|
|
190
|
+
except BaseException:
|
|
191
|
+
# routing.py exits on a malformed config. A hook must never inherit that.
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def decide(dispatch, name, is_routable):
|
|
196
|
+
"""(action, reason). The entire policy, and deliberately four lines of it."""
|
|
197
|
+
if dispatch is None:
|
|
198
|
+
return ALLOW, "not-a-dispatch"
|
|
199
|
+
if dispatch.agent.startswith("leo-"):
|
|
200
|
+
return ALLOW, "leo-tier" # the agent definition carries the model
|
|
201
|
+
if dispatch.model:
|
|
202
|
+
return ALLOW, "explicit-model" # a choice was typed; cheap or not
|
|
203
|
+
if not is_routable:
|
|
204
|
+
return ALLOW, "harness-cannot-route"
|
|
205
|
+
return BLOCK, "no-model"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def render_block(dispatch):
|
|
209
|
+
"""The refusal. It must name the remedies, or it costs a turn to discover them."""
|
|
210
|
+
return (
|
|
211
|
+
'[leo routing] BLOCKED - dispatch to agent "%s" names no model, so it would\n'
|
|
212
|
+
"silently inherit the parent's. Re-dispatch with one of:\n"
|
|
213
|
+
' subagent_type "leo-runner" reading, search, tests, logs, codemods, fan-out\n'
|
|
214
|
+
' subagent_type "leo-executor" an approved plan or a specified change\n'
|
|
215
|
+
' model: "<name>" investigation/debugging - naming it IS the reason\n'
|
|
216
|
+
"Set LEOS_AGENT_DISPATCH_GUARD=off to disable, =warn to log only."
|
|
217
|
+
) % dispatch.agent
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def render_notice(dispatch):
|
|
221
|
+
return (
|
|
222
|
+
"[leo routing] %d-byte brief to %s: a fresh agent context is uncached and "
|
|
223
|
+
"costs more than an inline read. Inline it when one file answers it."
|
|
224
|
+
) % (dispatch.prompt_bytes, dispatch.agent)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _log(entry):
|
|
228
|
+
"""Best effort, always. A breadcrumb that cannot be written must not break a
|
|
229
|
+
dispatch, so every failure here is swallowed -- including a missing module."""
|
|
230
|
+
try:
|
|
231
|
+
import dispatch_log
|
|
232
|
+
dispatch_log.append(entry)
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def evaluate(event, name=None):
|
|
238
|
+
"""(action, reason, dispatch, trivial) for one event. Shared by every adapter.
|
|
239
|
+
|
|
240
|
+
An in-process adapter knows which harness it is and passes `name`; detection
|
|
241
|
+
exists only for the command hooks, which get no say.
|
|
242
|
+
"""
|
|
243
|
+
name = name or harness(event)
|
|
244
|
+
dispatch = normalize(event, name)
|
|
245
|
+
action, reason = decide(dispatch, name, routable(name))
|
|
246
|
+
return action, reason, dispatch, triviality(dispatch)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def main(argv=None):
|
|
250
|
+
mode = os.environ.get("LEOS_AGENT_DISPATCH_GUARD", "on").strip().lower()
|
|
251
|
+
if mode == "off":
|
|
252
|
+
return 0
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
raw = sys.stdin.buffer.read().decode("utf-8", "replace")
|
|
256
|
+
event = json.loads(raw) if raw.strip() else None
|
|
257
|
+
except Exception:
|
|
258
|
+
return 0
|
|
259
|
+
|
|
260
|
+
name = harness(event if isinstance(event, dict) else {})
|
|
261
|
+
try:
|
|
262
|
+
action, reason, dispatch, trivial = evaluate(event)
|
|
263
|
+
except Exception as exc:
|
|
264
|
+
# The guard broke. Allow, and say so distinctly -- an "error" row is the
|
|
265
|
+
# only way a dead guard is ever noticed.
|
|
266
|
+
_breadcrumb(name, exc)
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
if dispatch is None:
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
session = _first_str(event, ("session_id", "sessionId")) if isinstance(event, dict) else ""
|
|
273
|
+
cwd = _first_str(event, ("cwd", "workspace", "directory")) if isinstance(event, dict) else ""
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
import dispatch_log
|
|
277
|
+
entry = dispatch_log.record(dispatch, action, reason, name, session, cwd, trivial)
|
|
278
|
+
_log(entry)
|
|
279
|
+
except Exception:
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
if action == BLOCK and mode != "warn":
|
|
283
|
+
sys.stderr.write(render_block(dispatch) + "\n")
|
|
284
|
+
return 2
|
|
285
|
+
|
|
286
|
+
# A notice is user-facing only. systemMessage reaches Leo's transcript and
|
|
287
|
+
# never the model's context, so a false positive here costs zero tokens.
|
|
288
|
+
# verbose additionally spends ~60 tokens putting it in front of the model;
|
|
289
|
+
# it stays off until the log says the problem is frequent enough to be worth
|
|
290
|
+
# paying for.
|
|
291
|
+
if trivial >= 2:
|
|
292
|
+
payload = {"systemMessage": render_notice(dispatch)}
|
|
293
|
+
if mode == "verbose":
|
|
294
|
+
payload["hookSpecificOutput"] = {
|
|
295
|
+
"hookEventName": "PreToolUse",
|
|
296
|
+
"additionalContext": render_notice(dispatch),
|
|
297
|
+
}
|
|
298
|
+
sys.stdout.write(json.dumps(payload) + "\n")
|
|
299
|
+
return 0
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _breadcrumb(name, exc):
|
|
303
|
+
try:
|
|
304
|
+
import dispatch_log
|
|
305
|
+
dispatch_log.append({
|
|
306
|
+
"v": dispatch_log.RECORD_VERSION,
|
|
307
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
308
|
+
"harness": name,
|
|
309
|
+
"decision": "error",
|
|
310
|
+
"reason": "%s: %s" % (type(exc).__name__, exc),
|
|
311
|
+
})
|
|
312
|
+
except Exception:
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
if __name__ == "__main__":
|
|
317
|
+
sys.exit(main())
|