leos-agent 6.1.0 → 6.3.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.
Files changed (35) hide show
  1. package/README.md +43 -0
  2. package/adapters/cursor/agents/executor.md +1 -1
  3. package/adapters/cursor/agents/implementer.md +1 -1
  4. package/adapters/cursor/agents/reviewer.md +1 -0
  5. package/adapters/opencode/agents.json +3 -3
  6. package/adapters/opencode/plugin.js +131 -29
  7. package/config/models.json +379 -33
  8. package/hooks/session-start.py +27 -0
  9. package/package.json +18 -4
  10. package/roles/executor.md +1 -1
  11. package/roles/implementer.md +1 -1
  12. package/roles/reviewer.md +1 -0
  13. package/scripts/doctor.py +284 -0
  14. package/scripts/ghreview.py +554 -0
  15. package/scripts/memory.py +705 -0
  16. package/scripts/render_adapters.py +244 -97
  17. package/scripts/resolve_attach_target.py +357 -0
  18. package/scripts/setup.py +161 -0
  19. package/skills/delegation/SKILL.md +1 -1
  20. package/skills/doctor/SKILL.md +105 -0
  21. package/skills/freshness/SKILL.md +118 -0
  22. package/skills/memory/SKILL.md +144 -0
  23. package/skills/resolve-ticket/SKILL.md +269 -0
  24. package/skills/review-pr/SKILL.md +317 -0
  25. package/skills/setup/SKILL.md +85 -0
  26. package/skills/using-leo/SKILL.md +8 -1
  27. package/skills/using-leo/references/claude-mapping.md +22 -1
  28. package/skills/using-leo/references/codex-mapping.md +17 -7
  29. package/skills/using-leo/references/cursor-mapping.md +18 -6
  30. package/skills/using-leo/references/hermes-mapping.md +17 -7
  31. package/skills/using-leo/references/opencode-mapping.md +16 -8
  32. package/skills/verification/SKILL.md +7 -0
  33. package/skills/visual-verification/SKILL.md +114 -0
  34. package/skills/watch-review/SKILL.md +125 -0
  35. package/skills/writing-skills/SKILL.md +134 -0
@@ -0,0 +1,357 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve an identifier (PR number/URL, branch name, or ticket id) to everything
3
+ `/attach-pr` needs: the branch, its PR, and the working directory to attach from.
4
+
5
+ Prints a single JSON object to stdout:
6
+
7
+ {"status": "ok", "branch": ..., "pr_number": ..., "pr_url": ..., "base_ref": ...,
8
+ "pr_state": ..., "pr_title": ..., "workdir": ... | null, "workdir_kind": ...,
9
+ "attach_command": ...}
10
+ {"status": "ambiguous", "message": ..., "candidates": [ {...}, ... ]}
11
+ {"status": "error", "message": ...}
12
+
13
+ Exit code is 0 for "ok" and 1 otherwise, so callers can branch on it directly.
14
+ """
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+
23
+ PR_NUM_RE = re.compile(r"^#?(\d+)$")
24
+ PR_URL_RE = re.compile(r"^https?://[^/]*github\.com/([^/]+)/([^/]+)/pull/(\d+)")
25
+ TICKET_RE = re.compile(r"^([A-Za-z][A-Za-z0-9]*)-(\d+)$")
26
+
27
+ PR_FIELDS = "number,url,headRefName,baseRefName,state,title"
28
+
29
+
30
+ def run(cmd, cwd=None):
31
+ """Run a command, returning (returncode, stdout, stderr) with output stripped."""
32
+ try:
33
+ p = subprocess.run(
34
+ cmd, cwd=cwd, capture_output=True, text=True, timeout=60, check=False
35
+ )
36
+ except (OSError, subprocess.TimeoutExpired) as exc:
37
+ return 1, "", str(exc)
38
+ return p.returncode, p.stdout.strip(), p.stderr.strip()
39
+
40
+
41
+ def die(message):
42
+ print(json.dumps({"status": "error", "message": message}, indent=2))
43
+ sys.exit(1)
44
+
45
+
46
+ def emit(payload):
47
+ print(json.dumps(payload, indent=2))
48
+ sys.exit(0 if payload.get("status") == "ok" else 1)
49
+
50
+
51
+ # --- environment checks ----------------------------------------------------
52
+
53
+
54
+ def repo_root():
55
+ rc, out, _ = run(["git", "rev-parse", "--show-toplevel"])
56
+ if rc != 0:
57
+ die("not inside a git repository — cd into the repo before running /attach-pr")
58
+ return out
59
+
60
+
61
+ def require_gh():
62
+ if not shutil.which("gh"):
63
+ die("`gh` is not installed or not on PATH; /attach-pr needs the GitHub CLI")
64
+ rc, _, err = run(["gh", "auth", "status"])
65
+ if rc != 0:
66
+ die(f"`gh` is not authenticated: {err or 'run `gh auth login`'}")
67
+
68
+
69
+ def name_with_owner():
70
+ rc, out, err = run(["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"])
71
+ if rc != 0:
72
+ die(f"could not determine the GitHub repo for this directory: {err}")
73
+ return out
74
+
75
+
76
+ # --- git helpers -----------------------------------------------------------
77
+
78
+
79
+ def worktrees():
80
+ """Map branch name -> worktree path, from `git worktree list --porcelain`."""
81
+ rc, out, _ = run(["git", "worktree", "list", "--porcelain"])
82
+ if rc != 0:
83
+ return {}
84
+ result, path = {}, None
85
+ for line in out.splitlines():
86
+ if line.startswith("worktree "):
87
+ path = line[len("worktree ") :]
88
+ elif line.startswith("branch ") and path:
89
+ branch = line[len("branch ") :]
90
+ if branch.startswith("refs/heads/"):
91
+ result[branch[len("refs/heads/") :]] = path
92
+ return result
93
+
94
+
95
+ def branch_exists_local(branch):
96
+ rc, _, _ = run(["git", "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"])
97
+ return rc == 0
98
+
99
+
100
+ def branch_exists_remote(branch):
101
+ rc, out, _ = run(["git", "ls-remote", "--heads", "origin", branch])
102
+ return rc == 0 and bool(out)
103
+
104
+
105
+ def all_known_branches():
106
+ rc, out, _ = run(
107
+ [
108
+ "git",
109
+ "for-each-ref",
110
+ "--format=%(refname:short)",
111
+ "refs/heads",
112
+ "refs/remotes/origin",
113
+ ]
114
+ )
115
+ if rc != 0:
116
+ return []
117
+ names = []
118
+ for ref in out.splitlines():
119
+ name = ref[len("origin/") :] if ref.startswith("origin/") else ref
120
+ if name and name != "HEAD" and name not in names:
121
+ names.append(name)
122
+ return names
123
+
124
+
125
+ # --- PR lookups ------------------------------------------------------------
126
+
127
+
128
+ def pr_by_number(number):
129
+ rc, out, err = run(["gh", "pr", "view", str(number), "--json", PR_FIELDS])
130
+ if rc != 0:
131
+ if "Could not resolve to a PullRequest" in err:
132
+ return None, f"there is no pull request #{number} in this repo"
133
+ return None, err or f"no pull request #{number} in this repo"
134
+ try:
135
+ return json.loads(out), None
136
+ except json.JSONDecodeError as exc:
137
+ return None, f"could not parse `gh pr view` output: {exc}"
138
+
139
+
140
+ def prs_for_branch(branch):
141
+ rc, out, err = run(
142
+ [
143
+ "gh", "pr", "list", "--head", branch, "--state", "all",
144
+ "--json", PR_FIELDS, "--limit", "20",
145
+ ]
146
+ )
147
+ if rc != 0:
148
+ return [], err or f"`gh pr list` failed for branch {branch}"
149
+ try:
150
+ return json.loads(out), None
151
+ except json.JSONDecodeError as exc:
152
+ return [], f"could not parse `gh pr list` output: {exc}"
153
+
154
+
155
+ def prs_by_search(term):
156
+ rc, out, _ = run(
157
+ [
158
+ "gh", "pr", "list", "--search", term, "--state", "all",
159
+ "--json", PR_FIELDS, "--limit", "20",
160
+ ]
161
+ )
162
+ if rc != 0:
163
+ return []
164
+ try:
165
+ return json.loads(out)
166
+ except json.JSONDecodeError:
167
+ return []
168
+
169
+
170
+ def pick_one(prs):
171
+ """Prefer the single OPEN PR; otherwise the highest-numbered one."""
172
+ open_prs = [p for p in prs if p.get("state") == "OPEN"]
173
+ if len(open_prs) == 1:
174
+ return open_prs[0]
175
+ pool = open_prs or prs
176
+ return max(pool, key=lambda p: p.get("number", 0)) if pool else None
177
+
178
+
179
+ def as_candidate(pr):
180
+ return {
181
+ "pr_number": pr.get("number"),
182
+ "url": pr.get("url"),
183
+ "branch": pr.get("headRefName"),
184
+ "base_ref": pr.get("baseRefName"),
185
+ "state": pr.get("state"),
186
+ "title": pr.get("title"),
187
+ }
188
+
189
+
190
+ # --- resolution ------------------------------------------------------------
191
+
192
+
193
+ def resolve(identifier, repo):
194
+ """Return (pr_dict, note) or emit an error/ambiguous payload and exit."""
195
+ ident = identifier.strip()
196
+
197
+ url_match = PR_URL_RE.match(ident)
198
+ if url_match:
199
+ owner, name, number = url_match.groups()
200
+ if f"{owner}/{name}".lower() != repo.lower():
201
+ die(
202
+ f"that PR URL belongs to {owner}/{name}, but this directory is {repo}. "
203
+ "cd into the right repo, or pass an identifier from this one."
204
+ )
205
+ pr, err = pr_by_number(number)
206
+ if not pr:
207
+ die(err)
208
+ return pr, f"resolved from PR URL #{number}"
209
+
210
+ num_match = PR_NUM_RE.match(ident)
211
+ if num_match:
212
+ number = num_match.group(1)
213
+ pr, err = pr_by_number(number)
214
+ if not pr:
215
+ die(err)
216
+ return pr, f"resolved from PR number #{number}"
217
+
218
+ # A ticket-shaped identifier may also be a literal branch name — colony's bare-ticket
219
+ # branches (`DOCS-5943`) and kebab variants (`docs-6171`) both look like ticket ids.
220
+ # An exact branch match is the more specific reading, so it wins; ticket search is the
221
+ # fallback for ids that name no branch directly.
222
+ if TICKET_RE.match(ident):
223
+ if branch_exists_local(ident) or branch_exists_remote(ident):
224
+ return resolve_branch(ident), f"resolved from branch {ident} (ticket-shaped name)"
225
+ return resolve_ticket(ident), f"resolved from ticket {ident.upper()}"
226
+
227
+ return resolve_branch(ident), f"resolved from branch {ident}"
228
+
229
+
230
+ def resolve_branch(branch):
231
+ local = branch_exists_local(branch)
232
+ remote = branch_exists_remote(branch)
233
+ if not local and not remote:
234
+ die(
235
+ f"branch `{branch}` does not exist locally or on origin. "
236
+ "Check the name (`git branch -a`), or pass a PR number or ticket id instead."
237
+ )
238
+
239
+ prs, err = prs_for_branch(branch)
240
+ if err:
241
+ die(err)
242
+ if not prs:
243
+ where = "locally and on origin" if local and remote else ("locally" if local else "on origin")
244
+ die(
245
+ f"branch `{branch}` exists {where} but has no pull request "
246
+ "(any state). Open a PR for it first — /attach-pr only attaches to an existing PR."
247
+ )
248
+ if len(prs) > 1:
249
+ chosen = pick_one(prs)
250
+ if not (chosen and chosen.get("state") == "OPEN" and
251
+ sum(1 for p in prs if p.get("state") == "OPEN") == 1):
252
+ emit({
253
+ "status": "ambiguous",
254
+ "message": f"branch `{branch}` has {len(prs)} pull requests; ask which one to attach.",
255
+ "candidates": [as_candidate(p) for p in prs],
256
+ })
257
+ return chosen
258
+ return prs[0]
259
+
260
+
261
+ def resolve_ticket(ticket):
262
+ """Ticket-tracker-agnostic: match the id against branch names and PR text."""
263
+ key = ticket.upper()
264
+ found = {}
265
+
266
+ for branch in all_known_branches():
267
+ if key in branch.upper():
268
+ prs, _ = prs_for_branch(branch)
269
+ for pr in prs:
270
+ found[pr["number"]] = pr
271
+
272
+ for pr in prs_by_search(key):
273
+ haystack = f"{pr.get('title', '')} {pr.get('headRefName', '')}".upper()
274
+ if key in haystack:
275
+ found.setdefault(pr["number"], pr)
276
+
277
+ prs = list(found.values())
278
+ if not prs:
279
+ die(
280
+ f"found no branch or pull request referencing `{key}` in this repo. "
281
+ "If the ticket exists but no PR does yet, there is nothing to attach; "
282
+ "otherwise pass the branch name or PR number directly."
283
+ )
284
+ if len(prs) > 1:
285
+ open_prs = [p for p in prs if p.get("state") == "OPEN"]
286
+ if len(open_prs) != 1:
287
+ emit({
288
+ "status": "ambiguous",
289
+ "message": f"`{key}` matches {len(prs)} pull requests; ask which one to attach.",
290
+ "candidates": [as_candidate(p) for p in prs],
291
+ })
292
+ return open_prs[0]
293
+ return prs[0]
294
+
295
+
296
+ # --- working directory -----------------------------------------------------
297
+
298
+
299
+ def resolve_workdir(branch, root):
300
+ """Where to attach from: an existing worktree, the base checkout, or nowhere."""
301
+ wts = worktrees()
302
+ if branch in wts:
303
+ path = wts[branch]
304
+ kind = "worktree" if os.path.realpath(path) != os.path.realpath(root) else "checkout"
305
+ return path, kind
306
+
307
+ rc, current, _ = run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=root)
308
+ if rc == 0 and current == branch:
309
+ return root, "checkout"
310
+
311
+ return None, "not_checked_out"
312
+
313
+
314
+ def main():
315
+ if len(sys.argv) != 2 or not sys.argv[1].strip():
316
+ die("usage: resolve_attach_target.py <pr-number|pr-url|branch|TICKET-123>")
317
+
318
+ root = repo_root()
319
+ require_gh()
320
+ repo = name_with_owner()
321
+
322
+ pr, note = resolve(sys.argv[1], repo)
323
+ branch = pr.get("headRefName")
324
+ if not branch:
325
+ die(f"PR #{pr.get('number')} has no head branch recorded; cannot attach")
326
+
327
+ workdir, kind = resolve_workdir(branch, root)
328
+
329
+ payload = {
330
+ "status": "ok",
331
+ "note": note,
332
+ "repo": repo,
333
+ "branch": branch,
334
+ "pr_number": pr.get("number"),
335
+ "pr_url": pr.get("url"),
336
+ "base_ref": pr.get("baseRefName") or "main",
337
+ "pr_state": pr.get("state"),
338
+ "pr_title": pr.get("title"),
339
+ "workdir": workdir,
340
+ "workdir_kind": kind,
341
+ "repo_root": root,
342
+ "suggested_worktree": os.path.join(
343
+ root, ".claude", "worktrees", branch.replace("/", "-")
344
+ ),
345
+ }
346
+ if workdir:
347
+ payload["attach_command"] = (
348
+ 'gh() { echo "$PR_URL"; }; '
349
+ f"cd {workdir}; "
350
+ f"PR_URL={pr.get('url')} gh pr create --draft "
351
+ f"--base {payload['base_ref']} --head {branch}"
352
+ )
353
+ emit(payload)
354
+
355
+
356
+ if __name__ == "__main__":
357
+ main()
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env python3
2
+ """setup: turn on the wiring a plugin install cannot turn on for itself.
3
+
4
+ Everything here is opt-in and idempotent. Nothing in this file runs at
5
+ install or session start — the harnesses' own plugin systems have no
6
+ install-time hook to hang it on (Hermes' register() only runs at session
7
+ start), so consent is asked for once, explicitly, and recorded in
8
+ machine-local state.
9
+
10
+ The one thing it enables today is Hermes memory projection. The other four
11
+ harnesses project into a file whose whole purpose is user instructions;
12
+ Hermes' only user-owned global file is SOUL.md, its agent-identity prompt and
13
+ the opening section of every system prompt on the machine. That is a blast
14
+ radius worth a deliberate yes.
15
+
16
+ setup.py report what is on and what is available
17
+ setup.py --json the same facts as JSON
18
+ setup.py enable <feature> turn one on
19
+ setup.py disable <feature> turn it off again
20
+
21
+ Features: hermes-memory
22
+
23
+ Exit code is 0 on success, 1 on an unknown feature or a failed write.
24
+ """
25
+ import json
26
+ import os
27
+ import sys
28
+
29
+ _HERE = os.path.dirname(os.path.abspath(__file__))
30
+ sys.path.insert(0, _HERE)
31
+
32
+ import memory # noqa: E402 (path fix must precede the import)
33
+ import state # noqa: E402
34
+
35
+ FEATURES = {
36
+ "hermes-memory": {
37
+ "state": ("hermes", "projectMemory"),
38
+ "summary": "project global memory facts into $HERMES_HOME/SOUL.md",
39
+ },
40
+ }
41
+
42
+
43
+ def _read():
44
+ return state.load(state.state_file(memory.SETUP_STATE))
45
+
46
+
47
+ def _write(feature, value):
48
+ section, key = FEATURES[feature]["state"]
49
+ path = state.state_file(memory.SETUP_STATE)
50
+ # Same lock the state CLI takes, so a concurrent merge cannot lose this.
51
+ with state._locked(path):
52
+ data = state.deep_merge(state.load(path), {section: {key: value}})
53
+ state.atomic_write(path, data)
54
+
55
+
56
+ def _enabled(data, feature):
57
+ section, key = FEATURES[feature]["state"]
58
+ return bool((data.get(section) or {}).get(key))
59
+
60
+
61
+ def _hermes_facts():
62
+ """What projection would actually do right now, without doing it."""
63
+ home = memory.hermes_home()
64
+ soul = os.path.join(home, "SOUL.md")
65
+ return {
66
+ "home": home,
67
+ "soul": soul,
68
+ "home_exists": os.path.isdir(home),
69
+ "soul_exists": os.path.isfile(soul),
70
+ "projected": os.path.isfile(soul) and memory.BEGIN in _slurp(soul),
71
+ }
72
+
73
+
74
+ def _slurp(path):
75
+ try:
76
+ with open(path, encoding="utf-8", errors="replace") as fh:
77
+ return fh.read()
78
+ except OSError:
79
+ return ""
80
+
81
+
82
+ def collect():
83
+ data = _read()
84
+ return {
85
+ "state_file": state.state_file(memory.SETUP_STATE),
86
+ "features": {
87
+ name: {"enabled": _enabled(data, name), "summary": spec["summary"]}
88
+ for name, spec in FEATURES.items()
89
+ },
90
+ "hermes": _hermes_facts(),
91
+ }
92
+
93
+
94
+ def _render(data):
95
+ lines = ["leo setup", ""]
96
+ for name, info in sorted(data["features"].items()):
97
+ lines.append(f" {'on ' if info['enabled'] else 'off'} {name} — {info['summary']}")
98
+ lines.append("")
99
+
100
+ hermes = data["hermes"]
101
+ if data["features"]["hermes-memory"]["enabled"]:
102
+ if not hermes["home_exists"]:
103
+ lines.append(f" Hermes is enabled but {hermes['home']} does not exist, so nothing")
104
+ lines.append(" is written. That is the not-installed case, not a fault.")
105
+ elif not hermes["soul_exists"]:
106
+ lines.append(f" Hermes is enabled but {hermes['soul']} does not exist.")
107
+ lines.append(" Leo never creates it: Hermes falls back to a built-in persona when")
108
+ lines.append(" the file is absent, so creating it would replace your agent's")
109
+ lines.append(" identity. Write the file yourself and Leo will splice into it.")
110
+ elif hermes["projected"]:
111
+ lines.append(f" Hermes: projecting into {hermes['soul']}")
112
+ else:
113
+ lines.append(f" Hermes: enabled, {hermes['soul']} present, not yet written")
114
+ lines.append(" (projection runs at the next session start).")
115
+ else:
116
+ lines.append(" Hermes memory projection is off. Turn it on with:")
117
+ lines.append(" setup.py enable hermes-memory")
118
+ lines.append("")
119
+ lines.append(" It splices a marked block into $HERMES_HOME/SOUL.md, which is the")
120
+ lines.append(" opening section of every Hermes system prompt. Everything outside")
121
+ lines.append(" Leo's markers is preserved byte for byte, one .leo-backup is taken")
122
+ lines.append(" before the first write, and the file is never created.")
123
+ lines.append("")
124
+ lines.append(f" state: {data['state_file']}")
125
+ return "\n".join(lines) + "\n"
126
+
127
+
128
+ def main(argv):
129
+ argv = list(argv)
130
+ if argv and argv[0] in ("enable", "disable"):
131
+ if len(argv) < 2:
132
+ print(f"setup: {argv[0]} needs a feature name: {', '.join(sorted(FEATURES))}",
133
+ file=sys.stderr)
134
+ return 1
135
+ feature = argv[1]
136
+ if feature not in FEATURES:
137
+ print(f"setup: unknown feature {feature!r} "
138
+ f"(known: {', '.join(sorted(FEATURES))})", file=sys.stderr)
139
+ return 1
140
+ want = argv[0] == "enable"
141
+ if _enabled(_read(), feature) == want:
142
+ print(f"{feature} is already {'on' if want else 'off'}; nothing to do")
143
+ return 0
144
+ try:
145
+ _write(feature, want)
146
+ except Exception as exc:
147
+ print(f"setup: could not record the change: {exc}", file=sys.stderr)
148
+ return 1
149
+ print(f"{feature} is now {'on' if want else 'off'}")
150
+ return 0
151
+
152
+ data = collect()
153
+ if "--json" in argv:
154
+ print(json.dumps(data, indent=2, sort_keys=True))
155
+ else:
156
+ sys.stdout.write(_render(data))
157
+ return 0
158
+
159
+
160
+ if __name__ == "__main__":
161
+ sys.exit(main(sys.argv[1:]))
@@ -53,7 +53,7 @@ a report that hedges across two of them.
53
53
  |---|---|---|
54
54
  | `done` | Work finished, matches the brief | Verify against artifacts — see leo:verification — never take the self-report at face value |
55
55
  | `concerns` | Finished, but flags something worth a second look | Read the concerns before accepting; they're often the real finding |
56
- | `needs-context` | Blocked on missing information you can supply | Send the missing piece to the same agent (SendMessage) so it keeps the context it already built; cold re-dispatch only if that agent is gone. Either way **once** — a second needs-context on the same gap means the brief itself is broken, escalate the tier |
56
+ | `needs-context` | Blocked on missing information you can supply | Send the missing piece to the same agent (`SendMessage` on Claude Code — elsewhere see the *Follow-up to a live agent* row of your mapping, and where none is established, cold re-dispatch with the context restated is the whole mechanism) so it keeps the context it already built. Either way **once** — a second needs-context on the same gap means the brief itself is broken, escalate the tier |
57
57
  | `blocked` | Blocked on something you can't hand over inline | Resolve the blocker, or escalate per the ladder — never a silent same-tier retry |
58
58
 
59
59
  `needs-context` and `blocked` look similar; the test is whether the missing
@@ -0,0 +1,105 @@
1
+ ---
2
+ name: doctor
3
+ description: >
4
+ Self-check for Leo's own wiring. Reports which harness this is, what each
5
+ tier name resolves to here, whether the bootstrap is installed, where
6
+ machine-local state and the memory store live, and which skills shipped
7
+ versus which this session can actually invoke. Disk facts come from a
8
+ helper script; the context facts only the running session can answer, and
9
+ a disagreement between the two columns is the diagnosis.
10
+ when_to_use: >
11
+ Leo asks whether the policy loaded, why routing or a skill is misbehaving,
12
+ or invokes doctor by name after installing, updating, or switching harness.
13
+ Also the first move when a leo skill cannot be found. NOT a general
14
+ environment or project health check, NOT for debugging the project's own
15
+ code (that is leo:debugging), and never run unprompted — it reports on the
16
+ agent, not on the work.
17
+ ---
18
+
19
+ # doctor
20
+
21
+ Doctor answers two questions that look like one: what shipped to disk, and what
22
+ reached this session. A skill the harness never registered is indistinguishable
23
+ from a skill that does not exist, right up until the moment you invoke it.
24
+
25
+ ## Run the script
26
+
27
+ ```sh
28
+ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/doctor.py" --harness <name>
29
+ ```
30
+
31
+ Pass `--harness` with the harness you are on — the mapping appendix in your
32
+ context names it in its own heading (`# Hermes mapping` → `hermes`). Detection
33
+ without it relies on a plugin-root variable that Hermes and OpenCode do not
34
+ export, so on those two the script reports `unknown` rather than guessing.
35
+ `unknown` on a harness whose mapping you can plainly read is a missing
36
+ argument, not a fault.
37
+
38
+ `${CLAUDE_PLUGIN_ROOT}` is the Claude Code spelling. Codex exports
39
+ `$PLUGIN_ROOT` and Cursor `$CURSOR_PLUGIN_ROOT`. On Hermes and OpenCode no
40
+ plugin-root variable exists at all — but the policy already in your context had
41
+ its placeholders substituted before injection, so the absolute path appears in
42
+ its machine-local state paragraph. Read it from there. Being unable to locate
43
+ the payload at all is itself the first finding: the harness is not looking where
44
+ the plugin was installed.
45
+
46
+ Add `--json` when you want the same facts as data.
47
+
48
+ ## Then answer the three it cannot
49
+
50
+ A script can prove the hook is installed and that the policy renders. It cannot
51
+ prove the policy arrived. Only you can see your own context.
52
+
53
+ 1. **Did the policy load?** Look for the policy wrapper in your context, and
54
+ check that the mapping following it names *this* harness. A policy present
55
+ but carrying another harness's mapping is worse than none, because routing
56
+ then points at models that do not exist here.
57
+ 2. **Which skills are actually invocable?** Compare your own skill list against
58
+ the script's shipped roster. Mind the naming rule: most harnesses namespace
59
+ them as `leo:<name>`, while OpenCode registers the directory by path and
60
+ names each skill from its own frontmatter, so they appear bare there. A
61
+ skill that looks missing on OpenCode may simply be listed without a prefix.
62
+ 3. **Is memory present and delivered?** The script reports whether the store
63
+ exists and whether each native surface received its generated copy. Whether
64
+ those facts are in front of you right now is something only you can confirm.
65
+ Report the two separately; they disagree more often than expected.
66
+
67
+ ## Reading the report
68
+
69
+ Every row carries its source — `env`, `disk`, `config`, or `context` — so a
70
+ reader can tell a fact from an inference. Close with one verdict from exactly
71
+ three: **healthy**, **degraded**, or **not loaded**. Never free prose. `not
72
+ loaded` outranks everything else: if the policy did not arrive, nothing else in
73
+ the report describes how this session will actually behave.
74
+
75
+ **Breadcrumb logs are history, not a verdict.** They carry no timestamps, and
76
+ the test suite drives the failure paths deliberately, so entries accumulate on
77
+ any machine where the tests have ever run. Quote the newest line if it is
78
+ useful, but never conclude "the hook failed this session" from it.
79
+
80
+ ## Failure modes
81
+
82
+ | Symptom | Likely cause | Fix |
83
+ |---|---|---|
84
+ | Policy absent, bootstrap installed | the hook fired and failed open | read the newest breadcrumb, then confirm it describes this session before believing it |
85
+ | Policy present, mapping names another harness | detection resolved wrong, usually a stray plugin-root variable exported in an unrelated shell | unset it, restart the session |
86
+ | Harness reported as `unknown` | no `--harness`, and this harness exports no plugin-root variable | re-run with `--harness <name>` read off your mapping heading |
87
+ | Shipped roster exceeds what you can invoke | the harness cached an older payload, or the skills directory is not registered | update the plugin; on OpenCode check `opencode debug skill` for each skill's `location` |
88
+ | Skills listed without the `leo:` prefix | OpenCode, working as designed | invoke them bare; not a fault |
89
+ | Tier names resolve to models this harness cannot run | mapping and harness disagree | same as row 2 |
90
+ | Machine-local state not writable | the path override points somewhere unwritable | fix or unset it |
91
+ | A skill is genuinely absent from disk | it was never added | see leo:writing-skills |
92
+
93
+ ## Doctor never repairs
94
+
95
+ It reports, and it names the fix. It does not reinstall, rewrite configuration,
96
+ or delete state — which is what keeps it safe to run at any tier and at any
97
+ moment.
98
+
99
+ ## Works with
100
+
101
+ - leo:writing-skills — for a skill that turned out to be missing because nobody
102
+ wrote it yet.
103
+ - leo:memory — doctor reports whether the store exists and reached each surface.
104
+ - leo:verification — this report is a claim like any other: the script ran this
105
+ turn and its output was read.