pantheon-opencode 1.4.2 → 1.5.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.
Files changed (98) hide show
  1. package/.pantheon/code-mode/.gitkeep +0 -0
  2. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +8 -0
  3. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +8 -0
  4. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +8 -0
  5. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +8 -0
  6. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +8 -0
  7. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +8 -0
  8. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +8 -0
  9. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +8 -0
  10. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +8 -0
  11. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +8 -0
  12. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +8 -0
  13. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +8 -0
  14. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +11 -0
  15. package/.pantheon/code-mode/README.md +93 -0
  16. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  17. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  18. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  19. package/.pantheon/code-mode/compress-inline.py +450 -0
  20. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  21. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  22. package/.pantheon/code-mode/eval-run.py +197 -0
  23. package/.pantheon/code-mode/eval-static.py +321 -0
  24. package/.pantheon/code-mode/example-sync.sh +5 -0
  25. package/.pantheon/code-mode/session-end-save.py +186 -0
  26. package/.pantheon/code-mode/session-end-save.sh +18 -0
  27. package/.pantheon/code-mode/test-allowed.sh +5 -0
  28. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  29. package/.pantheon/code-mode/test-env.sh +7 -0
  30. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  31. package/.pantheon/code-mode/test-timeout.sh +6 -0
  32. package/README.md +158 -1189
  33. package/README.pt-BR.md +222 -0
  34. package/bin/pantheon-init.mjs +30 -11
  35. package/opencode.json +1 -0
  36. package/package.json +14 -3
  37. package/plugin.json +15 -0
  38. package/scripts/_pantheon_paths.py +48 -7
  39. package/scripts/ci-validate-yaml.py +7 -2
  40. package/scripts/code_mode_server.py +33 -2
  41. package/scripts/doctor.mjs +135 -127
  42. package/scripts/generate-preset-docs.mjs +127 -66
  43. package/scripts/generate-prompts.sh +2 -2
  44. package/scripts/install/cli-ui.mjs +8 -1
  45. package/scripts/install/config-migration.mjs +401 -0
  46. package/scripts/install/health-check.mjs +3 -3
  47. package/scripts/install/model-picker.mjs +72 -23
  48. package/scripts/install/opencode-version.mjs +47 -0
  49. package/scripts/install/opencode.mjs +261 -88
  50. package/scripts/install/plugin.mjs +112 -42
  51. package/scripts/install/shared.mjs +13 -158
  52. package/scripts/install-mcp.mjs +44 -232
  53. package/scripts/mcp_persistence_server.py +344 -15
  54. package/scripts/mcp_resources_server.py +144 -40
  55. package/scripts/memory_mcp_server.py +2 -2
  56. package/scripts/recover-zenodo-deposition.mjs +146 -0
  57. package/scripts/sync-tui.mjs +32 -7
  58. package/scripts/test-opencode-v1-v2-sandbox.sh +591 -0
  59. package/scripts/uninstall.mjs +298 -954
  60. package/scripts/validate-zenodo-release.mjs +154 -0
  61. package/scripts/zenodo-artifact.mjs +21 -0
  62. package/scripts/zenodo-release-state.mjs +53 -0
  63. package/src/mcp/_pantheon_paths.py +48 -7
  64. package/src/mcp/code_mode_server.py +33 -2
  65. package/src/mcp/eval_store.py +1 -1
  66. package/src/mcp/mcp_persistence_server.py +344 -15
  67. package/src/mcp/mcp_resources_server.py +144 -40
  68. package/src/mcp/pantheon_vision_server.py +1 -1
  69. package/src/mcp/toon_codec.py +491 -0
  70. package/src/pantheon/background-job-board.ts +67 -1
  71. package/src/pantheon/cost-command.ts +6 -2
  72. package/src/pantheon/delegate-manager.ts +384 -0
  73. package/src/pantheon/delegate-task-adapter.ts +444 -0
  74. package/src/pantheon/delegation-notify.ts +35 -20
  75. package/src/pantheon/model-command.ts +86 -32
  76. package/src/pantheon/token-opt.ts +555 -0
  77. package/src/pantheon/toon-codec.ts +481 -0
  78. package/src/pantheon/tui-version-check.ts +1 -3
  79. package/src/pantheon/v2-bridge.ts +129 -0
  80. package/src/pantheon/v2-events.ts +131 -0
  81. package/src/pantheon/v2-hooks.ts +287 -0
  82. package/src/pantheon/v2-tool-definitions.ts +331 -0
  83. package/src/plugin-v2/index.ts +21 -0
  84. package/src/plugin-v2.ts +836 -0
  85. package/src/plugin.ts +57 -10
  86. package/src/plugins/tui/dist/server.d.ts +1 -1
  87. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  88. package/src/plugins/tui/dist/server.js +1 -1
  89. package/src/plugins/tui/dist/server.js.map +1 -1
  90. package/src/plugins/tui/dist/tui.d.ts +10 -7
  91. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  92. package/src/plugins/tui/dist/tui.js +11 -8
  93. package/src/plugins/tui/dist/tui.js.map +1 -1
  94. package/src/plugins/tui/dist/tui.tsx +22 -16
  95. package/src/plugins/tui/package-lock.json +51 -51
  96. package/src/plugins/tui/package.json +7 -7
  97. package/src/plugins/tui/src/index.tsx +22 -16
  98. package/src/skills/README.md +0 -2
@@ -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())