pantheon-opencode 1.5.2 → 1.6.0-beta.1
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/.pantheon/code-mode/README.md +4 -2
- package/.pantheon/code-mode/checkpoint_session.py +138 -102
- package/.pantheon/code-mode/compress-inline.py +102 -57
- package/.pantheon/code-mode/manifest.json +2 -8
- package/CHANGELOG.md +17 -0
- package/README.md +86 -52
- package/README.pt-BR.md +63 -43
- package/commands/pantheon-optimize.md +2 -2
- package/package.json +13 -1
- package/plugin.json +1 -1
- package/pyproject.toml +8 -15
- package/scripts/build-agents-md.mjs +2 -2
- package/scripts/check-plugin-v2-coverage.mjs +213 -0
- package/scripts/ci-validate-yaml.py +7 -5
- package/scripts/code_mode_server.py +116 -24
- package/scripts/doctor.mjs +85 -28
- package/scripts/install/cli-ui.mjs +1 -1
- package/scripts/install/config-migration.mjs +2 -2
- package/scripts/install/health-check.mjs +6 -19
- package/scripts/install/interactive.mjs +6 -6
- package/scripts/install/migrate.mjs +1 -1
- package/scripts/mcp_persistence_server.py +390 -76
- package/scripts/memory_mcp_server.py +370 -196
- package/scripts/postinstall.mjs +0 -7
- package/scripts/probe-context-rehydrate.mjs +28 -11
- package/scripts/prune.mjs +2 -2
- package/scripts/test-opencode-v2-sandbox.sh +1759 -0
- package/scripts/validate-routing.mjs +40 -0
- package/skills-lock.json +9 -10
- package/src/agents/mnemosyne.md +15 -16
- package/src/agents/zeus.md +1 -2
- package/src/mcp/code_mode_server.py +116 -24
- package/src/mcp/mcp_codemap_module.py +559 -129
- package/src/mcp/mcp_persistence_server.py +390 -76
- package/src/mcp/memory_mcp_server.py +371 -248
- package/src/mcp/pantheon_vision_server.py +136 -24
- package/src/mcp/requirements-mcp.txt +5 -3
- package/src/mcp/requirements-vision.txt +2 -0
- package/src/pantheon/model-command.ts +18 -7
- package/src/pantheon/todo-enforcer.ts +1 -1
- package/src/pantheon/vision.ts +1 -1
- package/src/plugin-v2.ts +134 -104
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/package-lock.json +20 -20
- package/src/plugins/tui/package.json +12 -12
- package/src/routing.yml +0 -1
- package/src/skills/README.md +31 -36
- package/src/skills/auto-continue/SKILL.md +25 -75
- package/.pantheon/code-mode/eval-llm-judge.py +0 -255
- package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
- package/.pantheon/code-mode/eval-run.py +0 -197
- package/.pantheon/code-mode/eval-static.py +0 -321
- package/.pantheon/code-mode/session-end-save.py +0 -186
- package/.pantheon/code-mode/session-end-save.sh +0 -18
- package/scripts/recover-zenodo-deposition.mjs +0 -146
- package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
- package/scripts/validate-zenodo-release.mjs +0 -154
- package/scripts/zenodo-artifact.mjs +0 -94
- package/scripts/zenodo-release-state.mjs +0 -53
- package/src/skills/orchestration-workflow/SKILL.md +0 -311
|
@@ -1,368 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# ---
|
|
3
|
-
# description: Monte Carlo reliability scoring for skills/agents — simulates N runs of the core workflow (plugin-eval layer 3)
|
|
4
|
-
# timeout: 120
|
|
5
|
-
# ---
|
|
6
|
-
"""Monte Carlo reliability layer of the plugin-eval certification pipeline (PR 3).
|
|
7
|
-
|
|
8
|
-
Simulates N runs of a skill/agent's core workflow. Each run re-verifies the
|
|
9
|
-
frontmatter, a seeded random subset of referenced files/commands, and (when
|
|
10
|
-
present) the skill's test/verify command. Reliability = passes / runs * 100.
|
|
11
|
-
|
|
12
|
-
For agents, additionally verifies that `skills:` and `@agent` references point
|
|
13
|
-
at real skills/agents and that the YAML frontmatter is valid.
|
|
14
|
-
|
|
15
|
-
Emits a single JSON document on stdout:
|
|
16
|
-
{runs, passes, reliability (0-100), failures: [{run, reasons}, ...], ...}
|
|
17
|
-
|
|
18
|
-
Exit codes: 0 = reliability >= 75, 1 = reliability < 75, 2 = usage/IO error.
|
|
19
|
-
The declared test command executes once per invocation and its result is
|
|
20
|
-
reused across runs (it is deterministic; re-running it N times only burns time).
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
from __future__ import annotations
|
|
24
|
-
|
|
25
|
-
import argparse
|
|
26
|
-
import json
|
|
27
|
-
import os
|
|
28
|
-
import random
|
|
29
|
-
import re
|
|
30
|
-
import shlex
|
|
31
|
-
import signal
|
|
32
|
-
import subprocess
|
|
33
|
-
import sys
|
|
34
|
-
from datetime import date
|
|
35
|
-
from pathlib import Path
|
|
36
|
-
from typing import Any
|
|
37
|
-
|
|
38
|
-
DEFAULT_RUNS = 20
|
|
39
|
-
DEFAULT_SEED = 42
|
|
40
|
-
TEST_TIMEOUT = 15.0
|
|
41
|
-
SAFE_COMMAND_PREFIXES = ("pytest", "python -m pytest", "npm test", "npm run test", "npm run verify", "bash", "python", "python3", "./")
|
|
42
|
-
KNOWN_AGENTS = frozenset(
|
|
43
|
-
{
|
|
44
|
-
"athena", "apollo", "hermes", "aphrodite", "demeter", "themis",
|
|
45
|
-
"prometheus", "hephaestus", "nyx", "gaia", "iris", "mnemosyne",
|
|
46
|
-
"talos", "zeus",
|
|
47
|
-
}
|
|
48
|
-
)
|
|
49
|
-
FILE_REF_RE = re.compile(r"`([\w./-]+\.(?:py|sh|js|ts|mjs|md|json|yml|yaml|toml))`")
|
|
50
|
-
COMMAND_STARTS = ("python", "python3", "bash", "npm", "node", "npx", "pytest", "uv", "./")
|
|
51
|
-
SUPPORTED_COMMANDS = frozenset({"python", "python3", "bash", "npm", "node", "npx", "pytest", "uv"})
|
|
52
|
-
SHELL_OPERATORS = (";", "&&", "||", "|", ">", "<", "`", "$(")
|
|
53
|
-
TEST_SECTION_RE = re.compile(r"^##+\s+(?:Verification|Testing|Test)\b.*$", re.MULTILINE)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def _coerce(value: str) -> Any:
|
|
57
|
-
"""Coerce a scalar string into bool/int/float when possible."""
|
|
58
|
-
lowered = value.lower()
|
|
59
|
-
if lowered in ("true", "false"):
|
|
60
|
-
return lowered == "true"
|
|
61
|
-
if lowered in ("null", "none", "~"):
|
|
62
|
-
return None
|
|
63
|
-
try:
|
|
64
|
-
return int(value)
|
|
65
|
-
except ValueError:
|
|
66
|
-
pass
|
|
67
|
-
try:
|
|
68
|
-
return float(value)
|
|
69
|
-
except ValueError:
|
|
70
|
-
pass
|
|
71
|
-
return value
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def _minimal_yaml(block: str) -> dict[str, Any]:
|
|
75
|
-
"""Tiny YAML-subset parser for flat frontmatter (key: value, lists)."""
|
|
76
|
-
data: dict[str, Any] = {}
|
|
77
|
-
current_key: str | None = None
|
|
78
|
-
for raw in block.splitlines():
|
|
79
|
-
line = raw.rstrip()
|
|
80
|
-
if not line.strip() or line.lstrip().startswith("#"):
|
|
81
|
-
continue
|
|
82
|
-
if line.lstrip().startswith("- "):
|
|
83
|
-
item = line.split("- ", 1)[1].strip().strip('"').strip("'")
|
|
84
|
-
if current_key is not None:
|
|
85
|
-
data.setdefault(current_key, []).append(item)
|
|
86
|
-
continue
|
|
87
|
-
if ":" not in line:
|
|
88
|
-
continue
|
|
89
|
-
key, _, value = line.partition(":")
|
|
90
|
-
current_key = key.strip()
|
|
91
|
-
value = value.strip()
|
|
92
|
-
if not value:
|
|
93
|
-
data[current_key] = []
|
|
94
|
-
else:
|
|
95
|
-
data[current_key] = _coerce(value.strip('"').strip("'"))
|
|
96
|
-
return data
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def _parse_frontmatter(text: str) -> dict[str, Any] | None:
|
|
100
|
-
"""Parse a YAML frontmatter block; return None when absent or invalid."""
|
|
101
|
-
if not text.startswith("---"):
|
|
102
|
-
return None
|
|
103
|
-
end = text.find("\n---", 3)
|
|
104
|
-
if end == -1:
|
|
105
|
-
return None
|
|
106
|
-
block = text[3:end]
|
|
107
|
-
try:
|
|
108
|
-
import yaml # local import: optional dependency
|
|
109
|
-
|
|
110
|
-
data = yaml.safe_load(block)
|
|
111
|
-
return data if isinstance(data, dict) else None
|
|
112
|
-
except ImportError:
|
|
113
|
-
return _minimal_yaml(block)
|
|
114
|
-
except Exception: # noqa: BLE001 - any YAML error means invalid frontmatter
|
|
115
|
-
return None
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def _discover_doc(path: Path) -> Path:
|
|
119
|
-
"""Return the primary doc (SKILL.md or agent .md) for a path."""
|
|
120
|
-
if path.is_file():
|
|
121
|
-
return path
|
|
122
|
-
skill = path / "SKILL.md"
|
|
123
|
-
if skill.is_file():
|
|
124
|
-
return skill
|
|
125
|
-
candidates = sorted(path.glob("*.md"))
|
|
126
|
-
for candidate in candidates:
|
|
127
|
-
try:
|
|
128
|
-
text = candidate.read_text(encoding="utf-8", errors="replace")
|
|
129
|
-
except OSError:
|
|
130
|
-
continue
|
|
131
|
-
if _parse_frontmatter(text) is not None:
|
|
132
|
-
return candidate
|
|
133
|
-
if candidates:
|
|
134
|
-
return candidates[0]
|
|
135
|
-
raise ValueError(f"No SKILL.md or .md file found in {path}")
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def _as_list(value: Any) -> list[Any]:
|
|
139
|
-
if isinstance(value, list):
|
|
140
|
-
return value
|
|
141
|
-
if isinstance(value, str) and value.strip():
|
|
142
|
-
return [value]
|
|
143
|
-
return []
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def _extract_references(doc: Path, text: str, is_agent: bool) -> dict[str, list[str]]:
|
|
147
|
-
"""Extract file refs, commands, agent mentions, and (agents) skill refs."""
|
|
148
|
-
refs: dict[str, list[str]] = {"files": [], "commands": [], "agents": [], "skills": []}
|
|
149
|
-
for match in FILE_REF_RE.finditer(text):
|
|
150
|
-
ref = match.group(1)
|
|
151
|
-
if ref not in refs["files"]:
|
|
152
|
-
refs["files"].append(ref)
|
|
153
|
-
for line in text.splitlines():
|
|
154
|
-
stripped = line.strip()
|
|
155
|
-
if not stripped.startswith(COMMAND_STARTS) or stripped.startswith(("#", "//")):
|
|
156
|
-
continue
|
|
157
|
-
cmd = stripped.split("&&")[0].strip()
|
|
158
|
-
if cmd and cmd not in refs["commands"]:
|
|
159
|
-
refs["commands"].append(cmd)
|
|
160
|
-
for mention in re.findall(r"@([a-z][a-z0-9-]*)", text):
|
|
161
|
-
if mention not in refs["agents"]:
|
|
162
|
-
refs["agents"].append(mention)
|
|
163
|
-
if is_agent:
|
|
164
|
-
frontmatter = _parse_frontmatter(text) or {}
|
|
165
|
-
refs["skills"] = [str(s) for s in _as_list(frontmatter.get("skills"))]
|
|
166
|
-
return refs
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def _find_test_command(doc: Path, text: str) -> str | None:
|
|
170
|
-
"""Return a test/verify command from frontmatter or a Verification section."""
|
|
171
|
-
frontmatter = _parse_frontmatter(text) or {}
|
|
172
|
-
for key in ("test", "test_command", "verify_command", "check_command"):
|
|
173
|
-
value = frontmatter.get(key)
|
|
174
|
-
if isinstance(value, str) and value.strip():
|
|
175
|
-
return value.strip()
|
|
176
|
-
for match in TEST_SECTION_RE.finditer(text):
|
|
177
|
-
section = text[match.end() :].split("\n##", 1)[0]
|
|
178
|
-
for line in section.splitlines():
|
|
179
|
-
stripped = line.strip()
|
|
180
|
-
if stripped.startswith(SAFE_COMMAND_PREFIXES) and not stripped.startswith(("#", "//")):
|
|
181
|
-
return stripped.split("&&")[0].strip()
|
|
182
|
-
return None
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def _check_frontmatter(doc: Path, is_agent: bool) -> tuple[bool, str]:
|
|
186
|
-
"""Validate the doc's YAML frontmatter and required keys."""
|
|
187
|
-
try:
|
|
188
|
-
text = doc.read_text(encoding="utf-8", errors="replace")
|
|
189
|
-
except OSError as exc:
|
|
190
|
-
return False, f"cannot read {doc.name}: {exc}"
|
|
191
|
-
frontmatter = _parse_frontmatter(text)
|
|
192
|
-
if frontmatter is None:
|
|
193
|
-
return False, "frontmatter missing or invalid YAML"
|
|
194
|
-
required = ("name", "description", "mode") if is_agent else ("name", "description")
|
|
195
|
-
missing = [key for key in required if key not in frontmatter]
|
|
196
|
-
if missing:
|
|
197
|
-
return False, f"frontmatter missing keys: {', '.join(missing)}"
|
|
198
|
-
return True, ""
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
def _resolve_file(ref: str, base: Path, repo_root: Path) -> Path | None:
|
|
202
|
-
"""Resolve a file reference against the doc dir, repo root, or CWD."""
|
|
203
|
-
candidate = Path(ref)
|
|
204
|
-
if candidate.is_absolute():
|
|
205
|
-
return candidate if candidate.exists() else None
|
|
206
|
-
for root in (base, repo_root, Path.cwd()):
|
|
207
|
-
resolved = root / ref
|
|
208
|
-
if resolved.exists():
|
|
209
|
-
return resolved
|
|
210
|
-
return None
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
def _check_file(ref: str, base: Path, repo_root: Path) -> tuple[bool, str]:
|
|
214
|
-
"""Verify a referenced file exists (and is executable for shell scripts)."""
|
|
215
|
-
resolved = _resolve_file(ref, base, repo_root)
|
|
216
|
-
if resolved is None:
|
|
217
|
-
return False, f"missing file: {ref}"
|
|
218
|
-
if resolved.suffix == ".sh" and not os.access(resolved, os.X_OK):
|
|
219
|
-
return False, f"not executable: {ref}"
|
|
220
|
-
return True, ""
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def _check_command(cmd: str, base: Path, repo_root: Path) -> tuple[bool, str]:
|
|
224
|
-
"""Verify a command's file argument exists (module-only commands pass)."""
|
|
225
|
-
for token in cmd.split()[1:]:
|
|
226
|
-
if "/" in token or token.endswith((".py", ".sh", ".js", ".mjs")):
|
|
227
|
-
if _resolve_file(token, base, repo_root) is None:
|
|
228
|
-
return False, f"command references missing file: {token}"
|
|
229
|
-
break
|
|
230
|
-
return True, ""
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
def _run_test_command(cmd: str, base: Path) -> tuple[bool, str]:
|
|
234
|
-
"""Run an allowlisted command without invoking a shell."""
|
|
235
|
-
if any(operator in cmd for operator in SHELL_OPERATORS):
|
|
236
|
-
return False, f"unsafe test command skipped: shell operator in {cmd}"
|
|
237
|
-
try:
|
|
238
|
-
argv = shlex.split(cmd)
|
|
239
|
-
except ValueError as exc:
|
|
240
|
-
return False, f"unsafe test command skipped: invalid command syntax: {exc}"
|
|
241
|
-
if not argv:
|
|
242
|
-
return False, "unsafe test command skipped: empty command"
|
|
243
|
-
executable = Path(argv[0]).name if argv[0].startswith("./") else argv[0]
|
|
244
|
-
if executable not in SUPPORTED_COMMANDS and not argv[0].startswith("./"):
|
|
245
|
-
return False, f"unsafe test command skipped: unsupported command: {argv[0]}"
|
|
246
|
-
try:
|
|
247
|
-
proc = subprocess.Popen(
|
|
248
|
-
argv,
|
|
249
|
-
cwd=base,
|
|
250
|
-
stdout=subprocess.PIPE,
|
|
251
|
-
stderr=subprocess.PIPE,
|
|
252
|
-
text=True,
|
|
253
|
-
start_new_session=True,
|
|
254
|
-
)
|
|
255
|
-
try:
|
|
256
|
-
_stdout, _stderr = proc.communicate(timeout=TEST_TIMEOUT)
|
|
257
|
-
except subprocess.TimeoutExpired:
|
|
258
|
-
os.killpg(proc.pid, signal.SIGKILL)
|
|
259
|
-
proc.wait()
|
|
260
|
-
return False, f"test command timed out: {cmd}"
|
|
261
|
-
except subprocess.TimeoutExpired:
|
|
262
|
-
return False, f"test command timed out: {cmd}"
|
|
263
|
-
except OSError as exc:
|
|
264
|
-
return False, f"test command failed to start: {exc}"
|
|
265
|
-
if proc.returncode != 0:
|
|
266
|
-
return False, f"test command exited {proc.returncode}: {cmd}"
|
|
267
|
-
return True, ""
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def _simulate(
|
|
271
|
-
rng: random.Random,
|
|
272
|
-
doc: Path,
|
|
273
|
-
is_agent: bool,
|
|
274
|
-
refs: dict[str, list[str]],
|
|
275
|
-
test_cmd: str | None,
|
|
276
|
-
test_result: tuple[bool, str] | None,
|
|
277
|
-
repo_root: Path,
|
|
278
|
-
skills_dirs: list[Path],
|
|
279
|
-
agents_dir: Path,
|
|
280
|
-
) -> tuple[list[str], tuple[bool, str] | None]:
|
|
281
|
-
"""Run one simulated execution; return (failure reasons, cached test result)."""
|
|
282
|
-
failures: list[str] = []
|
|
283
|
-
ok, reason = _check_frontmatter(doc, is_agent)
|
|
284
|
-
if not ok:
|
|
285
|
-
failures.append(reason)
|
|
286
|
-
files = refs["files"]
|
|
287
|
-
if files:
|
|
288
|
-
k = rng.randint(max(1, len(files) // 2), len(files))
|
|
289
|
-
for ref in rng.sample(files, k):
|
|
290
|
-
ok, reason = _check_file(ref, doc.parent, repo_root)
|
|
291
|
-
if not ok:
|
|
292
|
-
failures.append(reason)
|
|
293
|
-
commands = refs["commands"]
|
|
294
|
-
if commands:
|
|
295
|
-
k = rng.randint(max(1, len(commands) // 2), len(commands))
|
|
296
|
-
for cmd in rng.sample(commands, k):
|
|
297
|
-
ok, reason = _check_command(cmd, doc.parent, repo_root)
|
|
298
|
-
if not ok:
|
|
299
|
-
failures.append(reason)
|
|
300
|
-
for agent in refs["agents"]:
|
|
301
|
-
if agent in KNOWN_AGENTS and not (agents_dir / f"{agent}.md").exists():
|
|
302
|
-
failures.append(f"unknown agent reference: @{agent}")
|
|
303
|
-
for skill in refs["skills"]:
|
|
304
|
-
if not any((directory / skill / "SKILL.md").exists() for directory in skills_dirs):
|
|
305
|
-
failures.append(f"unknown skill reference: {skill}")
|
|
306
|
-
if test_cmd:
|
|
307
|
-
if test_result is None:
|
|
308
|
-
test_result = _run_test_command(test_cmd, doc.parent)
|
|
309
|
-
if not test_result[0]:
|
|
310
|
-
failures.append(test_result[1])
|
|
311
|
-
return failures, test_result
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
def main(argv: list[str] | None = None) -> int:
|
|
315
|
-
"""CLI entry point: `python eval-monte-carlo.py <path> [--runs N]`."""
|
|
316
|
-
parser = argparse.ArgumentParser(
|
|
317
|
-
description="Monte Carlo reliability scoring (plugin-eval layer 3)."
|
|
318
|
-
)
|
|
319
|
-
parser.add_argument("path", help="Path to a skill/agent directory or doc file")
|
|
320
|
-
parser.add_argument("--runs", type=int, default=DEFAULT_RUNS, help="Simulated runs (default 20)")
|
|
321
|
-
parser.add_argument("--seed", type=int, default=DEFAULT_SEED, help="RNG seed (default 42)")
|
|
322
|
-
args = parser.parse_args(argv)
|
|
323
|
-
if args.runs < 1:
|
|
324
|
-
print("eval-monte-carlo: --runs must be >= 1", file=sys.stderr)
|
|
325
|
-
return 2
|
|
326
|
-
repo_root = Path(__file__).resolve().parent.parent.parent
|
|
327
|
-
agents_dir = repo_root / "src" / "agents"
|
|
328
|
-
skills_dirs = [repo_root / ".opencode" / "skills", Path.home() / ".config" / "opencode" / "skills"]
|
|
329
|
-
try:
|
|
330
|
-
doc = _discover_doc(Path(args.path))
|
|
331
|
-
except ValueError as exc:
|
|
332
|
-
print(f"eval-monte-carlo: {exc}", file=sys.stderr)
|
|
333
|
-
return 2
|
|
334
|
-
text = doc.read_text(encoding="utf-8", errors="replace")
|
|
335
|
-
frontmatter = _parse_frontmatter(text) or {}
|
|
336
|
-
is_agent = "mode" in frontmatter
|
|
337
|
-
refs = _extract_references(doc, text, is_agent)
|
|
338
|
-
test_cmd = _find_test_command(doc, text)
|
|
339
|
-
rng = random.Random(args.seed)
|
|
340
|
-
failures_list: list[dict[str, Any]] = []
|
|
341
|
-
test_result: tuple[bool, str] | None = None
|
|
342
|
-
passes = 0
|
|
343
|
-
for run in range(1, args.runs + 1):
|
|
344
|
-
failures, test_result = _simulate(
|
|
345
|
-
rng, doc, is_agent, refs, test_cmd, test_result, repo_root, skills_dirs, agents_dir
|
|
346
|
-
)
|
|
347
|
-
if failures:
|
|
348
|
-
failures_list.append({"run": run, "reasons": failures})
|
|
349
|
-
else:
|
|
350
|
-
passes += 1
|
|
351
|
-
reliability = round(100.0 * passes / args.runs, 1)
|
|
352
|
-
report = {
|
|
353
|
-
"name": frontmatter.get("name") or doc.stem,
|
|
354
|
-
"date": date.today().isoformat(),
|
|
355
|
-
"runs": args.runs,
|
|
356
|
-
"passes": passes,
|
|
357
|
-
"reliability": reliability,
|
|
358
|
-
"seed": args.seed,
|
|
359
|
-
"kind": "agent" if is_agent else "skill",
|
|
360
|
-
"test_command": test_cmd,
|
|
361
|
-
"failures": failures_list,
|
|
362
|
-
}
|
|
363
|
-
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
364
|
-
return 0 if reliability >= 75.0 else 1
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
if __name__ == "__main__":
|
|
368
|
-
sys.exit(main())
|
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# ---
|
|
3
|
-
# description: plugin-eval orchestrator — runs static + LLM judge + Monte Carlo layers and emits one report JSON
|
|
4
|
-
# timeout: 300
|
|
5
|
-
# ---
|
|
6
|
-
"""Orchestrator for the plugin-eval certification pipeline (PR 3).
|
|
7
|
-
|
|
8
|
-
Runs the three layers in sequence (static → LLM judge → Monte Carlo) and
|
|
9
|
-
aggregates them into a single report JSON:
|
|
10
|
-
|
|
11
|
-
{name, date, static, llm_judge, monte_carlo, overall_score, verdict}
|
|
12
|
-
|
|
13
|
-
Verdict thresholds: certified >= 75, needs_work 50-74, failed < 50. Layer
|
|
14
|
-
scripts may exit non-zero to signal a below-threshold score while still
|
|
15
|
-
printing a valid report; such layers are marked ``below_threshold`` and
|
|
16
|
-
their scores count toward the overall. Layers that fail outright (e.g. no
|
|
17
|
-
LLM available, invalid JSON) are recorded with an `error` field and
|
|
18
|
-
excluded from the overall score; the report still emits.
|
|
19
|
-
"""
|
|
20
|
-
|
|
21
|
-
from __future__ import annotations
|
|
22
|
-
|
|
23
|
-
import argparse
|
|
24
|
-
import importlib.util
|
|
25
|
-
import json
|
|
26
|
-
import os
|
|
27
|
-
import subprocess
|
|
28
|
-
import sys
|
|
29
|
-
from datetime import date
|
|
30
|
-
from pathlib import Path
|
|
31
|
-
from typing import Any
|
|
32
|
-
|
|
33
|
-
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
34
|
-
STATIC_SCRIPT = SCRIPT_DIR / "eval-static.py"
|
|
35
|
-
JUDGE_SCRIPT = SCRIPT_DIR / "eval-llm-judge.py"
|
|
36
|
-
MONTE_SCRIPT = SCRIPT_DIR / "eval-monte-carlo.py"
|
|
37
|
-
LAYER_TIMEOUT = 240.0
|
|
38
|
-
SCORE_KEYS = ("score", "overall", "overall_score", "reliability", "total")
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def _run_layer(script: Path, target: str, extra: list[str] | None = None) -> dict[str, Any]:
|
|
42
|
-
"""Run one layer script; return parsed JSON or an error dict.
|
|
43
|
-
|
|
44
|
-
A non-zero exit is not fatal by itself: layer scripts print their report
|
|
45
|
-
JSON before exiting non-zero to signal a below-threshold score (e.g.
|
|
46
|
-
eval-monte-carlo.py exits 1 when reliability < 75). In that case the
|
|
47
|
-
parsed report is returned with ``below_threshold`` set so its score
|
|
48
|
-
still counts toward the overall verdict. Only when stdout holds no
|
|
49
|
-
usable report does the layer degrade to an error dict.
|
|
50
|
-
"""
|
|
51
|
-
cmd = [sys.executable, str(script), target, *(extra or [])]
|
|
52
|
-
try:
|
|
53
|
-
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=LAYER_TIMEOUT, check=False)
|
|
54
|
-
except subprocess.TimeoutExpired:
|
|
55
|
-
return {"error": f"{script.name} timed out"}
|
|
56
|
-
try:
|
|
57
|
-
data = json.loads(proc.stdout)
|
|
58
|
-
except json.JSONDecodeError as exc:
|
|
59
|
-
if proc.returncode != 0:
|
|
60
|
-
return {"error": proc.stderr.strip() or f"{script.name} exited {proc.returncode}"}
|
|
61
|
-
return {"error": f"{script.name} returned invalid JSON: {exc}"}
|
|
62
|
-
if not isinstance(data, dict):
|
|
63
|
-
if proc.returncode != 0:
|
|
64
|
-
return {"error": proc.stderr.strip() or f"{script.name} exited {proc.returncode}"}
|
|
65
|
-
return {"error": f"{script.name} returned non-object JSON"}
|
|
66
|
-
if proc.returncode != 0:
|
|
67
|
-
data["below_threshold"] = True
|
|
68
|
-
return data
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def _extract_score(data: dict[str, Any], keys: tuple[str, ...]) -> float | None:
|
|
72
|
-
"""Find the first numeric score under any key, recursing into nested dicts."""
|
|
73
|
-
for key in keys:
|
|
74
|
-
value = data.get(key)
|
|
75
|
-
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
76
|
-
return float(value)
|
|
77
|
-
for value in data.values():
|
|
78
|
-
if isinstance(value, dict):
|
|
79
|
-
found = _extract_score(value, keys)
|
|
80
|
-
if found is not None:
|
|
81
|
-
return found
|
|
82
|
-
return None
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def _verdict(score: float) -> str:
|
|
86
|
-
if score >= 75:
|
|
87
|
-
return "certified"
|
|
88
|
-
if score >= 50:
|
|
89
|
-
return "needs_work"
|
|
90
|
-
return "failed"
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def _doc_name(path: Path) -> str:
|
|
94
|
-
"""Best-effort name from the doc frontmatter, falling back to the stem."""
|
|
95
|
-
if path.is_file():
|
|
96
|
-
doc = path
|
|
97
|
-
else:
|
|
98
|
-
skill = path / "SKILL.md"
|
|
99
|
-
doc = skill if skill.is_file() else path
|
|
100
|
-
try:
|
|
101
|
-
text = doc.read_text(encoding="utf-8", errors="replace")
|
|
102
|
-
except OSError:
|
|
103
|
-
return path.stem
|
|
104
|
-
if text.startswith("---"):
|
|
105
|
-
end = text.find("\n---", 3)
|
|
106
|
-
if end != -1:
|
|
107
|
-
for line in text[3:end].splitlines():
|
|
108
|
-
if line.startswith("name:"):
|
|
109
|
-
return line.split(":", 1)[1].strip().strip('"').strip("'")
|
|
110
|
-
return path.stem
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def _persist_report(report: dict[str, Any]) -> None:
|
|
114
|
-
"""Best-effort publish of the final report to the ``plugin_eval`` namespace.
|
|
115
|
-
|
|
116
|
-
Reuses ``src/mcp/eval_store.py`` (stdlib-only, designed to be loaded from
|
|
117
|
-
.pantheon/code-mode/ scripts via importlib) so DB path resolution
|
|
118
|
-
(PANTHEON_HOME → XDG → ~/.config/opencode), schema and dedup stay in one
|
|
119
|
-
place. Never raises: failures print a hint to stderr — the JSON report on
|
|
120
|
-
stdout remains the single source of truth. No-op under pytest so tests
|
|
121
|
-
never touch the real memory DB.
|
|
122
|
-
"""
|
|
123
|
-
if "PYTEST_CURRENT_TEST" in os.environ:
|
|
124
|
-
return
|
|
125
|
-
try:
|
|
126
|
-
store_path = SCRIPT_DIR.parent.parent / "src" / "mcp" / "eval_store.py"
|
|
127
|
-
spec = importlib.util.spec_from_file_location("eval_store", str(store_path))
|
|
128
|
-
module = importlib.util.module_from_spec(spec)
|
|
129
|
-
spec.loader.exec_module(module)
|
|
130
|
-
result = module.store_eval(
|
|
131
|
-
report["name"], report, int(report.get("overall_score", 0)), report["date"]
|
|
132
|
-
)
|
|
133
|
-
if "error" in result:
|
|
134
|
-
print(f"eval_store: {result['error']}", file=sys.stderr)
|
|
135
|
-
except Exception: # noqa: BLE001
|
|
136
|
-
print("To publish: use eval_store.store_eval() or the pantheon-memory MCP",
|
|
137
|
-
file=sys.stderr)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def main(argv: list[str] | None = None) -> int:
|
|
141
|
-
"""CLI entry point: `python eval-run.py <path> [--runs N] [--skip-llm]`."""
|
|
142
|
-
parser = argparse.ArgumentParser(
|
|
143
|
-
description="plugin-eval orchestrator (static + LLM judge + Monte Carlo)."
|
|
144
|
-
)
|
|
145
|
-
parser.add_argument("path", help="Path to a skill/agent directory or doc file")
|
|
146
|
-
parser.add_argument("--runs", type=int, default=20, help="Monte Carlo runs (default 20)")
|
|
147
|
-
parser.add_argument("--skip-llm", action="store_true", help="Skip the LLM judge layer")
|
|
148
|
-
parser.add_argument("--allow-external-llm", action="store_true", help="Permit the LLM judge to send skill content externally")
|
|
149
|
-
args = parser.parse_args(argv)
|
|
150
|
-
|
|
151
|
-
report: dict[str, Any] = {
|
|
152
|
-
"name": _doc_name(Path(args.path)),
|
|
153
|
-
"date": date.today().isoformat(),
|
|
154
|
-
"static": {},
|
|
155
|
-
"llm_judge": {},
|
|
156
|
-
"monte_carlo": {},
|
|
157
|
-
}
|
|
158
|
-
scores: list[float] = []
|
|
159
|
-
|
|
160
|
-
if STATIC_SCRIPT.exists():
|
|
161
|
-
static = _run_layer(STATIC_SCRIPT, args.path)
|
|
162
|
-
report["static"] = static
|
|
163
|
-
score = _extract_score(static, SCORE_KEYS)
|
|
164
|
-
if score is not None:
|
|
165
|
-
scores.append(score)
|
|
166
|
-
else:
|
|
167
|
-
report["static"] = {"skipped": True, "reason": "eval-static.py not present yet"}
|
|
168
|
-
|
|
169
|
-
if args.skip_llm:
|
|
170
|
-
report["llm_judge"] = {"skipped": True}
|
|
171
|
-
else:
|
|
172
|
-
judge_extra = ["--allow-external-llm"] if args.allow_external_llm else []
|
|
173
|
-
judge = _run_layer(JUDGE_SCRIPT, args.path, judge_extra)
|
|
174
|
-
report["llm_judge"] = judge
|
|
175
|
-
score = _extract_score(judge, ("overall", "score"))
|
|
176
|
-
if score is not None:
|
|
177
|
-
scores.append(score)
|
|
178
|
-
|
|
179
|
-
monte = _run_layer(MONTE_SCRIPT, args.path, ["--runs", str(args.runs)])
|
|
180
|
-
report["monte_carlo"] = monte
|
|
181
|
-
score = _extract_score(monte, ("reliability", "score"))
|
|
182
|
-
if score is not None:
|
|
183
|
-
scores.append(score)
|
|
184
|
-
|
|
185
|
-
overall = round(sum(scores) / len(scores), 1) if scores else 0.0
|
|
186
|
-
report["overall_score"] = overall
|
|
187
|
-
report["layers_scored"] = len(scores)
|
|
188
|
-
if not scores:
|
|
189
|
-
report["note"] = "no layer produced a score; overall_score is 0"
|
|
190
|
-
report["verdict"] = _verdict(overall)
|
|
191
|
-
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
192
|
-
_persist_report(report)
|
|
193
|
-
return 0
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if __name__ == "__main__":
|
|
197
|
-
sys.exit(main())
|