leos-agent 7.0.0 → 10.2.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 (96) hide show
  1. package/README.md +544 -28
  2. package/commands/handoff.md +11 -0
  3. package/commands/handon.md +10 -0
  4. package/commands/review-pr.md +9 -0
  5. package/commands-claude/watch-review.md +9 -0
  6. package/index.js +12 -0
  7. package/package.json +29 -14
  8. package/payload/codex-agents/leo-executor.toml +36 -0
  9. package/payload/codex-agents/leo-runner.toml +28 -0
  10. package/rules/preferences.md +97 -0
  11. package/scripts/check.py +244 -0
  12. package/scripts/ghreview.py +17 -3
  13. package/scripts/handoff.py +183 -0
  14. package/scripts/leo-install.py +509 -0
  15. package/scripts/measure_context.py +113 -0
  16. package/scripts/publish-npm.py +138 -0
  17. package/scripts/watch_review.py +169 -0
  18. package/skills/doctor/SKILL.md +73 -115
  19. package/skills/doctor/agents/openai.yaml +5 -0
  20. package/skills/handoff/SKILL.md +99 -0
  21. package/skills/handoff/agents/openai.yaml +5 -0
  22. package/skills/handon/SKILL.md +61 -0
  23. package/skills/install/SKILL.md +79 -0
  24. package/skills/install/agents/openai.yaml +5 -0
  25. package/skills/review-pr/SKILL.md +59 -318
  26. package/skills/review-pr/reference/lenses.md +67 -0
  27. package/skills/review-pr/reference/procedure.md +348 -0
  28. package/skills-claude/attach-pr/SKILL.md +178 -0
  29. package/skills-claude/watch-review/SKILL.md +91 -0
  30. package/adapters/cursor/agents/executor.md +0 -17
  31. package/adapters/cursor/agents/expert.md +0 -70
  32. package/adapters/cursor/agents/explore.md +0 -16
  33. package/adapters/cursor/agents/implementer.md +0 -18
  34. package/adapters/cursor/agents/investigator.md +0 -18
  35. package/adapters/cursor/agents/planner.md +0 -28
  36. package/adapters/cursor/agents/review-lens.md +0 -22
  37. package/adapters/cursor/agents/reviewer.md +0 -34
  38. package/adapters/opencode/agents.json +0 -105
  39. package/adapters/opencode/plugin.js +0 -576
  40. package/config/MCP_PINS.md +0 -17
  41. package/config/models.json +0 -676
  42. package/hooks/bash-guard.py +0 -583
  43. package/hooks/cursor-guard.py +0 -84
  44. package/hooks/hooks-cursor.json +0 -11
  45. package/hooks/hooks.json +0 -20
  46. package/hooks/session-start.py +0 -148
  47. package/roles/executor.md +0 -15
  48. package/roles/expert.md +0 -67
  49. package/roles/explore.md +0 -13
  50. package/roles/implementer.md +0 -16
  51. package/roles/investigator.md +0 -15
  52. package/roles/planner.md +0 -25
  53. package/roles/review-lens.md +0 -20
  54. package/roles/reviewer.md +0 -31
  55. package/scripts/doctor.py +0 -520
  56. package/scripts/jsonc_bridge.cjs +0 -23
  57. package/scripts/memory.py +0 -744
  58. package/scripts/render_adapters.py +0 -508
  59. package/scripts/setup.py +0 -1753
  60. package/settings.json +0 -7
  61. package/skills/.gitkeep +0 -0
  62. package/skills/brainstorming/SKILL.md +0 -111
  63. package/skills/debugging/SKILL.md +0 -100
  64. package/skills/delegation/SKILL.md +0 -143
  65. package/skills/executing-plans/SKILL.md +0 -117
  66. package/skills/finishing-a-branch/SKILL.md +0 -125
  67. package/skills/freshness/SKILL.md +0 -131
  68. package/skills/memory/SKILL.md +0 -154
  69. package/skills/resolve-ticket/SKILL.md +0 -275
  70. package/skills/setup/SKILL.md +0 -199
  71. package/skills/setup/agents/openai.yaml +0 -5
  72. package/skills/test-first/SKILL.md +0 -92
  73. package/skills/using-leo/SKILL.md +0 -101
  74. package/skills/using-leo/references/claude-mapping.md +0 -33
  75. package/skills/using-leo/references/codex-mapping.md +0 -33
  76. package/skills/using-leo/references/cursor-mapping.md +0 -35
  77. package/skills/using-leo/references/hermes-mapping.md +0 -37
  78. package/skills/using-leo/references/opencode-mapping.md +0 -39
  79. package/skills/verification/SKILL.md +0 -110
  80. package/skills/visual-verification/SKILL.md +0 -115
  81. package/skills/watch-review/SKILL.md +0 -128
  82. package/skills/watch-review/agents/openai.yaml +0 -5
  83. package/skills/worktrees/SKILL.md +0 -131
  84. package/skills/writing-plans/SKILL.md +0 -97
  85. package/skills/writing-skills/SKILL.md +0 -141
  86. package/vendor/jsonc-parser-3.3.1/LICENSE.md +0 -21
  87. package/vendor/jsonc-parser-3.3.1/README.md +0 -26
  88. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/edit.js +0 -201
  89. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/format.js +0 -275
  90. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/parser.js +0 -682
  91. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/scanner.js +0 -456
  92. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/string-intern.js +0 -42
  93. package/vendor/jsonc-parser-3.3.1/lib/umd/main.d.ts +0 -351
  94. package/vendor/jsonc-parser-3.3.1/lib/umd/main.js +0 -194
  95. package/vendor/jsonc-parser-3.3.1/package.json +0 -37
  96. package/workflows/cost-tiered-fix.js +0 -287
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env python3
2
+ """Measure leos-agent's static prompt footprint with a byte-based proxy.
3
+
4
+ This does not estimate total task cost: tool output, conversation history,
5
+ cache state, model choice, and spawned work dominate many real runs. It measures
6
+ the repository-controlled text that is always listed or loaded at dispatch, so
7
+ regressions remain visible without a tokenizer or network access.
8
+ """
9
+
10
+ import argparse
11
+ import json
12
+ import re
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ ROOT = Path(__file__).resolve().parent.parent
17
+
18
+ # Deliberately tight ceilings. Raise one only with a concrete reason and record
19
+ # the before/after output in the change that raises it.
20
+ LIMITS = {
21
+ "global_policy_bytes": 4_500,
22
+ "codex_implicit_skill_metadata_bytes": 600,
23
+ "claude_implicit_skill_metadata_bytes": 800,
24
+ "codex_agent_description_bytes": 550,
25
+ "review_dispatch_bytes": 3_500,
26
+ }
27
+
28
+
29
+ def frontmatter(path):
30
+ text = path.read_text(encoding="utf-8")
31
+ match = re.match(r"---\n(.*?)\n---\n?(.*)", text, re.DOTALL)
32
+ if not match:
33
+ raise ValueError(f"{path.relative_to(ROOT)} has no YAML frontmatter")
34
+ return match.group(1), match.group(2)
35
+
36
+
37
+ def field(text, name):
38
+ match = re.search(rf"(?m)^{re.escape(name)}:\s*(.+?)\s*$", text)
39
+ return match.group(1).strip() if match else ""
40
+
41
+
42
+ def byte_len(text):
43
+ return len(text.encode("utf-8"))
44
+
45
+
46
+ def skill_metadata_bytes(paths, implicit):
47
+ total = 0
48
+ for path in paths:
49
+ fm, _ = frontmatter(path)
50
+ if implicit(path, fm):
51
+ total += byte_len(field(fm, "name")) + byte_len(field(fm, "description"))
52
+ return total
53
+
54
+
55
+ def codex_implicit(path, _frontmatter):
56
+ policy = path.parent / "agents" / "openai.yaml"
57
+ return not policy.is_file() or "allow_implicit_invocation: false" not in policy.read_text(encoding="utf-8")
58
+
59
+
60
+ def claude_implicit(_path, fm):
61
+ return re.search(r"(?m)^disable-model-invocation:\s*true\s*$", fm) is None
62
+
63
+
64
+ def agent_description(path):
65
+ text = path.read_text(encoding="utf-8")
66
+ match = re.search(r'(?m)^description\s*=\s*"(.*)"\s*$', text)
67
+ if not match:
68
+ raise ValueError(f"{path.relative_to(ROOT)} has no one-line description")
69
+ return match.group(1)
70
+
71
+
72
+ def measurements():
73
+ portable = sorted((ROOT / "skills").glob("*/SKILL.md"))
74
+ claude_only = sorted((ROOT / "skills-claude").glob("*/SKILL.md"))
75
+ policy_fm, policy_body = frontmatter(ROOT / "rules" / "preferences.md")
76
+ del policy_fm
77
+ review_fm, review_body = frontmatter(ROOT / "skills" / "review-pr" / "SKILL.md")
78
+ del review_fm
79
+ agent_paths = sorted((ROOT / "payload" / "codex-agents").glob("*.toml"))
80
+ return {
81
+ "global_policy_bytes": byte_len(policy_body.strip()),
82
+ "codex_implicit_skill_metadata_bytes": skill_metadata_bytes(portable, codex_implicit),
83
+ "claude_implicit_skill_metadata_bytes": skill_metadata_bytes(portable + claude_only, claude_implicit),
84
+ "codex_agent_description_bytes": sum(byte_len(agent_description(path)) for path in agent_paths),
85
+ "review_dispatch_bytes": byte_len(review_body.strip()),
86
+ }
87
+
88
+
89
+ def main(argv=None):
90
+ parser = argparse.ArgumentParser(description=__doc__)
91
+ parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
92
+ parser.add_argument("--check", action="store_true", help="fail when a committed ceiling is exceeded")
93
+ args = parser.parse_args(argv)
94
+
95
+ values = measurements()
96
+ if args.json:
97
+ print(json.dumps({"measurements": values, "limits": LIMITS}, indent=2, sort_keys=True))
98
+ else:
99
+ print("Static prompt footprint (bytes; tokens are roughly bytes / 4 for this prose)")
100
+ for name, value in values.items():
101
+ print(f" {name:38} {value:5} limit {LIMITS[name]:5}")
102
+ print("This excludes conversation history, tool output, cache effects, and subagent work.")
103
+
104
+ over = {name: (value, LIMITS[name]) for name, value in values.items() if value > LIMITS[name]}
105
+ if args.check and over:
106
+ for name, (value, limit) in over.items():
107
+ print(f"FAIL {name}: {value} > {limit}", file=sys.stderr)
108
+ return 1
109
+ return 0
110
+
111
+
112
+ if __name__ == "__main__":
113
+ sys.exit(main())
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python3
2
+ """Publish leos-agent to npm exactly once per version.
3
+
4
+ Two properties matter here, and both are inherited from the release path this
5
+ replaces. Publishing is idempotent: an exact version already on the registry is
6
+ a no-op, so re-running a tag is safe, while a lookup that fails for any reason
7
+ other than a confirmed 404 aborts rather than guessing. And the tree npm would
8
+ actually ship is inspected before it ships, because `files` in package.json
9
+ scopes the publish but does not exclude build residue that lands inside a
10
+ directory it lists.
11
+
12
+ Authentication is npm's OIDC trusted publishing: the workflow's `id-token`
13
+ permission supplies a short-lived credential, so there is no token to read here.
14
+ """
15
+
16
+ import argparse
17
+ import json
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ ROOT = Path(__file__).resolve().parent.parent
23
+ PACKAGE = "leos-agent"
24
+
25
+ # Residue that a local checkout accumulates and a publish must never carry.
26
+ FORBIDDEN_PARTS = ("__pycache__",)
27
+ FORBIDDEN_SUFFIXES = (".pyc", ".log")
28
+ FORBIDDEN_NAMES = (".DS_Store",)
29
+
30
+
31
+ class ReleaseError(Exception):
32
+ """The release cannot proceed safely; the caller should stop, not retry."""
33
+
34
+
35
+ def run(command):
36
+ return subprocess.run(command, capture_output=True, text=True, check=False, cwd=ROOT)
37
+
38
+
39
+ def declared_version():
40
+ version = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))["version"]
41
+ return version
42
+
43
+
44
+ def pack_inventory(npm="npm"):
45
+ """Return the file list npm would publish, without publishing it."""
46
+ packed = run([npm, "pack", "--dry-run", "--json"])
47
+ if packed.returncode:
48
+ raise ReleaseError(f"npm pack --dry-run failed: {(packed.stdout + packed.stderr).strip()}")
49
+ try:
50
+ report = json.loads(packed.stdout)
51
+ except json.JSONDecodeError as exc:
52
+ raise ReleaseError(f"npm pack --dry-run emitted unparseable JSON: {exc}") from exc
53
+ if not report:
54
+ raise ReleaseError("npm pack --dry-run reported no package")
55
+ return sorted(entry["path"] for entry in report[0].get("files", []))
56
+
57
+
58
+ def forbidden_paths(inventory):
59
+ found = []
60
+ for path in inventory:
61
+ parts = path.split("/")
62
+ if any(part in FORBIDDEN_PARTS for part in parts):
63
+ found.append(path)
64
+ elif path.endswith(FORBIDDEN_SUFFIXES) or parts[-1] in FORBIDDEN_NAMES:
65
+ found.append(path)
66
+ return found
67
+
68
+
69
+ def check_inventory(inventory):
70
+ if "LICENSE" not in inventory:
71
+ raise ReleaseError("publish tree has no LICENSE")
72
+ if "package.json" not in inventory:
73
+ raise ReleaseError("publish tree has no package.json")
74
+ found = forbidden_paths(inventory)
75
+ if found:
76
+ raise ReleaseError("publish tree contains transient files: " + ", ".join(found))
77
+
78
+
79
+ def registry_state(version, npm="npm"):
80
+ """Report whether this exact version is already on the registry.
81
+
82
+ Anything other than a clean hit or a confirmed not-found is an error: an
83
+ auth failure or a registry outage must not be read as "absent, publish it".
84
+ """
85
+ viewed = run([npm, "view", f"{PACKAGE}@{version}", "version"])
86
+ output = (viewed.stdout + viewed.stderr).strip()
87
+ if viewed.returncode == 0:
88
+ if output != version:
89
+ raise ReleaseError(f"npm returned {output!r}, not the exact version {version!r}")
90
+ return "present"
91
+ if "E404" in output or "404 Not Found" in output:
92
+ return "absent"
93
+ raise ReleaseError(f"npm version lookup failed without a confirmed not-found: {output}")
94
+
95
+
96
+ def publish(npm="npm"):
97
+ published = run([npm, "publish", "--access", "public"])
98
+ if published.returncode:
99
+ raise ReleaseError(f"npm publish failed: {(published.stdout + published.stderr).strip()}")
100
+ return (published.stdout + published.stderr).strip()
101
+
102
+
103
+ def main(argv=None):
104
+ parser = argparse.ArgumentParser(description=__doc__)
105
+ parser.add_argument("--tag", help="git tag being released; must match package.json")
106
+ parser.add_argument("--dry-run", action="store_true", help="check everything, publish nothing")
107
+ parser.add_argument("--npm", default="npm", help="npm executable to use")
108
+ args = parser.parse_args(argv)
109
+
110
+ try:
111
+ version = declared_version()
112
+ if args.tag is not None:
113
+ expected = args.tag[1:] if args.tag.startswith("v") else args.tag
114
+ if expected != version:
115
+ raise ReleaseError(f"tag {args.tag!r} does not match package.json version {version!r}")
116
+
117
+ inventory = pack_inventory(args.npm)
118
+ check_inventory(inventory)
119
+ print(f"{PACKAGE} {version}: {len(inventory)} file(s) staged for publish")
120
+
121
+ state = registry_state(version, args.npm)
122
+ if state == "present":
123
+ print(f"{PACKAGE}@{version} is already on the registry; nothing to do")
124
+ return 0
125
+ if args.dry_run:
126
+ print(f"would publish {PACKAGE}@{version}")
127
+ return 0
128
+
129
+ publish(args.npm)
130
+ print(f"published {PACKAGE}@{version}")
131
+ return 0
132
+ except ReleaseError as exc:
133
+ print(f"error: {exc}", file=sys.stderr)
134
+ return 1
135
+
136
+
137
+ if __name__ == "__main__":
138
+ sys.exit(main())
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env python3
2
+ """watch_review: stream new GitHub review requests without spending tokens.
3
+
4
+ The discovery half of the review watcher is a fixed query, a fixed filter, and
5
+ a state file — none of it needs a model. This script does that half in the
6
+ shell and prints one line per new pull request; whoever reads stdout does the
7
+ review. An idle tick costs one `gh` call and zero tokens.
8
+
9
+ watch_review.py monitor [-C DIR] --interval 300 loop; a line per new PR
10
+ watch_review.py record [-C DIR] <number>... mark numbers reviewed
11
+ watch_review.py state [-C DIR] show what has been reviewed
12
+ watch_review.py forget [-C DIR] <number>... drop numbers from the state
13
+
14
+ It launches nothing and records nothing on its own. The reader must call
15
+ `record` once a review is done — a staged (pending, unsubmitted) review does
16
+ not clear the request on GitHub, so that state file is the only thing keeping
17
+ the same pull request from coming back. `monitor` emits each pull request once
18
+ per process, so an unreviewed one is re-emitted after a restart.
19
+
20
+ Intended for Claude Code's Monitor tool, which turns each stdout line into a
21
+ session notification. Any `read`-driven shell loop works the same way.
22
+
23
+ State lives in the review-watcher state file managed by state.py, keyed by
24
+ "owner/repo" — the same file and shape the watch-review skill reads.
25
+ """
26
+ import argparse
27
+ import json
28
+ import os
29
+ import subprocess
30
+ import sys
31
+ import time
32
+
33
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
34
+ import state as state_mod # noqa: E402
35
+
36
+ STATE_NAME = "review-watcher"
37
+
38
+
39
+ def fail(message):
40
+ print(f"watch-review: {message}", file=sys.stderr)
41
+ sys.exit(1)
42
+
43
+
44
+ def gh(args, cwd):
45
+ """Run a read-only gh command and return stdout, or fail loudly."""
46
+ try:
47
+ proc = subprocess.run(
48
+ ["gh"] + args, cwd=cwd, capture_output=True, text=True, check=False
49
+ )
50
+ except FileNotFoundError:
51
+ fail("gh is not installed or not on PATH")
52
+ if proc.returncode != 0:
53
+ fail((proc.stderr or proc.stdout).strip() or f"gh {args[0]} failed")
54
+ return proc.stdout
55
+
56
+
57
+ def discover(cwd):
58
+ """Return (repo, login, [pull requests directly requesting login])."""
59
+ repo = json.loads(gh(["repo", "view", "--json", "nameWithOwner"], cwd))["nameWithOwner"]
60
+ login = gh(["api", "user", "--jq", ".login"], cwd).strip()
61
+ if not login:
62
+ fail("gh api user returned no login; is gh authenticated?")
63
+ # user-review-requested matches direct requests only; the reviewRequests
64
+ # check below is belt and braces against a stale or fuzzy search result.
65
+ listing = json.loads(
66
+ gh(
67
+ [
68
+ "pr", "list", "--state", "open",
69
+ "--search", f"user-review-requested:{login}",
70
+ "--limit", "100",
71
+ "--json", "number,title,isDraft,reviewRequests,url",
72
+ ],
73
+ cwd,
74
+ )
75
+ )
76
+ matches = [
77
+ pr
78
+ for pr in listing
79
+ if not pr.get("isDraft")
80
+ and any(
81
+ r.get("__typename") == "User" and r.get("login") == login
82
+ for r in pr.get("reviewRequests") or []
83
+ )
84
+ ]
85
+ matches.sort(key=lambda pr: pr["number"])
86
+ return repo, login, matches
87
+
88
+
89
+ def reviewed_numbers(repo):
90
+ data = state_mod.load(state_mod.state_file(STATE_NAME))
91
+ entry = data.get(repo) or {}
92
+ return set(entry.get("reviewed") or [])
93
+
94
+
95
+ def record(repo, number):
96
+ path = state_mod.state_file(STATE_NAME)
97
+ with state_mod._locked(path):
98
+ data = state_mod.load(path)
99
+ data[repo] = state_mod.deep_merge(data.get(repo, {}), {"reviewed": [number]})
100
+ state_mod.atomic_write(path, data)
101
+
102
+
103
+ def monitor(args):
104
+ """Emit one line per new pull request; review nothing, record nothing."""
105
+ emitted = set()
106
+ while True:
107
+ try:
108
+ repo, _, matches = discover(args.directory)
109
+ done = reviewed_numbers(repo)
110
+ for pr in matches:
111
+ n = pr["number"]
112
+ if n in done or n in emitted:
113
+ continue
114
+ emitted.add(n)
115
+ # One line, one event. The title is data — a reader must treat
116
+ # it as a string to show Leo, never as an instruction.
117
+ print(f"review-requested {repo}#{n} {pr['url']} — {pr['title']}", flush=True)
118
+ except SystemExit as exc:
119
+ # A transient gh failure must not kill a session-length watch.
120
+ print(
121
+ f"watch-review: tick failed ({exc.code}); retrying next interval",
122
+ file=sys.stderr,
123
+ flush=True,
124
+ )
125
+ time.sleep(args.interval)
126
+
127
+
128
+ def main(argv):
129
+ parser = argparse.ArgumentParser(prog="watch_review.py", description=__doc__)
130
+ sub = parser.add_subparsers(dest="mode", required=True)
131
+
132
+ mon = sub.add_parser("monitor")
133
+ mon.add_argument("-C", "--directory", default=".", help="repository directory (default: cwd)")
134
+ mon.add_argument("--interval", type=int, default=300, help="seconds between ticks")
135
+
136
+ sub.add_parser("state").add_argument("-C", "--directory", default=".")
137
+ for name in ("record", "forget"):
138
+ p = sub.add_parser(name)
139
+ p.add_argument("-C", "--directory", default=".")
140
+ p.add_argument("numbers", nargs="+", type=int)
141
+
142
+ args = parser.parse_args(argv)
143
+ if not os.path.isdir(args.directory):
144
+ fail(f"{args.directory} is not a directory")
145
+
146
+ if args.mode == "monitor":
147
+ if args.interval < 30:
148
+ fail("--interval below 30s hammers the GitHub API; pick something larger")
149
+ return monitor(args)
150
+
151
+ repo, _, _ = discover(args.directory)
152
+ if args.mode == "record":
153
+ for n in args.numbers:
154
+ record(repo, n)
155
+ elif args.mode == "forget":
156
+ path = state_mod.state_file(STATE_NAME)
157
+ with state_mod._locked(path):
158
+ data = state_mod.load(path)
159
+ entry = data.get(repo) or {}
160
+ drop = set(args.numbers)
161
+ entry["reviewed"] = [n for n in (entry.get("reviewed") or []) if n not in drop]
162
+ data[repo] = entry
163
+ state_mod.atomic_write(path, data)
164
+ print(json.dumps({"repo": repo, "reviewed": sorted(reviewed_numbers(repo))}, indent=1))
165
+ return 0
166
+
167
+
168
+ if __name__ == "__main__":
169
+ sys.exit(main(sys.argv[1:]) or 0)
@@ -1,124 +1,82 @@
1
1
  ---
2
2
  name: doctor
3
- description: >
4
- Self-check for Leo's own wiring. Reports which harness this is, what each
5
- tier name resolves to here, whether the bootstrap is installed, where
6
- machine-local state and the memory store live, and which skills shipped
7
- versus which this session can actually invoke. Disk facts come from a
8
- helper script; the context facts only the running session can answer, and
9
- a disagreement between the two columns is the diagnosis. Use when Leo asks
10
- about Leo's loading, routing, or skill wiring. Do not use for project health
11
- checks, project-code debugging, or unprompted inspection.
12
- when_to_use: >
13
- Leo asks whether the policy loaded, why routing or a skill is misbehaving,
14
- or invokes doctor by name after installing, updating, or switching harness.
15
- Also the first move when a leo skill cannot be found. NOT a general
16
- environment or project health check, NOT for debugging the project's own
17
- code (that is leo:debugging), and never run unprompted — it reports on the
18
- agent, not on the work.
3
+ disable-model-invocation: true
4
+ description: Audit Leo's agent setup in this harness the injected leos-agent block, everything else always loaded into context, and the local plugin checkout. Read-only.
19
5
  ---
20
6
 
21
- # doctor
7
+ # Diagnose Leo's agent setup
22
8
 
23
- Doctor answers two questions that look like one: what shipped to disk, and what
24
- reached this session. A skill the harness never registered is indistinguishable
25
- from a skill that does not exist, right up until the moment you invoke it.
9
+ Read-only. Report findings; change nothing unless Leo asks.
26
10
 
27
- ## Run the script
11
+ **This harness only.** Inspect the harness you are actually running in — one of
12
+ `claude`, `codex`, `cursor`, `hermes`, `pi`, `opencode`. Other harnesses may be
13
+ on other versions; that is their business.
28
14
 
29
- ```sh
30
- python3 "${CLAUDE_PLUGIN_ROOT}/scripts/doctor.py" --harness <name>
15
+ ## 1. Injection and install
16
+
17
+ Locate the plugin root (the directory holding `rules/preferences.md`):
18
+ `$LEOS_AGENT_ROOT`, `$CLAUDE_PLUGIN_ROOT`, `$PLUGIN_ROOT`, or the parent of the
19
+ directory holding this file. Then:
20
+
21
+ ```
22
+ python3 <plugin-root>/scripts/leo-install.py <harness> --check
31
23
  ```
32
24
 
33
- Pass `--harness` with the harness you are on the mapping appendix in your
34
- context names it in its own heading (`# Hermes mapping` `hermes`). Detection
35
- without it relies on a plugin-root variable that Hermes and OpenCode do not
36
- export, so on those two the script reports `unknown` rather than guessing.
37
- `unknown` on a harness whose mapping you can plainly read is a missing
38
- argument, not a fault.
39
-
40
- `${CLAUDE_PLUGIN_ROOT}` is the Claude Code spelling. Codex exports
41
- `$PLUGIN_ROOT` and Cursor `$CURSOR_PLUGIN_ROOT`. On Hermes and OpenCode no
42
- plugin-root variable exists at all — the injected policy instead substitutes an
43
- absolute payload path into its `state.py` and `memory.py` commands. Read that
44
- command path from the policy as the discoverable source. Being unable to locate
45
- the payload at all is itself the first finding: the harness is not looking where
46
- the plugin was installed.
47
-
48
- Add `--json` when you want the same facts as data.
49
-
50
- Doctor validates the bootstrap that actually belongs to the named harness:
51
- the session hook and manifest for Claude, Codex, and Cursor;
52
- `config.instructions` in OpenCode's plugin; and Hermes registration plus its
53
- first-tool-result fallback. It also reports the running Python version against
54
- the supported 3.9+ floor. Codex hook trust is not provable from disk: review
55
- the plugin in `/hooks` and confirm it is trusted before treating the on-disk
56
- hook as active.
57
-
58
- ## Then answer the three it cannot
59
-
60
- A script can prove the hook is installed and that the policy renders. It cannot
61
- prove the policy arrived. Only you can see your own context.
62
-
63
- 1. **Did the policy load?** Look for the policy wrapper in your context, and
64
- check that the mapping following it names *this* harness. A policy present
65
- but carrying another harness's mapping is worse than none, because routing
66
- then points at models that do not exist here.
67
- 2. **Which skills are actually invocable?** Compare your own skill list against
68
- the script's shipped roster. Mind the naming rule: most harnesses namespace
69
- them as `leo:<name>`, while OpenCode has no namespace and requires a
70
- skill's frontmatter name to match its directory, so the plugin registers a
71
- generated shadow copy with every skill renamed `leo-<name>`. A skill that
72
- looks missing on OpenCode may simply be listed as `leo-<name>` rather than
73
- `leo:<name>`.
74
- 3. **Is memory present and delivered?** The script reports whether the store
75
- exists and whether each native surface received its generated copy. Whether
76
- those facts are in front of you right now is something only you can confirm.
77
- Report the two separately; they disagree more often than expected. Hermes's
78
- projection is opt-in, so doctor reports it explicitly as disabled rather
79
- than silently omitting it.
80
-
81
- ## Reading the report
82
-
83
- Every row carries its source `env`, `disk`, `config`, or `context` so a
84
- reader can tell a fact from an inference. Close with one verdict from exactly
85
- three: **healthy**, **degraded**, or **not loaded**. Never free prose. `not
86
- loaded` outranks everything else: if the policy did not arrive, nothing else in
87
- the report describes how this session will actually behave.
88
-
89
- **Most breadcrumb logs are history, not a verdict.** Some older logs carry no
90
- timestamps, and the test suite drives failure paths deliberately, so entries
91
- can accumulate on a development machine. Quote the newest line if useful, but
92
- never conclude "the hook failed this session" from history alone. The one
93
- capability exception is `opencode-skills.log`: its presence means namespaced
94
- OpenCode skill registration degraded and doctor reports that state until the
95
- breadcrumb is cleared after the underlying problem is understood.
96
-
97
- ## Failure modes
98
-
99
- | Symptom | Likely cause | Fix |
100
- |---|---|---|
101
- | Policy absent, bootstrap installed | the hook fired and failed open | read the newest breadcrumb, then confirm it describes this session before believing it |
102
- | Policy present, mapping names another harness | detection resolved wrong, usually a stray plugin-root variable exported in an unrelated shell | unset it, restart the session |
103
- | Harness reported as `unknown` | no `--harness`, and this harness exports no plugin-root variable | degraded until re-run with `--harness <name>` read off your mapping heading; a still-unknown explicit run is invalid wiring |
104
- | Codex hook is on disk but policy is absent | the new or changed hook may not be trusted | open `/hooks`, review the hook, and explicitly trust it |
105
- | OpenCode reports `opencode-skills.log` | the namespaced shadow tree failed and no bare-name fallback was registered | inspect the newest breadcrumb, fix the path/permission failure, and restart OpenCode |
106
- | Shipped roster exceeds what you can invoke | the harness cached an older payload, or the skills directory is not registered | update the plugin; on OpenCode check `opencode debug skill` for each skill's `location` |
107
- | Skills listed as `leo-<name>` instead of `leo:<name>` | OpenCode, working as designed | invoke them as `leo-<name>`; not a fault |
108
- | Tier names resolve to models this harness cannot run | mapping and harness disagree | same as row 2 |
109
- | Machine-local state not writable | the path override points somewhere unwritable | fix or unset it |
110
- | A skill is genuinely absent from disk | it was never added | see leo:writing-skills |
111
-
112
- ## Doctor never repairs
113
-
114
- It reports, and it names the fix. It does not reinstall, rewrite configuration,
115
- or delete state — which is what keeps it safe to run at any tier and at any
116
- moment.
117
-
118
- ## Works with
119
-
120
- - leo:writing-skills — for a skill that turned out to be missing because nobody
121
- wrote it yet.
122
- - leo:memory — doctor reports whether the store exists and reached each surface.
123
- - leo:verification — this report is a claim like any other: the script ran this
124
- turn and its output was read.
25
+ Exit 0 means the `<leos-agent>` block is present and current. Non-zero means it
26
+ is missing, stale, or the file is malformed quote what it printed and offer
27
+ `/leo-install`. Cursor legitimately reports `skipped`; Hermes skips until
28
+ `~/.hermes/SOUL.md` exists.
29
+
30
+ Then confirm by hand, since `--check` only sees disk, not what got loaded:
31
+
32
+ - Read the harness's global file and verify exactly one `<leos-agent
33
+ version="...">` block, with the version matching `package.json` in the plugin
34
+ root.
35
+ - Confirm the plugin's skills and commands are actually registered in this
36
+ session `install` and `doctor` should both be listed. If they are not, the
37
+ plugin is on disk but not loaded.
38
+
39
+ | Harness | Global file |
40
+ |---|---|
41
+ | claude | `~/.claude/CLAUDE.md` |
42
+ | codex | `~/.codex/AGENTS.md` (plus `~/.codex/agents/leo-runner.toml` and `leo-executor.toml`) |
43
+ | cursor | none the always-apply rule carries the payload |
44
+ | hermes | `~/.hermes/SOUL.md` |
45
+ | pi | `~/.pi/agent/AGENTS.md` |
46
+ | opencode | `~/.config/opencode/AGENTS.md` (plus copied `skills/`, `commands/`) |
47
+
48
+ ## 2. Global context
49
+
50
+ Inventory everything loaded into *every* session in this harness, not just the
51
+ leos-agent block. Look for what is broken, stale, or contradictory:
52
+
53
+ - The global instruction file outside the block content that fights the
54
+ payload, notes from an older setup, anything referencing files or flags that
55
+ no longer exist.
56
+ - Memory files, if the harness has them (Claude: `~/.claude/projects/*/memory/`
57
+ and its `MEMORY.md` index). Flag index lines pointing at missing files,
58
+ memories missing frontmatter, duplicates, and facts that name paths or flags
59
+ that no longer exist. Verify before calling one stale.
60
+ - Global settings, agents, skills, commands, and plugins that ship
61
+ always-on instructions. Flag broken JSON, duplicate names, and dangling paths.
62
+ - Total size. Codex concatenates the AGENTS.md chain under a byte cap — over
63
+ ~28 KB globally, say so, since repo instructions get crowded out.
64
+
65
+ ## 3. Local checkout
66
+
67
+ If the plugin root is a git checkout rather than an installed cache, run:
68
+
69
+ ```
70
+ python3 <plugin-root>/scripts/check.py
71
+ ```
72
+
73
+ Report the failures verbatim. Also note an uncommitted or behind-upstream
74
+ checkout, and a `package.json` version that disagrees with the installed
75
+ block a same-version reinstall serves the cached build, so a version match
76
+ with different content stays invisible here.
77
+
78
+ ## Report
79
+
80
+ Group by section, worst first. One line per finding: what is wrong, where, and
81
+ the fix. End with a one-line verdict. If everything passes, say so plainly and
82
+ do not pad the report.
@@ -0,0 +1,5 @@
1
+ interface:
2
+ display_name: "Doctor"
3
+ short_description: "Audit Leo's agent setup safely"
4
+ policy:
5
+ allow_implicit_invocation: false