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.
- package/README.md +43 -0
- package/adapters/cursor/agents/executor.md +1 -1
- package/adapters/cursor/agents/implementer.md +1 -1
- package/adapters/cursor/agents/reviewer.md +1 -0
- package/adapters/opencode/agents.json +3 -3
- package/adapters/opencode/plugin.js +131 -29
- package/config/models.json +379 -33
- package/hooks/session-start.py +27 -0
- package/package.json +18 -4
- package/roles/executor.md +1 -1
- package/roles/implementer.md +1 -1
- package/roles/reviewer.md +1 -0
- package/scripts/doctor.py +284 -0
- package/scripts/ghreview.py +554 -0
- package/scripts/memory.py +705 -0
- package/scripts/render_adapters.py +244 -97
- package/scripts/resolve_attach_target.py +357 -0
- package/scripts/setup.py +161 -0
- package/skills/delegation/SKILL.md +1 -1
- package/skills/doctor/SKILL.md +105 -0
- package/skills/freshness/SKILL.md +118 -0
- package/skills/memory/SKILL.md +144 -0
- package/skills/resolve-ticket/SKILL.md +269 -0
- package/skills/review-pr/SKILL.md +317 -0
- package/skills/setup/SKILL.md +85 -0
- package/skills/using-leo/SKILL.md +8 -1
- package/skills/using-leo/references/claude-mapping.md +22 -1
- package/skills/using-leo/references/codex-mapping.md +17 -7
- package/skills/using-leo/references/cursor-mapping.md +18 -6
- package/skills/using-leo/references/hermes-mapping.md +17 -7
- package/skills/using-leo/references/opencode-mapping.md +16 -8
- package/skills/verification/SKILL.md +7 -0
- package/skills/visual-verification/SKILL.md +114 -0
- package/skills/watch-review/SKILL.md +125 -0
- 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()
|
package/scripts/setup.py
ADDED
|
@@ -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
|
|
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.
|