leos-agent 6.3.0 → 10.1.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/LICENSE +21 -0
- package/README.md +547 -24
- package/commands/handoff.md +11 -0
- package/commands/handon.md +10 -0
- package/commands/leo-doctor.md +22 -0
- package/commands/leo-install.md +9 -0
- package/commands/review-pr.md +9 -0
- package/commands-claude/watch-review.md +9 -0
- package/index.js +12 -0
- package/package.json +30 -18
- package/payload/codex-agents/leo-executor.toml +36 -0
- package/payload/codex-agents/leo-runner.toml +28 -0
- package/rules/preferences.md +97 -0
- package/scripts/check.py +244 -0
- package/scripts/ghreview.py +24 -6
- package/scripts/handoff.py +183 -0
- package/scripts/leo-install.py +509 -0
- package/scripts/measure_context.py +113 -0
- package/scripts/publish-npm.py +138 -0
- package/scripts/resolve_attach_target.py +45 -13
- package/scripts/watch_review.py +169 -0
- package/skills/doctor/SKILL.md +73 -96
- package/skills/doctor/agents/openai.yaml +5 -0
- package/skills/handoff/SKILL.md +99 -0
- package/skills/handoff/agents/openai.yaml +5 -0
- package/skills/handon/SKILL.md +61 -0
- package/skills/install/SKILL.md +79 -0
- package/skills/install/agents/openai.yaml +5 -0
- package/skills/review-pr/SKILL.md +59 -308
- package/skills/review-pr/reference/lenses.md +67 -0
- package/skills/review-pr/reference/procedure.md +348 -0
- package/skills-claude/attach-pr/SKILL.md +178 -0
- package/skills-claude/watch-review/SKILL.md +91 -0
- package/adapters/cursor/agents/executor.md +0 -17
- package/adapters/cursor/agents/expert.md +0 -70
- package/adapters/cursor/agents/explore.md +0 -16
- package/adapters/cursor/agents/implementer.md +0 -18
- package/adapters/cursor/agents/investigator.md +0 -18
- package/adapters/cursor/agents/planner.md +0 -28
- package/adapters/cursor/agents/reviewer.md +0 -34
- package/adapters/opencode/agents.json +0 -66
- package/adapters/opencode/plugin.js +0 -288
- package/config/models.json +0 -408
- package/hooks/bash-guard.py +0 -541
- package/hooks/cursor-guard.py +0 -84
- package/hooks/hooks-cursor.json +0 -11
- package/hooks/hooks.json +0 -20
- package/hooks/session-start.py +0 -148
- package/roles/executor.md +0 -15
- package/roles/expert.md +0 -67
- package/roles/explore.md +0 -13
- package/roles/implementer.md +0 -16
- package/roles/investigator.md +0 -15
- package/roles/planner.md +0 -25
- package/roles/reviewer.md +0 -31
- package/scripts/doctor.py +0 -284
- package/scripts/memory.py +0 -705
- package/scripts/render_adapters.py +0 -473
- package/scripts/setup.py +0 -161
- package/settings.json +0 -7
- package/skills/.gitkeep +0 -0
- package/skills/brainstorming/SKILL.md +0 -109
- package/skills/debugging/SKILL.md +0 -98
- package/skills/delegation/SKILL.md +0 -141
- package/skills/executing-plans/SKILL.md +0 -116
- package/skills/finishing-a-branch/SKILL.md +0 -123
- package/skills/freshness/SKILL.md +0 -118
- package/skills/memory/SKILL.md +0 -144
- package/skills/resolve-ticket/SKILL.md +0 -269
- package/skills/setup/SKILL.md +0 -85
- package/skills/test-first/SKILL.md +0 -90
- package/skills/using-leo/SKILL.md +0 -96
- package/skills/using-leo/references/claude-mapping.md +0 -32
- package/skills/using-leo/references/codex-mapping.md +0 -34
- package/skills/using-leo/references/cursor-mapping.md +0 -34
- package/skills/using-leo/references/hermes-mapping.md +0 -36
- package/skills/using-leo/references/opencode-mapping.md +0 -36
- package/skills/verification/SKILL.md +0 -109
- package/skills/visual-verification/SKILL.md +0 -114
- package/skills/watch-review/SKILL.md +0 -125
- package/skills/worktrees/SKILL.md +0 -129
- package/skills/writing-plans/SKILL.md +0 -96
- package/skills/writing-skills/SKILL.md +0 -134
- package/workflows/cost-tiered-fix.js +0 -259
|
@@ -1,473 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Render harness adapters from config/models.json and canonical role prompts."""
|
|
3
|
-
|
|
4
|
-
import argparse
|
|
5
|
-
import json
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
import sys
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
ROOT = Path(__file__).resolve().parents[1]
|
|
11
|
-
CONFIG_PATH = ROOT / "config" / "models.json"
|
|
12
|
-
ROLE_ROOT = ROOT / "roles"
|
|
13
|
-
CLAUDE_MANIFEST = ROOT / ".claude-plugin" / "plugin.json"
|
|
14
|
-
GENERATED = "<!-- Generated by scripts/render_adapters.py; do not edit. -->\n"
|
|
15
|
-
TIERS = ("fable", "opus", "sonnet", "haiku")
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def _roles(config):
|
|
19
|
-
"""(role, tier, access) triples, sorted.
|
|
20
|
-
|
|
21
|
-
`access` used to be a literal set here, duplicating a fact already stated
|
|
22
|
-
by each role prompt's own `tools:` line and duplicated a third time in the
|
|
23
|
-
OpenCode test, with nothing tying the three together.
|
|
24
|
-
"""
|
|
25
|
-
for role, spec in sorted(config["roles"].items()):
|
|
26
|
-
yield role, spec["tier"], spec["access"]
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _cap(config, key, harness):
|
|
30
|
-
for row in config["capabilities"]:
|
|
31
|
-
if row["key"] == key:
|
|
32
|
-
return row["values"][harness]
|
|
33
|
-
raise KeyError(f"no capability row {key!r}")
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def _validate(config):
|
|
37
|
-
"""Refuse to render an incoherent matrix.
|
|
38
|
-
|
|
39
|
-
This is the only place a contradiction can be caught before --check ships
|
|
40
|
-
it: a mapping that renders cleanly is indistinguishable from a true one.
|
|
41
|
-
"""
|
|
42
|
-
harnesses = set(config["harnesses"])
|
|
43
|
-
for harness, rows in config["harnesses"].items():
|
|
44
|
-
for tier in rows.get("absentTiers", ()):
|
|
45
|
-
# Two conditions, and collapse alone is not enough.
|
|
46
|
-
#
|
|
47
|
-
# Dropping a tier drops its roles. The ceiling tier is the only one
|
|
48
|
-
# whose role exists *solely* to be stronger than the rung below —
|
|
49
|
-
# `expert` cannot break a deadlock a collapsed Opus already lost, so
|
|
50
|
-
# when it collapses it has no remaining purpose. Every other role
|
|
51
|
-
# keeps its job through a collapse: "routing between collapsed rungs
|
|
52
|
-
# buys role, not power" is the policy's own line. Sonnet and Haiku
|
|
53
|
-
# share a model on two harnesses today, and declaring Sonnet absent
|
|
54
|
-
# there would silently delete `implementer` — a real rung with a
|
|
55
|
-
# real job — which is exactly the failure this whole rule replaced.
|
|
56
|
-
if tier != TIERS[0]:
|
|
57
|
-
raise ValueError(
|
|
58
|
-
f"{harness}: tier {tier!r} is declared absent, but only the ceiling "
|
|
59
|
-
f"tier ({TIERS[0]!r}) can be — every other rung's roles keep their "
|
|
60
|
-
"job through a collapse"
|
|
61
|
-
)
|
|
62
|
-
twins = [t for t in TIERS if t != tier and rows[t]["model"] == rows[tier]["model"]]
|
|
63
|
-
if not twins:
|
|
64
|
-
raise ValueError(
|
|
65
|
-
f"{harness}: tier {tier!r} is declared absent but has a model of its own — "
|
|
66
|
-
"absence is only ever justified by tier collapse"
|
|
67
|
-
)
|
|
68
|
-
for row in config["capabilities"]:
|
|
69
|
-
unanswered = harnesses - set(row["values"])
|
|
70
|
-
if unanswered:
|
|
71
|
-
raise ValueError(
|
|
72
|
-
f"capability {row['key']!r} unanswered for {sorted(unanswered)} — every "
|
|
73
|
-
"harness answers every row, that is the whole point of the matrix"
|
|
74
|
-
)
|
|
75
|
-
for harness, value in row["values"].items():
|
|
76
|
-
if value["mode"] not in row["modes"]:
|
|
77
|
-
raise ValueError(
|
|
78
|
-
f"capability {row['key']!r}/{harness}: mode {value['mode']!r} "
|
|
79
|
-
f"is not one of {row['modes']}"
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def _split_role(text):
|
|
84
|
-
lines = text.splitlines()
|
|
85
|
-
if not lines or lines[0] != "---":
|
|
86
|
-
raise ValueError("role prompt is missing YAML frontmatter")
|
|
87
|
-
try:
|
|
88
|
-
end = lines.index("---", 1)
|
|
89
|
-
except ValueError as exc:
|
|
90
|
-
raise ValueError("role prompt has unterminated YAML frontmatter") from exc
|
|
91
|
-
return lines[1:end], "\n".join(lines[end + 1 :]).strip() + "\n"
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def _without(frontmatter, keys):
|
|
95
|
-
return [line for line in frontmatter if not any(line.startswith(key + ":") for key in keys)]
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def _agent_docs(role, tier, access, config):
|
|
99
|
-
source = (ROLE_ROOT / f"{role}.md").read_text(encoding="utf-8")
|
|
100
|
-
frontmatter, body = _split_role(source)
|
|
101
|
-
|
|
102
|
-
claude_frontmatter = _without(frontmatter, {"model", "effort"})
|
|
103
|
-
# Concrete model, never a plugin-config placeholder: Claude Code does not
|
|
104
|
-
# interpolate plugin options into agent frontmatter, so a placeholder here
|
|
105
|
-
# reaches the model selector verbatim and every spawn dies with "issue
|
|
106
|
-
# with the selected model". config/models.json stays the one source.
|
|
107
|
-
#
|
|
108
|
-
# And a *bare* alias, never an alias with a context suffix: the agent
|
|
109
|
-
# frontmatter `model` field documents exactly three accepted shapes — a
|
|
110
|
-
# bare alias, a full model id, or `inherit`. `opus[1m]` is valid for
|
|
111
|
-
# /model and for skill frontmatter, but it is not one of those three, so
|
|
112
|
-
# it is the same undocumented-assumption bet that the placeholder was.
|
|
113
|
-
# Skill frontmatter (skills-claude/*) keeps [1m]; agents do not.
|
|
114
|
-
claude_frontmatter.append(f"model: {config['harnesses']['claude'][tier]['model']}")
|
|
115
|
-
effort = config["harnesses"]["claude"][tier].get("effort")
|
|
116
|
-
if effort:
|
|
117
|
-
claude_frontmatter.append(f"effort: {effort}")
|
|
118
|
-
claude = (
|
|
119
|
-
"---\n"
|
|
120
|
-
+ "\n".join(claude_frontmatter)
|
|
121
|
-
+ "\n---\n\n"
|
|
122
|
-
+ GENERATED
|
|
123
|
-
+ "\n"
|
|
124
|
-
+ body
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
cursor_frontmatter = _without(frontmatter, {"model", "effort", "tools"})
|
|
128
|
-
cursor_frontmatter.append("model: inherit")
|
|
129
|
-
if access == "read-only" and _cap(config, "readOnlyRoles", "cursor")["mode"] == "frontmatter":
|
|
130
|
-
cursor_frontmatter.append("readonly: true")
|
|
131
|
-
cursor = (
|
|
132
|
-
"---\n"
|
|
133
|
-
+ "\n".join(cursor_frontmatter)
|
|
134
|
-
+ "\n---\n\n"
|
|
135
|
-
+ GENERATED
|
|
136
|
-
+ "\n"
|
|
137
|
-
+ body
|
|
138
|
-
)
|
|
139
|
-
return claude, cursor
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def _opencode_agents(config):
|
|
143
|
-
"""Render adapters/opencode/agents.json: one entry per role, keyed by
|
|
144
|
-
role name, sourced from the same roles/*.md canonical prompts and the
|
|
145
|
-
same config/models.json tiers every other harness reads.
|
|
146
|
-
|
|
147
|
-
A role is dropped only when its tier is *declared absent* on this harness
|
|
148
|
-
(`absentTiers`), rather than registering a fake rung. That is `expert`
|
|
149
|
-
here, whose fable tier collapses onto opus.
|
|
150
|
-
|
|
151
|
-
The rule this replaced dropped any role whose tier resolved to the same
|
|
152
|
-
model as opus — model identity, not intent. The day `sonnet` collapsed
|
|
153
|
-
onto the opus model, `implementer` would have silently vanished from the
|
|
154
|
-
roster, and the only test that noticed would have said "6 != 5".
|
|
155
|
-
"""
|
|
156
|
-
opencode = config["harnesses"]["opencode"]
|
|
157
|
-
absent = set(opencode.get("absentTiers", ()))
|
|
158
|
-
adapter = opencode["adapter"]
|
|
159
|
-
agents = {}
|
|
160
|
-
for role, tier, access in _roles(config):
|
|
161
|
-
model = opencode[tier]["model"]
|
|
162
|
-
if tier in absent:
|
|
163
|
-
continue
|
|
164
|
-
source = (ROLE_ROOT / f"{role}.md").read_text(encoding="utf-8")
|
|
165
|
-
frontmatter, body = _split_role(source)
|
|
166
|
-
fm = {}
|
|
167
|
-
for line in frontmatter:
|
|
168
|
-
if ":" not in line:
|
|
169
|
-
continue
|
|
170
|
-
key, _, value = line.partition(":")
|
|
171
|
-
fm[key.strip()] = value.strip()
|
|
172
|
-
if access == "read-only":
|
|
173
|
-
permission = dict(adapter["readOnlyPermission"])
|
|
174
|
-
else:
|
|
175
|
-
# Stopgap for opencode#5894 (unconfirmed whether
|
|
176
|
-
# tool.execute.before intercepts subagent bash): coarse denies
|
|
177
|
-
# on the catastrophic rm class for write-capable agents. The
|
|
178
|
-
# precise tripwire stays hooks/bash-guard.py.
|
|
179
|
-
permission = {"bash": {cmd: "deny" for cmd in adapter["writeBashDeny"]}}
|
|
180
|
-
agents[role] = {
|
|
181
|
-
"description": fm.get("description", ""),
|
|
182
|
-
"mode": "subagent",
|
|
183
|
-
"model": f"openrouter/{model}",
|
|
184
|
-
"prompt": body,
|
|
185
|
-
"permission": permission,
|
|
186
|
-
}
|
|
187
|
-
return json.dumps(agents, indent=2, sort_keys=True) + "\n"
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def _table(rows):
|
|
191
|
-
lines = ["| Tier | Model | Effort |", "|---|---|---|"]
|
|
192
|
-
for tier in ("fable", "opus", "sonnet", "haiku"):
|
|
193
|
-
item = rows[tier]
|
|
194
|
-
lines.append(f"| {tier.title()} | `{item['model']}` | {item.get('effort', 'native default')} |")
|
|
195
|
-
return "\n".join(lines)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
def _collapse_note(rows):
|
|
199
|
-
"""Name tiers that share one model on this harness.
|
|
200
|
-
|
|
201
|
-
The policy treats the four tiers as distinct rungs, and its "no Fable
|
|
202
|
-
tier here, cap escalation at Opus" branch only fires if the harness says
|
|
203
|
-
so. A mapping that silently points two rungs at one model leaves that
|
|
204
|
-
branch unreachable while the expert rung is materially fake.
|
|
205
|
-
"""
|
|
206
|
-
by_model = {}
|
|
207
|
-
for tier in ("fable", "opus", "sonnet", "haiku"):
|
|
208
|
-
by_model.setdefault(rows[tier]["model"], []).append(tier)
|
|
209
|
-
collapsed = [tiers for tiers in by_model.values() if len(tiers) > 1]
|
|
210
|
-
if not collapsed:
|
|
211
|
-
return ""
|
|
212
|
-
groups = ", ".join(
|
|
213
|
-
"≡".join(t.title() for t in tiers) + f" (`{rows[tiers[0]]['model']}`)"
|
|
214
|
-
for tiers in collapsed
|
|
215
|
-
)
|
|
216
|
-
note = f"\nTier collapse here: {groups} — routing between collapsed rungs buys role, not power. "
|
|
217
|
-
if any("fable" in tiers and "opus" in tiers for tiers in collapsed):
|
|
218
|
-
note += (
|
|
219
|
-
"Fable is not a real rung: `expert` cannot break a deadlock a "
|
|
220
|
-
"collapsed Opus already lost, so cap escalation at Opus and report. "
|
|
221
|
-
)
|
|
222
|
-
return note.rstrip() + "\n"
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
def _skill_notes(config, harness):
|
|
226
|
-
"""Name the leo:* skills this harness does not get, and why.
|
|
227
|
-
|
|
228
|
-
Silence here is worse than the exclusion: a policy Skill index that lists
|
|
229
|
-
skills the harness never registered reads as "available" until someone
|
|
230
|
-
invokes one. Reason strings describe placeholders in prose on purpose —
|
|
231
|
-
tests/test_harness_mappings.py fails the build if the literal Claude
|
|
232
|
-
token appears in a non-Claude mapping.
|
|
233
|
-
"""
|
|
234
|
-
skills = config.get("skills", {})
|
|
235
|
-
reasons = skills.get("reasons", {})
|
|
236
|
-
missing = set(skills.get("exclude", {}).get(harness, ()))
|
|
237
|
-
if harness != "claude":
|
|
238
|
-
missing |= set(skills.get("claudeOnly", ()))
|
|
239
|
-
if not missing:
|
|
240
|
-
# Claude's appendix used to end here, silent in both directions: it
|
|
241
|
-
# never listed what only it has, and the exclusion list renders only
|
|
242
|
-
# for the others. A Claude session could not tell from its own mapping
|
|
243
|
-
# which of its skills do not travel.
|
|
244
|
-
exclusive = sorted(set(skills.get("claudeOnly", ())))
|
|
245
|
-
if not exclusive:
|
|
246
|
-
return ""
|
|
247
|
-
lines = ["", "## Leo skills only available here", ""]
|
|
248
|
-
for name in exclusive:
|
|
249
|
-
lines.append(f"- `leo:{name}` — {reasons.get(name, 'not portable to other harnesses')}.")
|
|
250
|
-
lines.append("")
|
|
251
|
-
lines.append(
|
|
252
|
-
"Every other skill in the policy's Skill index is registered on every "
|
|
253
|
-
"harness and behaves the same. These are not, so a procedure that "
|
|
254
|
-
"leans on one of them does not transfer."
|
|
255
|
-
)
|
|
256
|
-
return "\n".join(lines) + "\n"
|
|
257
|
-
lines = ["", "## Leo skills not available here", ""]
|
|
258
|
-
for name in sorted(missing):
|
|
259
|
-
lines.append(f"- `leo:{name}` — {reasons.get(name, 'not portable to this harness')}.")
|
|
260
|
-
lines.append("")
|
|
261
|
-
lines.append(
|
|
262
|
-
"Every other skill in the policy's Skill index is registered here and "
|
|
263
|
-
"behaves the same, and so are the operational skills — `leo:review-pr`, "
|
|
264
|
-
"`leo:resolve-ticket` and `leo:watch-review` all run on this harness. "
|
|
265
|
-
"Where they name a capability the table above says is missing, take "
|
|
266
|
-
"the fallback each one documents."
|
|
267
|
-
)
|
|
268
|
-
return "\n".join(lines) + "\n"
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
def _capability_table(config, harness):
|
|
272
|
-
"""The declarative half of every appendix.
|
|
273
|
-
|
|
274
|
-
Every harness answers every row, so a *gap* is disclosed by exactly the
|
|
275
|
-
same mechanism as a capability. Hand-written prose is what let the
|
|
276
|
-
worktree tools and the workflow runner go unmentioned on three harnesses
|
|
277
|
-
each: nothing forces a paragraph that was never written, and nothing
|
|
278
|
-
notices when one goes stale.
|
|
279
|
-
|
|
280
|
-
The notes are still human-written English. The matrix does not make them
|
|
281
|
-
true; it makes them structurally complete and impossible to omit.
|
|
282
|
-
"""
|
|
283
|
-
lines = ["", "## Capabilities here", "", "| Capability | Here |", "|---|---|"]
|
|
284
|
-
for row in config["capabilities"]:
|
|
285
|
-
lines.append(f"| {row['label']} | {row['values'][harness]['note']} |")
|
|
286
|
-
return "\n".join(lines) + "\n"
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
def _capability_notes(config, harness):
|
|
290
|
-
"""Per-harness visual-evidence rungs and memory projection target.
|
|
291
|
-
|
|
292
|
-
Both are things leo:visual-verification and leo:memory would otherwise have
|
|
293
|
-
to guess at from inside a session. Written as prose rather than a literal
|
|
294
|
-
plugin-root token: tests/test_harness_mappings.py fails the build if the
|
|
295
|
-
Claude placeholder leaks into another harness's mapping.
|
|
296
|
-
"""
|
|
297
|
-
visual = config.get("visual", {}).get(harness)
|
|
298
|
-
target = config.get("memoryTarget", {}).get(harness)
|
|
299
|
-
if not visual and not target:
|
|
300
|
-
return ""
|
|
301
|
-
out = []
|
|
302
|
-
if visual:
|
|
303
|
-
out.append(
|
|
304
|
-
f"\nVisual evidence here: {visual}. When no rung answers, "
|
|
305
|
-
"leo:visual-verification requires the unverified-change warning in "
|
|
306
|
-
"place of a done report.\n"
|
|
307
|
-
)
|
|
308
|
-
if target:
|
|
309
|
-
out.append(
|
|
310
|
-
f"\nMemory projection here writes to {target}. Only global-scope "
|
|
311
|
-
"facts are projected — every per-user surface loads in every "
|
|
312
|
-
"repository, so repo facts would leak across projects; they reach "
|
|
313
|
-
"the model through the session context block instead. Leo's block "
|
|
314
|
-
"is marker-delimited; the rest of the file is untouched.\n"
|
|
315
|
-
)
|
|
316
|
-
return "".join(out)
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
def _mapping_docs(config):
|
|
320
|
-
"""One loop, not five hand-written blocks.
|
|
321
|
-
|
|
322
|
-
Everything the per-harness prose used to say is now either a capability
|
|
323
|
-
note or a shared paragraph, so a new harness gets a complete appendix by
|
|
324
|
-
answering the matrix rather than by someone remembering to write one.
|
|
325
|
-
"""
|
|
326
|
-
out = {}
|
|
327
|
-
for harness, rows in sorted(config["harnesses"].items()):
|
|
328
|
-
parts = [GENERATED, f"# {rows['title']} mapping\n\n"]
|
|
329
|
-
if rows.get("provider"):
|
|
330
|
-
parts.append(f"Provider: `{rows['provider']}`\n\n")
|
|
331
|
-
parts.append(_table(rows))
|
|
332
|
-
parts.append("\n")
|
|
333
|
-
parts.append(_capability_table(config, harness))
|
|
334
|
-
parts.append(_capability_notes(config, harness))
|
|
335
|
-
parts.append(_collapse_note(rows))
|
|
336
|
-
parts.append(_skill_notes(config, harness))
|
|
337
|
-
out[harness] = "".join(parts)
|
|
338
|
-
return out
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
def _absent_tier_sentence(config):
|
|
342
|
-
"""Derived, not asserted: the roles OpenCode does not register.
|
|
343
|
-
|
|
344
|
-
Hand-written, this sentence named `expert` and Fable directly and would
|
|
345
|
-
have gone quietly wrong the first time the tier table changed.
|
|
346
|
-
"""
|
|
347
|
-
opencode = config["harnesses"]["opencode"]
|
|
348
|
-
absent = list(opencode.get("absentTiers", ()))
|
|
349
|
-
if not absent:
|
|
350
|
-
return "Every tier is a distinct rung here."
|
|
351
|
-
roles = [r for r, tier, _ in _roles(config) if tier in absent]
|
|
352
|
-
tiers = " and ".join(t.title() for t in absent)
|
|
353
|
-
verb = "is" if len(absent) == 1 else "are"
|
|
354
|
-
listed = ", ".join(f"`{r}`" for r in roles)
|
|
355
|
-
return (
|
|
356
|
-
f"{tiers} {verb} not a real rung here, so {listed} "
|
|
357
|
-
f"{'is' if len(roles) == 1 else 'are'} not registered as an agent and "
|
|
358
|
-
"escalation caps at Opus."
|
|
359
|
-
)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
def _payload_readme(config):
|
|
363
|
-
"""The npm landing page for `leos-agent`.
|
|
364
|
-
|
|
365
|
-
The root README cannot be symlinked in: every harness copies or caches
|
|
366
|
-
this payload on its own, so a link pointing outside it dangles, and the
|
|
367
|
-
packaging tests forbid symlinks here for exactly that reason. Generating
|
|
368
|
-
it instead keeps one source and lets --check catch drift.
|
|
369
|
-
|
|
370
|
-
Scoped to OpenCode deliberately — npm is only how OpenCode installs this.
|
|
371
|
-
Every other harness has a native marketplace, and its instructions would
|
|
372
|
-
be noise on this page.
|
|
373
|
-
"""
|
|
374
|
-
opencode = config["harnesses"]["opencode"]
|
|
375
|
-
return (
|
|
376
|
-
GENERATED
|
|
377
|
-
+ "\n# Leo's Agent\n\n"
|
|
378
|
-
"Leo's Agent is a portable agent operating policy: cost-tiered model routing, specialist "
|
|
379
|
-
"subagent roles, process skills, execute-then-review discipline, and a narrow "
|
|
380
|
-
"catastrophic-command guard.\n\n"
|
|
381
|
-
"This npm package is the **OpenCode** distribution. Claude Code, Codex, Cursor, and Hermes "
|
|
382
|
-
"each install it through their own plugin system — see "
|
|
383
|
-
"[the repository](https://github.com/foxhatleo/leos-agent) for those.\n\n"
|
|
384
|
-
"## Install\n\n"
|
|
385
|
-
"```sh\nopencode plugin leos-agent --global\n```\n\n"
|
|
386
|
-
"On builds without the `plugin` subcommand, add it to "
|
|
387
|
-
"`~/.config/opencode/opencode.json` (or `opencode.jsonc`) by hand:\n\n"
|
|
388
|
-
'```json\n{ "$schema": "https://opencode.ai/config.json", "plugin": ["leos-agent"] }\n```\n\n'
|
|
389
|
-
"Start a new OpenCode session. The plugin registers the skills directory, the "
|
|
390
|
-
f"{len(json.loads(_opencode_agents(config)))} subagent roles, and the operating policy, and installs the "
|
|
391
|
-
"bash deletion tripwire.\n\n"
|
|
392
|
-
"If the skills do not appear, run `opencode debug skill` — each one should list a "
|
|
393
|
-
"`location` inside this package. The plugin resolves its own install path and registers "
|
|
394
|
-
"it, so none needs to be written by hand.\n\n"
|
|
395
|
-
"## Model tiers\n\n"
|
|
396
|
-
"Tier names describe the kind of work, not a fixed provider model.\n\n"
|
|
397
|
-
+ _table({tier: opencode[tier] for tier in TIERS})
|
|
398
|
-
+ "\n\n"
|
|
399
|
-
+ _absent_tier_sentence(config)
|
|
400
|
-
+ " Retier by editing `config/models.json` and re-running "
|
|
401
|
-
"`scripts/render_adapters.py`.\n\n"
|
|
402
|
-
"## Links\n\n"
|
|
403
|
-
"- [Repository and full documentation](https://github.com/foxhatleo/leos-agent)\n"
|
|
404
|
-
"- [Operating policy](https://github.com/foxhatleo/leos-agent/blob/main/plugins/leo/skills/using-leo/SKILL.md)\n\n"
|
|
405
|
-
"MIT licensed.\n"
|
|
406
|
-
)
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
def render(config):
|
|
410
|
-
_validate(config)
|
|
411
|
-
outputs = {}
|
|
412
|
-
for role, tier, access in _roles(config):
|
|
413
|
-
claude, cursor = _agent_docs(role, tier, access, config)
|
|
414
|
-
# Claude Code agents MUST live at the conventional agents/ path: a
|
|
415
|
-
# manifest "agents" array of file paths validates but silently loads
|
|
416
|
-
# zero agents, and the conventional directory auto-loads.
|
|
417
|
-
outputs[ROOT / "agents" / f"{role}.md"] = claude
|
|
418
|
-
outputs[ROOT / "adapters" / "cursor" / "agents" / f"{role}.md"] = cursor
|
|
419
|
-
for harness, content in _mapping_docs(config).items():
|
|
420
|
-
outputs[ROOT / "skills" / "using-leo" / "references" / f"{harness}-mapping.md"] = content
|
|
421
|
-
outputs[ROOT / "adapters" / "opencode" / "agents.json"] = _opencode_agents(config)
|
|
422
|
-
outputs[ROOT / "README.md"] = _payload_readme(config)
|
|
423
|
-
|
|
424
|
-
# The manifest is no longer rewritten here: per-install model overrides
|
|
425
|
-
# were retired along with the placeholder they fed. Retiering means editing
|
|
426
|
-
# config/models.json and re-running this script, the flow README documents.
|
|
427
|
-
return outputs
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
def main():
|
|
431
|
-
parser = argparse.ArgumentParser()
|
|
432
|
-
parser.add_argument("--check", action="store_true", help="fail if generated adapters drift")
|
|
433
|
-
args = parser.parse_args()
|
|
434
|
-
config = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
435
|
-
outputs = render(config)
|
|
436
|
-
drift = []
|
|
437
|
-
for path, expected in outputs.items():
|
|
438
|
-
if path.exists() and path.read_text(encoding="utf-8") == expected:
|
|
439
|
-
continue
|
|
440
|
-
if args.check:
|
|
441
|
-
drift.append(path.relative_to(ROOT).as_posix())
|
|
442
|
-
else:
|
|
443
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
444
|
-
path.write_text(expected, encoding="utf-8")
|
|
445
|
-
|
|
446
|
-
# Stale generated files are drift too. Comparing only what render() would
|
|
447
|
-
# produce is blind to a file that exists and *shouldn't* — a role dropped
|
|
448
|
-
# from models.json, a harness renamed — so the orphan survives every
|
|
449
|
-
# --check and ships. Sweep the generated trees and flag anything unclaimed.
|
|
450
|
-
for pattern in (
|
|
451
|
-
"agents/*.md",
|
|
452
|
-
"adapters/cursor/agents/*.md",
|
|
453
|
-
"adapters/opencode/agents.json",
|
|
454
|
-
"README.md",
|
|
455
|
-
"skills/using-leo/references/*-mapping.md",
|
|
456
|
-
):
|
|
457
|
-
for path in sorted(ROOT.glob(pattern)):
|
|
458
|
-
if path in outputs:
|
|
459
|
-
continue
|
|
460
|
-
rel = path.relative_to(ROOT).as_posix()
|
|
461
|
-
if args.check:
|
|
462
|
-
drift.append(f"{rel} (stale: nothing in config/models.json generates it)")
|
|
463
|
-
else:
|
|
464
|
-
path.unlink()
|
|
465
|
-
print(f"removed stale generated file: {rel}", file=sys.stderr)
|
|
466
|
-
if drift:
|
|
467
|
-
print("generated adapter drift: " + ", ".join(drift), file=sys.stderr)
|
|
468
|
-
return 1
|
|
469
|
-
return 0
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
if __name__ == "__main__":
|
|
473
|
-
raise SystemExit(main())
|
package/scripts/setup.py
DELETED
|
@@ -1,161 +0,0 @@
|
|
|
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:]))
|
package/settings.json
DELETED
package/skills/.gitkeep
DELETED
|
File without changes
|