leos-agent 6.1.0 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +43 -0
  2. package/adapters/cursor/agents/executor.md +1 -1
  3. package/adapters/cursor/agents/implementer.md +1 -1
  4. package/adapters/cursor/agents/reviewer.md +1 -0
  5. package/adapters/opencode/agents.json +3 -3
  6. package/adapters/opencode/plugin.js +131 -29
  7. package/config/models.json +379 -33
  8. package/hooks/session-start.py +27 -0
  9. package/package.json +18 -4
  10. package/roles/executor.md +1 -1
  11. package/roles/implementer.md +1 -1
  12. package/roles/reviewer.md +1 -0
  13. package/scripts/doctor.py +284 -0
  14. package/scripts/ghreview.py +554 -0
  15. package/scripts/memory.py +705 -0
  16. package/scripts/render_adapters.py +244 -97
  17. package/scripts/resolve_attach_target.py +357 -0
  18. package/scripts/setup.py +161 -0
  19. package/skills/delegation/SKILL.md +1 -1
  20. package/skills/doctor/SKILL.md +105 -0
  21. package/skills/freshness/SKILL.md +118 -0
  22. package/skills/memory/SKILL.md +144 -0
  23. package/skills/resolve-ticket/SKILL.md +269 -0
  24. package/skills/review-pr/SKILL.md +317 -0
  25. package/skills/setup/SKILL.md +85 -0
  26. package/skills/using-leo/SKILL.md +8 -1
  27. package/skills/using-leo/references/claude-mapping.md +22 -1
  28. package/skills/using-leo/references/codex-mapping.md +17 -7
  29. package/skills/using-leo/references/cursor-mapping.md +18 -6
  30. package/skills/using-leo/references/hermes-mapping.md +17 -7
  31. package/skills/using-leo/references/opencode-mapping.md +16 -8
  32. package/skills/verification/SKILL.md +7 -0
  33. package/skills/visual-verification/SKILL.md +114 -0
  34. package/skills/watch-review/SKILL.md +125 -0
  35. package/skills/writing-skills/SKILL.md +134 -0
@@ -12,7 +12,72 @@ CONFIG_PATH = ROOT / "config" / "models.json"
12
12
  ROLE_ROOT = ROOT / "roles"
13
13
  CLAUDE_MANIFEST = ROOT / ".claude-plugin" / "plugin.json"
14
14
  GENERATED = "<!-- Generated by scripts/render_adapters.py; do not edit. -->\n"
15
- READ_ONLY = {"expert", "explore", "investigator", "planner", "reviewer"}
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
+ )
16
81
 
17
82
 
18
83
  def _split_role(text):
@@ -30,7 +95,7 @@ def _without(frontmatter, keys):
30
95
  return [line for line in frontmatter if not any(line.startswith(key + ":") for key in keys)]
31
96
 
32
97
 
33
- def _agent_docs(role, tier, config):
98
+ def _agent_docs(role, tier, access, config):
34
99
  source = (ROLE_ROOT / f"{role}.md").read_text(encoding="utf-8")
35
100
  frontmatter, body = _split_role(source)
36
101
 
@@ -61,7 +126,7 @@ def _agent_docs(role, tier, config):
61
126
 
62
127
  cursor_frontmatter = _without(frontmatter, {"model", "effort", "tools"})
63
128
  cursor_frontmatter.append("model: inherit")
64
- if role in READ_ONLY:
129
+ if access == "read-only" and _cap(config, "readOnlyRoles", "cursor")["mode"] == "frontmatter":
65
130
  cursor_frontmatter.append("readonly: true")
66
131
  cursor = (
67
132
  "---\n"
@@ -79,18 +144,22 @@ def _opencode_agents(config):
79
144
  role name, sourced from the same roles/*.md canonical prompts and the
80
145
  same config/models.json tiers every other harness reads.
81
146
 
82
- A role whose tier resolves to the same model as `opus` on this harness
83
- is skipped — that is `expert`/fable here, since opencode's tier table
84
- collapses Fable onto Opus (see the opencode harnesses block). Dropping
85
- it, rather than registering a fake rung, matches the removed v3.1
86
- bridge's posture.
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".
87
155
  """
88
156
  opencode = config["harnesses"]["opencode"]
89
- opus_model = opencode["opus"]["model"]
157
+ absent = set(opencode.get("absentTiers", ()))
158
+ adapter = opencode["adapter"]
90
159
  agents = {}
91
- for role, tier in sorted(config["roles"].items()):
160
+ for role, tier, access in _roles(config):
92
161
  model = opencode[tier]["model"]
93
- if model == opus_model and tier != "opus":
162
+ if tier in absent:
94
163
  continue
95
164
  source = (ROLE_ROOT / f"{role}.md").read_text(encoding="utf-8")
96
165
  frontmatter, body = _split_role(source)
@@ -100,21 +169,14 @@ def _opencode_agents(config):
100
169
  continue
101
170
  key, _, value = line.partition(":")
102
171
  fm[key.strip()] = value.strip()
103
- if role in READ_ONLY:
104
- permission = {"edit": "deny"}
172
+ if access == "read-only":
173
+ permission = dict(adapter["readOnlyPermission"])
105
174
  else:
106
175
  # Stopgap for opencode#5894 (unconfirmed whether
107
176
  # tool.execute.before intercepts subagent bash): coarse denies
108
177
  # on the catastrophic rm class for write-capable agents. The
109
178
  # precise tripwire stays hooks/bash-guard.py.
110
- permission = {
111
- "bash": {
112
- "rm -rf ~": "deny",
113
- "rm -rf ~/*": "deny",
114
- "rm -rf /": "deny",
115
- "rm -rf /*": "deny",
116
- }
117
- }
179
+ permission = {"bash": {cmd: "deny" for cmd in adapter["writeBashDeny"]}}
118
180
  agents[role] = {
119
181
  "description": fm.get("description", ""),
120
182
  "mode": "subagent",
@@ -170,102 +232,185 @@ def _skill_notes(config, harness):
170
232
  token appears in a non-Claude mapping.
171
233
  """
172
234
  skills = config.get("skills", {})
235
+ reasons = skills.get("reasons", {})
173
236
  missing = set(skills.get("exclude", {}).get(harness, ()))
174
237
  if harness != "claude":
175
238
  missing |= set(skills.get("claudeOnly", ()))
176
239
  if not missing:
177
- return ""
178
- reasons = skills.get("reasons", {})
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"
179
257
  lines = ["", "## Leo skills not available here", ""]
180
258
  for name in sorted(missing):
181
259
  lines.append(f"- `leo:{name}` — {reasons.get(name, 'not portable to this harness')}.")
182
260
  lines.append("")
183
261
  lines.append(
184
262
  "Every other skill in the policy's Skill index is registered here and "
185
- "behaves the same. Reviewing a pull request on this harness means "
186
- "running the canonical reviewer role prompt against the diff by hand."
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."
187
267
  )
188
268
  return "\n".join(lines) + "\n"
189
269
 
190
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
+
191
319
  def _mapping_docs(config):
192
- claude_rows = config["harnesses"]["claude"]
193
- codex = config["harnesses"]["codex"]
194
- cursor = config["harnesses"]["cursor"]
195
- hermes = config["harnesses"]["hermes"]
196
- hermes_rows = {tier: hermes[tier] for tier in ("fable", "opus", "sonnet", "haiku")}
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
+ """
197
347
  opencode = config["harnesses"]["opencode"]
198
- opencode_rows = {tier: opencode[tier] for tier in ("fable", "opus", "sonnet", "haiku")}
199
- return {
200
- "claude": GENERATED
201
- + "# Claude Code mapping\n\n"
202
- + _table(claude_rows)
203
- + "\n\nSpawn the named native agent; its generated frontmatter selects the configured model.\n"
204
- + _collapse_note(claude_rows)
205
- + _skill_notes(config, "claude"),
206
- "codex": GENERATED
207
- + "# Codex mapping\n\n"
208
- + _table(codex)
209
- + "\n\nSpawn a generic subagent with the canonical `roles/<role>.md` prompt and pass both "
210
- "`model` and `reasoning_effort` explicitly. A model override in the user's prompt or "
211
- "native `AGENTS.md` wins over these defaults.\n"
212
- "\nRead-only is prompt-enforced here, not harness-enforced: the judge roles "
213
- "(planner, investigator, reviewer, explore) are pasted prompts, so nothing stops a "
214
- "subagent that ignores them from editing. Treat their read-only contract as a "
215
- "convention, and never route work here that depends on it being a guarantee.\n"
216
- + _collapse_note(codex)
217
- + _skill_notes(config, "codex"),
218
- "cursor": GENERATED
219
- + "# Cursor mapping\n\n"
220
- + _table(cursor)
221
- + "\n\nCursor plugin agents use `model: inherit`. Select the mapped model in Cursor before "
222
- "starting a homogeneous tier batch; the plugin does not claim to enforce arbitrary "
223
- "per-agent model names.\n"
224
- + _collapse_note(cursor)
225
- + _skill_notes(config, "cursor"),
226
- "hermes": GENERATED
227
- + "# Hermes mapping\n\n"
228
- + f"Provider: `{hermes['provider']}`\n\n"
229
- + _table(hermes_rows)
230
- + "\n\nHermes native `delegate_task` has one configured delegation model. Group work into "
231
- "homogeneous Kimi or GLM batches, switch the parent with `/model`, and set "
232
- "`delegation.provider: openrouter` plus the matching `delegation.model` before spawning.\n"
233
- "\nThis policy is NOT injected automatically here. Hermes accepts a `pre_llm_call` "
234
- "hook but its runtime never invokes one, so the plugin registers `leo:using-leo` as "
235
- "an ordinary skill instead — read it at the start of a session to load the policy. "
236
- "Read-only is prompt-enforced only: the judge roles are pasted prompts, so their "
237
- "read-only contract is a convention here, not a guarantee.\n"
238
- + _collapse_note(hermes_rows)
239
- + _skill_notes(config, "hermes"),
240
- "opencode": GENERATED
241
- + "# OpenCode mapping\n\n"
242
- + f"Provider: `{opencode['provider']}`\n\n"
243
- + _table(opencode_rows)
244
- + "\n\nRoles register as native OpenCode agents (from `adapters/opencode/agents.json`, "
245
- "generated from `config/models.json` and `roles/*.md`) and are spawned via the task tool "
246
- "as subagents. There is no per-spawn model override on this harness: each agent always "
247
- "runs its registered model, so `reviewer` always runs the full Opus-tier model — the "
248
- "trivial-diff Sonnet-tier downscale does not apply here; every diff gets the full review.\n"
249
- "\nRead-only is harness-enforced here, unlike Codex and Cursor: read-only roles carry a "
250
- "generated `permission.edit: deny`, so an off-policy write attempt is refused by OpenCode "
251
- "itself, not merely discouraged by the prompt. Write-capable agents additionally carry "
252
- "coarse `rm -rf` bash denies as a stopgap for opencode#5894 (unconfirmed whether "
253
- "`tool.execute.before` also intercepts subagent bash); the precise tripwire stays "
254
- "`hooks/bash-guard.py` on the primary agent.\n"
255
- "\nNo `EnterWorktree` tool exists here — use leo:worktrees' raw `git worktree` fallback for "
256
- "isolated branch work. State reads and writes go through `python3 <plugin-root>/scripts/state.py` "
257
- "(`get` / `merge` / `path`), same contract as every other harness. There is no Workflow tool "
258
- "and no `cost-tiered-fix.js` here — a batch of independent tasks is fanned out as manual "
259
- "parallel task-tool subagent spawns instead.\n"
260
- + _collapse_note(opencode_rows)
261
- + _skill_notes(config, "opencode"),
262
- }
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
+ )
263
407
 
264
408
 
265
409
  def render(config):
410
+ _validate(config)
266
411
  outputs = {}
267
- for role, tier in sorted(config["roles"].items()):
268
- claude, cursor = _agent_docs(role, tier, config)
412
+ for role, tier, access in _roles(config):
413
+ claude, cursor = _agent_docs(role, tier, access, config)
269
414
  # Claude Code agents MUST live at the conventional agents/ path: a
270
415
  # manifest "agents" array of file paths validates but silently loads
271
416
  # zero agents, and the conventional directory auto-loads.
@@ -274,6 +419,7 @@ def render(config):
274
419
  for harness, content in _mapping_docs(config).items():
275
420
  outputs[ROOT / "skills" / "using-leo" / "references" / f"{harness}-mapping.md"] = content
276
421
  outputs[ROOT / "adapters" / "opencode" / "agents.json"] = _opencode_agents(config)
422
+ outputs[ROOT / "README.md"] = _payload_readme(config)
277
423
 
278
424
  # The manifest is no longer rewritten here: per-install model overrides
279
425
  # were retired along with the placeholder they fed. Retiering means editing
@@ -305,6 +451,7 @@ def main():
305
451
  "agents/*.md",
306
452
  "adapters/cursor/agents/*.md",
307
453
  "adapters/opencode/agents.json",
454
+ "README.md",
308
455
  "skills/using-leo/references/*-mapping.md",
309
456
  ):
310
457
  for path in sorted(ROOT.glob(pattern)):