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.
@@ -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. Bulk work belongs
11
- in subagents.
10
+ dispatch subagents, integrate what they return, report to Leo.
12
11
 
13
- Delegate work whose byproducts you do not want to keep: many files read, long
14
- output, several attempts before it lands — investigation, code search,
15
- debugging, execution, test runs. Only the conclusion comes back; the rest dies
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
- Keep it inline when delegating costs more than it saves. Your context is already
19
- cached; a subagent starts cold and pays a full cache write on its system prompt
20
- and brief before reading anything. That write, not a screenful of output, is the
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
- Do not spawn a subagent to avoid thinking. If you are a subagent, this section
24
- does not apply do the work yourself.
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 where supported. Report the gap if history inheritance cannot be
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 it should start from.
34
+ - Name the files, paths, symbols, and commands to start from.
34
35
  - Include settled decisions, so it does not relitigate them.
35
- - Where supported, grant only the skills and tools it needs; extra schemas cost
36
- context and invite wandering.
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
- ## Model routing
43
-
44
- Every brief names one of two tiers; there is no third.
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
- **Standard** is the model Leo is running now, inherited with no override.
49
+ ## Model routing
47
50
 
48
- **Economical** is min(current model, the cheapest sufficient profile): runner =
49
- Haiku on Claude Code or `gpt-5.6-luna`/low on Codex; executor = Sonnet or
50
- `gpt-5.6-terra`/medium. Elsewhere use the current model. Never upgrade a cheaper
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
- Match the tier to the kind of work, not to the size of the request.
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
- **Thinking work runs standard** — investigation, debugging, adjudication, and
56
- orchestration. A weak diagnosis makes every later step wasteful.
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
- **Doing work runs economical** runner for tests, reading, search, logs,
59
- codemods, and every fan-out; executor for an approved plan or well-specified
60
- code change. On Codex these are `leo-runner` and `leo-executor`. Wide standard
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
- for skill in skills:
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())