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.
@@ -6,26 +6,41 @@ a state file — none of it needs a model. This script does that half in the
6
6
  shell and prints one line per new pull request; whoever reads stdout does the
7
7
  review. An idle tick costs one `gh` call and zero tokens.
8
8
 
9
- watch_review.py monitor [-C DIR] --interval 300 loop; a line per new PR
10
- watch_review.py record [-C DIR] <number>... mark numbers reviewed
11
- watch_review.py state [-C DIR] show what has been reviewed
12
- watch_review.py forget [-C DIR] <number>... drop numbers from the state
9
+ watch_review.py monitor [-C DIR] --interval 300 loop; a line per PR to review
10
+ watch_review.py record [-C DIR] <n> --head <sha> mark a PR reviewed at a head
11
+ watch_review.py state [-C DIR] show what has been reviewed
12
+ watch_review.py forget [-C DIR] <number>... drop numbers from the state
13
+
14
+ State is keyed on the reviewed **head commit**, not the pull request number, so
15
+ a pull request comes back when someone pushes to it. That is the whole point:
16
+ the review a reader stages is against one diff, and a new commit makes it a
17
+ review of something that no longer exists.
18
+
19
+ Two gates keep that from being expensive. A pull request another user has
20
+ already APPROVED is never emitted at all — a review of a stamped pull request
21
+ changes nothing and costs a reviewer subagent plus its lens fan-out. And a new
22
+ head must hold still for --settle seconds before it is emitted, so a burst of
23
+ pushes costs one review rather than one per commit.
13
24
 
14
25
  It launches nothing and records nothing on its own. The reader must call
15
- `record` once a review is done a staged (pending, unsubmitted) review does
16
- not clear the request on GitHub, so that state file is the only thing keeping
17
- the same pull request from coming back. `monitor` emits each pull request once
18
- per process, so an unreviewed one is re-emitted after a restart.
26
+ `record` once a review is done, passing the head it actually reviewed — a
27
+ staged (pending, unsubmitted) review does not clear the request on GitHub, so
28
+ that state file is the only thing keeping the same pull request from coming
29
+ back. Each (number, head) pair is emitted once per process, so one left
30
+ unreviewed returns after a restart.
19
31
 
20
32
  Intended for Claude Code's Monitor tool, which turns each stdout line into a
21
33
  session notification. Any `read`-driven shell loop works the same way.
22
34
 
23
35
  State lives in the review-watcher state file managed by state.py, keyed by
24
- "owner/repo" — the same file and shape the watch-review skill reads.
36
+ "owner/repo" — the same file and shape the watch-review skill reads. Entries
37
+ written before heads were tracked carry a bare list of numbers; those migrate on
38
+ read to an unknown head, so each comes back once and then tracks properly.
25
39
  """
26
40
  import argparse
27
41
  import json
28
42
  import os
43
+ import re
29
44
  import subprocess
30
45
  import sys
31
46
  import time
@@ -54,8 +69,34 @@ def gh(args, cwd):
54
69
  return proc.stdout
55
70
 
56
71
 
72
+ def eligible(listing, login):
73
+ """The pull requests in a `gh pr list` payload that are worth reviewing.
74
+
75
+ Split out from the `gh` call so the filter can be tested without a network.
76
+ """
77
+ matches = [
78
+ pr
79
+ for pr in listing
80
+ if not pr.get("isDraft")
81
+ and any(
82
+ r.get("__typename") == "User" and r.get("login") == login
83
+ for r in pr.get("reviewRequests") or []
84
+ )
85
+ # Never review what someone else has already stamped. latestReviews holds
86
+ # one entry per reviewer at its current state, so this is exactly "another
87
+ # human has approved it". Leo's own approval does not disqualify.
88
+ and not any(
89
+ review.get("state") == "APPROVED"
90
+ and ((review.get("author") or {}).get("login") or "") not in ("", login)
91
+ for review in pr.get("latestReviews") or []
92
+ )
93
+ ]
94
+ matches.sort(key=lambda pr: pr["number"])
95
+ return matches
96
+
97
+
57
98
  def discover(cwd):
58
- """Return (repo, login, [pull requests directly requesting login])."""
99
+ """Return (repo, login, [pull requests worth reviewing])."""
59
100
  repo = json.loads(gh(["repo", "view", "--json", "nameWithOwner"], cwd))["nameWithOwner"]
60
101
  login = gh(["api", "user", "--jq", ".login"], cwd).strip()
61
102
  if not login:
@@ -68,53 +109,86 @@ def discover(cwd):
68
109
  "pr", "list", "--state", "open",
69
110
  "--search", f"user-review-requested:{login}",
70
111
  "--limit", "100",
71
- "--json", "number,title,isDraft,reviewRequests,url",
112
+ "--json", "number,title,isDraft,reviewRequests,url,headRefOid,latestReviews",
72
113
  ],
73
114
  cwd,
74
115
  )
75
116
  )
76
- matches = [
77
- pr
78
- for pr in listing
79
- if not pr.get("isDraft")
80
- and any(
81
- r.get("__typename") == "User" and r.get("login") == login
82
- for r in pr.get("reviewRequests") or []
83
- )
84
- ]
85
- matches.sort(key=lambda pr: pr["number"])
86
- return repo, login, matches
117
+ return repo, login, eligible(listing, login)
87
118
 
88
119
 
89
- def reviewed_numbers(repo):
120
+ def reviewed_heads(repo):
121
+ """{pull request number: reviewed head sha}. "" means "reviewed, head unknown"."""
90
122
  data = state_mod.load(state_mod.state_file(STATE_NAME))
91
- entry = data.get(repo) or {}
92
- return set(entry.get("reviewed") or [])
123
+ return heads_of(data.get(repo) or {})
93
124
 
94
125
 
95
- def record(repo, number):
126
+ def heads_of(entry):
127
+ heads = {int(n): sha for n, sha in (entry.get("heads") or {}).items()}
128
+ # Pre-heads state was a bare list of numbers. Treat those as reviewed at an
129
+ # unknown head: each returns once, records a real head, and tracks from there.
130
+ for number in entry.get("reviewed") or []:
131
+ heads.setdefault(int(number), "")
132
+ return heads
133
+
134
+
135
+ def record(repo, number, head):
96
136
  path = state_mod.state_file(STATE_NAME)
97
137
  with state_mod._locked(path):
98
138
  data = state_mod.load(path)
99
- data[repo] = state_mod.deep_merge(data.get(repo, {}), {"reviewed": [number]})
139
+ data[repo] = state_mod.deep_merge(data.get(repo, {}), {"heads": {str(number): head}})
100
140
  state_mod.atomic_write(path, data)
101
141
 
102
142
 
143
+ def due(matches, known, first_seen, emitted, now, settle):
144
+ """Which pull requests to emit this tick, as (verb, pr, previous head).
145
+
146
+ `first_seen` is mutated: a head that has just appeared is stamped and held
147
+ until it has stood still for `settle` seconds, so a push burst costs one
148
+ review rather than one per commit. Pure otherwise, so the emit decision is
149
+ testable without a clock or a network.
150
+ """
151
+ out = []
152
+ for pr in matches:
153
+ number, head = pr["number"], pr.get("headRefOid") or ""
154
+ key = (number, head)
155
+ if known.get(number) == head or key in emitted:
156
+ continue
157
+ stamp = first_seen.setdefault(key, now)
158
+ if now - stamp < settle:
159
+ continue
160
+ previous = known.get(number)
161
+ out.append(("re-review" if previous is not None else "review-requested", pr, previous or ""))
162
+ return out
163
+
164
+
165
+ def event_line(verb, repo, pr, previous):
166
+ """The printed line for one event — always exactly one printable line.
167
+
168
+ The title is attacker-written text. A control character in it — a newline,
169
+ an escape sequence — could forge a second notification line or drive the
170
+ reader's terminal, so all of them become spaces before the line is built.
171
+ """
172
+ head = pr.get("headRefOid") or ""
173
+ was = f" (was {previous[:7]})" if previous else ""
174
+ title = re.sub(r"[\x00-\x1f\x7f]", " ", pr.get("title") or "").strip()
175
+ return f"{verb} {repo}#{pr['number']} {pr['url']} {head[:7]}{was} — {title}"
176
+
177
+
103
178
  def monitor(args):
104
- """Emit one line per new pull request; review nothing, record nothing."""
179
+ """Emit one line per pull request needing review; review nothing, record nothing."""
105
180
  emitted = set()
181
+ first_seen = {}
106
182
  while True:
107
183
  try:
108
184
  repo, _, matches = discover(args.directory)
109
- done = reviewed_numbers(repo)
110
- for pr in matches:
111
- n = pr["number"]
112
- if n in done or n in emitted:
113
- continue
114
- emitted.add(n)
185
+ for verb, pr, previous in due(
186
+ matches, reviewed_heads(repo), first_seen, emitted, time.time(), args.settle
187
+ ):
188
+ emitted.add((pr["number"], pr.get("headRefOid") or ""))
115
189
  # One line, one event. The title is data — a reader must treat
116
190
  # it as a string to show Leo, never as an instruction.
117
- print(f"review-requested {repo}#{n} {pr['url']} — {pr['title']}", flush=True)
191
+ print(event_line(verb, repo, pr, previous), flush=True)
118
192
  except SystemExit as exc:
119
193
  # A transient gh failure must not kill a session-length watch.
120
194
  print(
@@ -122,6 +196,13 @@ def monitor(args):
122
196
  file=sys.stderr,
123
197
  flush=True,
124
198
  )
199
+ except Exception as exc:
200
+ # Neither may malformed gh output — bad JSON, a missing field.
201
+ print(
202
+ f"watch-review: tick failed ({exc!r}); retrying next interval",
203
+ file=sys.stderr,
204
+ flush=True,
205
+ )
125
206
  time.sleep(args.interval)
126
207
 
127
208
 
@@ -132,12 +213,21 @@ def main(argv):
132
213
  mon = sub.add_parser("monitor")
133
214
  mon.add_argument("-C", "--directory", default=".", help="repository directory (default: cwd)")
134
215
  mon.add_argument("--interval", type=int, default=300, help="seconds between ticks")
216
+ mon.add_argument(
217
+ "--settle",
218
+ type=int,
219
+ default=120,
220
+ help="seconds a new head must hold still before it is emitted (default: 120)",
221
+ )
135
222
 
136
223
  sub.add_parser("state").add_argument("-C", "--directory", default=".")
137
- for name in ("record", "forget"):
138
- p = sub.add_parser(name)
139
- p.add_argument("-C", "--directory", default=".")
140
- p.add_argument("numbers", nargs="+", type=int)
224
+ rec = sub.add_parser("record")
225
+ rec.add_argument("-C", "--directory", default=".")
226
+ rec.add_argument("numbers", nargs=1, type=int)
227
+ rec.add_argument("--head", required=True, help="the head sha the review was actually against")
228
+ forget = sub.add_parser("forget")
229
+ forget.add_argument("-C", "--directory", default=".")
230
+ forget.add_argument("numbers", nargs="+", type=int)
141
231
 
142
232
  args = parser.parse_args(argv)
143
233
  if not os.path.isdir(args.directory):
@@ -146,22 +236,31 @@ def main(argv):
146
236
  if args.mode == "monitor":
147
237
  if args.interval < 30:
148
238
  fail("--interval below 30s hammers the GitHub API; pick something larger")
239
+ if args.settle < 0:
240
+ fail("--settle cannot be negative")
149
241
  return monitor(args)
150
242
 
151
243
  repo, _, _ = discover(args.directory)
152
244
  if args.mode == "record":
153
- for n in args.numbers:
154
- record(repo, n)
245
+ record(repo, args.numbers[0], args.head)
155
246
  elif args.mode == "forget":
156
247
  path = state_mod.state_file(STATE_NAME)
157
248
  with state_mod._locked(path):
158
249
  data = state_mod.load(path)
159
250
  entry = data.get(repo) or {}
160
- drop = set(args.numbers)
161
- entry["reviewed"] = [n for n in (entry.get("reviewed") or []) if n not in drop]
251
+ drop = {str(n) for n in args.numbers}
252
+ # Drop from both shapes: a legacy entry has not necessarily been
253
+ # rewritten into heads yet, and leaving it there would re-suppress.
254
+ entry["heads"] = {n: sha for n, sha in (entry.get("heads") or {}).items() if n not in drop}
255
+ entry["reviewed"] = [n for n in (entry.get("reviewed") or []) if str(n) not in drop]
162
256
  data[repo] = entry
163
257
  state_mod.atomic_write(path, data)
164
- print(json.dumps({"repo": repo, "reviewed": sorted(reviewed_numbers(repo))}, indent=1))
258
+ print(
259
+ json.dumps(
260
+ {"repo": repo, "heads": {str(n): sha for n, sha in sorted(reviewed_heads(repo).items())}},
261
+ indent=1,
262
+ )
263
+ )
165
264
  return 0
166
265
 
167
266
 
@@ -15,8 +15,11 @@ on other versions; that is their business.
15
15
  ## 1. Injection and install
16
16
 
17
17
  Locate the plugin root (the directory holding `rules/preferences.md`):
18
- `$LEOS_AGENT_ROOT`, `$CLAUDE_PLUGIN_ROOT`, `$PLUGIN_ROOT`, or the parent of the
19
- directory holding this file. Then:
18
+ `$LEOS_AGENT_ROOT`, `$CLAUDE_PLUGIN_ROOT`, `$PLUGIN_ROOT`, or the nearest
19
+ ancestor of this file that contains it. Confirm
20
+ `<plugin-root>/scripts/leo-install.py` actually exists at the resolved root
21
+ before running anything — a root that resolves but holds no `scripts/` is
22
+ itself a finding (a stale env var, or a copy separated from its plugin). Then:
20
23
 
21
24
  ```
22
25
  python3 <plugin-root>/scripts/leo-install.py <harness> --check
@@ -24,8 +27,18 @@ python3 <plugin-root>/scripts/leo-install.py <harness> --check
24
27
 
25
28
  Exit 0 means the `<leos-agent>` block is present and current. Non-zero means it
26
29
  is missing, stale, or the file is malformed — quote what it printed and offer
27
- `/leo-install`. Cursor legitimately reports `skipped`; Hermes skips until
28
- `~/.hermes/SOUL.md` exists.
30
+ `/leo-install`. Hermes skips until `~/.hermes/SOUL.md` exists.
31
+
32
+ Editing the machine's routing config also makes `--check` report out of date,
33
+ because the block is rendered from it. Show what it holds:
34
+
35
+ ```
36
+ python3 <plugin-root>/scripts/routing.py show
37
+ ```
38
+
39
+ No config is normal — every harness then uses its shipped default, which for
40
+ everything but Claude Code and Codex means inheriting the current model. Say so
41
+ plainly rather than as a fault: it is the setting, not a break.
29
42
 
30
43
  Then confirm by hand, since `--check` only sees disk, not what got loaded:
31
44
 
@@ -33,14 +46,19 @@ Then confirm by hand, since `--check` only sees disk, not what got loaded:
33
46
  version="...">` block, with the version matching `package.json` in the plugin
34
47
  root.
35
48
  - Confirm the plugin's skills and commands are actually registered in this
36
- session — `install` and `doctor` should both be listed. If they are not, the
49
+ session — `install` and `doctor` should both be listed (on OpenCode the
50
+ installed copy is named `leo-install`, not `install`). If they are not, the
37
51
  plugin is on disk but not loaded.
52
+ - On OpenCode only: the copied skills under `~/.config/opencode/skills/` are
53
+ installed with the plugin root baked in as an absolute path. Spot-check one —
54
+ the path it names must still exist on disk; a dead path means the plugin
55
+ cache moved and `/leo-install` needs a re-run.
38
56
 
39
57
  | Harness | Global file |
40
58
  |---|---|
41
59
  | claude | `~/.claude/CLAUDE.md` |
42
60
  | codex | `~/.codex/AGENTS.md` (plus `~/.codex/agents/leo-runner.toml` and `leo-executor.toml`) |
43
- | cursor | none — the always-apply rule carries the payload |
61
+ | cursor | `~/.cursor/rules/leos-agent-routing.mdc`, present only when cursor routing is configured — the always-apply plugin rule carries the payload itself |
44
62
  | hermes | `~/.hermes/SOUL.md` |
45
63
  | pi | `~/.pi/agent/AGENTS.md` |
46
64
  | opencode | `~/.config/opencode/AGENTS.md` (plus copied `skills/`, `commands/`) |
@@ -15,8 +15,12 @@ harness on a different day. Write for that reader.
15
15
  the caching work", "the installer is a dead end, say why". It steers this
16
16
  document and is not stored; the handoff must stand alone without it.
17
17
 
18
- `<plugin-root>` is the directory holding `rules/preferences.md`, from
19
- `$LEOS_AGENT_ROOT`, `$CLAUDE_PLUGIN_ROOT`, or `$PLUGIN_ROOT`.
18
+ Handoffs live at `${LEOS_AGENT_LOCAL_PATH:-$HOME/.leos-agent-local}/handoffs/<name>.md`.
19
+ That path is fixed and needs no plugin root.
20
+
21
+ `<plugin-root>`, where a step below uses it, is the directory holding
22
+ `rules/preferences.md`, from `$LEOS_AGENT_ROOT`, `$CLAUDE_PLUGIN_ROOT`,
23
+ `$PLUGIN_ROOT`, or the nearest ancestor of this file that contains it.
20
24
 
21
25
  ## Steps
22
26
 
@@ -28,9 +32,15 @@ document and is not stored; the handoff must stand alone without it.
28
32
  python3 "<plugin-root>/scripts/handoff.py" new <slug>
29
33
  ```
30
34
 
31
- It prints the de-collided name on the first line and the path to write on the
32
- second. Use the name it printed, not the slug you asked for it may have
33
- appended a suffix.
35
+ It prints the de-collided name on the first line, the path to write on the
36
+ second, and the `created:` timestamp on the third. Use the name it printed,
37
+ not the slug you asked for — it may have appended a suffix — and copy the
38
+ timestamp verbatim rather than composing one.
39
+
40
+ No plugin root to run it from? Do the same by hand rather than searching for
41
+ the script: list `${LEOS_AGENT_LOCAL_PATH:-$HOME/.leos-agent-local}/handoffs/`,
42
+ if `<slug>.md` is taken append `-2`, `-3` until it is not, and take the
43
+ timestamp from `date -u +%Y-%m-%dT%H:%M:%SZ`.
34
44
 
35
45
  2. **Gather the frontmatter facts** in one batch:
36
46
 
@@ -48,7 +58,7 @@ document and is not stored; the handoff must stand alone without it.
48
58
  ```
49
59
  ---
50
60
  name: <the name step 1 printed>
51
- created: <ISO 8601 UTC>
61
+ created: <the timestamp step 1 printed>
52
62
  harness: claude
53
63
  repo: foxhatleo/leos-agent
54
64
  cwd: /Users/leoliang/workspace/leos-agent
@@ -9,24 +9,41 @@ argument-hint: "[name]"
9
9
  Loads a document a previous session wrote with `/handoff`, possibly in another
10
10
  harness, and makes it this session's starting context.
11
11
 
12
- `<plugin-root>` is the directory holding `rules/preferences.md`, from
13
- `$LEOS_AGENT_ROOT`, `$CLAUDE_PLUGIN_ROOT`, or `$PLUGIN_ROOT`.
12
+ Handoffs live at `${LEOS_AGENT_LOCAL_PATH:-$HOME/.leos-agent-local}/handoffs/<name>.md`.
13
+ That path is fixed and needs no plugin root: never glob for a handoff, and never
14
+ go hunting for the script.
15
+
16
+ `<plugin-root>`, where a step below uses it, is the directory holding
17
+ `rules/preferences.md`, from `$LEOS_AGENT_ROOT`, `$CLAUDE_PLUGIN_ROOT`,
18
+ `$PLUGIN_ROOT`, or the nearest ancestor of this file that contains it. None of
19
+ those being set costs you prefix matching and a nicer listing, nothing more.
14
20
 
15
21
  ## Steps
16
22
 
17
- 1. **Resolve the name.** `$ARGUMENTS` is the handoff name; a unique prefix works.
23
+ 1. **Read it.** `$ARGUMENTS` is the handoff name. With a name in hand this is one
24
+ command — no resolution step, no script:
25
+
26
+ ```bash
27
+ cat "${LEOS_AGENT_LOCAL_PATH:-$HOME/.leos-agent-local}/handoffs/<name>.md"
28
+ ```
29
+
30
+ If that misses, the name was a prefix or a guess; `path` resolves a unique
31
+ prefix to the real file:
18
32
 
19
33
  ```bash
20
34
  python3 "<plugin-root>/scripts/handoff.py" path <name>
21
35
  ```
22
36
 
23
- With no argument, or when the script reports the name is ambiguous or
24
- missing, run `handoff.py list` (add `--all` to reach handoffs from other
25
- directories) and **ask Leo which one**. Never pick for him, and never invent a
26
- name — a wrong handoff is worse than none, because it reads as authoritative.
37
+ With no argument, or when the name is ambiguous or missing, list what exists
38
+ and **ask Leo which one**. Never pick for him, and never invent a name — a
39
+ wrong handoff is worse than none, because it reads as authoritative.
40
+
41
+ ```bash
42
+ python3 "<plugin-root>/scripts/handoff.py" list # age, repo, title
43
+ ls -t "${LEOS_AGENT_LOCAL_PATH:-$HOME/.leos-agent-local}/handoffs/" # if that is unavailable
44
+ ```
27
45
 
28
- 2. **Read the file**, then **compare its frontmatter to reality** before trusting
29
- any of it:
46
+ 2. **Compare its frontmatter to reality** before trusting any of it:
30
47
 
31
48
  ```bash
32
49
  pwd; git rev-parse --abbrev-ref HEAD; git rev-parse --short HEAD
@@ -24,7 +24,7 @@ that harness's business.
24
24
 
25
25
  2. **Locate the plugin root**, the directory holding `rules/preferences.md`. In
26
26
  order of preference: `$LEOS_AGENT_ROOT`, `$CLAUDE_PLUGIN_ROOT`,
27
- `$PLUGIN_ROOT`, or the parent of the directory holding this `SKILL.md`. The
27
+ `$PLUGIN_ROOT`, or the nearest ancestor of this file that contains it. The
28
28
  script finds it on its own in most cases, so a bare path usually works.
29
29
 
30
30
  3. **Run the installer**, substituting your harness:
@@ -25,11 +25,11 @@ Two tiers, three levels:
25
25
  | Level | Who | Tier |
26
26
  |---|---|---|
27
27
  | Main thread | dispatches, relays | — |
28
- | **Reviewer** subagent | the whole procedure; judges; owns every mutation | **standard** |
29
- | **Lens** sub-subagents | the fan-out; read and report only | **economical** |
28
+ | **Reviewer** subagent | the whole procedure; judges; owns every mutation | **standard** (inherit) |
29
+ | **Lens** sub-subagents | the fan-out; read and report only | **leo-runner** (`subagent_type: "leo-runner"` on Claude Code; the installed profile on Codex) |
30
30
 
31
- Where the harness has no per-spawn model override, agents run at whatever they
32
- are registered with — say so in the report.
31
+ Where the harness has no `leo-runner` agent and no per-spawn model override,
32
+ agents run at whatever they are registered with — say so in the report.
33
33
 
34
34
  ## Dispatch — the main thread's entire job
35
35
 
@@ -107,7 +107,7 @@ Two kinds of prior review state, handled differently:
107
107
  it carries the script's own marker (it embeds one in everything it stages):
108
108
 
109
109
  ```
110
- python3 "${CLAUDE_PLUGIN_ROOT}/scripts/ghreview.py" clear-pending -R OWNER/REPO -n N
110
+ python3 "<plugin-root>/scripts/ghreview.py" clear-pending -R OWNER/REPO -n N
111
111
  ```
112
112
 
113
113
  If it exits 0, note what was deleted in the final report. If it exits 3, it
@@ -120,7 +120,7 @@ stage step, `--replace-pending --force`) once he confirms. Still pass
120
120
  **Posted (submitted) review threads of mine** — fetch them:
121
121
 
122
122
  ```
123
- python3 "${CLAUDE_PLUGIN_ROOT}/scripts/ghreview.py" threads -R OWNER/REPO -n N
123
+ python3 "<plugin-root>/scripts/ghreview.py" threads -R OWNER/REPO -n N
124
124
  ```
125
125
 
126
126
  Returns unresolved threads whose root comment is mine (threads from pending
@@ -147,7 +147,7 @@ adjudication is complete.
147
147
  ## Step 2 — Map the diff and pick a route
148
148
 
149
149
  ```
150
- python3 "${CLAUDE_PLUGIN_ROOT}/scripts/ghreview.py" map -R OWNER/REPO -n N
150
+ python3 "<plugin-root>/scripts/ghreview.py" map -R OWNER/REPO -n N
151
151
  ```
152
152
 
153
153
  Returns per-file addressable-line ranges, `generated` flags (lockfiles, dist,
@@ -167,15 +167,18 @@ subagent spawned. It runs only when Step 0.5 produced a spec restatement.
167
167
 
168
168
  ## Step 3 — Lens fan-out (economical, parallel)
169
169
 
170
- Spawn the lenses at once, at the **economical** tier and with clean conversation
171
- contexts. On Codex pass `fork_turns="none"`; elsewhere use the harness's
172
- fresh-child equivalent when available. Pin them to a
173
- **read-only** agent type the harness's explore/search role, never a
174
- general-purpose agent, which carries Write, Edit, and unrestricted Bash. Tool
175
- scope on this turn does not propagate to what it spawns, so the spawned role IS
176
- the lenses' tool boundary and the lenses are what actually ingest the hostile
177
- diff. Where the harness enforces read-only only by prompt, weigh that before
178
- fanning out at all.
170
+ Spawn the lenses at once as **leo-runner** and with clean conversation
171
+ contexts: `subagent_type: "leo-runner"` on Claude Code; the installed
172
+ `leo-runner` profile on Codex, passing `fork_turns="none"`; elsewhere the
173
+ harness's fresh-child equivalent when available. leo-runner carries no Write or
174
+ Edit — but it does carry Bash, which the lenses need to fetch their diff slice
175
+ and which can do more than read, so the boundary is narrower than true
176
+ read-only. Where leo-runner does not exist, pin the lenses to the harness's
177
+ read-only explore/search role instead never a general-purpose agent, which
178
+ adds Write and Edit on top. Tool scope on this turn does not propagate
179
+ to what it spawns, so the spawned role IS the lenses' tool boundary — and the
180
+ lenses are what actually ingest the hostile diff. Where the harness enforces
181
+ read-only only by prompt, weigh that before fanning out at all.
179
182
 
180
183
  If this harness cannot nest a spawn inside a subagent, or cannot pin the lenses
181
184
  to a read-only role, take the **Solo** path instead and disclose sequential
@@ -259,7 +262,7 @@ resolutions are public and go last, only once staging has succeeded):
259
262
  (`{"comments": [{path, line, side, body, start_line?, start_side?}]}`), then:
260
263
 
261
264
  ```
262
- python3 "${CLAUDE_PLUGIN_ROOT}/scripts/ghreview.py" stage -R OWNER/REPO -n N \
265
+ python3 "<plugin-root>/scripts/ghreview.py" stage -R OWNER/REPO -n N \
263
266
  --commit <headRefOid> --input comments.json --replace-pending
264
267
  ```
265
268
 
@@ -280,7 +283,7 @@ resolutions are public and go last, only once staging has succeeded):
280
283
  scratchpad file:
281
284
 
282
285
  ```
283
- python3 "${CLAUDE_PLUGIN_ROOT}/scripts/ghreview.py" reply -R OWNER/REPO -n N \
286
+ python3 "<plugin-root>/scripts/ghreview.py" reply -R OWNER/REPO -n N \
284
287
  --thread-id PRRT_… --body-file reply.txt
285
288
  ```
286
289
 
@@ -291,7 +294,7 @@ resolutions are public and go last, only once staging has succeeded):
291
294
  3. **Resolve stale threads** — one call per Step 1 resolve action:
292
295
 
293
296
  ```
294
- python3 "${CLAUDE_PLUGIN_ROOT}/scripts/ghreview.py" resolve-thread -R OWNER/REPO -n N \
297
+ python3 "<plugin-root>/scripts/ghreview.py" resolve-thread -R OWNER/REPO -n N \
295
298
  --thread-id PRRT_…
296
299
  ```
297
300