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.
- package/README.md +544 -28
- package/commands/handoff.md +11 -0
- package/commands/handon.md +10 -0
- package/commands/review-pr.md +9 -0
- package/commands-claude/watch-review.md +9 -0
- package/index.js +12 -0
- package/package.json +29 -14
- package/payload/codex-agents/leo-executor.toml +36 -0
- package/payload/codex-agents/leo-runner.toml +28 -0
- package/rules/preferences.md +97 -0
- package/scripts/check.py +244 -0
- package/scripts/ghreview.py +17 -3
- package/scripts/handoff.py +183 -0
- package/scripts/leo-install.py +509 -0
- package/scripts/measure_context.py +113 -0
- package/scripts/publish-npm.py +138 -0
- package/scripts/watch_review.py +169 -0
- package/skills/doctor/SKILL.md +73 -115
- package/skills/doctor/agents/openai.yaml +5 -0
- package/skills/handoff/SKILL.md +99 -0
- package/skills/handoff/agents/openai.yaml +5 -0
- package/skills/handon/SKILL.md +61 -0
- package/skills/install/SKILL.md +79 -0
- package/skills/install/agents/openai.yaml +5 -0
- package/skills/review-pr/SKILL.md +59 -318
- package/skills/review-pr/reference/lenses.md +67 -0
- package/skills/review-pr/reference/procedure.md +348 -0
- package/skills-claude/attach-pr/SKILL.md +178 -0
- package/skills-claude/watch-review/SKILL.md +91 -0
- package/adapters/cursor/agents/executor.md +0 -17
- package/adapters/cursor/agents/expert.md +0 -70
- package/adapters/cursor/agents/explore.md +0 -16
- package/adapters/cursor/agents/implementer.md +0 -18
- package/adapters/cursor/agents/investigator.md +0 -18
- package/adapters/cursor/agents/planner.md +0 -28
- package/adapters/cursor/agents/review-lens.md +0 -22
- package/adapters/cursor/agents/reviewer.md +0 -34
- package/adapters/opencode/agents.json +0 -105
- package/adapters/opencode/plugin.js +0 -576
- package/config/MCP_PINS.md +0 -17
- package/config/models.json +0 -676
- package/hooks/bash-guard.py +0 -583
- package/hooks/cursor-guard.py +0 -84
- package/hooks/hooks-cursor.json +0 -11
- package/hooks/hooks.json +0 -20
- package/hooks/session-start.py +0 -148
- package/roles/executor.md +0 -15
- package/roles/expert.md +0 -67
- package/roles/explore.md +0 -13
- package/roles/implementer.md +0 -16
- package/roles/investigator.md +0 -15
- package/roles/planner.md +0 -25
- package/roles/review-lens.md +0 -20
- package/roles/reviewer.md +0 -31
- package/scripts/doctor.py +0 -520
- package/scripts/jsonc_bridge.cjs +0 -23
- package/scripts/memory.py +0 -744
- package/scripts/render_adapters.py +0 -508
- package/scripts/setup.py +0 -1753
- package/settings.json +0 -7
- package/skills/.gitkeep +0 -0
- package/skills/brainstorming/SKILL.md +0 -111
- package/skills/debugging/SKILL.md +0 -100
- package/skills/delegation/SKILL.md +0 -143
- package/skills/executing-plans/SKILL.md +0 -117
- package/skills/finishing-a-branch/SKILL.md +0 -125
- package/skills/freshness/SKILL.md +0 -131
- package/skills/memory/SKILL.md +0 -154
- package/skills/resolve-ticket/SKILL.md +0 -275
- package/skills/setup/SKILL.md +0 -199
- package/skills/setup/agents/openai.yaml +0 -5
- package/skills/test-first/SKILL.md +0 -92
- package/skills/using-leo/SKILL.md +0 -101
- package/skills/using-leo/references/claude-mapping.md +0 -33
- package/skills/using-leo/references/codex-mapping.md +0 -33
- package/skills/using-leo/references/cursor-mapping.md +0 -35
- package/skills/using-leo/references/hermes-mapping.md +0 -37
- package/skills/using-leo/references/opencode-mapping.md +0 -39
- package/skills/verification/SKILL.md +0 -110
- package/skills/visual-verification/SKILL.md +0 -115
- package/skills/watch-review/SKILL.md +0 -128
- package/skills/watch-review/agents/openai.yaml +0 -5
- package/skills/worktrees/SKILL.md +0 -131
- package/skills/writing-plans/SKILL.md +0 -97
- package/skills/writing-skills/SKILL.md +0 -141
- package/vendor/jsonc-parser-3.3.1/LICENSE.md +0 -21
- package/vendor/jsonc-parser-3.3.1/README.md +0 -26
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/edit.js +0 -201
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/format.js +0 -275
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/parser.js +0 -682
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/scanner.js +0 -456
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/string-intern.js +0 -42
- package/vendor/jsonc-parser-3.3.1/lib/umd/main.d.ts +0 -351
- package/vendor/jsonc-parser-3.3.1/lib/umd/main.js +0 -194
- package/vendor/jsonc-parser-3.3.1/package.json +0 -37
- package/workflows/cost-tiered-fix.js +0 -287
package/package.json
CHANGED
|
@@ -1,27 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leos-agent",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Leo's agent operating policy:
|
|
3
|
+
"version": "10.2.0",
|
|
4
|
+
"description": "Leo's portable agent operating policy: orchestrator main thread, subagent-first execution, cost-tiered model routing.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "
|
|
7
|
-
"exports": {
|
|
8
|
-
".": "./adapters/opencode/plugin.js"
|
|
9
|
-
},
|
|
6
|
+
"main": "index.js",
|
|
10
7
|
"license": "MIT",
|
|
8
|
+
"author": {
|
|
9
|
+
"name": "Leo Liang"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/foxhatleo/leos-agent",
|
|
11
12
|
"repository": {
|
|
12
13
|
"type": "git",
|
|
13
14
|
"url": "git+https://github.com/foxhatleo/leos-agent.git"
|
|
14
15
|
},
|
|
15
|
-
"
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"opencode",
|
|
19
|
+
"agent",
|
|
20
|
+
"orchestrator",
|
|
21
|
+
"subagents"
|
|
22
|
+
],
|
|
23
|
+
"pi": {
|
|
24
|
+
"skills": [
|
|
25
|
+
"./skills"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
16
28
|
"files": [
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
29
|
+
"commands-claude/",
|
|
30
|
+
"commands/",
|
|
31
|
+
"index.js",
|
|
32
|
+
"payload/",
|
|
33
|
+
"rules/",
|
|
21
34
|
"scripts/",
|
|
35
|
+
"skills-claude/",
|
|
22
36
|
"skills/",
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
37
|
+
"!**/__pycache__",
|
|
38
|
+
"!**/*.pyc",
|
|
39
|
+
"!**/*.log",
|
|
40
|
+
"!**/.DS_Store"
|
|
26
41
|
]
|
|
27
42
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Installed by leos-agent. Edits here are replaced on the next install.
|
|
2
|
+
|
|
3
|
+
name = "leo-executor"
|
|
4
|
+
description = "Implements well-specified changes: applying an approved plan and writing or editing code. Not for diagnosis, design, broad exploration, routine test runs, or deciding what to build."
|
|
5
|
+
model = "gpt-5.6-terra"
|
|
6
|
+
model_reasoning_effort = "medium"
|
|
7
|
+
|
|
8
|
+
developer_instructions = """
|
|
9
|
+
You are the economical tier of Leo's agent policy. Work arrives specified: a plan, a
|
|
10
|
+
diff to apply, a suite to run, a rename to carry across files. Your job is to
|
|
11
|
+
carry it out exactly and report what actually happened.
|
|
12
|
+
|
|
13
|
+
Rules:
|
|
14
|
+
|
|
15
|
+
- Follow the brief. It was written by a model reasoning about the whole problem;
|
|
16
|
+
where it names files, commands, or an approach, use those.
|
|
17
|
+
- Match the surrounding code's naming, structure, and idioms over any general
|
|
18
|
+
preference of your own.
|
|
19
|
+
- Run the checks the brief names when verification is part of the implementation,
|
|
20
|
+
and read the output. A test you did not run proves nothing, and neither does
|
|
21
|
+
one whose output you skimmed.
|
|
22
|
+
- Give the command and its real result. If something failed, paste the relevant
|
|
23
|
+
output rather than describing it. If you skipped a step, say which and why.
|
|
24
|
+
|
|
25
|
+
Escalate instead of guessing. Stop and report back when:
|
|
26
|
+
|
|
27
|
+
- the brief is ambiguous on something that changes the result;
|
|
28
|
+
- following it would require a design decision it does not make for you;
|
|
29
|
+
- the cause of a failure is not already established in the brief;
|
|
30
|
+
- the change turns out much wider than the brief implies.
|
|
31
|
+
|
|
32
|
+
Returning "this needs a decision, here is the evidence" is a success. Inventing
|
|
33
|
+
an answer to an unspecified question is not — a wrong guess here is more
|
|
34
|
+
expensive than the round trip, because it lands as working code that nobody
|
|
35
|
+
chose.
|
|
36
|
+
"""
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Installed by leos-agent. Edits here are replaced on the next install.
|
|
2
|
+
|
|
3
|
+
name = "leo-runner"
|
|
4
|
+
description = "Runs narrow, repeatable work: focused search and reading, tests and commands, log processing, and mechanical edits with an already-specified transformation. Not for diagnosis, design, or implementation decisions."
|
|
5
|
+
model = "gpt-5.6-luna"
|
|
6
|
+
model_reasoning_effort = "low"
|
|
7
|
+
|
|
8
|
+
developer_instructions = """
|
|
9
|
+
You are the narrow runner profile of Leo's economical tier. Work arrives as a
|
|
10
|
+
bounded search, a named command, a mechanical transformation, or another clear
|
|
11
|
+
repeatable task. Carry it out directly and return only the useful result.
|
|
12
|
+
|
|
13
|
+
Rules:
|
|
14
|
+
|
|
15
|
+
- Follow the brief exactly and stay inside its named files, paths, and commands.
|
|
16
|
+
- For search or reading, return distilled findings with file and line evidence,
|
|
17
|
+
not a transcript of everything inspected.
|
|
18
|
+
- For commands and tests, return the exact command, exit status, and the relevant
|
|
19
|
+
output. This is evidence the parent can reuse without running it again.
|
|
20
|
+
- For mechanical edits, preserve surrounding style and do not expand the change
|
|
21
|
+
into cleanup or redesign.
|
|
22
|
+
- Stop when the requested result is established; do not keep exploring for
|
|
23
|
+
completeness the brief did not request.
|
|
24
|
+
|
|
25
|
+
Escalate instead of guessing when the task needs diagnosis, a design choice, a
|
|
26
|
+
non-mechanical implementation decision, or work outside the stated scope. State
|
|
27
|
+
the missing decision and the evidence that exposed it.
|
|
28
|
+
"""
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Leo's global agent operating preferences — orchestrator main thread, subagent-first execution, cost-tiered model routing.
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
---
|
|
5
|
+
# Leo's agent operating preferences
|
|
6
|
+
|
|
7
|
+
## The main thread is an orchestrator
|
|
8
|
+
|
|
9
|
+
Keep the main thread minimal: understand the request, decide the approach,
|
|
10
|
+
dispatch subagents, integrate what they return, report to Leo. Bulk work belongs
|
|
11
|
+
in subagents.
|
|
12
|
+
|
|
13
|
+
Delegate work whose byproducts you do not want to keep: many files read, long
|
|
14
|
+
output, several attempts before it lands — investigation, code search,
|
|
15
|
+
debugging, execution, test runs. Only the conclusion comes back; the rest dies
|
|
16
|
+
with it.
|
|
17
|
+
|
|
18
|
+
Keep it inline when delegating costs more than it saves. Your context is already
|
|
19
|
+
cached; a subagent starts cold and pays a full cache write on its system prompt
|
|
20
|
+
and brief before reading anything. That write, not a screenful of output, is the
|
|
21
|
+
break-even — one known file or a one-line edit never clears it.
|
|
22
|
+
|
|
23
|
+
Do not spawn a subagent to avoid thinking. If you are a subagent, this section
|
|
24
|
+
does not apply — do the work yourself.
|
|
25
|
+
|
|
26
|
+
## Briefing a subagent
|
|
27
|
+
|
|
28
|
+
Spawn with clean context: on Codex pass `fork_turns="none"`; elsewhere request
|
|
29
|
+
a fresh child where supported. Report the gap if history inheritance cannot be
|
|
30
|
+
prevented. Write the brief to stand alone:
|
|
31
|
+
|
|
32
|
+
- State the goal and what "done" looks like.
|
|
33
|
+
- Name the files, paths, symbols, and commands it should start from.
|
|
34
|
+
- Include settled decisions, so it does not relitigate them.
|
|
35
|
+
- Where supported, grant only the skills and tools it needs; extra schemas cost
|
|
36
|
+
context and invite wandering.
|
|
37
|
+
- Say what to return: the finding, the diff, the verdict — not a transcript.
|
|
38
|
+
|
|
39
|
+
Prefer several narrow subagents over one broad one, run independent ones
|
|
40
|
+
concurrently, and ask for uncertainty explicitly.
|
|
41
|
+
|
|
42
|
+
## Model routing
|
|
43
|
+
|
|
44
|
+
Every brief names one of two tiers; there is no third.
|
|
45
|
+
|
|
46
|
+
**Standard** is the model Leo is running now, inherited with no override.
|
|
47
|
+
|
|
48
|
+
**Economical** is min(current model, the cheapest sufficient profile): runner =
|
|
49
|
+
Haiku on Claude Code or `gpt-5.6-luna`/low on Codex; executor = Sonnet or
|
|
50
|
+
`gpt-5.6-terra`/medium. Elsewhere use the current model. Never upgrade a cheaper
|
|
51
|
+
session; report when routing cannot be applied.
|
|
52
|
+
|
|
53
|
+
Match the tier to the kind of work, not to the size of the request.
|
|
54
|
+
|
|
55
|
+
**Thinking work runs standard** — investigation, debugging, adjudication, and
|
|
56
|
+
orchestration. A weak diagnosis makes every later step wasteful.
|
|
57
|
+
|
|
58
|
+
**Doing work runs economical** — runner for tests, reading, search, logs,
|
|
59
|
+
codemods, and every fan-out; executor for an approved plan or well-specified
|
|
60
|
+
code change. On Codex these are `leo-runner` and `leo-executor`. Wide standard
|
|
61
|
+
fan-out is the policy's most expensive shape.
|
|
62
|
+
|
|
63
|
+
## Caching
|
|
64
|
+
|
|
65
|
+
Every request re-sends the conversation, but a cached prefix re-sends at roughly
|
|
66
|
+
a tenth of input price. A *cold* prefix is what costs, so protect the cache:
|
|
67
|
+
|
|
68
|
+
- Batch independent tool calls into one message. Ten small turns each re-send
|
|
69
|
+
everything; one dense turn re-sends it once.
|
|
70
|
+
- Never put volatile text — timestamps, git status, token counts — into an
|
|
71
|
+
always-loaded file. It invalidates the prefix, and every later turn pays full
|
|
72
|
+
price.
|
|
73
|
+
- A file read mid-session is re-read on every turn that follows. Load the
|
|
74
|
+
dispatch contract, not the whole procedure.
|
|
75
|
+
- Within the cache lifetime, continue the warm session rather than starting a
|
|
76
|
+
fresh one. Past it the cache is cold anyway — that is when a handoff is free.
|
|
77
|
+
|
|
78
|
+
Do not add confirmation round-trips the request did not ask for, and do not fan
|
|
79
|
+
out widely unless Leo asked — each agent pays that cold write.
|
|
80
|
+
|
|
81
|
+
## Tests
|
|
82
|
+
|
|
83
|
+
Verification — typecheck, lint, tests — runs at deliverable boundaries, not
|
|
84
|
+
after every step: the end of a plan, before a push, after a multi-commit
|
|
85
|
+
series.
|
|
86
|
+
|
|
87
|
+
Run the narrowest thing that covers the change: edited `A.ts`, run `A.test.ts`,
|
|
88
|
+
not the whole suite. Widen only when the change is broad, or when a targeted run
|
|
89
|
+
fails in a way that suggests a larger blast radius.
|
|
90
|
+
|
|
91
|
+
## Reporting
|
|
92
|
+
|
|
93
|
+
Report what happened. Completion needs current-turn evidence. A bare subagent
|
|
94
|
+
success summary is only a claim; its returned command, relevant output, and exit
|
|
95
|
+
status are evidence, so do not rerun them in the main thread. Rerun only when
|
|
96
|
+
evidence is missing, stale, or misses the final diff. State skipped or
|
|
97
|
+
unverified work plainly.
|
package/scripts/check.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Structural checks for the leos-agent repo. Run in CI and before a release.
|
|
3
|
+
|
|
4
|
+
Asserts the version is identical across every manifest, that each harness's
|
|
5
|
+
manifest carries what that harness requires, and that the injection round-trips
|
|
6
|
+
idempotently and refuses to touch files whose markers are malformed. Stdlib only.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
16
|
+
NAME = "leos-agent"
|
|
17
|
+
|
|
18
|
+
failures = []
|
|
19
|
+
checks = 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check(condition, message):
|
|
23
|
+
global checks
|
|
24
|
+
checks += 1
|
|
25
|
+
if not condition:
|
|
26
|
+
failures.append(message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def raises_block_error(installer, text):
|
|
30
|
+
try:
|
|
31
|
+
installer.inject(text, "<leos-agent version=\"0\">\nx\n</leos-agent>\n")
|
|
32
|
+
except installer.BlockError:
|
|
33
|
+
return True
|
|
34
|
+
except Exception as exc: # a different failure is still a failed check, not a crash
|
|
35
|
+
failures.append(f"expected BlockError but got {type(exc).__name__}: {exc}")
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_installer():
|
|
40
|
+
spec = importlib.util.spec_from_file_location("leo_install", ROOT / "scripts" / "leo-install.py")
|
|
41
|
+
module = importlib.util.module_from_spec(spec)
|
|
42
|
+
spec.loader.exec_module(module)
|
|
43
|
+
return module
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main():
|
|
47
|
+
installer = load_installer()
|
|
48
|
+
canonical = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))["version"]
|
|
49
|
+
check(re.fullmatch(r"\d+\.\d+\.\d+", canonical) is not None, f"package.json version {canonical!r} is not strict semver")
|
|
50
|
+
|
|
51
|
+
# 1. Every manifest agrees on version and name.
|
|
52
|
+
for rel in (".claude-plugin/plugin.json", ".codex-plugin/plugin.json", ".cursor-plugin/plugin.json"):
|
|
53
|
+
data = json.loads((ROOT / rel).read_text(encoding="utf-8"))
|
|
54
|
+
check(data.get("version") == canonical, f"{rel}: version {data.get('version')!r} != {canonical!r}")
|
|
55
|
+
check(data.get("name") == NAME, f"{rel}: name {data.get('name')!r} != {NAME!r}")
|
|
56
|
+
|
|
57
|
+
yaml_text = (ROOT / "plugin.yaml").read_text(encoding="utf-8")
|
|
58
|
+
yaml_version = re.search(r"^version:\s*['\"]?([^'\"\s]+)", yaml_text, re.MULTILINE)
|
|
59
|
+
check(yaml_version is not None and yaml_version.group(1) == canonical, f"plugin.yaml: version != {canonical!r}")
|
|
60
|
+
yaml_name = re.search(r"^name:\s*['\"]?([^'\"\s]+)", yaml_text, re.MULTILINE)
|
|
61
|
+
check(yaml_name is not None and yaml_name.group(1) == NAME, f"plugin.yaml: name != {NAME!r}")
|
|
62
|
+
|
|
63
|
+
# 2. Neither Claude Code nor Codex may declare hooks/hooks.json: both load it
|
|
64
|
+
# automatically, and naming it again is a duplicate. Claude Code fails the
|
|
65
|
+
# whole plugin at load time for this, and its own `plugin validate` does not
|
|
66
|
+
# catch it -- only a real install does.
|
|
67
|
+
claude_manifest = json.loads((ROOT / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8"))
|
|
68
|
+
declared = claude_manifest.get("hooks")
|
|
69
|
+
declared = declared if isinstance(declared, list) else [declared] if declared else []
|
|
70
|
+
check(
|
|
71
|
+
not any(str(p).endswith("hooks/hooks.json") for p in declared),
|
|
72
|
+
".claude-plugin/plugin.json: must not declare hooks/hooks.json (auto-loaded; declaring it fails the plugin)",
|
|
73
|
+
)
|
|
74
|
+
codex = json.loads((ROOT / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8"))
|
|
75
|
+
check("hooks" not in codex, ".codex-plugin/plugin.json: must omit `hooks` (validator rejects it; hooks/ is auto-discovered)")
|
|
76
|
+
check(codex.get("description"), ".codex-plugin/plugin.json: description is required")
|
|
77
|
+
check(codex.get("author", {}).get("name"), ".codex-plugin/plugin.json: author.name is required")
|
|
78
|
+
interface = codex.get("interface", {})
|
|
79
|
+
for field in ("displayName", "shortDescription", "longDescription", "developerName", "category"):
|
|
80
|
+
check(interface.get(field), f".codex-plugin/plugin.json: interface.{field} is required")
|
|
81
|
+
|
|
82
|
+
# 3. Marketplaces parse, name this plugin, and carry no stale version.
|
|
83
|
+
for rel in (".claude-plugin/marketplace.json", ".agents/plugins/marketplace.json"):
|
|
84
|
+
data = json.loads((ROOT / rel).read_text(encoding="utf-8"))
|
|
85
|
+
plugins = data.get("plugins", [])
|
|
86
|
+
check(len(plugins) == 1, f"{rel}: expected exactly one plugin entry")
|
|
87
|
+
if plugins:
|
|
88
|
+
entry = plugins[0]
|
|
89
|
+
check(entry.get("name") == NAME, f"{rel}: plugin name != {NAME!r}")
|
|
90
|
+
if rel.startswith(".claude-plugin"):
|
|
91
|
+
check(entry.get("version") == canonical, f"{rel}: plugin version {entry.get('version')!r} != {canonical!r}")
|
|
92
|
+
|
|
93
|
+
# Every version string the README hardcodes must be the current one: the
|
|
94
|
+
# uninstall commands point at versioned cache paths.
|
|
95
|
+
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
|
96
|
+
check(canonical in readme, f"README.md does not mention version {canonical}")
|
|
97
|
+
stale = {v for v in re.findall(r"\b\d+\.\d+\.\d+\b", readme) if v != canonical}
|
|
98
|
+
check(not stale, f"README.md mentions non-current version(s): {sorted(stale)}")
|
|
99
|
+
|
|
100
|
+
# 4. The payload is a valid Cursor rule with a real body.
|
|
101
|
+
prefs = (ROOT / "rules" / "preferences.md").read_text(encoding="utf-8")
|
|
102
|
+
check(prefs.startswith("---\n"), "rules/preferences.md: missing YAML frontmatter")
|
|
103
|
+
frontmatter = prefs.split("---", 2)[1] if prefs.count("---") >= 2 else ""
|
|
104
|
+
check("alwaysApply: true" in frontmatter, "rules/preferences.md: frontmatter needs alwaysApply: true")
|
|
105
|
+
check("description:" in frontmatter, "rules/preferences.md: frontmatter needs a description")
|
|
106
|
+
body = installer.payload_body(ROOT)
|
|
107
|
+
check(len(body) > 200, "rules/preferences.md: body is suspiciously short")
|
|
108
|
+
check("<leos-agent" not in body and "</leos-agent>" not in body, "rules/preferences.md: body must not contain a marker")
|
|
109
|
+
|
|
110
|
+
# 5. Skills and commands exist and carry the portable frontmatter subset.
|
|
111
|
+
skills = sorted((ROOT / "skills").glob("*/SKILL.md"))
|
|
112
|
+
check(len(skills) >= 1, "skills/: no SKILL.md found; the plugin must ship at least one skill")
|
|
113
|
+
for skill in skills:
|
|
114
|
+
text = skill.read_text(encoding="utf-8")
|
|
115
|
+
check(text.startswith("---\n"), f"{skill.relative_to(ROOT)}: missing frontmatter")
|
|
116
|
+
fm = text.split("---", 2)[1] if text.count("---") >= 2 else ""
|
|
117
|
+
check(re.search(r"^name:", fm, re.MULTILINE) is not None, f"{skill.relative_to(ROOT)}: needs name")
|
|
118
|
+
check(re.search(r"^description:", fm, re.MULTILINE) is not None, f"{skill.relative_to(ROOT)}: needs description")
|
|
119
|
+
commands = sorted((ROOT / "commands").glob("*.md"))
|
|
120
|
+
check(len(commands) >= 1, "commands/: no command files found")
|
|
121
|
+
|
|
122
|
+
# 6. Every path a manifest points at must exist, and hook files must parse in
|
|
123
|
+
# their own harness's format. A manifest referencing a missing file ships a
|
|
124
|
+
# broken plugin, so absence has to fail rather than skip.
|
|
125
|
+
for rel, keys in (
|
|
126
|
+
(".claude-plugin/plugin.json", ("skills", "commands", "hooks")),
|
|
127
|
+
(".cursor-plugin/plugin.json", ("rules", "skills", "commands", "hooks")),
|
|
128
|
+
(".codex-plugin/plugin.json", ("skills",)),
|
|
129
|
+
):
|
|
130
|
+
data = json.loads((ROOT / rel).read_text(encoding="utf-8"))
|
|
131
|
+
for key in keys:
|
|
132
|
+
value = data.get(key)
|
|
133
|
+
for declared in (value if isinstance(value, list) else [value] if value else []):
|
|
134
|
+
check((ROOT / declared).exists(), f"{rel}: {key} points at {declared}, which does not exist")
|
|
135
|
+
|
|
136
|
+
shared_hooks = ROOT / "hooks" / "hooks.json"
|
|
137
|
+
check(shared_hooks.is_file(), "hooks/hooks.json is missing (Claude Code and Codex read it)")
|
|
138
|
+
if shared_hooks.is_file():
|
|
139
|
+
data = json.loads(shared_hooks.read_text(encoding="utf-8"))
|
|
140
|
+
check(isinstance(data.get("hooks"), dict), "hooks/hooks.json: needs a top-level `hooks` object")
|
|
141
|
+
cursor_hooks = ROOT / "hooks" / "hooks-cursor.json"
|
|
142
|
+
check(cursor_hooks.is_file(), "hooks/hooks-cursor.json is missing (Cursor reads it)")
|
|
143
|
+
if cursor_hooks.is_file():
|
|
144
|
+
data = json.loads(cursor_hooks.read_text(encoding="utf-8"))
|
|
145
|
+
check(data.get("version") == 1, "hooks/hooks-cursor.json: Cursor requires version 1")
|
|
146
|
+
check(isinstance(data.get("hooks"), dict), "hooks/hooks-cursor.json: needs a top-level `hooks` object")
|
|
147
|
+
|
|
148
|
+
# Payload files copied by the installer must carry the provenance string, or
|
|
149
|
+
# it will mistake its own installed copy for a stranger's file and refuse to
|
|
150
|
+
# upgrade or remove it. The list is derived from the installer's own copy sets,
|
|
151
|
+
# so a skill added there can never slip past this check.
|
|
152
|
+
copied = ["skills/install/SKILL.md"]
|
|
153
|
+
copied.extend(f"payload/codex-agents/{name}.toml" for name in installer.CODEX_AGENTS)
|
|
154
|
+
for name in installer.OPENCODE_SKILLS:
|
|
155
|
+
copied.append(f"skills/{name}/SKILL.md")
|
|
156
|
+
copied.extend(str(p.relative_to(ROOT)) for p in sorted((ROOT / "skills" / name / "reference").glob("*.md")))
|
|
157
|
+
copied.extend(f"commands/{name}.md" for name in installer.OPENCODE_COMMANDS)
|
|
158
|
+
for rel in sorted(set(copied)):
|
|
159
|
+
path = ROOT / rel
|
|
160
|
+
check(path.is_file(), f"{rel}: the installer copies this file, but it does not exist")
|
|
161
|
+
if path.is_file():
|
|
162
|
+
check(installer.PROVENANCE in path.read_text(encoding="utf-8"), f"{rel}: must contain {installer.PROVENANCE!r} so the installer recognises its own copy")
|
|
163
|
+
|
|
164
|
+
# 5b. Invocation split: a skill is either user-invoked (and hidden from the
|
|
165
|
+
# model's always-loaded skill listing) or deliberately model-invocable. Claude
|
|
166
|
+
# reads the SKILL.md flag; Codex reads the sibling agents/openai.yaml policy.
|
|
167
|
+
# Missing either half makes an explicit-only portable skill an unintended
|
|
168
|
+
# permanent per-session token cost in one of the harnesses.
|
|
169
|
+
MODEL_INVOCABLE = {"review-pr", "handon"}
|
|
170
|
+
for skill in sorted((ROOT / "skills").glob("*/SKILL.md")) + sorted((ROOT / "skills-claude").glob("*/SKILL.md")):
|
|
171
|
+
rel = skill.relative_to(ROOT)
|
|
172
|
+
fm = skill.read_text(encoding="utf-8").split("---", 2)[1]
|
|
173
|
+
name_match = re.search(r"^name:\s*(\S+)", fm, re.MULTILINE)
|
|
174
|
+
name = name_match.group(1) if name_match else skill.parent.name
|
|
175
|
+
disabled = re.search(r"^disable-model-invocation:\s*true", fm, re.MULTILINE) is not None
|
|
176
|
+
if name in MODEL_INVOCABLE:
|
|
177
|
+
check(not disabled, f"{rel}: {name} is meant to be model-invocable; remove disable-model-invocation")
|
|
178
|
+
else:
|
|
179
|
+
check(disabled, f"{rel}: needs `disable-model-invocation: true`, or add {name!r} to MODEL_INVOCABLE in check.py")
|
|
180
|
+
|
|
181
|
+
# Claude-only skills are never surfaced to Codex. Portable explicit-only
|
|
182
|
+
# skills need the corresponding Codex policy file as well.
|
|
183
|
+
if skill.parent.parent.name == "skills":
|
|
184
|
+
openai_yaml = skill.parent / "agents" / "openai.yaml"
|
|
185
|
+
if name in MODEL_INVOCABLE:
|
|
186
|
+
if openai_yaml.exists():
|
|
187
|
+
text = openai_yaml.read_text(encoding="utf-8")
|
|
188
|
+
check(
|
|
189
|
+
"allow_implicit_invocation: false" not in text,
|
|
190
|
+
f"{openai_yaml.relative_to(ROOT)}: {name} is meant to be model-invocable",
|
|
191
|
+
)
|
|
192
|
+
else:
|
|
193
|
+
check(openai_yaml.is_file(), f"{openai_yaml.relative_to(ROOT)}: explicit-only Codex skill policy is missing")
|
|
194
|
+
if openai_yaml.is_file():
|
|
195
|
+
text = openai_yaml.read_text(encoding="utf-8")
|
|
196
|
+
check(
|
|
197
|
+
re.search(r"(?m)^policy:\s*\n\s+allow_implicit_invocation:\s*false\s*$", text) is not None,
|
|
198
|
+
f"{openai_yaml.relative_to(ROOT)}: needs policy.allow_implicit_invocation false",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# 7. Injection is idempotent, and uninstall round-trips exactly.
|
|
202
|
+
block = installer.build_block(ROOT)
|
|
203
|
+
check(block.startswith(f'<leos-agent version="{canonical}">'), "block header must carry the version")
|
|
204
|
+
|
|
205
|
+
original = "# My notes\n\nSomething I wrote myself.\n"
|
|
206
|
+
once = installer.inject(original, block)
|
|
207
|
+
twice = installer.inject(once, block)
|
|
208
|
+
check(once == twice, "inject is not idempotent: second run differs from first")
|
|
209
|
+
check(original.strip() in once, "inject dropped pre-existing content")
|
|
210
|
+
# Uninstall normalizes the file to a single trailing newline, which restores
|
|
211
|
+
# the original exactly for any file that ended with one.
|
|
212
|
+
restored = installer.strip_block(once).rstrip("\n") + "\n"
|
|
213
|
+
check(restored == original, "uninstall did not restore the original content")
|
|
214
|
+
|
|
215
|
+
stale_block = installer.inject(original, '<leos-agent version="9.9.9">\nold payload\n</leos-agent>\n')
|
|
216
|
+
upgraded = installer.inject(stale_block, block)
|
|
217
|
+
check("old payload" not in upgraded, "inject did not replace an older version's block")
|
|
218
|
+
check(upgraded == once, "upgrading a stale block did not converge on the current content")
|
|
219
|
+
check(installer.inject("", block) == block, "inject into an empty file should yield just the block")
|
|
220
|
+
|
|
221
|
+
# Content on both sides of the block survives, and no-trailing-newline works.
|
|
222
|
+
sandwich = "top\n\n" + block + "\nbottom\n"
|
|
223
|
+
check("top" in installer.inject(sandwich, block) and "bottom" in installer.inject(sandwich, block), "inject lost content around the block")
|
|
224
|
+
check(installer.strip_block(sandwich) == "top\n\n\nbottom\n", "strip_block mangled surrounding content")
|
|
225
|
+
no_newline = "note\n\n" + block.rstrip("\n")
|
|
226
|
+
check("note" in installer.strip_block(no_newline), "strip_block lost content when the block ends at EOF")
|
|
227
|
+
|
|
228
|
+
# 8. Malformed markers must raise rather than silently swallow user content.
|
|
229
|
+
dangling = "# mine\n<leos-agent>\nsecret note\n\nmore notes\n"
|
|
230
|
+
check(raises_block_error(installer, dangling), "an unclosed <leos-agent> opener must refuse, not swallow content")
|
|
231
|
+
check(raises_block_error(installer, "stray\n</leos-agent>\n"), "a stray closer must refuse")
|
|
232
|
+
check(raises_block_error(installer, block + "\n" + block), "two blocks must refuse rather than update only the first")
|
|
233
|
+
|
|
234
|
+
print(f"checked {checks} invariant(s)")
|
|
235
|
+
if failures:
|
|
236
|
+
for message in failures:
|
|
237
|
+
print(f"FAIL {message}", file=sys.stderr)
|
|
238
|
+
return 1
|
|
239
|
+
print("all checks passed")
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if __name__ == "__main__":
|
|
244
|
+
sys.exit(main())
|
package/scripts/ghreview.py
CHANGED
|
@@ -162,12 +162,26 @@ def snap_line(diffmap, side, line):
|
|
|
162
162
|
def validate_comments(comments, maps):
|
|
163
163
|
staged, snapped, dropped = [], [], []
|
|
164
164
|
for c in comments:
|
|
165
|
-
|
|
165
|
+
if not isinstance(c, dict):
|
|
166
|
+
dropped.append({"value": c, "reason": "comment must be an object"})
|
|
167
|
+
continue
|
|
168
|
+
path = c.get("path")
|
|
169
|
+
raw_body = c.get("body")
|
|
170
|
+
body = raw_body.strip() if isinstance(raw_body, str) else ""
|
|
166
171
|
side = c.get("side", "RIGHT")
|
|
167
172
|
line = c.get("line")
|
|
168
|
-
if
|
|
173
|
+
if (
|
|
174
|
+
not isinstance(path, str)
|
|
175
|
+
or not path
|
|
176
|
+
or not body
|
|
177
|
+
or not isinstance(line, int)
|
|
178
|
+
or isinstance(line, bool)
|
|
179
|
+
):
|
|
169
180
|
dropped.append({**c, "reason": "missing path/line/body"})
|
|
170
181
|
continue
|
|
182
|
+
if side not in ("RIGHT", "LEFT"):
|
|
183
|
+
dropped.append({**c, "reason": f"invalid side {side!r}; expected RIGHT or LEFT"})
|
|
184
|
+
continue
|
|
171
185
|
diffmap = maps.get(path)
|
|
172
186
|
if diffmap is None:
|
|
173
187
|
dropped.append({**c, "reason": "file not in diff (or binary/no patch)"})
|
|
@@ -180,7 +194,7 @@ def validate_comments(comments, maps):
|
|
|
180
194
|
# Multi-line ranges: keep only if the start anchors cleanly before the
|
|
181
195
|
# end on the same side; otherwise degrade to a single-line comment.
|
|
182
196
|
start = c.get("start_line")
|
|
183
|
-
if isinstance(start, int):
|
|
197
|
+
if isinstance(start, int) and not isinstance(start, bool):
|
|
184
198
|
start_side = c.get("start_side", side)
|
|
185
199
|
snapped_start = snap_line(maps[path], start_side, start)
|
|
186
200
|
if snapped_start is not None and snapped_start < new_line and start_side == side:
|