pantheon-opencode 1.4.3 → 1.5.0-beta.2
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 +93 -0
- package/.pantheon/code-mode/checkpoint-session.py +7 -0
- package/.pantheon/code-mode/checkpoint-session.sh +11 -0
- package/.pantheon/code-mode/checkpoint_session.py +481 -0
- package/.pantheon/code-mode/compress-inline.py +450 -0
- package/.pantheon/code-mode/eval-llm-judge.py +255 -0
- package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
- package/.pantheon/code-mode/eval-run.py +197 -0
- package/.pantheon/code-mode/eval-static.py +321 -0
- package/.pantheon/code-mode/example-sync.sh +5 -0
- package/.pantheon/code-mode/session-end-save.py +186 -0
- package/.pantheon/code-mode/session-end-save.sh +18 -0
- package/.pantheon/code-mode/test-allowed.sh +5 -0
- package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
- package/.pantheon/code-mode/test-env.sh +7 -0
- package/.pantheon/code-mode/test-prlimit.sh +9 -0
- package/.pantheon/code-mode/test-timeout.sh +6 -0
- package/README.md +161 -1101
- package/README.pt-BR.md +222 -0
- package/bin/pantheon-init.mjs +30 -11
- package/opencode.json +1 -0
- package/package.json +14 -3
- package/plugin.json +15 -0
- package/scripts/_pantheon_paths.py +48 -7
- package/scripts/ci-validate-yaml.py +7 -2
- package/scripts/code_mode_server.py +33 -2
- package/scripts/cost.mjs +30 -14
- package/scripts/doctor.mjs +135 -127
- package/scripts/generate-preset-docs.mjs +127 -66
- package/scripts/generate-prompts.sh +2 -2
- package/scripts/install/cli-ui.mjs +8 -1
- package/scripts/install/config-migration.mjs +401 -0
- package/scripts/install/health-check.mjs +3 -3
- package/scripts/install/model-picker.mjs +72 -23
- package/scripts/install/opencode-version.mjs +47 -0
- package/scripts/install/opencode.mjs +261 -88
- package/scripts/install/plugin.mjs +112 -42
- package/scripts/install/shared.mjs +13 -158
- package/scripts/install-mcp.mjs +44 -232
- package/scripts/mcp_persistence_server.py +382 -16
- package/scripts/mcp_resources_server.py +144 -40
- package/scripts/memory_mcp_server.py +2 -2
- package/scripts/probe-context-rehydrate.mjs +240 -0
- package/scripts/probe-pantheon-cost.mjs +304 -0
- package/scripts/recover-zenodo-deposition.mjs +146 -0
- package/scripts/sync-tui.mjs +32 -7
- package/scripts/test-opencode-v1-v2-sandbox.sh +944 -0
- package/scripts/uninstall.mjs +298 -954
- package/scripts/validate-zenodo-release.mjs +154 -0
- package/scripts/zenodo-artifact.mjs +21 -0
- package/scripts/zenodo-release-state.mjs +53 -0
- package/src/agents/zeus.md +25 -14
- package/src/mcp/_pantheon_paths.py +48 -7
- package/src/mcp/code_mode_server.py +33 -2
- package/src/mcp/eval_store.py +1 -1
- package/src/mcp/mcp_persistence_server.py +382 -16
- package/src/mcp/mcp_resources_server.py +144 -40
- package/src/mcp/pantheon_vision_server.py +1 -1
- package/src/mcp/toon_codec.py +491 -0
- package/src/pantheon/background-job-board.ts +67 -1
- package/src/pantheon/cost-command.ts +60 -39
- package/src/pantheon/delegate-manager.ts +669 -0
- package/src/pantheon/delegation-classifier.ts +307 -0
- package/src/pantheon/delegation-enforce.ts +13 -12
- package/src/pantheon/delegation-finalize.ts +374 -0
- package/src/pantheon/delegation-notify.ts +212 -0
- package/src/pantheon/delegation-toast.ts +73 -0
- package/src/pantheon/delegation.ts +1120 -0
- package/src/pantheon/goal-loop.ts +2 -2
- package/src/pantheon/goal-store.ts +1 -1
- package/src/pantheon/hashline/tool.ts +2 -2
- package/src/pantheon/model-command.ts +2 -1
- package/src/pantheon/permission-eval.ts +284 -0
- package/src/pantheon/step-cap.ts +110 -0
- package/src/pantheon/token-opt.ts +781 -0
- package/src/pantheon/toon-codec.ts +481 -0
- package/src/pantheon/tui-version-check.ts +1 -3
- package/src/pantheon/v2-bridge.ts +129 -0
- package/src/pantheon/v2-events.ts +131 -0
- package/src/pantheon/v2-hooks.ts +287 -0
- package/src/pantheon/v2-tool-definitions.ts +331 -0
- package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
- package/src/plugin-v2/index.ts +21 -0
- package/src/plugin-v2.ts +824 -0
- package/src/plugin.ts +236 -42
- package/src/plugins/tui/dist/server.d.ts +1 -1
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/server.js +1 -1
- package/src/plugins/tui/dist/server.js.map +1 -1
- package/src/plugins/tui/dist/tui.d.ts +10 -7
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +11 -8
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/dist/tui.tsx +22 -16
- package/src/plugins/tui/package-lock.json +51 -51
- package/src/plugins/tui/package.json +7 -7
- package/src/plugins/tui/src/index.tsx +28 -24
- package/src/skills/README.md +0 -2
- package/src/pantheon/tool-context.ts +0 -41
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# ---
|
|
3
|
+
# description: LLM judge for skill/agent quality — scores 4 dimensions (0-100) via an OpenAI-compatible endpoint
|
|
4
|
+
# timeout: 120
|
|
5
|
+
# ---
|
|
6
|
+
"""LLM judge layer of the plugin-eval certification pipeline.
|
|
7
|
+
|
|
8
|
+
Scores a skill/agent directory on 4 dimensions (correctness, maintainability,
|
|
9
|
+
security, practicality), each 0-100, via ONE structured prompt to an
|
|
10
|
+
OpenAI-compatible chat endpoint. Emits a single JSON document on stdout:
|
|
11
|
+
|
|
12
|
+
{name, dimensions: {correctness, maintainability, security, practicality},
|
|
13
|
+
overall, notes: {<dimension>: <justification>}}
|
|
14
|
+
|
|
15
|
+
Without explicit opt-in it emits a structured skipped result; missing credentials
|
|
16
|
+
or failed calls exit 2 with a clear message.
|
|
17
|
+
|
|
18
|
+
Env vars:
|
|
19
|
+
OPENAI_API_KEY required bearer token
|
|
20
|
+
OPENAI_BASE_URL optional endpoint base (default https://api.openai.com/v1)
|
|
21
|
+
EVAL_JUDGE_MODEL optional model id (default gpt-4o-mini)
|
|
22
|
+
|
|
23
|
+
Stdlib only (urllib.request) — no third-party dependencies.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import sys
|
|
33
|
+
import urllib.error
|
|
34
|
+
import urllib.request
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any
|
|
37
|
+
from urllib.parse import urlparse
|
|
38
|
+
|
|
39
|
+
DEFAULT_MODEL = "gpt-4o-mini"
|
|
40
|
+
DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
|
41
|
+
REQUEST_TIMEOUT = 90.0
|
|
42
|
+
MAX_CONTENT_CHARS = 12_000
|
|
43
|
+
MAX_SUPPORTING_CHARS = 8_000
|
|
44
|
+
MAX_SUPPORTING_FILES = 6
|
|
45
|
+
DIMENSIONS = ("correctness", "maintainability", "security", "practicality")
|
|
46
|
+
|
|
47
|
+
JUDGE_PROMPT = """You are a rigorous quality auditor for AI agent skills and agent definitions used in an agentic coding framework. You decide whether a skill or agent definition is production-quality or AI slop.
|
|
48
|
+
|
|
49
|
+
Apply an explicit ANTI-SLOP bias: flag overengineering, unnecessary abstractions, persona proliferation (multiple fake personas/characters), unverifiable claims, hype language, and instructions that sound impressive but are not actionable.
|
|
50
|
+
|
|
51
|
+
Score the content on 4 dimensions, each 0-100:
|
|
52
|
+
1. correctness — instructions are accurate and internally consistent; no contradictions; no broken references (files, commands, or agents that do not exist or are misnamed).
|
|
53
|
+
2. maintainability — clear structure, single responsibility, no overengineering, YAGNI-respecting, easy to update.
|
|
54
|
+
3. security — no secrets or credentials; no prompt-injection vectors; no dangerous instructions (e.g. rm -rf, curl | bash, disabling safety checks).
|
|
55
|
+
4. practicality — solves a real problem, actionable, not AI slop or hype; a human would actually use it.
|
|
56
|
+
|
|
57
|
+
Return ONLY a JSON object with this exact shape:
|
|
58
|
+
{"correctness": <int 0-100>, "maintainability": <int 0-100>, "security": <int 0-100>, "practicality": <int 0-100>, "notes": {"correctness": "<1-2 sentences>", "maintainability": "<1-2 sentences>", "security": "<1-2 sentences>", "practicality": "<1-2 sentences>"}}
|
|
59
|
+
|
|
60
|
+
Content to evaluate:
|
|
61
|
+
--- BEGIN CONTENT ---
|
|
62
|
+
{content}
|
|
63
|
+
--- END CONTENT ---
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
_NAME_RE = re.compile(r"^name:\s*[\"']?([^\"'\n]+?)[\"']?\s*$", re.MULTILINE)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class JudgeError(Exception):
|
|
70
|
+
"""Expected, user-facing judge failure."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _frontmatter_name(text: str) -> str | None:
|
|
74
|
+
"""Extract the frontmatter `name` value, or None when absent."""
|
|
75
|
+
if not text.startswith("---"):
|
|
76
|
+
return None
|
|
77
|
+
end = text.find("\n---", 3)
|
|
78
|
+
if end == -1:
|
|
79
|
+
return None
|
|
80
|
+
match = _NAME_RE.search(text[3:end])
|
|
81
|
+
return match.group(1).strip() if match else None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _discover_doc(path: Path) -> Path:
|
|
85
|
+
"""Return the primary doc (SKILL.md or agent .md) for a path."""
|
|
86
|
+
if path.is_file():
|
|
87
|
+
return path
|
|
88
|
+
skill = path / "SKILL.md"
|
|
89
|
+
if skill.is_file():
|
|
90
|
+
return skill
|
|
91
|
+
candidates = sorted(path.glob("*.md"))
|
|
92
|
+
for candidate in candidates:
|
|
93
|
+
try:
|
|
94
|
+
text = candidate.read_text(encoding="utf-8", errors="replace")
|
|
95
|
+
except OSError:
|
|
96
|
+
continue
|
|
97
|
+
if text.startswith("---") and text.find("\n---", 3) != -1:
|
|
98
|
+
return candidate
|
|
99
|
+
if candidates:
|
|
100
|
+
return candidates[0]
|
|
101
|
+
raise JudgeError(f"No SKILL.md or .md file found in {path}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _supporting_files(doc: Path) -> list[Path]:
|
|
105
|
+
"""Script files next to the doc or in its scripts/ subdir (capped)."""
|
|
106
|
+
roots = [doc.parent]
|
|
107
|
+
scripts_dir = doc.parent / "scripts"
|
|
108
|
+
if scripts_dir.is_dir():
|
|
109
|
+
roots.append(scripts_dir)
|
|
110
|
+
found: list[Path] = []
|
|
111
|
+
for root in roots:
|
|
112
|
+
for pattern in ("*.py", "*.sh", "*.js", "*.ts", "*.mjs"):
|
|
113
|
+
found.extend(sorted(root.glob(pattern)))
|
|
114
|
+
return [p for p in found if p.is_file() and p != doc][:MAX_SUPPORTING_FILES]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _load_content(doc: Path) -> tuple[str, str]:
|
|
118
|
+
"""Return (name, content) with the doc plus small supporting files."""
|
|
119
|
+
try:
|
|
120
|
+
text = doc.read_text(encoding="utf-8", errors="replace")
|
|
121
|
+
except OSError as exc:
|
|
122
|
+
raise JudgeError(f"Cannot read {doc}: {exc}") from exc
|
|
123
|
+
name = _frontmatter_name(text) or doc.stem
|
|
124
|
+
parts = [f"# {name}\n", text[:MAX_CONTENT_CHARS]]
|
|
125
|
+
if len(text) > MAX_CONTENT_CHARS:
|
|
126
|
+
parts.append(f"\n[... truncated {len(text) - MAX_CONTENT_CHARS} chars ...]")
|
|
127
|
+
support: list[str] = []
|
|
128
|
+
total = 0
|
|
129
|
+
for sibling in _supporting_files(doc):
|
|
130
|
+
try:
|
|
131
|
+
data = sibling.read_text(encoding="utf-8", errors="replace")
|
|
132
|
+
except OSError:
|
|
133
|
+
continue
|
|
134
|
+
chunk = f"\n--- supporting file: {sibling.name} ---\n{data[:2000]}"
|
|
135
|
+
if total + len(chunk) > MAX_SUPPORTING_CHARS:
|
|
136
|
+
break
|
|
137
|
+
support.append(chunk)
|
|
138
|
+
total += len(chunk)
|
|
139
|
+
if support:
|
|
140
|
+
parts.append("\n".join(support))
|
|
141
|
+
return name, "".join(parts)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _call_llm(prompt: str, allow_external_llm: bool = False) -> str:
|
|
145
|
+
"""Send one chat completion request via urllib; return the assistant text."""
|
|
146
|
+
if not allow_external_llm:
|
|
147
|
+
raise JudgeError("External LLM calls require --allow-external-llm or PANTHEON_ALLOW_EXTERNAL_LLM=1")
|
|
148
|
+
key = os.getenv("OPENAI_API_KEY")
|
|
149
|
+
if not key:
|
|
150
|
+
raise JudgeError(
|
|
151
|
+
"No LLM available: set OPENAI_API_KEY "
|
|
152
|
+
"(optionally OPENAI_BASE_URL and EVAL_JUDGE_MODEL)."
|
|
153
|
+
)
|
|
154
|
+
base_url = os.getenv("OPENAI_BASE_URL", DEFAULT_BASE_URL).rstrip("/")
|
|
155
|
+
parsed_url = urlparse(base_url)
|
|
156
|
+
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
|
|
157
|
+
raise JudgeError("OPENAI_BASE_URL must use an http or https URL")
|
|
158
|
+
endpoint = base_url + "/chat/completions"
|
|
159
|
+
model = os.getenv("EVAL_JUDGE_MODEL") or DEFAULT_MODEL
|
|
160
|
+
body = {
|
|
161
|
+
"model": model,
|
|
162
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
163
|
+
"temperature": 0.2,
|
|
164
|
+
}
|
|
165
|
+
request = urllib.request.Request(
|
|
166
|
+
endpoint,
|
|
167
|
+
data=json.dumps(body).encode("utf-8"),
|
|
168
|
+
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
|
169
|
+
method="POST",
|
|
170
|
+
)
|
|
171
|
+
try:
|
|
172
|
+
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
|
|
173
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
174
|
+
except urllib.error.HTTPError as exc:
|
|
175
|
+
detail = exc.read().decode("utf-8", errors="replace")[:200].replace(key, "[redacted]")
|
|
176
|
+
raise JudgeError(f"LLM endpoint returned HTTP {exc.code}: {detail}") from exc
|
|
177
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
178
|
+
reason = getattr(exc, "reason", exc)
|
|
179
|
+
raise JudgeError(f"LLM request failed: {reason}") from exc
|
|
180
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
181
|
+
raise JudgeError(f"LLM returned invalid JSON payload: {exc}") from exc
|
|
182
|
+
choices = payload.get("choices") if isinstance(payload, dict) else None
|
|
183
|
+
message = choices[0].get("message") if choices else None
|
|
184
|
+
text = message.get("content") if isinstance(message, dict) else None
|
|
185
|
+
if not isinstance(text, str) or not text.strip():
|
|
186
|
+
raise JudgeError("LLM returned an empty response.")
|
|
187
|
+
return text.replace(key, "[redacted]")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _parse_json_object(value: str) -> dict[str, Any] | None:
|
|
191
|
+
"""Parse strict JSON and the fenced JSON commonly returned by models."""
|
|
192
|
+
candidates = [value.strip()]
|
|
193
|
+
if candidates[0].startswith("```") and candidates[0].endswith("```"):
|
|
194
|
+
candidates.append(candidates[0].split("\n", 1)[-1][:-3].strip())
|
|
195
|
+
for candidate in candidates:
|
|
196
|
+
try:
|
|
197
|
+
parsed = json.loads(candidate)
|
|
198
|
+
except json.JSONDecodeError:
|
|
199
|
+
continue
|
|
200
|
+
if isinstance(parsed, dict):
|
|
201
|
+
return parsed
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _parse_scores(text: str) -> dict[str, Any]:
|
|
206
|
+
"""Validate the LLM JSON into the report's dimensions/overall/notes."""
|
|
207
|
+
parsed = _parse_json_object(text)
|
|
208
|
+
if parsed is None:
|
|
209
|
+
raise JudgeError("LLM did not return valid JSON.")
|
|
210
|
+
dimensions: dict[str, int] = {}
|
|
211
|
+
for dim in DIMENSIONS:
|
|
212
|
+
raw = parsed.get(dim)
|
|
213
|
+
try:
|
|
214
|
+
value = int(raw)
|
|
215
|
+
except (TypeError, ValueError) as exc:
|
|
216
|
+
raise JudgeError(f"LLM returned non-integer score for {dim}: {raw!r}") from exc
|
|
217
|
+
if not 0 <= value <= 100:
|
|
218
|
+
raise JudgeError(f"LLM returned out-of-range score for {dim}: {value}")
|
|
219
|
+
dimensions[dim] = value
|
|
220
|
+
raw_notes = parsed.get("notes")
|
|
221
|
+
notes = (
|
|
222
|
+
{dim: str(raw_notes.get(dim, "")) for dim in DIMENSIONS}
|
|
223
|
+
if isinstance(raw_notes, dict)
|
|
224
|
+
else {dim: "" for dim in DIMENSIONS}
|
|
225
|
+
)
|
|
226
|
+
overall = round(sum(dimensions.values()) / len(dimensions), 1)
|
|
227
|
+
return {"dimensions": dimensions, "overall": overall, "notes": notes}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def main(argv: list[str] | None = None) -> int:
|
|
231
|
+
"""CLI entry point: `python eval-llm-judge.py <skill-or-agent-dir>`."""
|
|
232
|
+
parser = argparse.ArgumentParser(
|
|
233
|
+
description="LLM judge for skill/agent quality (plugin-eval layer 2)."
|
|
234
|
+
)
|
|
235
|
+
parser.add_argument("path", help="Path to a skill/agent directory or doc file")
|
|
236
|
+
parser.add_argument("--allow-external-llm", action="store_true", help="Permit sending content to the configured LLM endpoint")
|
|
237
|
+
args = parser.parse_args(argv)
|
|
238
|
+
allow_external_llm = args.allow_external_llm or os.getenv("PANTHEON_ALLOW_EXTERNAL_LLM") == "1"
|
|
239
|
+
try:
|
|
240
|
+
doc = _discover_doc(Path(args.path))
|
|
241
|
+
name, content = _load_content(doc)
|
|
242
|
+
if not allow_external_llm:
|
|
243
|
+
print(json.dumps({"name": name, "skipped": True, "reason": "external LLM calls not opted in"}, ensure_ascii=False, indent=2))
|
|
244
|
+
return 0
|
|
245
|
+
raw = _call_llm(JUDGE_PROMPT.replace("{content}", content), allow_external_llm=True)
|
|
246
|
+
result = _parse_scores(raw)
|
|
247
|
+
except JudgeError as exc:
|
|
248
|
+
print(f"eval-llm-judge: {exc}", file=sys.stderr)
|
|
249
|
+
return 2
|
|
250
|
+
print(json.dumps({"name": name, **result}, ensure_ascii=False, indent=2))
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
if __name__ == "__main__":
|
|
255
|
+
sys.exit(main())
|
|
@@ -0,0 +1,368 @@
|
|
|
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())
|