leos-agent 10.2.0 → 10.7.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 +174 -30
- package/hooks/README.md +93 -0
- package/hooks/hooks-cursor.json +11 -0
- package/hooks/hooks.json +16 -0
- package/index.js +112 -6
- package/package.json +2 -1
- package/rules/preferences.md +36 -32
- package/scripts/check.py +122 -1
- package/scripts/dispatch_guard.py +317 -0
- package/scripts/dispatch_log.py +240 -0
- 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/usage_scan.py +455 -0
- 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/review-usage/SKILL.md +97 -0
- package/skills/review-usage/agents/openai.yaml +5 -0
- package/skills/review-usage/reference/sources.md +80 -0
- 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
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""dispatch_log: the append-only record of every subagent dispatch the guard saw.
|
|
3
|
+
|
|
4
|
+
WHY A LOG AT ALL. dispatch_guard.py decides one call at a time and can never see
|
|
5
|
+
a fan-out, so the signals that need a second dispatch to interpret -- was a block
|
|
6
|
+
followed by a compliant re-dispatch, was a small brief one of five siblings --
|
|
7
|
+
are recorded here and resolved at read time. Judgment that lives in the reader
|
|
8
|
+
costs nothing on the hot path and can be revised without a Codex /hooks
|
|
9
|
+
re-approval.
|
|
10
|
+
|
|
11
|
+
NEVER PROMPT TEXT. This file sits in a home directory forever and would otherwise
|
|
12
|
+
accumulate briefs about whatever Leo works on. Prompts and working directories
|
|
13
|
+
are stored as truncated SHA-256, which is enough to notice the same brief
|
|
14
|
+
re-dispatched after a block and useless to anyone reading the file. Raw text only
|
|
15
|
+
under LEOS_AGENT_DISPATCH_LOG_PROMPTS=1, truncated, and documented as debug-only.
|
|
16
|
+
|
|
17
|
+
The file is ${LEOS_AGENT_LOCAL_PATH:-$HOME/.leos-agent-local}/dispatch.jsonl,
|
|
18
|
+
beside routing.json and the handoffs -- data lives with the data, never inside
|
|
19
|
+
the plugin, so an upgrade or an uninstall cannot take it.
|
|
20
|
+
|
|
21
|
+
dispatch_log.py report [--limit N] [--json] what the guard has been seeing
|
|
22
|
+
dispatch_log.py path the log's path
|
|
23
|
+
|
|
24
|
+
Exit codes: 0 ok, 2 on bad usage.
|
|
25
|
+
"""
|
|
26
|
+
import argparse
|
|
27
|
+
import collections
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
import time
|
|
33
|
+
|
|
34
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
35
|
+
# Same data root and the same lock discipline as every other machine-local file.
|
|
36
|
+
# _locked takes any path (it locks a sibling .lock), which is why a .jsonl can
|
|
37
|
+
# use it even though state_file() would force a .json suffix.
|
|
38
|
+
from state import _data_root, _locked # noqa: E402
|
|
39
|
+
|
|
40
|
+
LOG_NAME = "dispatch.jsonl"
|
|
41
|
+
|
|
42
|
+
# One megabyte, one generation. ~230 bytes per record is roughly 4,500 dispatches
|
|
43
|
+
# per file and 9,000 retained -- months of history, bounded at 2 MiB forever, with
|
|
44
|
+
# no cron job and nothing to configure.
|
|
45
|
+
MAX_BYTES = 1 << 20
|
|
46
|
+
|
|
47
|
+
RECORD_VERSION = 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def path():
|
|
51
|
+
return os.path.join(_data_root(), LOG_NAME)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def digest(text):
|
|
55
|
+
"""A short, irreversible stand-in for text we refuse to store."""
|
|
56
|
+
if not text:
|
|
57
|
+
return None
|
|
58
|
+
return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()[:12]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _keep_prompts():
|
|
62
|
+
return os.environ.get("LEOS_AGENT_DISPATCH_LOG_PROMPTS") == "1"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def record(dispatch, decision, reason, harness, session=None, cwd=None, trivial=0):
|
|
66
|
+
"""The on-disk shape for one dispatch. Pure; writes nothing."""
|
|
67
|
+
entry = {
|
|
68
|
+
"v": RECORD_VERSION,
|
|
69
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
70
|
+
"harness": harness,
|
|
71
|
+
"session": digest(session),
|
|
72
|
+
# Parallel dispatches from one assistant message land in the same
|
|
73
|
+
# two-second bucket, which is how `report` tells a fan-out from a
|
|
74
|
+
# sequence of lone spawns without the hot path ever reading the log.
|
|
75
|
+
"burst": "%s:%d" % (digest(session) or "-", int(time.time() // 2)),
|
|
76
|
+
"project": digest(cwd),
|
|
77
|
+
"decision": decision,
|
|
78
|
+
"reason": reason,
|
|
79
|
+
}
|
|
80
|
+
if dispatch is not None:
|
|
81
|
+
entry.update({
|
|
82
|
+
"tool": dispatch.tool,
|
|
83
|
+
"agent": dispatch.agent,
|
|
84
|
+
"model": dispatch.model,
|
|
85
|
+
"trivial": trivial,
|
|
86
|
+
"prompt_bytes": dispatch.prompt_bytes,
|
|
87
|
+
"prompt_lines": dispatch.prompt_lines,
|
|
88
|
+
"paths": dispatch.path_count,
|
|
89
|
+
"prompt": dispatch.prompt_hash,
|
|
90
|
+
})
|
|
91
|
+
if _keep_prompts():
|
|
92
|
+
entry["prompt_text"] = dispatch.prompt_head
|
|
93
|
+
return entry
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def append(entry):
|
|
97
|
+
"""Append one record. Rotates at MAX_BYTES. Raises only on real I/O trouble.
|
|
98
|
+
|
|
99
|
+
Callers must treat a failure here as cosmetic: the guard's decision is made
|
|
100
|
+
before this is called and emitted after it, so a read-only home or a full
|
|
101
|
+
disk costs a log line, never a dispatch.
|
|
102
|
+
"""
|
|
103
|
+
target = path()
|
|
104
|
+
with _locked(target):
|
|
105
|
+
try:
|
|
106
|
+
if os.path.getsize(target) > MAX_BYTES:
|
|
107
|
+
os.replace(target, target + ".1")
|
|
108
|
+
except OSError:
|
|
109
|
+
pass # absent, or unstattable; either way there is nothing to rotate
|
|
110
|
+
# 0600 explicitly: open("a") yields 0644 under a default umask, which is
|
|
111
|
+
# the wrong mode for a file that indexes Leo's projects even in hashes.
|
|
112
|
+
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
113
|
+
with os.fdopen(fd, "a") as fh:
|
|
114
|
+
fh.write(json.dumps(entry, sort_keys=True) + "\n")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def read(limit=None, target=None):
|
|
118
|
+
"""Records, oldest first. Tolerates a truncated final line from a crash."""
|
|
119
|
+
out = []
|
|
120
|
+
for candidate in ((target,) if target else (path() + ".1", path())):
|
|
121
|
+
try:
|
|
122
|
+
with open(candidate, encoding="utf-8", errors="replace") as fh:
|
|
123
|
+
for line in fh:
|
|
124
|
+
line = line.strip()
|
|
125
|
+
if not line:
|
|
126
|
+
continue
|
|
127
|
+
try:
|
|
128
|
+
entry = json.loads(line)
|
|
129
|
+
except ValueError:
|
|
130
|
+
continue # a half-written last line, or a foreign line
|
|
131
|
+
if isinstance(entry, dict):
|
|
132
|
+
out.append(entry)
|
|
133
|
+
except FileNotFoundError:
|
|
134
|
+
continue
|
|
135
|
+
except OSError as exc:
|
|
136
|
+
sys.exit("dispatch_log: %s: %s" % (candidate, exc.strerror or exc))
|
|
137
|
+
return out[-limit:] if limit else out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def summarise(entries):
|
|
141
|
+
"""The read-time judgment: burst collapse, tiers, and block conversion."""
|
|
142
|
+
# Only dispatches that actually ran count toward a burst. A block and the
|
|
143
|
+
# re-dispatch it forced land in the same two-second bucket, and counting
|
|
144
|
+
# both would let every blocked retry pose as a fan-out of two.
|
|
145
|
+
bursts = collections.Counter(
|
|
146
|
+
e.get("burst") for e in entries if e.get("decision") not in ("block", "error")
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
tiers = collections.Counter()
|
|
150
|
+
for entry in entries:
|
|
151
|
+
agent = entry.get("agent") or ""
|
|
152
|
+
if agent.startswith("leo-"):
|
|
153
|
+
tiers[agent] += 1
|
|
154
|
+
elif entry.get("model"):
|
|
155
|
+
tiers["explicit model"] += 1
|
|
156
|
+
elif entry.get("decision") == "block":
|
|
157
|
+
tiers["blocked"] += 1
|
|
158
|
+
else:
|
|
159
|
+
tiers["inherited"] += 1
|
|
160
|
+
|
|
161
|
+
# A trivial-looking brief that was one of several in the same burst is a
|
|
162
|
+
# fan-out, which the policy wants. Only a lone small spawn is a finding --
|
|
163
|
+
# and only one that actually ran: a blocked dispatch spent nothing, so it
|
|
164
|
+
# cannot also be an over-delegation.
|
|
165
|
+
trivial = [
|
|
166
|
+
e for e in entries
|
|
167
|
+
if e.get("trivial", 0) >= 2
|
|
168
|
+
and e.get("decision") not in ("block", "error")
|
|
169
|
+
and bursts.get(e.get("burst"), 0) < 2
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
# Did a block actually change anything? A blocked brief whose hash comes back
|
|
173
|
+
# naming a tier is the guard working; one that never returns was abandoned.
|
|
174
|
+
blocked = {e.get("prompt") for e in entries if e.get("decision") == "block"}
|
|
175
|
+
blocked.discard(None)
|
|
176
|
+
converted = {
|
|
177
|
+
e.get("prompt") for e in entries
|
|
178
|
+
if e.get("prompt") in blocked and e.get("decision") != "block"
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
"records": len(entries),
|
|
183
|
+
"window": [entries[0].get("ts"), entries[-1].get("ts")] if entries else [],
|
|
184
|
+
"harnesses": dict(collections.Counter(e.get("harness") for e in entries)),
|
|
185
|
+
"decisions": dict(collections.Counter(e.get("decision") for e in entries)),
|
|
186
|
+
"tiers": dict(tiers),
|
|
187
|
+
"errors": sum(1 for e in entries if e.get("decision") == "error"),
|
|
188
|
+
"blocked": len(blocked),
|
|
189
|
+
"converted": len(converted),
|
|
190
|
+
"trivial_lone_spawns": len(trivial),
|
|
191
|
+
"agents": dict(collections.Counter(
|
|
192
|
+
"%s @ %s" % (e.get("agent") or "-", e.get("model") or "inherited")
|
|
193
|
+
for e in entries
|
|
194
|
+
)),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def render(summary):
|
|
199
|
+
lines = []
|
|
200
|
+
# Errors lead. Conflating "the guard crashed" with "the guard allowed" is how
|
|
201
|
+
# a dead guard goes unnoticed for months, so a nonzero count is the headline.
|
|
202
|
+
if summary["errors"]:
|
|
203
|
+
lines.append("!! %d guard error(s) -- the guard failed open this many times" % summary["errors"])
|
|
204
|
+
if not summary["records"]:
|
|
205
|
+
lines.append("no dispatches recorded yet (%s)" % path())
|
|
206
|
+
return "\n".join(lines)
|
|
207
|
+
|
|
208
|
+
lines.append("%d dispatch(es) %s .. %s" % (summary["records"], summary["window"][0], summary["window"][1]))
|
|
209
|
+
lines.append(" harnesses " + ", ".join("%s %d" % kv for kv in sorted(summary["harnesses"].items())))
|
|
210
|
+
lines.append(" tiers " + ", ".join("%s %d" % kv for kv in sorted(summary["tiers"].items())))
|
|
211
|
+
if summary["blocked"]:
|
|
212
|
+
lines.append(" blocks %d, of which %d re-dispatched with a tier named" % (
|
|
213
|
+
summary["blocked"], summary["converted"]))
|
|
214
|
+
lines.append(" lone small spawns %d (fan-outs excluded)" % summary["trivial_lone_spawns"])
|
|
215
|
+
lines.append(" agent @ model:")
|
|
216
|
+
for name, count in sorted(summary["agents"].items(), key=lambda kv: (-kv[1], kv[0])):
|
|
217
|
+
lines.append(" %-44s %d" % (name, count))
|
|
218
|
+
return "\n".join(lines)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def main(argv=None):
|
|
222
|
+
parser = argparse.ArgumentParser(prog="dispatch_log.py", description=__doc__.splitlines()[0])
|
|
223
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
224
|
+
report = sub.add_parser("report", help="what the guard has been seeing")
|
|
225
|
+
report.add_argument("--limit", type=int, default=None, help="only the most recent N records")
|
|
226
|
+
report.add_argument("--json", action="store_true", help="machine-readable summary")
|
|
227
|
+
sub.add_parser("path", help="the log file's path")
|
|
228
|
+
|
|
229
|
+
args = parser.parse_args(argv)
|
|
230
|
+
if args.command == "path":
|
|
231
|
+
print(path())
|
|
232
|
+
return 0
|
|
233
|
+
|
|
234
|
+
summary = summarise(read(limit=args.limit))
|
|
235
|
+
print(json.dumps(summary, indent=1, sort_keys=True) if args.json else render(summary))
|
|
236
|
+
return 0
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
sys.exit(main())
|
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}")
|
package/scripts/leo-install.py
CHANGED
|
@@ -23,7 +23,14 @@ import sys
|
|
|
23
23
|
import tempfile
|
|
24
24
|
from pathlib import Path
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
27
|
+
import routing # noqa: E402 owns the harness list and the machine-local model config
|
|
28
|
+
|
|
29
|
+
HARNESSES = routing.HARNESSES
|
|
30
|
+
|
|
31
|
+
# The routing region inside the payload, replaced per harness at install time.
|
|
32
|
+
ROUTING_OPEN = "<!-- leos-agent:routing -->"
|
|
33
|
+
ROUTING_CLOSE = "<!-- /leos-agent:routing -->"
|
|
27
34
|
|
|
28
35
|
OPEN_RE = re.compile(r"^<leos-agent\b[^>]*>[ \t]*$", re.MULTILINE)
|
|
29
36
|
CLOSE_RE = re.compile(r"^</leos-agent>[ \t]*$", re.MULTILINE)
|
|
@@ -36,10 +43,18 @@ CODEX_SOFT_CAP = 28 * 1024
|
|
|
36
43
|
# copies apart from a file the user happens to have put at the same path.
|
|
37
44
|
PROVENANCE = "leos-agent"
|
|
38
45
|
|
|
46
|
+
# Skill and command text refers to scripts as <plugin-root>/scripts/… and the
|
|
47
|
+
# model resolves the root once. OpenCode copies live apart from the scripts,
|
|
48
|
+
# and OpenCode sets no resolution env var, so their copies are installed with
|
|
49
|
+
# the token already replaced by this machine's absolute plugin root. Installing
|
|
50
|
+
# alternately from a checkout and a cache re-bakes the root each time — last
|
|
51
|
+
# install wins, and --check reports a stale root as out of date.
|
|
52
|
+
PLUGIN_ROOT_TOKEN = "<plugin-root>"
|
|
53
|
+
|
|
39
54
|
# OpenCode plugins cannot register skills or commands from JS, so these are
|
|
40
55
|
# copied to disk instead. check.py asserts every file they name carries
|
|
41
56
|
# PROVENANCE, without which the installer would refuse to upgrade its own copy.
|
|
42
|
-
OPENCODE_SKILLS = ("doctor", "review-pr", "handoff", "handon")
|
|
57
|
+
OPENCODE_SKILLS = ("doctor", "review-pr", "handoff", "handon", "tune-routing")
|
|
43
58
|
OPENCODE_COMMANDS = ("review-pr", "handoff", "handon")
|
|
44
59
|
|
|
45
60
|
# Codex plugins cannot package custom agent definitions directly, so these are
|
|
@@ -100,19 +115,71 @@ def read_version(root):
|
|
|
100
115
|
sys.exit(f"leo-install: {manifest} has no version field")
|
|
101
116
|
|
|
102
117
|
|
|
103
|
-
def
|
|
104
|
-
"""
|
|
118
|
+
def render_routing(body, harness, config):
|
|
119
|
+
"""Replace the routing region with the stanza for this machine's config.
|
|
120
|
+
|
|
121
|
+
The payload ships with a default inside the region, so an un-rendered read of
|
|
122
|
+
rules/preferences.md -- Cursor's plugin-delivered rule, a human opening the
|
|
123
|
+
file -- still says something true. Rendering only ever narrows it to the one
|
|
124
|
+
harness being installed, which is why the installed payload is smaller than
|
|
125
|
+
the file on disk rather than larger.
|
|
126
|
+
"""
|
|
127
|
+
start = body.find(ROUTING_OPEN)
|
|
128
|
+
end = body.find(ROUTING_CLOSE)
|
|
129
|
+
if start < 0 or end < start:
|
|
130
|
+
sys.exit(f"leo-install: rules/preferences.md is missing its {ROUTING_OPEN} region")
|
|
131
|
+
return body[:start] + routing.stanza(harness, config) + body[end + len(ROUTING_CLOSE):]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def payload_body(root, harness=None, config=None):
|
|
135
|
+
"""The canonical payload: rules/preferences.md with its frontmatter stripped.
|
|
136
|
+
|
|
137
|
+
With a harness, the routing region is rendered for it; without one the region
|
|
138
|
+
keeps its shipped default, markers and all.
|
|
139
|
+
"""
|
|
105
140
|
text = (root / "rules" / "preferences.md").read_text(encoding="utf-8")
|
|
106
141
|
body = re.sub(r"(?s)\A---\n.*?\n---\n", "", text, count=1).strip()
|
|
107
142
|
if not body:
|
|
108
143
|
sys.exit("leo-install: rules/preferences.md has no body below its frontmatter")
|
|
109
144
|
if OPEN_RE.search(body) or CLOSE_RE.search(body):
|
|
110
145
|
sys.exit("leo-install: rules/preferences.md contains a <leos-agent> marker; it must not")
|
|
146
|
+
if harness:
|
|
147
|
+
body = render_routing(body, harness, config if config is not None else routing.load())
|
|
111
148
|
return body
|
|
112
149
|
|
|
113
150
|
|
|
114
|
-
def build_block(root):
|
|
115
|
-
return f'<leos-agent version="{read_version(root)}">\n{payload_body(root)}\n</leos-agent>\n'
|
|
151
|
+
def build_block(root, harness=None, config=None):
|
|
152
|
+
return f'<leos-agent version="{read_version(root)}">\n{payload_body(root, harness, config)}\n</leos-agent>\n'
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def render_codex_agent(text, agent_name, config):
|
|
156
|
+
"""Substitute a Codex profile's model, leaving the shipped default when unset."""
|
|
157
|
+
entry = routing.profile(config, "codex", agent_name.split("-", 1)[1])
|
|
158
|
+
if not entry:
|
|
159
|
+
return text
|
|
160
|
+
text = re.sub(r'(?m)^model = ".*"$', f'model = "{entry["model"]}"', text, count=1)
|
|
161
|
+
if entry["effort"]:
|
|
162
|
+
text = re.sub(
|
|
163
|
+
r'(?m)^model_reasoning_effort = ".*"$',
|
|
164
|
+
f'model_reasoning_effort = "{entry["effort"]}"',
|
|
165
|
+
text,
|
|
166
|
+
count=1,
|
|
167
|
+
)
|
|
168
|
+
return text
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def cursor_routing_rule(harness, config):
|
|
172
|
+
"""Cursor reads its rules straight out of the plugin, so the per-machine half
|
|
173
|
+
has to arrive as its own always-applied rule file."""
|
|
174
|
+
return (
|
|
175
|
+
"---\n"
|
|
176
|
+
"description: leos-agent model routing for this machine.\n"
|
|
177
|
+
"alwaysApply: true\n"
|
|
178
|
+
"---\n"
|
|
179
|
+
"This supersedes the model-routing dispatch line in Leo's agent operating\n"
|
|
180
|
+
"preferences:\n\n"
|
|
181
|
+
f"{routing.stanza(harness, config)}\n"
|
|
182
|
+
)
|
|
116
183
|
|
|
117
184
|
|
|
118
185
|
def scan_markers(text):
|
|
@@ -124,16 +191,19 @@ def scan_markers(text):
|
|
|
124
191
|
into a permanent "two blocks" error, so fenced regions are skipped.
|
|
125
192
|
"""
|
|
126
193
|
opens, closes = [], []
|
|
127
|
-
fence = None
|
|
194
|
+
fence = None # (char, run length) of the currently open fence
|
|
128
195
|
offset = 0
|
|
129
196
|
for line in text.splitlines(keepends=True):
|
|
130
197
|
stripped = line.lstrip()
|
|
131
|
-
|
|
132
|
-
if
|
|
133
|
-
token =
|
|
198
|
+
run = re.match(r"(`{3,}|~{3,})", stripped)
|
|
199
|
+
if run:
|
|
200
|
+
token = run.group(1)
|
|
134
201
|
if fence is None:
|
|
135
|
-
fence = token
|
|
136
|
-
|
|
202
|
+
fence = (token[0], len(token))
|
|
203
|
+
# CommonMark: only a run of the same character at least as long as
|
|
204
|
+
# the opener closes a fence; a shorter run is fence content, so a
|
|
205
|
+
# ``` line inside a ```` example must not end the example.
|
|
206
|
+
elif fence[0] == token[0] and len(token) >= fence[1]:
|
|
137
207
|
fence = None
|
|
138
208
|
elif fence is None:
|
|
139
209
|
if OPEN_RE.match(line.rstrip("\n")):
|
|
@@ -283,11 +353,16 @@ def install_markdown(path, block, args, label, create=True):
|
|
|
283
353
|
return write_if_changed(path, inject(current, block), current, existed, crlf, args, label)
|
|
284
354
|
|
|
285
355
|
|
|
286
|
-
def install_file_copy(src, dest, args, label, owned_parent=False):
|
|
287
|
-
"""Install a payload file the harness's plugin system cannot deliver itself.
|
|
356
|
+
def install_file_copy(src, dest, args, label, owned_parent=False, payload=None):
|
|
357
|
+
"""Install a payload file the harness's plugin system cannot deliver itself.
|
|
358
|
+
|
|
359
|
+
`payload` overrides the source text for files rendered from the machine's
|
|
360
|
+
routing config rather than copied verbatim.
|
|
361
|
+
"""
|
|
288
362
|
dest = dest.expanduser()
|
|
289
363
|
existed = dest.is_file()
|
|
290
|
-
payload
|
|
364
|
+
if payload is None:
|
|
365
|
+
payload = src.read_text(encoding="utf-8")
|
|
291
366
|
current = dest.read_text(encoding="utf-8") if existed else ""
|
|
292
367
|
|
|
293
368
|
# Never clobber or delete a same-named file this tool did not put there.
|
|
@@ -307,8 +382,27 @@ def install_file_copy(src, dest, args, label, owned_parent=False):
|
|
|
307
382
|
return write_if_changed(dest, payload, current, existed, False, args, label)
|
|
308
383
|
|
|
309
384
|
|
|
385
|
+
def opencode_payload(src, root, rename_install=False):
|
|
386
|
+
"""An OpenCode copy's content: the plugin root baked in, optionally renamed.
|
|
387
|
+
|
|
388
|
+
OpenCode reads the copies out of ~/.config/opencode, far from the scripts
|
|
389
|
+
they invoke, and sets none of the resolution env vars — so the placeholder
|
|
390
|
+
is resolved here, at install time, where the root is known for certain.
|
|
391
|
+
The install skill is additionally renamed to match the leo-install/
|
|
392
|
+
directory it is copied into, keeping directory and frontmatter in
|
|
393
|
+
agreement whichever one OpenCode keys on.
|
|
394
|
+
"""
|
|
395
|
+
text = src.read_text(encoding="utf-8").replace(PLUGIN_ROOT_TOKEN, str(root))
|
|
396
|
+
if rename_install:
|
|
397
|
+
text = re.sub(r"(?m)^name:\s*install\s*$", "name: leo-install", text, count=1)
|
|
398
|
+
return text
|
|
399
|
+
|
|
400
|
+
|
|
310
401
|
def run(harness, root, args):
|
|
311
|
-
|
|
402
|
+
# Read once per run: rendering has to be a pure function of (version, config)
|
|
403
|
+
# or a second install would not come back "unchanged".
|
|
404
|
+
config = routing.load()
|
|
405
|
+
block = build_block(root, harness, config)
|
|
312
406
|
home = Path.home()
|
|
313
407
|
targets = []
|
|
314
408
|
|
|
@@ -328,22 +422,43 @@ def run(harness, root, args):
|
|
|
328
422
|
home / ".codex" / "agents" / f"{n}.toml",
|
|
329
423
|
args,
|
|
330
424
|
l,
|
|
425
|
+
payload=render_codex_agent(
|
|
426
|
+
(root / "payload" / "codex-agents" / f"{n}.toml").read_text(encoding="utf-8"),
|
|
427
|
+
n,
|
|
428
|
+
config,
|
|
429
|
+
),
|
|
331
430
|
),
|
|
332
431
|
)
|
|
333
432
|
)
|
|
334
433
|
|
|
335
434
|
elif harness == "cursor":
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
435
|
+
# The payload itself arrives natively through the plugin's alwaysApply
|
|
436
|
+
# rule, which is the file in the plugin directory -- so there is nothing
|
|
437
|
+
# per-machine in it. Only the routing stanza needs installing, and only
|
|
438
|
+
# as its own rule -- and only when something is actually configured: an
|
|
439
|
+
# unconfigured rule would restate the payload's default, an always-loaded
|
|
440
|
+
# no-op that costs context on every turn.
|
|
441
|
+
label = "~/.cursor/rules/leos-agent-routing.mdc"
|
|
442
|
+
dest = home / ".cursor" / "rules" / "leos-agent-routing.mdc"
|
|
443
|
+
configured = bool(
|
|
444
|
+
routing.profile(config, "cursor", "runner") or routing.profile(config, "cursor", "executor")
|
|
345
445
|
)
|
|
346
446
|
|
|
447
|
+
def cursor_rule_target(l=label):
|
|
448
|
+
if args.uninstall or configured:
|
|
449
|
+
return install_file_copy(None, dest, args, l, payload=cursor_routing_rule(harness, config))
|
|
450
|
+
# Unconfigured install: write nothing, and take back a stale rule a
|
|
451
|
+
# previous config left behind -- but only one that is provably ours.
|
|
452
|
+
if not dest.is_file():
|
|
453
|
+
return Result(l, "skipped", "no routing configured for cursor")
|
|
454
|
+
if PROVENANCE not in dest.read_text(encoding="utf-8"):
|
|
455
|
+
return Result(l, "skipped", "no routing configured; leaving the unrelated file at this path")
|
|
456
|
+
if args.writes:
|
|
457
|
+
dest.unlink()
|
|
458
|
+
return Result(l, "removed", "no routing configured; stale rule removed")
|
|
459
|
+
|
|
460
|
+
targets.append((label, cursor_rule_target))
|
|
461
|
+
|
|
347
462
|
elif harness == "hermes":
|
|
348
463
|
# Never create SOUL.md: Hermes writes its own starter identity file on
|
|
349
464
|
# first run, and pre-empting that would fight the bootstrap.
|
|
@@ -374,6 +489,9 @@ def run(harness, root, args):
|
|
|
374
489
|
args,
|
|
375
490
|
skill_label,
|
|
376
491
|
owned_parent=True,
|
|
492
|
+
payload=opencode_payload(
|
|
493
|
+
root / "skills" / "install" / "SKILL.md", root, rename_install=True
|
|
494
|
+
),
|
|
377
495
|
),
|
|
378
496
|
)
|
|
379
497
|
)
|
|
@@ -394,6 +512,7 @@ def run(harness, root, args):
|
|
|
394
512
|
args,
|
|
395
513
|
l,
|
|
396
514
|
owned_parent=True,
|
|
515
|
+
payload=opencode_payload(s, root),
|
|
397
516
|
),
|
|
398
517
|
)
|
|
399
518
|
)
|
|
@@ -407,6 +526,7 @@ def run(harness, root, args):
|
|
|
407
526
|
args,
|
|
408
527
|
l,
|
|
409
528
|
owned_parent=True,
|
|
529
|
+
payload=opencode_payload(root / "skills" / n / "SKILL.md", root),
|
|
410
530
|
),
|
|
411
531
|
)
|
|
412
532
|
)
|
|
@@ -420,6 +540,7 @@ def run(harness, root, args):
|
|
|
420
540
|
cfg / "commands" / f"{n}.md",
|
|
421
541
|
args,
|
|
422
542
|
l,
|
|
543
|
+
payload=opencode_payload(root / "commands" / f"{n}.md", root),
|
|
423
544
|
),
|
|
424
545
|
)
|
|
425
546
|
)
|