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.
Files changed (99) hide show
  1. package/.pantheon/code-mode/README.md +93 -0
  2. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  3. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  4. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  5. package/.pantheon/code-mode/compress-inline.py +450 -0
  6. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  7. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  8. package/.pantheon/code-mode/eval-run.py +197 -0
  9. package/.pantheon/code-mode/eval-static.py +321 -0
  10. package/.pantheon/code-mode/example-sync.sh +5 -0
  11. package/.pantheon/code-mode/session-end-save.py +186 -0
  12. package/.pantheon/code-mode/session-end-save.sh +18 -0
  13. package/.pantheon/code-mode/test-allowed.sh +5 -0
  14. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  15. package/.pantheon/code-mode/test-env.sh +7 -0
  16. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  17. package/.pantheon/code-mode/test-timeout.sh +6 -0
  18. package/README.md +161 -1101
  19. package/README.pt-BR.md +222 -0
  20. package/bin/pantheon-init.mjs +30 -11
  21. package/opencode.json +1 -0
  22. package/package.json +14 -3
  23. package/plugin.json +15 -0
  24. package/scripts/_pantheon_paths.py +48 -7
  25. package/scripts/ci-validate-yaml.py +7 -2
  26. package/scripts/code_mode_server.py +33 -2
  27. package/scripts/cost.mjs +30 -14
  28. package/scripts/doctor.mjs +135 -127
  29. package/scripts/generate-preset-docs.mjs +127 -66
  30. package/scripts/generate-prompts.sh +2 -2
  31. package/scripts/install/cli-ui.mjs +8 -1
  32. package/scripts/install/config-migration.mjs +401 -0
  33. package/scripts/install/health-check.mjs +3 -3
  34. package/scripts/install/model-picker.mjs +72 -23
  35. package/scripts/install/opencode-version.mjs +47 -0
  36. package/scripts/install/opencode.mjs +261 -88
  37. package/scripts/install/plugin.mjs +112 -42
  38. package/scripts/install/shared.mjs +13 -158
  39. package/scripts/install-mcp.mjs +44 -232
  40. package/scripts/mcp_persistence_server.py +382 -16
  41. package/scripts/mcp_resources_server.py +144 -40
  42. package/scripts/memory_mcp_server.py +2 -2
  43. package/scripts/probe-context-rehydrate.mjs +240 -0
  44. package/scripts/probe-pantheon-cost.mjs +304 -0
  45. package/scripts/recover-zenodo-deposition.mjs +146 -0
  46. package/scripts/sync-tui.mjs +32 -7
  47. package/scripts/test-opencode-v1-v2-sandbox.sh +944 -0
  48. package/scripts/uninstall.mjs +298 -954
  49. package/scripts/validate-zenodo-release.mjs +154 -0
  50. package/scripts/zenodo-artifact.mjs +21 -0
  51. package/scripts/zenodo-release-state.mjs +53 -0
  52. package/src/agents/zeus.md +25 -14
  53. package/src/mcp/_pantheon_paths.py +48 -7
  54. package/src/mcp/code_mode_server.py +33 -2
  55. package/src/mcp/eval_store.py +1 -1
  56. package/src/mcp/mcp_persistence_server.py +382 -16
  57. package/src/mcp/mcp_resources_server.py +144 -40
  58. package/src/mcp/pantheon_vision_server.py +1 -1
  59. package/src/mcp/toon_codec.py +491 -0
  60. package/src/pantheon/background-job-board.ts +67 -1
  61. package/src/pantheon/cost-command.ts +60 -39
  62. package/src/pantheon/delegate-manager.ts +669 -0
  63. package/src/pantheon/delegation-classifier.ts +307 -0
  64. package/src/pantheon/delegation-enforce.ts +13 -12
  65. package/src/pantheon/delegation-finalize.ts +374 -0
  66. package/src/pantheon/delegation-notify.ts +212 -0
  67. package/src/pantheon/delegation-toast.ts +73 -0
  68. package/src/pantheon/delegation.ts +1120 -0
  69. package/src/pantheon/goal-loop.ts +2 -2
  70. package/src/pantheon/goal-store.ts +1 -1
  71. package/src/pantheon/hashline/tool.ts +2 -2
  72. package/src/pantheon/model-command.ts +2 -1
  73. package/src/pantheon/permission-eval.ts +284 -0
  74. package/src/pantheon/step-cap.ts +110 -0
  75. package/src/pantheon/token-opt.ts +781 -0
  76. package/src/pantheon/toon-codec.ts +481 -0
  77. package/src/pantheon/tui-version-check.ts +1 -3
  78. package/src/pantheon/v2-bridge.ts +129 -0
  79. package/src/pantheon/v2-events.ts +131 -0
  80. package/src/pantheon/v2-hooks.ts +287 -0
  81. package/src/pantheon/v2-tool-definitions.ts +331 -0
  82. package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
  83. package/src/plugin-v2/index.ts +21 -0
  84. package/src/plugin-v2.ts +824 -0
  85. package/src/plugin.ts +236 -42
  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 +28 -24
  98. package/src/skills/README.md +0 -2
  99. package/src/pantheon/tool-context.ts +0 -41
@@ -0,0 +1,197 @@
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())
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env python3
2
+ # ---
3
+ # description: Static structural certification for a skill/agent directory
4
+ # timeout: 30
5
+ # ---
6
+ """eval-static.py — static structural checks for Pantheon skills/agents.
7
+
8
+ Runs deterministic, LLM-free structural checks against a skill or agent
9
+ directory and prints a JSON report to stdout:
10
+
11
+ frontmatter — SKILL.md (or agent *.md) exists with name + description
12
+ referenced_files — files referenced in frontmatter or markdown links exist
13
+ secrets — no obvious secrets (API keys, tokens, private keys)
14
+ file_size — every file is under MAX_FILE_SIZE (100 KiB)
15
+ yaml — YAML frontmatter parses
16
+
17
+ Usage:
18
+ python eval-static.py <path-to-skill-or-agent-dir>
19
+
20
+ Output (stdout):
21
+ {"name": ..., "checks": [{"check", "pass", "detail"}, ...], "score": 0-100}
22
+
23
+ Exit codes:
24
+ 0 — all checks passed
25
+ 1 — one or more checks failed
26
+ 2 — usage error (missing/invalid path)
27
+
28
+ Stdlib only. Paths are self-resolved (expanduser + resolve); nothing is
29
+ hardcoded. The frontmatter parser supports the flat YAML subset used by
30
+ skill manifests (scalars, flow lists, block lists) and raises on anything
31
+ it cannot parse so malformed frontmatter is flagged rather than ignored.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import json
38
+ import re
39
+ import sys
40
+ from pathlib import Path
41
+ from typing import Any
42
+
43
+ MAX_FILE_SIZE = 100 * 1024 # every file must be < 100 KB
44
+
45
+ # High-confidence credential shapes. Keep in sync with scripts/secret-scan.mjs.
46
+ SECRET_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
47
+ ("openai_key", re.compile(r"\b(sk|pk|rk)-[A-Za-z0-9]{20,}\b")),
48
+ ("aws_access_key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
49
+ ("github_pat", re.compile(r"\bghp_[A-Za-z0-9]{36}\b")),
50
+ ("slack_token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b")),
51
+ ("google_api_key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b")),
52
+ ("private_key", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")),
53
+ (
54
+ "credential_assignment",
55
+ re.compile(
56
+ r"\b(?:api[_-]?key|secret|pass" + r"word|token)\s*[:=]\s*['\"]?[A-Za-z0-9_\-./+]{12,}",
57
+ re.IGNORECASE,
58
+ ),
59
+ ),
60
+ ]
61
+
62
+ # Frontmatter keys whose values may reference local files.
63
+ _REFERENCE_KEYS = ("scripts", "files", "location", "reference", "references")
64
+
65
+ # Markdown link targets that are NOT local file references.
66
+ _EXTERNAL_PREFIXES = ("http://", "https://", "mailto:", "#", "data:")
67
+
68
+ _FM_RE = re.compile(r"\A---\s*\n(.*?)\n---", re.DOTALL)
69
+ _KEY_RE = re.compile(r"^([A-Za-z_][\w-]*):\s*(.*)$")
70
+ _CLOSERS = {"[": "]", "{": "}"}
71
+
72
+
73
+ # ── Minimal YAML-subset frontmatter parser (stdlib) ──────────────────────────
74
+
75
+
76
+ def _scalar(raw: str) -> str:
77
+ """Return a scalar value with optional surrounding quotes stripped."""
78
+ raw = raw.strip()
79
+ if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "'\"":
80
+ return raw[1:-1]
81
+ return raw
82
+
83
+
84
+ def parse_yaml_subset(text: str) -> dict[str, Any]:
85
+ """Parse the flat YAML subset used by skill frontmatter.
86
+
87
+ Supports ``key: scalar``, ``key: [a, b]`` flow lists and block lists
88
+ (``key:`` followed by ``- item`` lines). Raises ValueError on anything
89
+ it cannot parse (e.g. unbalanced brackets), so callers can flag
90
+ malformed frontmatter instead of silently ignoring it.
91
+ """
92
+ data: dict[str, Any] = {}
93
+ pending_list: str | None = None
94
+ for lineno, line in enumerate(text.splitlines(), start=1):
95
+ stripped = line.strip()
96
+ if not stripped or stripped.startswith("#"):
97
+ continue
98
+ if stripped.startswith("-"):
99
+ if pending_list is None:
100
+ raise ValueError(f"line {lineno}: list item outside a mapping key")
101
+ data[pending_list].append(_scalar(stripped[1:]))
102
+ continue
103
+ match = _KEY_RE.match(line)
104
+ if not match:
105
+ raise ValueError(f"line {lineno}: unparsable line: {stripped!r}")
106
+ key, raw_value = match.group(1), match.group(2).strip()
107
+ pending_list = None
108
+ if not raw_value:
109
+ data[key] = []
110
+ pending_list = key # promoted to a real list if "- item" follows
111
+ continue
112
+ if raw_value[0] in _CLOSERS:
113
+ closer = _CLOSERS[raw_value[0]]
114
+ if not raw_value.endswith(closer):
115
+ raise ValueError(f"line {lineno}: unbalanced {raw_value[0]!r} in {key!r}")
116
+ inner = raw_value[1:-1].strip()
117
+ data[key] = [_scalar(v) for v in inner.split(",")] if inner else []
118
+ continue
119
+ data[key] = _scalar(raw_value)
120
+ return data
121
+
122
+
123
+ def frontmatter_text(content: str) -> str | None:
124
+ """Extract the raw frontmatter body from markdown content, or None."""
125
+ match = _FM_RE.match(content)
126
+ return match.group(1) if match else None
127
+
128
+
129
+ def parse_frontmatter(filepath: Path) -> dict[str, Any]:
130
+ """Parse frontmatter into a dict; {} when absent or unparsable."""
131
+ text = frontmatter_text(filepath.read_text(encoding="utf-8", errors="replace"))
132
+ if text is None:
133
+ return {}
134
+ try:
135
+ return parse_yaml_subset(text)
136
+ except ValueError:
137
+ return {}
138
+
139
+
140
+ # ── Path helpers ──────────────────────────────────────────────────────────────
141
+
142
+
143
+ def find_manifest(dir_path: Path) -> Path | None:
144
+ """Return the primary manifest file for a skill/agent directory.
145
+
146
+ Prefers ``SKILL.md``; otherwise the first ``*.md`` file that is not
147
+ ``README.md`` (agent files). Returns None if no manifest exists.
148
+ """
149
+ skill = dir_path / "SKILL.md"
150
+ if skill.is_file():
151
+ return skill
152
+ for f in sorted(dir_path.iterdir()):
153
+ if f.is_file() and f.suffix == ".md" and f.stem.lower() != "readme":
154
+ return f
155
+ return None
156
+
157
+
158
+ def _iter_files(dir_path: Path) -> list[Path]:
159
+ """Return all regular files under dir_path (recursive, sorted)."""
160
+ return sorted(p for p in dir_path.rglob("*") if p.is_file())
161
+
162
+
163
+ # ── Checks ────────────────────────────────────────────────────────────────────
164
+
165
+
166
+ def check_frontmatter(dir_path: Path) -> tuple[bool, str]:
167
+ """Check (a): manifest exists with required name + description."""
168
+ manifest = find_manifest(dir_path)
169
+ if manifest is None:
170
+ return False, "No SKILL.md or agent .md manifest found"
171
+ fm = parse_frontmatter(manifest)
172
+ missing = [k for k in ("name", "description") if not fm.get(k)]
173
+ if missing:
174
+ return False, f"{manifest.name} missing frontmatter: {', '.join(missing)}"
175
+ return True, f"{manifest.name} has name + description"
176
+
177
+
178
+ def _frontmatter_references(fm: dict[str, Any]) -> list[str]:
179
+ """Collect file-path-looking values from frontmatter reference keys."""
180
+ refs: list[str] = []
181
+ for key in _REFERENCE_KEYS:
182
+ value = fm.get(key)
183
+ if isinstance(value, str):
184
+ refs.append(value)
185
+ elif isinstance(value, list):
186
+ refs.extend(v for v in value if isinstance(v, str))
187
+ return [r for r in refs if r and not r.startswith(("http://", "https://", "#"))]
188
+
189
+
190
+ def _markdown_references(manifest: Path) -> list[str]:
191
+ """Collect relative markdown link targets from the manifest body."""
192
+ content = manifest.read_text(encoding="utf-8", errors="replace")
193
+ refs: list[str] = []
194
+ for target in re.findall(r"\]\(([^)]+)\)", content):
195
+ target = target.strip()
196
+ if not target or target.startswith(_EXTERNAL_PREFIXES):
197
+ continue
198
+ # Strip optional anchor suffix (path#section)
199
+ refs.append(target.split("#", 1)[0])
200
+ return refs
201
+
202
+
203
+ def check_referenced_files(dir_path: Path) -> tuple[bool, str]:
204
+ """Check (b): referenced scripts/files exist relative to the dir."""
205
+ manifest = find_manifest(dir_path)
206
+ refs = _frontmatter_references(parse_frontmatter(manifest)) if manifest else []
207
+ if manifest is not None:
208
+ refs.extend(_markdown_references(manifest))
209
+
210
+ missing = [r for r in refs if r and not (dir_path / r).exists()]
211
+ if missing:
212
+ return False, f"Missing referenced files: {', '.join(sorted(set(missing)))}"
213
+ if refs:
214
+ return True, f"All {len(refs)} referenced files exist"
215
+ return True, "No local file references found"
216
+
217
+
218
+ def check_secrets(dir_path: Path) -> tuple[bool, str]:
219
+ """Check (c): no obvious secrets (API keys, tokens, private keys)."""
220
+ hits: list[str] = []
221
+ for f in _iter_files(dir_path):
222
+ try:
223
+ content = f.read_text(encoding="utf-8", errors="replace")
224
+ except OSError:
225
+ continue
226
+ for name, pattern in SECRET_PATTERNS:
227
+ if pattern.search(content):
228
+ hits.append(f"{f.name} ({name})")
229
+ if hits:
230
+ return False, f"Potential secrets: {', '.join(sorted(set(hits)))}"
231
+ return True, "No secrets detected"
232
+
233
+
234
+ def check_file_sizes(dir_path: Path) -> tuple[bool, str]:
235
+ """Check (d): every file is under MAX_FILE_SIZE."""
236
+ oversized = [f for f in _iter_files(dir_path) if f.stat().st_size > MAX_FILE_SIZE]
237
+ if oversized:
238
+ biggest = max(oversized, key=lambda f: f.stat().st_size)
239
+ return False, f"Oversized file: {biggest.name} ({biggest.stat().st_size} bytes)"
240
+ return True, f"All files under {MAX_FILE_SIZE} bytes"
241
+
242
+
243
+ def check_yaml(dir_path: Path) -> tuple[bool, str]:
244
+ """Check (e): YAML frontmatter parses."""
245
+ manifest = find_manifest(dir_path)
246
+ if manifest is None:
247
+ return True, "No manifest to parse"
248
+ text = frontmatter_text(manifest.read_text(encoding="utf-8", errors="replace"))
249
+ if text is None:
250
+ return True, "No frontmatter to parse"
251
+ try:
252
+ parse_yaml_subset(text)
253
+ except ValueError as exc:
254
+ return False, f"Frontmatter does not parse: {exc}"
255
+ return True, "Frontmatter parses"
256
+
257
+
258
+ _CHECK_FUNCS: tuple[tuple[str, Any], ...] = (
259
+ ("frontmatter", check_frontmatter),
260
+ ("referenced_files", check_referenced_files),
261
+ ("secrets", check_secrets),
262
+ ("file_size", check_file_sizes),
263
+ ("yaml", check_yaml),
264
+ )
265
+
266
+
267
+ # ── Report ────────────────────────────────────────────────────────────────────
268
+
269
+
270
+ def run_eval(dir_path: Path) -> dict[str, Any]:
271
+ """Run all checks and build the certification report dict."""
272
+ results = [(name, fn(dir_path)) for name, fn in _CHECK_FUNCS]
273
+ checks = [
274
+ {"check": name, "pass": ok, "detail": detail}
275
+ for name, (ok, detail) in results
276
+ ]
277
+ passed = sum(1 for _, (ok, _) in results if ok)
278
+ total = len(results)
279
+
280
+ manifest = find_manifest(dir_path)
281
+ fm = parse_frontmatter(manifest) if manifest else {}
282
+ return {
283
+ "name": str(fm.get("name") or dir_path.name),
284
+ "checks": checks,
285
+ "score": round(passed / total * 100) if total else 0,
286
+ }
287
+
288
+
289
+ # ── CLI ───────────────────────────────────────────────────────────────────────
290
+
291
+
292
+ def main(argv: list[str] | None = None) -> int:
293
+ """CLI entrypoint: eval a skill/agent dir, print JSON, set exit code."""
294
+ argv = sys.argv[1:] if argv is None else argv
295
+
296
+ parser = argparse.ArgumentParser(
297
+ prog="eval-static.py",
298
+ description="Static structural certification for a skill/agent directory.",
299
+ )
300
+ parser.add_argument("path", nargs="?", help="Path to the skill or agent directory")
301
+ args = parser.parse_args(argv)
302
+
303
+ if not args.path:
304
+ print("Usage: python eval-static.py <skill-or-agent-dir>", file=sys.stderr)
305
+ return 2
306
+
307
+ dir_path = Path(args.path).expanduser().resolve()
308
+ if not dir_path.exists():
309
+ print(f"Error: path not found: {args.path}", file=sys.stderr)
310
+ return 2
311
+ if not dir_path.is_dir():
312
+ print(f"Error: not a directory: {args.path}", file=sys.stderr)
313
+ return 2
314
+
315
+ report = run_eval(dir_path)
316
+ print(json.dumps(report, ensure_ascii=False, indent=2))
317
+ return 0 if all(c["pass"] for c in report["checks"]) else 1
318
+
319
+
320
+ if __name__ == "__main__":
321
+ sys.exit(main())
@@ -0,0 +1,5 @@
1
+ #!/bin/bash
2
+ echo "Pantheon Code Mode: Script executed successfully"
3
+ echo "Args: $@"
4
+ echo "CWD: $(pwd)"
5
+ echo "Time: $(date)"