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.
- package/adapters/cursor/agents/executor.md +17 -0
- package/adapters/cursor/agents/expert.md +70 -0
- package/adapters/cursor/agents/explore.md +16 -0
- package/adapters/cursor/agents/implementer.md +18 -0
- package/adapters/cursor/agents/investigator.md +18 -0
- package/adapters/cursor/agents/planner.md +28 -0
- package/adapters/cursor/agents/reviewer.md +33 -0
- package/adapters/opencode/agents.json +66 -0
- package/adapters/opencode/plugin.js +186 -0
- package/config/models.json +62 -0
- package/hooks/bash-guard.py +541 -0
- package/hooks/cursor-guard.py +84 -0
- package/hooks/hooks-cursor.json +11 -0
- package/hooks/hooks.json +20 -0
- package/hooks/session-start.py +121 -0
- package/package.json +16 -0
- package/roles/executor.md +15 -0
- package/roles/expert.md +67 -0
- package/roles/explore.md +13 -0
- package/roles/implementer.md +16 -0
- package/roles/investigator.md +15 -0
- package/roles/planner.md +25 -0
- package/roles/reviewer.md +30 -0
- package/scripts/render_adapters.py +326 -0
- package/scripts/state.py +127 -0
- package/settings.json +7 -0
- package/skills/.gitkeep +0 -0
- package/skills/brainstorming/SKILL.md +109 -0
- package/skills/debugging/SKILL.md +98 -0
- package/skills/delegation/SKILL.md +141 -0
- package/skills/executing-plans/SKILL.md +116 -0
- package/skills/finishing-a-branch/SKILL.md +123 -0
- package/skills/test-first/SKILL.md +90 -0
- package/skills/using-leo/SKILL.md +89 -0
- package/skills/using-leo/references/claude-mapping.md +11 -0
- package/skills/using-leo/references/codex-mapping.md +24 -0
- package/skills/using-leo/references/cursor-mapping.md +22 -0
- package/skills/using-leo/references/hermes-mapping.md +26 -0
- package/skills/using-leo/references/opencode-mapping.md +28 -0
- package/skills/verification/SKILL.md +102 -0
- package/skills/worktrees/SKILL.md +129 -0
- package/skills/writing-plans/SKILL.md +96 -0
- 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())
|
package/scripts/state.py
ADDED
|
@@ -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
package/skills/.gitkeep
ADDED
|
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.
|