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 +110 -31
- package/package.json +1 -1
- package/rules/preferences.md +37 -32
- package/scripts/check.py +77 -1
- 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/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/tune-routing/SKILL.md +129 -0
- package/skills/tune-routing/agents/openai.yaml +5 -0
- package/skills/tune-routing/reference/harnesses.md +63 -0
- package/skills-claude/attach-pr/SKILL.md +14 -4
- package/skills-claude/watch-review/SKILL.md +74 -25
package/README.md
CHANGED
|
@@ -1,28 +1,35 @@
|
|
|
1
1
|
# leos-agent
|
|
2
2
|
|
|
3
|
-
Leo's portable agent operating policy, version **10.
|
|
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,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
Investigation, brainstorming, debugging, and mechanical work all run in briefed
|
|
9
|
+
subagents, so the main thread never fills up with the files, retries, and logs
|
|
10
|
+
that produced an answer — only the answer. A single command you can filter at
|
|
11
|
+
the shell stays inline. Work runs at one of two named tiers: **standard**, the
|
|
12
|
+
model you are already using, for thinking and judging; **economical**, two
|
|
13
|
+
named agent profiles — `leo-runner` for narrow search, reading, testing, and
|
|
14
|
+
mechanical work, `leo-executor` for well-specified implementation — shipped as
|
|
15
|
+
first-class agent definitions on both Claude Code (`agents/`, Haiku and Sonnet)
|
|
16
|
+
and Codex (installed TOML profiles), so the cheaper model is baked into the
|
|
17
|
+
agent type rather than chosen per dispatch. Every other harness inherits unless
|
|
18
|
+
a machine-local [routing config](#per-machine-model-routing) names models for it.
|
|
15
19
|
|
|
16
20
|
## What it ships
|
|
17
21
|
|
|
18
|
-
Beyond the preferences payload:
|
|
19
|
-
|
|
22
|
+
Beyond the preferences payload: the two economical-tier agent definitions
|
|
23
|
+
(`agents/` for Claude Code, `payload/codex-agents/` for Codex), a setup
|
|
24
|
+
diagnostic, a routing tuner, a session handoff pair, and three GitHub skills.
|
|
25
|
+
The GitHub ones need `gh`, authenticated.
|
|
20
26
|
|
|
21
27
|
| Skill | What it does | Where |
|
|
22
28
|
|---|---|---|
|
|
23
29
|
| `review-pr` | Reviews a pull request and stages inline comments as a **pending** review — visible only to you until you submit or discard on GitHub. Never submits. Resolves the originating ticket (Linear, Jira, GitHub issue) from the PR's title, body, or branch when one is named, and adds a spec lens that checks the diff against it. | every skill-loading harness |
|
|
24
|
-
| `watch-review` | Arms a watcher that streams
|
|
30
|
+
| `watch-review` | Arms a watcher that streams direct review requests into the session for `review-pr` to handle, and re-streams one when its head moves. Never surfaces a pull request someone else has approved. Polling is a shell script (`scripts/watch_review.py`), not a model loop: an idle tick is one `gh` call and zero tokens. | **Claude Code only** — built on its Monitor tool |
|
|
25
31
|
| `doctor` | Diagnoses this harness's setup, read-only: whether the `<leos-agent>` block is injected and current, what else is loaded into every session (global instruction file, memories, settings, skills), and whether a local checkout passes `scripts/check.py`. Run it with `/doctor`. | every skill-loading harness |
|
|
32
|
+
| `tune-routing` | Picks the concrete models behind `leo-runner` and `leo-executor` on this machine, writes them to `~/.leos-agent-local/routing.json`, re-renders the install, and proves the choice with one live dispatch — model strings are never checked against a known-model list, so a typo surfaces at dispatch time and nowhere earlier. Run it with `/tune-routing`. | every skill-loading harness |
|
|
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
|
|
35
|
-
(override with `$LEOS_AGENT_LOCAL_PATH`), so
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
294
|
-
|
|
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.
|
|
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.
|
|
466
|
-
|
|
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.
|
|
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
package/rules/preferences.md
CHANGED
|
@@ -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.
|
|
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
|
+
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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.
|
package/scripts/ghreview.py
CHANGED
|
@@ -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))
|
package/scripts/handoff.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if
|
|
73
|
+
first = fh.readline()
|
|
74
|
+
if first.strip() == "---":
|
|
75
|
+
for line in fh:
|
|
76
|
+
if line.strip() == "---":
|
|
69
77
|
break
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if not
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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}")
|