leos-agent 6.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.
Files changed (43) hide show
  1. package/adapters/cursor/agents/executor.md +17 -0
  2. package/adapters/cursor/agents/expert.md +70 -0
  3. package/adapters/cursor/agents/explore.md +16 -0
  4. package/adapters/cursor/agents/implementer.md +18 -0
  5. package/adapters/cursor/agents/investigator.md +18 -0
  6. package/adapters/cursor/agents/planner.md +28 -0
  7. package/adapters/cursor/agents/reviewer.md +33 -0
  8. package/adapters/opencode/agents.json +66 -0
  9. package/adapters/opencode/plugin.js +186 -0
  10. package/config/models.json +62 -0
  11. package/hooks/bash-guard.py +541 -0
  12. package/hooks/cursor-guard.py +84 -0
  13. package/hooks/hooks-cursor.json +11 -0
  14. package/hooks/hooks.json +20 -0
  15. package/hooks/session-start.py +121 -0
  16. package/package.json +16 -0
  17. package/roles/executor.md +15 -0
  18. package/roles/expert.md +67 -0
  19. package/roles/explore.md +13 -0
  20. package/roles/implementer.md +16 -0
  21. package/roles/investigator.md +15 -0
  22. package/roles/planner.md +25 -0
  23. package/roles/reviewer.md +30 -0
  24. package/scripts/render_adapters.py +326 -0
  25. package/scripts/state.py +127 -0
  26. package/settings.json +7 -0
  27. package/skills/.gitkeep +0 -0
  28. package/skills/brainstorming/SKILL.md +109 -0
  29. package/skills/debugging/SKILL.md +98 -0
  30. package/skills/delegation/SKILL.md +141 -0
  31. package/skills/executing-plans/SKILL.md +116 -0
  32. package/skills/finishing-a-branch/SKILL.md +123 -0
  33. package/skills/test-first/SKILL.md +90 -0
  34. package/skills/using-leo/SKILL.md +89 -0
  35. package/skills/using-leo/references/claude-mapping.md +11 -0
  36. package/skills/using-leo/references/codex-mapping.md +24 -0
  37. package/skills/using-leo/references/cursor-mapping.md +22 -0
  38. package/skills/using-leo/references/hermes-mapping.md +26 -0
  39. package/skills/using-leo/references/opencode-mapping.md +28 -0
  40. package/skills/verification/SKILL.md +102 -0
  41. package/skills/worktrees/SKILL.md +129 -0
  42. package/skills/writing-plans/SKILL.md +96 -0
  43. package/workflows/cost-tiered-fix.js +259 -0
@@ -0,0 +1,326 @@
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
+ READ_ONLY = {"expert", "explore", "investigator", "planner", "reviewer"}
16
+
17
+
18
+ def _split_role(text):
19
+ lines = text.splitlines()
20
+ if not lines or lines[0] != "---":
21
+ raise ValueError("role prompt is missing YAML frontmatter")
22
+ try:
23
+ end = lines.index("---", 1)
24
+ except ValueError as exc:
25
+ raise ValueError("role prompt has unterminated YAML frontmatter") from exc
26
+ return lines[1:end], "\n".join(lines[end + 1 :]).strip() + "\n"
27
+
28
+
29
+ def _without(frontmatter, keys):
30
+ return [line for line in frontmatter if not any(line.startswith(key + ":") for key in keys)]
31
+
32
+
33
+ def _agent_docs(role, tier, config):
34
+ source = (ROLE_ROOT / f"{role}.md").read_text(encoding="utf-8")
35
+ frontmatter, body = _split_role(source)
36
+
37
+ claude_frontmatter = _without(frontmatter, {"model", "effort"})
38
+ # Concrete model, never a plugin-config placeholder: Claude Code does not
39
+ # interpolate plugin options into agent frontmatter, so a placeholder here
40
+ # reaches the model selector verbatim and every spawn dies with "issue
41
+ # with the selected model". config/models.json stays the one source.
42
+ #
43
+ # And a *bare* alias, never an alias with a context suffix: the agent
44
+ # frontmatter `model` field documents exactly three accepted shapes — a
45
+ # bare alias, a full model id, or `inherit`. `opus[1m]` is valid for
46
+ # /model and for skill frontmatter, but it is not one of those three, so
47
+ # it is the same undocumented-assumption bet that the placeholder was.
48
+ # Skill frontmatter (skills-claude/*) keeps [1m]; agents do not.
49
+ claude_frontmatter.append(f"model: {config['harnesses']['claude'][tier]['model']}")
50
+ effort = config["harnesses"]["claude"][tier].get("effort")
51
+ if effort:
52
+ claude_frontmatter.append(f"effort: {effort}")
53
+ claude = (
54
+ "---\n"
55
+ + "\n".join(claude_frontmatter)
56
+ + "\n---\n\n"
57
+ + GENERATED
58
+ + "\n"
59
+ + body
60
+ )
61
+
62
+ cursor_frontmatter = _without(frontmatter, {"model", "effort", "tools"})
63
+ cursor_frontmatter.append("model: inherit")
64
+ if role in READ_ONLY:
65
+ cursor_frontmatter.append("readonly: true")
66
+ cursor = (
67
+ "---\n"
68
+ + "\n".join(cursor_frontmatter)
69
+ + "\n---\n\n"
70
+ + GENERATED
71
+ + "\n"
72
+ + body
73
+ )
74
+ return claude, cursor
75
+
76
+
77
+ def _opencode_agents(config):
78
+ """Render adapters/opencode/agents.json: one entry per role, keyed by
79
+ role name, sourced from the same roles/*.md canonical prompts and the
80
+ same config/models.json tiers every other harness reads.
81
+
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.
87
+ """
88
+ opencode = config["harnesses"]["opencode"]
89
+ opus_model = opencode["opus"]["model"]
90
+ agents = {}
91
+ for role, tier in sorted(config["roles"].items()):
92
+ model = opencode[tier]["model"]
93
+ if model == opus_model and tier != "opus":
94
+ continue
95
+ source = (ROLE_ROOT / f"{role}.md").read_text(encoding="utf-8")
96
+ frontmatter, body = _split_role(source)
97
+ fm = {}
98
+ for line in frontmatter:
99
+ if ":" not in line:
100
+ continue
101
+ key, _, value = line.partition(":")
102
+ fm[key.strip()] = value.strip()
103
+ if role in READ_ONLY:
104
+ permission = {"edit": "deny"}
105
+ else:
106
+ # Stopgap for opencode#5894 (unconfirmed whether
107
+ # tool.execute.before intercepts subagent bash): coarse denies
108
+ # on the catastrophic rm class for write-capable agents. The
109
+ # 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
+ }
118
+ agents[role] = {
119
+ "description": fm.get("description", ""),
120
+ "mode": "subagent",
121
+ "model": f"openrouter/{model}",
122
+ "prompt": body,
123
+ "permission": permission,
124
+ }
125
+ return json.dumps(agents, indent=2, sort_keys=True) + "\n"
126
+
127
+
128
+ def _table(rows):
129
+ lines = ["| Tier | Model | Effort |", "|---|---|---|"]
130
+ for tier in ("fable", "opus", "sonnet", "haiku"):
131
+ item = rows[tier]
132
+ lines.append(f"| {tier.title()} | `{item['model']}` | {item.get('effort', 'native default')} |")
133
+ return "\n".join(lines)
134
+
135
+
136
+ def _collapse_note(rows):
137
+ """Name tiers that share one model on this harness.
138
+
139
+ The policy treats the four tiers as distinct rungs, and its "no Fable
140
+ tier here, cap escalation at Opus" branch only fires if the harness says
141
+ so. A mapping that silently points two rungs at one model leaves that
142
+ branch unreachable while the expert rung is materially fake.
143
+ """
144
+ by_model = {}
145
+ for tier in ("fable", "opus", "sonnet", "haiku"):
146
+ by_model.setdefault(rows[tier]["model"], []).append(tier)
147
+ collapsed = [tiers for tiers in by_model.values() if len(tiers) > 1]
148
+ if not collapsed:
149
+ return ""
150
+ groups = ", ".join(
151
+ "≡".join(t.title() for t in tiers) + f" (`{rows[tiers[0]]['model']}`)"
152
+ for tiers in collapsed
153
+ )
154
+ note = f"\nTier collapse here: {groups} — routing between collapsed rungs buys role, not power. "
155
+ if any("fable" in tiers and "opus" in tiers for tiers in collapsed):
156
+ note += (
157
+ "Fable is not a real rung: `expert` cannot break a deadlock a "
158
+ "collapsed Opus already lost, so cap escalation at Opus and report. "
159
+ )
160
+ return note.rstrip() + "\n"
161
+
162
+
163
+ def _skill_notes(config, harness):
164
+ """Name the leo:* skills this harness does not get, and why.
165
+
166
+ Silence here is worse than the exclusion: a policy Skill index that lists
167
+ skills the harness never registered reads as "available" until someone
168
+ invokes one. Reason strings describe placeholders in prose on purpose —
169
+ tests/test_harness_mappings.py fails the build if the literal Claude
170
+ token appears in a non-Claude mapping.
171
+ """
172
+ skills = config.get("skills", {})
173
+ missing = set(skills.get("exclude", {}).get(harness, ()))
174
+ if harness != "claude":
175
+ missing |= set(skills.get("claudeOnly", ()))
176
+ if not missing:
177
+ return ""
178
+ reasons = skills.get("reasons", {})
179
+ lines = ["", "## Leo skills not available here", ""]
180
+ for name in sorted(missing):
181
+ lines.append(f"- `leo:{name}` — {reasons.get(name, 'not portable to this harness')}.")
182
+ lines.append("")
183
+ lines.append(
184
+ "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."
187
+ )
188
+ return "\n".join(lines) + "\n"
189
+
190
+
191
+ 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")}
197
+ 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
+ }
263
+
264
+
265
+ def render(config):
266
+ outputs = {}
267
+ for role, tier in sorted(config["roles"].items()):
268
+ claude, cursor = _agent_docs(role, tier, config)
269
+ # Claude Code agents MUST live at the conventional agents/ path: a
270
+ # manifest "agents" array of file paths validates but silently loads
271
+ # zero agents, and the conventional directory auto-loads.
272
+ outputs[ROOT / "agents" / f"{role}.md"] = claude
273
+ outputs[ROOT / "adapters" / "cursor" / "agents" / f"{role}.md"] = cursor
274
+ for harness, content in _mapping_docs(config).items():
275
+ outputs[ROOT / "skills" / "using-leo" / "references" / f"{harness}-mapping.md"] = content
276
+ outputs[ROOT / "adapters" / "opencode" / "agents.json"] = _opencode_agents(config)
277
+
278
+ # The manifest is no longer rewritten here: per-install model overrides
279
+ # were retired along with the placeholder they fed. Retiering means editing
280
+ # config/models.json and re-running this script, the flow README documents.
281
+ return outputs
282
+
283
+
284
+ def main():
285
+ parser = argparse.ArgumentParser()
286
+ parser.add_argument("--check", action="store_true", help="fail if generated adapters drift")
287
+ args = parser.parse_args()
288
+ config = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
289
+ outputs = render(config)
290
+ drift = []
291
+ for path, expected in outputs.items():
292
+ if path.exists() and path.read_text(encoding="utf-8") == expected:
293
+ continue
294
+ if args.check:
295
+ drift.append(path.relative_to(ROOT).as_posix())
296
+ else:
297
+ path.parent.mkdir(parents=True, exist_ok=True)
298
+ path.write_text(expected, encoding="utf-8")
299
+
300
+ # Stale generated files are drift too. Comparing only what render() would
301
+ # produce is blind to a file that exists and *shouldn't* — a role dropped
302
+ # from models.json, a harness renamed — so the orphan survives every
303
+ # --check and ships. Sweep the generated trees and flag anything unclaimed.
304
+ for pattern in (
305
+ "agents/*.md",
306
+ "adapters/cursor/agents/*.md",
307
+ "adapters/opencode/agents.json",
308
+ "skills/using-leo/references/*-mapping.md",
309
+ ):
310
+ for path in sorted(ROOT.glob(pattern)):
311
+ if path in outputs:
312
+ continue
313
+ rel = path.relative_to(ROOT).as_posix()
314
+ if args.check:
315
+ drift.append(f"{rel} (stale: nothing in config/models.json generates it)")
316
+ else:
317
+ path.unlink()
318
+ print(f"removed stale generated file: {rel}", file=sys.stderr)
319
+ if drift:
320
+ print("generated adapter drift: " + ", ".join(drift), file=sys.stderr)
321
+ return 1
322
+ return 0
323
+
324
+
325
+ if __name__ == "__main__":
326
+ raise SystemExit(main())
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env python3
2
+ """state: machine-local JSON state for leos-agent skills and agents.
3
+
4
+ CODE ships inside the plugin (possibly a versioned cache that a plugin update
5
+ can wipe or relocate) — this file must never derive the data root from its own
6
+ __file__ location. DATA always lives under
7
+ ${LEOS_AGENT_LOCAL_PATH:-$HOME/.leos-agent-local}, independent of where this
8
+ script itself happens to run from, so a plugin update can never lose state.
9
+
10
+ State lives at $LEOS_AGENT_LOCAL_PATH/<name>.json (LEOS_AGENT_LOCAL_PATH is an
11
+ optional override; unset, it defaults to ~/.leos-agent-local). The base is a
12
+ dedicated data directory rather than a repo root, so there is no nested
13
+ local/ segment inside it: state never syncs between machines. Top-level
14
+ keys are "owner/repo" (or an
15
+ absolute project path when there is no GitHub repo) so data stays separate per
16
+ repo/project.
17
+
18
+ state.py get <name> [<repo-key>] print the repo's subtree, or the
19
+ whole file with no key ({} if absent)
20
+ state.py merge <name> <repo-key> <json> deep-merge <json> into the subtree
21
+ state.py path <name> print the backing file's path
22
+
23
+ merge semantics: dicts merge recursively, lists union in order (deduped,
24
+ so merging {"reviewed": [13]} twice never double-adds), scalars overwrite.
25
+ merge calls are serialized (flock on a sibling <name>.json.lock) and each
26
+ write is atomic (tempfile + os.replace), so concurrent merges from parallel
27
+ agents never lose an update; get is lock-free. Exit codes: 0 ok, non-zero on
28
+ error.
29
+ """
30
+ import contextlib
31
+ import fcntl
32
+ import json
33
+ import os
34
+ import sys
35
+ import tempfile
36
+
37
+
38
+ def _data_root():
39
+ return os.environ.get("LEOS_AGENT_LOCAL_PATH") or os.path.join(os.path.expanduser("~"), ".leos-agent-local")
40
+
41
+
42
+ def state_file(name):
43
+ if "/" in name or "\\" in name or ".." in name or os.path.isabs(name):
44
+ sys.exit(f"state: {name!r} is not a valid state name (no slashes, no .., not absolute)")
45
+ root = _data_root()
46
+ os.makedirs(root, exist_ok=True)
47
+ return os.path.join(root, f"{name}.json")
48
+
49
+
50
+ @contextlib.contextmanager
51
+ def _locked(path):
52
+ os.makedirs(os.path.dirname(path), exist_ok=True)
53
+ fd = os.open(path + ".lock", os.O_CREAT | os.O_RDWR, 0o600)
54
+ try:
55
+ fcntl.flock(fd, fcntl.LOCK_EX)
56
+ yield
57
+ finally:
58
+ fcntl.flock(fd, fcntl.LOCK_UN)
59
+ os.close(fd)
60
+
61
+
62
+ def load(path):
63
+ try:
64
+ with open(path) as fh:
65
+ data = json.load(fh)
66
+ except FileNotFoundError:
67
+ return {}
68
+ except json.JSONDecodeError as e:
69
+ sys.exit(f"state: {path} is corrupt ({e}) — fix or delete it")
70
+ if not isinstance(data, dict):
71
+ sys.exit(f"state: {path} is corrupt (top level is {type(data).__name__}, expected object) — fix or delete it")
72
+ return data
73
+
74
+
75
+ def deep_merge(base, patch):
76
+ if isinstance(patch, dict):
77
+ merged = dict(base) if isinstance(base, dict) else {}
78
+ for key, value in patch.items():
79
+ merged[key] = deep_merge(merged.get(key), value)
80
+ return merged
81
+ if isinstance(patch, list):
82
+ merged = list(base) if isinstance(base, list) else []
83
+ for v in patch:
84
+ if v not in merged:
85
+ merged.append(v)
86
+ return merged
87
+ return patch
88
+
89
+
90
+ def atomic_write(path, data):
91
+ os.makedirs(os.path.dirname(path), exist_ok=True)
92
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), suffix=".tmp")
93
+ try:
94
+ with os.fdopen(fd, "w") as fh:
95
+ json.dump(data, fh, indent=1, sort_keys=True)
96
+ fh.write("\n")
97
+ os.replace(tmp, path)
98
+ except BaseException:
99
+ os.unlink(tmp)
100
+ raise
101
+
102
+
103
+ def main(argv):
104
+ if len(argv) >= 2 and argv[0] == "path":
105
+ print(state_file(argv[1]))
106
+ elif len(argv) >= 2 and argv[0] == "get":
107
+ data = load(state_file(argv[1]))
108
+ if len(argv) >= 3:
109
+ data = data.get(argv[2], {})
110
+ print(json.dumps(data, indent=1, sort_keys=True))
111
+ elif len(argv) == 4 and argv[0] == "merge":
112
+ try:
113
+ patch = json.loads(argv[3])
114
+ except json.JSONDecodeError as e:
115
+ sys.exit(f"state: patch is not valid JSON ({e})")
116
+ path = state_file(argv[1])
117
+ with _locked(path):
118
+ data = load(path)
119
+ data[argv[2]] = deep_merge(data.get(argv[2], {}), patch)
120
+ atomic_write(path, data)
121
+ print(json.dumps(data[argv[2]], indent=1, sort_keys=True))
122
+ else:
123
+ sys.exit(__doc__.strip())
124
+
125
+
126
+ if __name__ == "__main__":
127
+ main(sys.argv[1:])
package/settings.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "permissions": { "defaultMode": "auto" },
3
+ "tui": "fullscreen",
4
+ "theme": "auto",
5
+ "skipWorkflowUsageWarning": true,
6
+ "agentPushNotifEnabled": true
7
+ }
File without changes
@@ -0,0 +1,109 @@
1
+ ---
2
+ name: brainstorming
3
+ description: >
4
+ Design gate before non-trivial code — proportional to blast radius and
5
+ reversibility, the deliberate opposite of an unconditional gate. Contained,
6
+ easily reversible changes clear with one sentence of rationale; changes with
7
+ wide blast radius, hard to reverse, or that introduce new surface need
8
+ genuine, viable alternatives with trade-offs weighed before any code gets
9
+ written. Produces the chosen approach and its trade-offs, sized to the gate,
10
+ handed off to leo:writing-plans.
11
+ when_to_use: >
12
+ Before starting non-trivial code: a new feature, a new integration surface,
13
+ a schema or data-model change, anything that's expensive or awkward to
14
+ undo. NOT for a contained, easily reversible tweak (that just needs one
15
+ sentence of rationale, not this skill's full procedure), NOT for pure
16
+ investigation (use investigator), and NOT for writing the plan itself
17
+ (leo:writing-plans) — brainstorming stops at a chosen approach, it never
18
+ slides into implementation.
19
+ ---
20
+
21
+ # brainstorming
22
+
23
+ Core rule: the depth of the design gate is proportional to blast radius and
24
+ reversibility, not to how the task felt when it landed. A one-line change to
25
+ a private helper does not need three alternatives; a new public API or a
26
+ schema migration does.
27
+
28
+ ## Size the gate first
29
+
30
+ Before generating anything, classify the change:
31
+
32
+ - **Contained + easily reversible** (a local refactor, an internal helper, a
33
+ flag you can flip back) → one sentence of rationale is enough. Say what
34
+ you're doing and why, then move to leo:writing-plans or straight to
35
+ implementation per the routing table.
36
+ - **Wide blast radius, hard to reverse, or new surface** (public API, schema
37
+ or data-model change, cross-service contract, anything users or other
38
+ systems will come to depend on) → full gate: genuine alternatives with
39
+ trade-offs, written down, before any code.
40
+
41
+ When unsure which bucket, treat it as the wider one — the cost of one extra
42
+ paragraph is nothing next to the cost of an unreversible wrong turn.
43
+
44
+ ## Alternatives must be viable
45
+
46
+ Every alternative in a full gate has to be something a reasonable engineer
47
+ could actually ship and defend, not a strawman stood up to make the first
48
+ idea look good by comparison. If you can't articulate a real reason someone
49
+ would pick alternative B, it isn't an alternative — go find one that's
50
+ actually competing for the job, or drop down to the one-sentence gate because
51
+ there's really only one sane approach.
52
+
53
+ Test: could you argue for this option in front of Leo without a "but
54
+ obviously we won't do this" tone? If not, it's a strawman — cut it.
55
+
56
+ ## Generation method
57
+
58
+ To surface genuinely different options, vary along a different axis each
59
+ time rather than producing three cosmetic variants of the same idea:
60
+
61
+ 1. **Data model vs. control flow vs. boundary/interface** — would this
62
+ problem look different if you moved the complexity into the data shape,
63
+ into how execution flows, or into where the interface/boundary sits?
64
+ 2. **Prior art in the repo** — grep for how this repo already solved a
65
+ similar problem (via explore, not inline digging) and steal that pattern
66
+ before inventing a new one. Consistency with existing structure is a real
67
+ trade-off, not a tie-breaker of last resort.
68
+ 3. **The 10x-simpler version** — what would this look like with one-tenth
69
+ the code/config/moving parts? Even when you don't ship it, it's usually
70
+ the sharpest lens on what the "proper" version is paying for.
71
+
72
+ ## Output
73
+
74
+ - Contained/reversible: one sentence of rationale, folded into the plan or
75
+ the commit itself.
76
+ - Full gate: the chosen approach plus the trade-offs record — a paragraph for
77
+ a medium decision, a short doc for a genuinely high-stakes one. Sized to
78
+ the gate, not padded to look thorough.
79
+
80
+ Either way, the output is a decision, not code. Hand it to
81
+ leo:writing-plans for the actual plan; brainstorming never slides into
82
+ implementation itself.
83
+
84
+ ## Self-talk to catch
85
+
86
+ - "I'll just list two options so it looks considered" — if you can't defend
87
+ both, that's a strawman, not a gate.
88
+ - "This is a big change but I already know the answer" — blast radius and
89
+ reversibility decide the gate size, not your confidence.
90
+ - "I'll sketch the plan while I'm at it" — that's leo:writing-plans' job;
91
+ stop at the chosen approach.
92
+ - "One sentence feels thin for something this exciting" — excitement isn't
93
+ blast radius; if it's contained and reversible, one sentence is correct.
94
+
95
+ ## Escalation
96
+
97
+ Planning-tier work runs at Opus per the routing table (plan mode in an Opus
98
+ session, or the `planner` subagent otherwise). Escalate per the standard
99
+ ladder: two failed passes at reaching a defensible set of alternatives step
100
+ up a tier; a genuine deadlock between two Opus-tier framings goes to
101
+ `expert`, announced in one line, never silently.
102
+
103
+ ## Works with
104
+
105
+ - leo:writing-plans — takes the chosen approach and turns it into an
106
+ executable plan.
107
+ - investigator — for questions that need evidence before a design question
108
+ can even be framed.
109
+ - explore — cheap prior-art search feeding the generation method above.
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: debugging
3
+ description: >
4
+ Root-cause-before-fix loop for bugs, failing tests, crashes, and surprising
5
+ behavior. Five named phases — Reproduce, Localize, Hypothesize, Prove, Fix —
6
+ each with an exit criterion, so a fix never lands before the cause is
7
+ pinned to file:line. Diagnosis is read-only judge work (investigator); the
8
+ fix happens separately, at the routed tier.
9
+ when_to_use: >
10
+ Any bug report, failing test, crash, stack trace, or "why does X happen"
11
+ before proposing a fix — used by the investigator agent and by the main
12
+ loop ahead of any edit that touches broken behavior. NOT for planned
13
+ feature work with no defect (that's planner), NOT for judging someone
14
+ else's diff (that's reviewer), and NOT a substitute for leo:verification
15
+ after the fix lands — this skill ends at Fix, verification is separate.
16
+ ---
17
+
18
+ # debugging
19
+
20
+ Core rule: no fix before the cause is REPRODUCED and LOCATED at file:line. A
21
+ symptom going away is not proof — it's a coincidence until the loop below
22
+ says otherwise.
23
+
24
+ ## When it fires
25
+
26
+ Bug reports, failing tests, crashes, stack traces, flaky behavior, "this
27
+ should work but doesn't." Route the diagnosis itself through `investigator`
28
+ (Opus, read-only) per the model-routing table — this skill is its loop.
29
+ Doesn't fire for greenfield feature work (no defect exists yet) or for
30
+ diffing someone else's change (that's `reviewer`).
31
+
32
+ ## The five phases
33
+
34
+ Named exactly, run in order, each with an exit criterion. Do not skip a
35
+ phase because the bug "looks obvious" — obvious bugs are exactly the ones
36
+ where a wrong guess ships fastest.
37
+
38
+ | Phase | Exit criterion |
39
+ |---|---|
40
+ | **Reproduce** | The failure fires on command — a test, a script, a repro sequence — not "worked once." No stable repro yet is itself a finding: report it, don't guess past it. |
41
+ | **Localize** | The failure is traced to a specific **file:line**, not a subsystem or a vibe ("something in auth"). Read the actual code path the repro exercises; don't infer from names or docs. |
42
+ | **Hypothesize** | One sentence: "X happens because file:line does Y instead of Z." One hypothesis at a time — write it down before touching anything. |
43
+ | **Prove** | The smallest evidence that the hypothesis IS the cause, not just correlated with it. Where the surface is testable, that's a failing test written per `leo:test-first` — red on the bug, and its assertion names the file:line from Localize. Where nothing is testable (infra, timing, external system), the next-smallest evidence: a log line, a debugger break, a minimal repro script. |
44
+ | **Fix** | The change that makes Prove's evidence pass. Happens at the routed tier (`executor` for mechanical, `implementer` for real changes) — never by the same pass that diagnosed it. |
45
+
46
+ Reproduce and Localize can compress into one step for a trivial case (a
47
+ crash with a one-frame stack trace pointing straight at the bug) — but
48
+ Hypothesize and Prove never collapse into Fix. If you catch yourself editing
49
+ code before you've written the hypothesis sentence, stop and back up.
50
+
51
+ ## One hypothesis, one change
52
+
53
+ Test one hypothesis at a time. If Fix doesn't clear Prove's evidence, the
54
+ hypothesis was wrong — REVERT the change before forming the next one. Never
55
+ stack a second speculative edit on top of a first that didn't pan out; you
56
+ lose the ability to tell which change did what, and the diff stops being
57
+ reviewable. Revert, re-enter Hypothesize with what the failed attempt taught
58
+ you, and go again.
59
+
60
+ ## Stuck: the ladder
61
+
62
+ After two failures on the same cause (two hypotheses tried and reverted, still
63
+ no Prove), step up one tier rather than retrying at the same one — investigator
64
+ haiku-assist steps to full investigator, investigator itself steps to a
65
+ second, more evidence-fed pass, capped at Opus. A genuine deadlock, or two
66
+ Opus verdicts on the same cause that disagree → `expert`, announced in one
67
+ line ("escalating to expert: <question>") before it's invoked, never silent.
68
+ Don't loop a third time at the same tier hoping the next guess lands — that's
69
+ the same failure mode as skipping Prove, just slower.
70
+
71
+ ## Diagnosis and fix stay separate
72
+
73
+ The phase that reaches the verdict (Reproduce through Prove) is read-only
74
+ judge work — no edits, no reverts-of-other-people's-code, just evidence and a
75
+ file:line. Whoever ran that pass hands the hypothesis and its proof to the
76
+ executing tier for Fix. This mirrors why `reviewer` never patches what it
77
+ finds: the same pass that wants to be right about the cause is a bad judge of
78
+ whether it actually is. After Fix lands, `leo:verification` (or a plain
79
+ `reviewer` pass on the diff) is the separate check that the fix is real and
80
+ didn't just make Prove's specific probe go quiet.
81
+
82
+ ## Self-talk to catch
83
+
84
+ - "It's obviously the timeout" — obvious is not file:line; go Localize it.
85
+ - "Passing now, good enough" — passing isn't Prove; did you write the
86
+ failing-first check, or did the symptom just stop reproducing?
87
+ - "I'll patch this and see if it helps" — that's skipping Hypothesize; name
88
+ the mechanism before touching code.
89
+ - "One more tweak on top, I'm close" — that's the stacked-edit trap; revert
90
+ first.
91
+ - "Third guess this tier, one more won't hurt" — it's the two-failures
92
+ trigger; escalate instead.
93
+
94
+ ## Works with
95
+
96
+ `leo:test-first` for writing Prove's failing test. `leo:verification` for
97
+ the post-Fix check. `investigator` runs this loop; `reviewer` judges the
98
+ resulting diff once Fix is applied.