leos-agent 10.2.0 → 10.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,28 +1,35 @@
1
1
  # leos-agent
2
2
 
3
- Leo's portable agent operating policy, version **10.2.0**, installable on Claude
3
+ Leo's portable agent operating policy, version **10.6.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, execution, testing, and mechanical work
9
- all run in briefed subagents, so the main thread never fills up with the files,
10
- retries, and logs that produced an answer — only the answer. Work runs at one of two
11
- named tiers: **standard**, the model you are already using, for thinking and
12
- judging; **economical**, the cheapest sufficient profile where the harness can
13
- select one. Narrow search, reading, testing, and mechanical work use the runner
14
- profile; well-specified implementation uses the executor profile.
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: a setup diagnostic, a session handoff pair, and
19
- three GitHub skills. The GitHub ones need `gh`, authenticated.
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 new direct review requests into the session, one notification per pull request, for `review-pr` to handle. 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 |
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 |
26
33
  | `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
34
  | `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
35
  | `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 +38,80 @@ The Claude-only pair live in `skills-claude/` and `commands-claude/`, listed in
31
38
  `.claude-plugin/plugin.json` and nowhere else. Hermes receives the preferences
32
39
  payload but no skills — it has no skill loader.
33
40
 
34
- The watcher records each reviewed pull request under `~/.leos-agent-local/`
35
- (override with `$LEOS_AGENT_LOCAL_PATH`), so it never surfaces the same one
36
- twice; `watch_review.py forget <n>` puts one back in play.
37
-
38
- Handoffs live in the same place, under `handoffs/`. Nothing there is ever pruned
39
- automatically: `handoff.py list [--all]` shows what exists and `handoff.py rm
40
- <name>` is the only way one goes away. The directory is deliberately outside
41
- the plugin, so upgrading or reinstalling can never take state with it.
41
+ The watcher records the **head commit** it reviewed each pull request at, under
42
+ `~/.leos-agent-local/` (override with `$LEOS_AGENT_LOCAL_PATH`), so a pull
43
+ request comes back when someone pushes to it and stays quiet otherwise;
44
+ `watch_review.py forget <n>` puts one back in play at its current head. Two
45
+ gates keep a continuous watch from being expensive: a pull request another user
46
+ has already approved is never surfaced at all, and a new head must hold still
47
+ for `--settle` seconds (default 120) before it is emitted, so a burst of pushes
48
+ costs one review rather than one per commit.
49
+
50
+ Handoffs live in the same place, at
51
+ `~/.leos-agent-local/handoffs/<name>.md` — a fixed path that needs no plugin
52
+ root, so `/handon` reads one with a single `cat` rather than going looking for
53
+ it. Nothing there is ever pruned automatically: `handoff.py list [--all]` shows
54
+ what exists and `handoff.py rm <name>` is the only way one goes away. The
55
+ directory is deliberately outside the plugin, so upgrading or reinstalling can
56
+ never take state with it.
57
+
58
+ ## Per-machine model routing
59
+
60
+ The economical tier only ever had teeth on Claude Code and Codex, because those
61
+ are the two harnesses whose model names the payload could hardcode. Everywhere
62
+ else, every fan-out ran at the current model — the most expensive shape the
63
+ policy has. Which models a harness offers varies by machine and by what an IT
64
+ department allows, so the mapping is machine-local config rather than something
65
+ the plugin can ship:
66
+
67
+ ```jsonc
68
+ // ~/.leos-agent-local/routing.json (override the directory with $LEOS_AGENT_LOCAL_PATH)
69
+ {
70
+ "cursor": {"runner": "grok-code-fast-1", "executor": "claude-sonnet-4.6"},
71
+ "opencode": {"runner": "anthropic/claude-haiku-4-5"},
72
+ "codex": {"runner": {"model": "gpt-5.6-luna", "effort": "low"}}
73
+ }
74
+ ```
75
+
76
+ Keys are harness names; each holds `runner` and/or `executor`, independently —
77
+ configuring only `runner` is the common case, since it is the fan-out that
78
+ costs. A bare string is shorthand for `{"model": ...}`. Model strings are
79
+ free-form and never checked against a known-model list: whatever the harness
80
+ accepts goes in verbatim. A misspelled *key*, though, is a hard error, because a
81
+ typo that silently left a harness on the expensive model is the one failure this
82
+ is here to prevent.
83
+
84
+ **Nothing reads it at run time.** `leo-install.py` renders the result into the
85
+ `<leos-agent>` block it already writes, so a session pays nothing to know its own
86
+ routing — no config read, no extra turn. It costs *less* than before: each
87
+ machine now carries only its own harness's dispatch line instead of all of them,
88
+ which took the installed payload from 4497 bytes to 4315–4344 depending on the
89
+ harness. `scripts/measure_context.py` prints the per-harness figure and fails if
90
+ an unconfigured harness ever grows past the old one.
91
+
92
+ Edit the file — by hand, or with `routing.py set --harness <h> --runner
93
+ <model>`, which [`/tune-routing`](#what-it-ships) drives end to end — then
94
+ re-run the installer to re-render; `leo-install.py <harness> --check` reports
95
+ "out of date" until you do, and `/doctor` surfaces it. Installing is
96
+ idempotent: same config, same version, same bytes, so a second run reports
97
+ `unchanged`.
98
+
99
+ **The config is yours, never the installer's.** `leo-install.py` only ever reads
100
+ it, and never creates, migrates, rewrites, or removes it, including under
101
+ `--uninstall`; it lives outside the plugin so an upgrade cannot take it. The one
102
+ thing that writes it is `routing.py set` / `unset`, run because you asked: it
103
+ creates the file if it is missing, replaces the one role you named, validates
104
+ the whole document before writing, and leaves every other harness's entry —
105
+ including the bare-string shorthand — exactly as you wrote it. `routing.py show`
106
+ prints what is configured; with no file at all, every harness uses its shipped
107
+ default and behaviour is exactly what it was before this existed.
108
+
109
+ Delivery differs by harness only in the last mile: Claude Code gets a `model:`
110
+ override alongside `subagent_type:` (the plugin-owned `agents/*.md` are never
111
+ rewritten), Codex gets the models substituted into its installed profile TOMLs,
112
+ Cursor gets its own `~/.cursor/rules/leos-agent-routing.mdc` because its rules
113
+ come straight out of the plugin directory, and the rest get the rendered line in
114
+ their global instruction file.
42
115
 
43
116
  ## How it works
44
117
 
@@ -49,7 +122,7 @@ gets it through its global instruction file, written by
49
122
  [`scripts/leo-install.py`](scripts/leo-install.py) into a marker block:
50
123
 
51
124
  ```
52
- <leos-agent version="10.2.0">
125
+ <leos-agent version="10.6.0">
53
126
  ...the payload...
54
127
  </leos-agent>
55
128
  ```
@@ -67,7 +140,7 @@ exactly the content it exists to protect.
67
140
 
68
141
  | Harness | Global file the installer writes |
69
142
  |---|---|
70
- | Claude Code | `~/.claude/CLAUDE.md` |
143
+ | Claude Code | `~/.claude/CLAUDE.md` (the `leo-runner` / `leo-executor` agents need no installer step — the plugin's `agents/` directory delivers them) |
71
144
  | Codex | `~/.codex/AGENTS.md` (plus `~/.codex/agents/leo-runner.toml` and `leo-executor.toml`) |
72
145
  | Cursor | none — the plugin's always-apply rule delivers it |
73
146
  | Hermes | `~/.hermes/SOUL.md` (edited only if it already exists) |
@@ -118,7 +191,7 @@ that it is already installed and changes nothing.
118
191
  Run the installer's uninstall first, while the script is still on disk:
119
192
 
120
193
  ```bash
121
- python3 ~/.claude/plugins/cache/leos-agent/leos-agent/10.2.0/scripts/leo-install.py claude --uninstall
194
+ python3 ~/.claude/plugins/cache/leos-agent/leos-agent/10.6.0/scripts/leo-install.py claude --uninstall
122
195
  ```
123
196
 
124
197
  ```bash
@@ -149,7 +222,7 @@ Then run the `install` skill in a Codex session (`$leos-agent`, then `install`),
149
222
  run the script directly:
150
223
 
151
224
  ```bash
152
- python3 ~/.codex/plugins/cache/leos-agent/leos-agent/10.2.0/scripts/leo-install.py codex
225
+ python3 ~/.codex/plugins/cache/leos-agent/leos-agent/10.6.0/scripts/leo-install.py codex
153
226
  ```
154
227
 
155
228
  This writes `~/.codex/AGENTS.md` and installs two economical agents:
@@ -174,7 +247,7 @@ threads only. Re-adding an already-installed plugin is idempotent.
174
247
  **Uninstall**
175
248
 
176
249
  ```bash
177
- python3 ~/.codex/plugins/cache/leos-agent/leos-agent/10.2.0/scripts/leo-install.py codex --uninstall
250
+ python3 ~/.codex/plugins/cache/leos-agent/leos-agent/10.6.0/scripts/leo-install.py codex --uninstall
178
251
  ```
179
252
 
180
253
  ```bash
@@ -290,8 +363,9 @@ Remove the `leos-agent` entry from `plugins.enabled`, and delete the clone if
290
363
  you made one. Your own `SOUL.md` content is left intact — only the block goes.
291
364
 
292
365
  **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. The
294
- policy therefore routes all Hermes work to the current model.
366
+ child of a `delegate_task` call, so it cannot vary the model per spawn. A
367
+ routing config still renders a stanza for it, and the stanza says to inherit and
368
+ say so where a per-spawn model is not available.
295
369
 
296
370
  ---
297
371
 
@@ -322,7 +396,7 @@ Pinned refs are reconciled, never silently advanced — to move to a new tag,
322
396
  install it explicitly:
323
397
 
324
398
  ```bash
325
- pi install git:github.com/foxhatleo/leos-agent@v10.2.0
399
+ pi install git:github.com/foxhatleo/leos-agent@v10.6.0
326
400
  ```
327
401
 
328
402
  Re-run `/skill:install` afterwards.
@@ -361,7 +435,10 @@ python3 ~/.cache/opencode/packages/leos-agent@latest/node_modules/leos-agent/scr
361
435
 
362
436
  That writes `~/.config/opencode/AGENTS.md` and copies the skills and commands into
363
437
  `~/.config/opencode/skills/` and `~/.config/opencode/commands/`. From then on
364
- `/leo-install` works inside OpenCode.
438
+ `/leo-install` works inside OpenCode. The copies are installed with the plugin
439
+ root already resolved to an absolute path — OpenCode sets no resolution env var,
440
+ and the copies live apart from the scripts they invoke — so re-run the installer
441
+ after clearing or moving the package cache to point them at the new location.
365
442
 
366
443
  **Upgrade**
367
444
 
@@ -462,8 +539,10 @@ is the worked example: the main thread loads a 3.2 KB contract, the reviewer
462
539
  subagent reads `reference/procedure.md`, and the lens sub-subagents read
463
540
  `reference/lenses.md` that the reviewer itself never loads. Before the split the
464
541
  main thread and the reviewer each loaded the same 21 KB file, and every turn
465
- after that re-read it. Split a file out only when some run genuinely does not
466
- read it; moving prose around costs the same tokens.
542
+ after that re-read it. `tune-routing` does the same with its per-harness model
543
+ discovery, in `reference/harnesses.md`, which only a run that actually tunes
544
+ ever loads. Split a file out only when some run genuinely does not read it;
545
+ moving prose around costs the same tokens.
467
546
 
468
547
  **Invocation split.** A skill is either *user-invoked* — reached by typing its
469
548
  slash command, and carrying `disable-model-invocation: true` — or *deliberately
@@ -537,7 +616,7 @@ claude plugin uninstall leos-agent@leos-agent && claude plugin install leos-agen
537
616
  ```
538
617
 
539
618
  or replace the cachebuster suffix in the Codex manifest with one in the form
540
- `10.2.0+codex.local-YYYYMMDD-HHMMSS` and re-add. Either way, plugin changes only
619
+ `10.6.0+codex.local-YYYYMMDD-HHMMSS` and re-add. Either way, plugin changes only
541
620
  reach a **new** session or thread.
542
621
 
543
622
  `--check` exits non-zero when a file is out of date, and `--force` replaces a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leos-agent",
3
- "version": "10.2.0",
3
+ "version": "10.6.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",
@@ -7,58 +7,63 @@ 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
+ Every subagent dispatch MUST name an explicit model or profile. The harness
52
+ inherits the parent model when you say nothing, so a dispatch with no model and
53
+ no stated reason for inheriting is a bug, not a default.
52
54
 
53
- Match the tier to the kind of work, not to the size of the request.
55
+ - Reading, search, tests, logs, codemods, and every fan-out **leo-runner**.
56
+ - An approved plan or a well-specified code change → **leo-executor**.
57
+ - Investigation, debugging, adjudication, orchestration → inherit the current
58
+ model, and say that inheriting is intended.
54
59
 
55
- **Thinking work runs standard** — investigation, debugging, adjudication, and
56
- orchestration. A weak diagnosis makes every later step wasteful.
60
+ <!-- leos-agent:routing -->
61
+ On Claude Code pass `subagent_type: "leo-runner"` or `"leo-executor"`; on Codex
62
+ the installed profiles carry the models. Elsewhere use the current model.
63
+ <!-- /leos-agent:routing -->
57
64
 
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.
65
+ Never upgrade a cheaper session; report when routing cannot be applied. Wide
66
+ inherited fan-out is the policy's most expensive shape.
62
67
 
63
68
  ## Caching
64
69
 
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 ""
@@ -161,6 +163,80 @@ def main():
161
163
  if path.is_file():
162
164
  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
165
 
166
+ # 5a-agents. Claude Code auto-discovers agents/ at the plugin root. The set
167
+ # must stay in lockstep with the Codex profiles (same names, one policy), and
168
+ # each definition needs the frontmatter Claude reads — a missing model field
169
+ # would silently inherit the parent model, which is the failure this tier
170
+ # exists to prevent.
171
+ claude_agents = sorted((ROOT / "agents").glob("*.md"))
172
+ check(
173
+ sorted(p.stem for p in claude_agents) == sorted(installer.CODEX_AGENTS),
174
+ f"agents/: expected exactly the Claude twins of {installer.CODEX_AGENTS}, found {[p.stem for p in claude_agents]}",
175
+ )
176
+ for agent in claude_agents:
177
+ rel = agent.relative_to(ROOT)
178
+ text = agent.read_text(encoding="utf-8")
179
+ check(text.startswith("---\n"), f"{rel}: missing frontmatter")
180
+ fm = text.split("---", 2)[1] if text.count("---") >= 2 else ""
181
+ name_match = re.search(r"^name:\s*(\S+)", fm, re.MULTILINE)
182
+ check(name_match is not None and name_match.group(1) == agent.stem, f"{rel}: frontmatter name must be {agent.stem!r}")
183
+ check(re.search(r"^description:", fm, re.MULTILINE) is not None, f"{rel}: needs description")
184
+ check(re.search(r"^model:\s*\S", fm, re.MULTILINE) is not None, f"{rel}: needs an explicit model")
185
+ check(re.search(r"^tools:\s*\S", fm, re.MULTILINE) is not None, f"{rel}: needs an explicit tools allowlist")
186
+
187
+ # 5a-routing. The routing region is what makes the economical tier
188
+ # configurable per machine. Rendering must be total (every harness gets a
189
+ # stanza), deterministic (or a second install would not report "unchanged"),
190
+ # and cheaper than the multi-harness prose it replaced.
191
+ prefs_body = installer.payload_body(ROOT)
192
+ for marker in (installer.ROUTING_OPEN, installer.ROUTING_CLOSE):
193
+ check(prefs_body.count(marker) == 1, f"rules/preferences.md: expected exactly one {marker}")
194
+ check(
195
+ prefs_body.find(installer.ROUTING_OPEN) < prefs_body.find(installer.ROUTING_CLOSE),
196
+ "rules/preferences.md: the routing region's closer precedes its opener",
197
+ )
198
+ for harness in installer.HARNESSES:
199
+ rendered = installer.payload_body(ROOT, harness, {})
200
+ check(bool(installer.routing.stanza(harness, {}).strip()), f"routing: {harness} renders an empty stanza")
201
+ check(
202
+ installer.ROUTING_OPEN not in rendered and installer.ROUTING_CLOSE not in rendered,
203
+ f"routing: {harness}'s rendered payload still carries the region markers",
204
+ )
205
+ check(
206
+ rendered == installer.payload_body(ROOT, harness, {}),
207
+ f"routing: rendering {harness} twice is not byte-identical",
208
+ )
209
+ check(
210
+ len(rendered.encode("utf-8")) < len(prefs_body.encode("utf-8")),
211
+ f"routing: {harness}'s rendered payload is not smaller than the unrendered file",
212
+ )
213
+ # The rule is only ever installed when cursor routing is configured, so the
214
+ # provenance requirement is checked on a configured render.
215
+ configured_cursor = {"cursor": {"runner": {"model": "example-model", "effort": None}}}
216
+ check(
217
+ installer.PROVENANCE in installer.cursor_routing_rule("cursor", configured_cursor),
218
+ f"routing: the Cursor rule must contain {installer.PROVENANCE!r} so the installer recognises its own copy",
219
+ )
220
+
221
+ # 5c. Plugin-root references. Skill and command text points at plugin files
222
+ # through the <plugin-root> placeholder, and the OpenCode installer bakes the
223
+ # absolute root into its copies — so every referenced path must actually
224
+ # exist, or an install ships a command that can only fail.
225
+ for base in ("skills", "skills-claude", "commands", "commands-claude"):
226
+ for doc in sorted((ROOT / base).rglob("*.md")):
227
+ text = doc.read_text(encoding="utf-8")
228
+ for ref in sorted({r.rstrip(".") for r in re.findall(r"<plugin-root>/([\w./-]+)", text)}):
229
+ check((ROOT / ref).exists(), f"{doc.relative_to(ROOT)}: <plugin-root>/{ref} does not exist")
230
+
231
+ # The OpenCode copy of the install skill is renamed to leo-install by a
232
+ # targeted regex in the installer; if the source name ever changes, that
233
+ # regex would silently no-op and ship a dir/name mismatch.
234
+ install_fm = (ROOT / "skills" / "install" / "SKILL.md").read_text(encoding="utf-8").split("---", 2)[1]
235
+ check(
236
+ re.search(r"(?m)^name:\s*install\s*$", install_fm) is not None,
237
+ "skills/install/SKILL.md: frontmatter name must stay 'install' — leo-install.py's OpenCode rename keys on it",
238
+ )
239
+
164
240
  # 5b. Invocation split: a skill is either user-invoked (and hidden from the
165
241
  # model's always-loaded skill listing) or deliberately model-invocable. Claude
166
242
  # reads the SKILL.md flag; Codex reads the sibling agents/openai.yaml policy.
@@ -48,6 +48,11 @@ SNAP_MAX_DISTANCE = 10 # beyond this from the requested line, drop instead of s
48
48
 
49
49
  MARKER = "<!-- leos-agent:review-pr -->"
50
50
 
51
+ # The reviewer's procedure caps a review at 15 comments; this is the script's
52
+ # own backstop well above it, so a reviewer talked past its cap by a hostile
53
+ # diff still cannot blanket a pull request.
54
+ MAX_STAGE_COMMENTS = 50
55
+
51
56
 
52
57
  def _mark(body):
53
58
  """Tag a comment body as tool-created, so clear-pending can tell it apart
@@ -462,6 +467,13 @@ def cmd_stage(a):
462
467
  if not comments:
463
468
  print(json.dumps({"staged": 0, "note": "no comments provided; nothing created"}))
464
469
  return
470
+ if len(comments) > MAX_STAGE_COMMENTS:
471
+ print(
472
+ f"input error: {len(comments)} comments exceeds the cap of {MAX_STAGE_COMMENTS}; "
473
+ "a review this wide should be narrowed, not staged",
474
+ file=sys.stderr,
475
+ )
476
+ sys.exit(2)
465
477
 
466
478
  files = fetch_files(a.repo, a.pr)
467
479
  staged, snapped, dropped = validate_comments(comments, build_maps(files))
@@ -18,7 +18,8 @@ it. Files land at <root>/handoffs/<name>.md.
18
18
 
19
19
  Nothing is ever pruned automatically; `rm` is the only way a handoff goes away.
20
20
  `list` shows only handoffs written in or under the current directory unless
21
- --all is passed. Exit codes: 0 ok, non-zero on error.
21
+ --all is passed; when that leaves nothing it falls back to showing all of them
22
+ rather than sending the caller away to re-run. Exit codes: 0 ok, non-zero on error.
22
23
  """
23
24
  import datetime as dt
24
25
  import os
@@ -32,8 +33,13 @@ SLUG = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
32
33
 
33
34
 
34
35
  def handoff_dir():
35
- path = os.path.join(_data_root(), "handoffs")
36
- os.makedirs(path, exist_ok=True)
36
+ # 0700 on creation, matching state.py: handoffs carry project context that
37
+ # is nobody else's business on a shared machine. The root is created first
38
+ # so it gets the mode too — makedirs applies mode to the leaf only.
39
+ root = _data_root()
40
+ os.makedirs(root, mode=0o700, exist_ok=True)
41
+ path = os.path.join(root, "handoffs")
42
+ os.makedirs(path, mode=0o700, exist_ok=True)
37
43
  return path
38
44
 
39
45
 
@@ -60,15 +66,17 @@ def frontmatter(path):
60
66
 
61
67
 
62
68
  def title_of(path):
69
+ """The first `# ` heading — after the frontmatter when there is one, from
70
+ the top of the file when there is not."""
63
71
  try:
64
72
  with open(path) as fh:
65
- seen_open = False
66
- for line in fh:
67
- if line.strip() == "---":
68
- if seen_open:
73
+ first = fh.readline()
74
+ if first.strip() == "---":
75
+ for line in fh:
76
+ if line.strip() == "---":
69
77
  break
70
- seen_open = True
71
- continue
78
+ elif first.startswith("# "):
79
+ return first[2:].strip()
72
80
  for line in fh:
73
81
  if line.startswith("# "):
74
82
  return line[2:].strip()
@@ -128,6 +136,9 @@ def cmd_new(argv):
128
136
  suffix += 1
129
137
  print(name)
130
138
  print(file_for(name))
139
+ # The `created:` value, ready to copy verbatim — a model asked to invent
140
+ # "now" gets it wrong often enough to matter.
141
+ print(dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
131
142
 
132
143
 
133
144
  def cmd_path(argv):
@@ -145,20 +156,32 @@ def cmd_list(argv):
145
156
  except (IndexError, ValueError):
146
157
  sys.exit("handoff: --limit needs a number")
147
158
  here = os.path.realpath(os.getcwd())
148
- rows = []
149
- for name, path, meta, _ in entries():
150
- cwd = os.path.realpath(meta.get("cwd", "")) if meta.get("cwd") else ""
151
- if not show_all and cwd:
152
- related = here == cwd or here.startswith(cwd + os.sep) or cwd.startswith(here + os.sep)
153
- if not related:
154
- continue
155
- rows.append((name, age_of(meta.get("created", "")), meta.get("repo", meta.get("cwd", "?")), title_of(path)))
156
- if len(rows) >= limit:
157
- break
159
+
160
+ def collect(unfiltered):
161
+ rows = []
162
+ for name, path, meta, _ in entries():
163
+ cwd = os.path.realpath(meta.get("cwd", "")) if meta.get("cwd") else ""
164
+ if not unfiltered and cwd:
165
+ related = here == cwd or here.startswith(cwd + os.sep) or cwd.startswith(here + os.sep)
166
+ if not related:
167
+ continue
168
+ rows.append((name, age_of(meta.get("created", "")), meta.get("repo", meta.get("cwd", "?")), title_of(path)))
169
+ if len(rows) >= limit:
170
+ break
171
+ return rows
172
+
173
+ rows = collect(show_all)
174
+ note = ""
175
+ if not rows and not show_all:
176
+ # Falling back beats printing "try --all": the caller is usually a model
177
+ # one round trip from giving up and searching the filesystem instead.
178
+ rows = collect(True)
179
+ note = "none written in or under this directory; showing all"
158
180
  if not rows:
159
- scope = "" if show_all else " for this directory (try --all)"
160
- print(f"no handoffs{scope}")
181
+ print("no handoffs")
161
182
  return
183
+ if note:
184
+ print(note)
162
185
  width = max(len(row[0]) for row in rows)
163
186
  for name, age, repo, title in rows:
164
187
  print(f"{name.ljust(width)} {age.rjust(4)} {repo} {title}")