master-skill 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +2 -2
- package/README.md +60 -12
- package/README_EN.md +57 -11
- package/SKILL.md +55 -298
- package/gemini-extension.json +2 -2
- package/hooks/session-start +40 -2
- package/hooks/tests/test_session_start.sh +149 -0
- package/package.json +7 -3
- package/prebuilt/compare/SKILL.md +1 -1
- package/prebuilt/compare/tests/fidelity.jsonl +13 -0
- package/prebuilt/master-ajahn-chah/meta.json +17 -1
- package/prebuilt/master-atisha/meta.json +17 -1
- package/prebuilt/master-buddhaghosa/meta.json +26 -1
- package/prebuilt/master-curriculum/SKILL.md +87 -0
- package/prebuilt/master-curriculum/references/chan.md +29 -0
- package/prebuilt/master-curriculum/references/gelug-madhyamaka.md +33 -0
- package/prebuilt/master-curriculum/references/huayan.md +28 -0
- package/prebuilt/master-curriculum/references/jingtu.md +29 -0
- package/prebuilt/master-curriculum/references/sanlun-zhongguan.md +30 -0
- package/prebuilt/master-curriculum/references/theravada-vipassana.md +39 -0
- package/prebuilt/master-curriculum/references/tiantai.md +30 -0
- package/prebuilt/master-curriculum/references/weishi.md +31 -0
- package/prebuilt/master-curriculum/tests/fidelity.jsonl +8 -0
- package/prebuilt/master-debate/SKILL.md +274 -0
- package/prebuilt/master-debate/meta.json +25 -0
- package/prebuilt/master-debate/tests/fidelity.jsonl +8 -0
- package/prebuilt/master-fazang/meta.json +25 -0
- package/prebuilt/master-huineng/meta.json +37 -0
- package/prebuilt/master-huineng/references/teaching.md +1 -1
- package/prebuilt/master-huineng/sources/INDEX.md +1 -1
- package/prebuilt/master-huineng/sources/tanjing-excerpts.md +17 -1
- package/prebuilt/master-kumarajiva/meta.json +16 -0
- package/prebuilt/master-kumarajiva/references/teaching.md +2 -2
- package/prebuilt/master-mahasi-sayadaw/meta.json +17 -1
- package/prebuilt/master-milarepa/meta.json +26 -1
- package/prebuilt/master-ouyi/meta.json +17 -0
- package/prebuilt/master-ouyi/references/teaching.md +4 -4
- package/prebuilt/master-ouyi/sources/INDEX.md +1 -1
- package/prebuilt/master-ouyi/sources/jiaoguan-gangzong-excerpts.md +3 -3
- package/prebuilt/master-tsongkhapa/meta.json +18 -1
- package/prebuilt/master-xuanzang/meta.json +16 -0
- package/prebuilt/master-xuanzang/references/teaching.md +5 -5
- package/prebuilt/master-xuanzang/references/voice.md +1 -1
- package/prebuilt/master-xuyun/meta.json +39 -0
- package/prebuilt/master-yinguang/SKILL.md +1 -1
- package/prebuilt/master-yinguang/meta.json +17 -0
- package/prebuilt/master-yinguang/references/teaching.md +7 -7
- package/prebuilt/master-yinguang/references/voice.md +1 -1
- package/prebuilt/master-yinguang/sources/INDEX.md +3 -3
- package/prebuilt/master-yinguang/sources/wenchao-excerpts.md +4 -4
- package/prebuilt/master-yinguang/sources/yihanbianfu-excerpts.md +3 -3
- package/prebuilt/master-zhiyi/meta.json +28 -0
- package/prebuilt/master-zhiyi/references/teaching.md +2 -2
- package/scripts/check-manifest-versions.py +142 -0
- package/scripts/tests/test_check_manifest_versions.py +217 -0
- package/scripts/tests/test_debate_protocol.py +159 -0
- package/scripts/tests/test_validate_cross_critique.py +149 -0
- package/scripts/tests/test_validate_curriculum_sources.py +144 -0
- package/scripts/tests/test_validate_lore_triggers_content.py +372 -0
- package/scripts/tests/test_validate_persona_fidelity.py +317 -0
- package/scripts/tests/test_validate_promptfoo_configs.py +386 -0
- package/scripts/validate-cross-critique.py +137 -0
- package/scripts/validate-curriculum-sources.py +111 -0
- package/scripts/validate-fidelity.py +16 -1
- package/scripts/validate-lore-triggers-content.py +393 -0
- package/scripts/validate-persona-fidelity.py +210 -0
- package/scripts/validate-promptfoo-configs.py +383 -0
- package/scripts/validate.py +212 -4
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate tests/persona/*.promptfooconfig.yaml against repo conventions.
|
|
3
|
+
|
|
4
|
+
This validator complements `promptfoo validate` (which checks the upstream
|
|
5
|
+
schema). It enforces Master-skill specific contracts that promptfoo cannot
|
|
6
|
+
know about:
|
|
7
|
+
|
|
8
|
+
- Filename convention: tests/persona/<slug>.promptfooconfig.yaml — <slug>
|
|
9
|
+
must match a real master under prebuilt/master-<slug>/.
|
|
10
|
+
- Test coverage: at least 4 tests, each with a `description` prefixed by
|
|
11
|
+
one of "RAW:", "SPE:", or "CUS:" — and all three dimensions must appear
|
|
12
|
+
at least once.
|
|
13
|
+
- Each test must carry at least one `llm-rubric` assertion (otherwise it
|
|
14
|
+
isn't actually grading persona fidelity).
|
|
15
|
+
- Any `contains-any` assertion value must be drawn from a known
|
|
16
|
+
fidelity-anchor set for that master — currently the master's own
|
|
17
|
+
`signature_phrases` plus a short curated whitelist per master
|
|
18
|
+
(Pāli term variants, common gloss spellings, etc.). This blocks the
|
|
19
|
+
drift mode where a tester slips in arbitrary keywords and the rubric
|
|
20
|
+
silently passes on noise.
|
|
21
|
+
- The inlined prompt string in each promptfooconfig must match the
|
|
22
|
+
corresponding template key inside tests/persona/shared.yaml (after
|
|
23
|
+
whitespace normalisation). shared.yaml is the source of truth; the
|
|
24
|
+
inlining is a promptfoo limitation (no `file://shared.yaml#key`
|
|
25
|
+
indirection at the time of writing).
|
|
26
|
+
|
|
27
|
+
Run:
|
|
28
|
+
python3 scripts/validate-promptfoo-configs.py
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import re
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
38
|
+
PREBUILT_DIR = REPO_ROOT / "prebuilt"
|
|
39
|
+
PERSONA_DIR = REPO_ROOT / "tests" / "persona"
|
|
40
|
+
|
|
41
|
+
# Per-master whitelist additions on top of signature_phrases.
|
|
42
|
+
# Keep this list short and curated — every entry is justified by
|
|
43
|
+
# language/script variation, not arbitrary keywords.
|
|
44
|
+
EXTRA_ALLOWED_CONTAINS: dict[str, set[str]] = {
|
|
45
|
+
"huineng": {
|
|
46
|
+
"顿", # short form of 顿悟
|
|
47
|
+
"本来面目", # Tan Jing canonical line, not in signature_phrases
|
|
48
|
+
"无念", # short form of 无念为宗
|
|
49
|
+
"自性", # short form of 何期自性 — core southern-school term
|
|
50
|
+
"见性", # short form of 明心见性
|
|
51
|
+
},
|
|
52
|
+
"ajahn-chah": {
|
|
53
|
+
# Pāli term + diacritic / spelling variants
|
|
54
|
+
"sila",
|
|
55
|
+
"sīla",
|
|
56
|
+
"virtue",
|
|
57
|
+
"precepts",
|
|
58
|
+
"moral conduct",
|
|
59
|
+
# Plain-English signature reformulations
|
|
60
|
+
"letting go",
|
|
61
|
+
"let go",
|
|
62
|
+
"mindfulness",
|
|
63
|
+
"the heart",
|
|
64
|
+
"the middle way",
|
|
65
|
+
"as they are",
|
|
66
|
+
},
|
|
67
|
+
"tsongkhapa": {
|
|
68
|
+
"应成", # short form of 应成中观
|
|
69
|
+
"空性", # core Gelug-Madhyamaka term (in 三主要道 list)
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# -----------------------------------------------------------------------------
|
|
75
|
+
# Minimal YAML loader (no PyYAML dependency to keep CI surface small)
|
|
76
|
+
# -----------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def _load_yaml(path: Path) -> dict:
|
|
79
|
+
"""Load a YAML file. Prefers PyYAML if available; otherwise calls a
|
|
80
|
+
bundled JSON-via-Python fallback. Persona configs are simple enough
|
|
81
|
+
that PyYAML is reliable when present, and CI installs it already
|
|
82
|
+
(see .github/workflows/validate-and-test.yml -> `pip install ... pyyaml`).
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
import yaml # type: ignore
|
|
86
|
+
except ImportError as exc: # pragma: no cover
|
|
87
|
+
raise RuntimeError(
|
|
88
|
+
"PyYAML is required to validate promptfoo configs. "
|
|
89
|
+
"Install with: pip install pyyaml"
|
|
90
|
+
) from exc
|
|
91
|
+
with path.open("r", encoding="utf-8") as f:
|
|
92
|
+
data = yaml.safe_load(f)
|
|
93
|
+
if not isinstance(data, dict):
|
|
94
|
+
raise ValueError(f"{path.name}: top-level YAML must be a mapping")
|
|
95
|
+
return data
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# -----------------------------------------------------------------------------
|
|
99
|
+
# Master metadata helpers
|
|
100
|
+
# -----------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def _load_master_meta(slug: str) -> dict | None:
|
|
103
|
+
path = PREBUILT_DIR / f"master-{slug}" / "meta.json"
|
|
104
|
+
if not path.exists():
|
|
105
|
+
return None
|
|
106
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _allowed_contains_values(slug: str, meta: dict) -> set[str]:
|
|
110
|
+
"""Build the whitelist of allowed `contains-any` values for this master."""
|
|
111
|
+
allowed: set[str] = set()
|
|
112
|
+
phrases = meta.get("signature_phrases", [])
|
|
113
|
+
if isinstance(phrases, list):
|
|
114
|
+
allowed.update(p for p in phrases if isinstance(p, str) and p.strip())
|
|
115
|
+
allowed.update(EXTRA_ALLOWED_CONTAINS.get(slug, set()))
|
|
116
|
+
return allowed
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# -----------------------------------------------------------------------------
|
|
120
|
+
# shared.yaml prompt key resolution
|
|
121
|
+
# -----------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
SHARED_KEY_MAP = {
|
|
124
|
+
"huineng": "huineng_persona_prompt",
|
|
125
|
+
"ajahn-chah": "ajahn_chah_persona_prompt",
|
|
126
|
+
"tsongkhapa": "tsongkhapa_persona_prompt",
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _normalise(text: str) -> str:
|
|
131
|
+
"""Collapse trailing whitespace per line + trim outer blanks for diffing."""
|
|
132
|
+
lines = [line.rstrip() for line in text.splitlines()]
|
|
133
|
+
# drop leading/trailing all-blank lines
|
|
134
|
+
while lines and not lines[0].strip():
|
|
135
|
+
lines.pop(0)
|
|
136
|
+
while lines and not lines[-1].strip():
|
|
137
|
+
lines.pop()
|
|
138
|
+
return "\n".join(lines)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _check_prompt_sync(slug: str, cfg: dict, shared: dict) -> list[str]:
|
|
142
|
+
errors: list[str] = []
|
|
143
|
+
key = SHARED_KEY_MAP.get(slug)
|
|
144
|
+
if key is None:
|
|
145
|
+
# Unknown master — we still require it appears in shared.yaml under
|
|
146
|
+
# a deterministic name. Skip sync check but warn.
|
|
147
|
+
errors.append(
|
|
148
|
+
f"{slug}: no entry in SHARED_KEY_MAP — add the master's prompt to "
|
|
149
|
+
f"shared.yaml and update validate-promptfoo-configs.py."
|
|
150
|
+
)
|
|
151
|
+
return errors
|
|
152
|
+
if key not in shared:
|
|
153
|
+
errors.append(f"{slug}: shared.yaml missing key '{key}'")
|
|
154
|
+
return errors
|
|
155
|
+
shared_text = shared[key]
|
|
156
|
+
prompts = cfg.get("prompts", [])
|
|
157
|
+
if not prompts or not isinstance(prompts, list):
|
|
158
|
+
errors.append(f"{slug}: promptfooconfig has no `prompts:` list")
|
|
159
|
+
return errors
|
|
160
|
+
inlined = prompts[0]
|
|
161
|
+
if not isinstance(inlined, str):
|
|
162
|
+
errors.append(f"{slug}: first prompt is not an inlined string")
|
|
163
|
+
return errors
|
|
164
|
+
if _normalise(inlined) != _normalise(shared_text):
|
|
165
|
+
errors.append(
|
|
166
|
+
f"{slug}: inlined prompt does not match shared.yaml#{key} "
|
|
167
|
+
f"(update either side to re-sync)"
|
|
168
|
+
)
|
|
169
|
+
# The prompt template MUST reference {{question}} — without it, every
|
|
170
|
+
# test case gets the same input and the eval is meaningless. Easy footgun
|
|
171
|
+
# when a contributor copies a prompt and forgets to wire vars through.
|
|
172
|
+
if "{{question}}" not in inlined:
|
|
173
|
+
errors.append(
|
|
174
|
+
f"{slug}: inlined prompt does not reference '{{{{question}}}}' — "
|
|
175
|
+
f"vars.question would never be injected, rendering tests no-ops"
|
|
176
|
+
)
|
|
177
|
+
return errors
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# -----------------------------------------------------------------------------
|
|
181
|
+
# Per-config checks
|
|
182
|
+
# -----------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
DIM_PREFIXES = ("RAW:", "SPE:", "CUS:")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _check_filename_and_slug(path: Path) -> tuple[str | None, list[str]]:
|
|
188
|
+
name = path.name
|
|
189
|
+
suffix = ".promptfooconfig.yaml"
|
|
190
|
+
if not name.endswith(suffix):
|
|
191
|
+
return None, [f"{name}: filename must end with '{suffix}'"]
|
|
192
|
+
slug = name[: -len(suffix)]
|
|
193
|
+
if not re.fullmatch(r"[a-z][a-z0-9-]*", slug):
|
|
194
|
+
return None, [
|
|
195
|
+
f"{name}: slug '{slug}' must be lowercase letters / digits / "
|
|
196
|
+
f"hyphen, starting with a letter"
|
|
197
|
+
]
|
|
198
|
+
if not (PREBUILT_DIR / f"master-{slug}").is_dir():
|
|
199
|
+
return slug, [
|
|
200
|
+
f"{name}: no matching master at prebuilt/master-{slug}/"
|
|
201
|
+
]
|
|
202
|
+
return slug, []
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _check_tests(slug: str, cfg: dict, allowed_contains: set[str]) -> list[str]:
|
|
206
|
+
errors: list[str] = []
|
|
207
|
+
tests = cfg.get("tests")
|
|
208
|
+
if not isinstance(tests, list) or not tests:
|
|
209
|
+
return [f"{slug}: `tests:` must be a non-empty list"]
|
|
210
|
+
if len(tests) < 4:
|
|
211
|
+
errors.append(
|
|
212
|
+
f"{slug}: at least 4 tests required, got {len(tests)}"
|
|
213
|
+
)
|
|
214
|
+
seen_dims: set[str] = set()
|
|
215
|
+
for i, t in enumerate(tests):
|
|
216
|
+
prefix = f"{slug}.tests[{i}]"
|
|
217
|
+
if not isinstance(t, dict):
|
|
218
|
+
errors.append(f"{prefix}: entry must be a mapping")
|
|
219
|
+
continue
|
|
220
|
+
desc = t.get("description", "")
|
|
221
|
+
if not isinstance(desc, str) or not desc.strip():
|
|
222
|
+
errors.append(f"{prefix}: description must be a non-blank string")
|
|
223
|
+
else:
|
|
224
|
+
matched_dim = None
|
|
225
|
+
for dp in DIM_PREFIXES:
|
|
226
|
+
if desc.startswith(dp):
|
|
227
|
+
matched_dim = dp.rstrip(":")
|
|
228
|
+
break
|
|
229
|
+
if matched_dim is None:
|
|
230
|
+
errors.append(
|
|
231
|
+
f"{prefix}: description must start with one of "
|
|
232
|
+
f"{', '.join(DIM_PREFIXES)} — got {desc!r}"
|
|
233
|
+
)
|
|
234
|
+
else:
|
|
235
|
+
seen_dims.add(matched_dim)
|
|
236
|
+
# vars present?
|
|
237
|
+
if "vars" not in t or not isinstance(t["vars"], dict):
|
|
238
|
+
errors.append(f"{prefix}: missing `vars:` mapping")
|
|
239
|
+
elif not t["vars"].get("question"):
|
|
240
|
+
errors.append(f"{prefix}: vars.question must be set (non-empty)")
|
|
241
|
+
# assertions
|
|
242
|
+
asserts = t.get("assert", [])
|
|
243
|
+
if not isinstance(asserts, list) or not asserts:
|
|
244
|
+
errors.append(f"{prefix}: `assert:` must be a non-empty list")
|
|
245
|
+
continue
|
|
246
|
+
has_rubric = False
|
|
247
|
+
for j, a in enumerate(asserts):
|
|
248
|
+
apath = f"{prefix}.assert[{j}]"
|
|
249
|
+
if not isinstance(a, dict):
|
|
250
|
+
errors.append(f"{apath}: entry must be a mapping")
|
|
251
|
+
continue
|
|
252
|
+
atype = a.get("type")
|
|
253
|
+
if atype == "llm-rubric":
|
|
254
|
+
has_rubric = True
|
|
255
|
+
value = a.get("value")
|
|
256
|
+
if not isinstance(value, str) or len(value.strip()) < 10:
|
|
257
|
+
errors.append(
|
|
258
|
+
f"{apath}: llm-rubric.value must be a non-trivial "
|
|
259
|
+
f"string (>= 10 chars)"
|
|
260
|
+
)
|
|
261
|
+
elif atype in ("contains-any", "icontains-any"):
|
|
262
|
+
vals = a.get("value")
|
|
263
|
+
if not isinstance(vals, list) or not vals:
|
|
264
|
+
errors.append(
|
|
265
|
+
f"{apath}: {atype}.value must be a non-empty list"
|
|
266
|
+
)
|
|
267
|
+
else:
|
|
268
|
+
for v in vals:
|
|
269
|
+
if not isinstance(v, str):
|
|
270
|
+
errors.append(
|
|
271
|
+
f"{apath}: {atype}.value entries must be "
|
|
272
|
+
f"strings (got {type(v).__name__})"
|
|
273
|
+
)
|
|
274
|
+
continue
|
|
275
|
+
if v not in allowed_contains:
|
|
276
|
+
errors.append(
|
|
277
|
+
f"{apath}: {atype}.value entry "
|
|
278
|
+
f"{v!r} is not in {slug}'s fidelity anchors "
|
|
279
|
+
f"(signature_phrases + curated extras). "
|
|
280
|
+
f"Add it to meta.json or to "
|
|
281
|
+
f"EXTRA_ALLOWED_CONTAINS if intentional."
|
|
282
|
+
)
|
|
283
|
+
elif atype in (None, ""):
|
|
284
|
+
errors.append(f"{apath}: missing `type:`")
|
|
285
|
+
# Other deterministic assertion types are allowed but not required.
|
|
286
|
+
if not has_rubric:
|
|
287
|
+
errors.append(
|
|
288
|
+
f"{prefix}: must have at least one llm-rubric assertion"
|
|
289
|
+
)
|
|
290
|
+
# All three dimensions must appear at least once
|
|
291
|
+
for dim in ("RAW", "SPE", "CUS"):
|
|
292
|
+
if dim not in seen_dims:
|
|
293
|
+
errors.append(
|
|
294
|
+
f"{slug}: missing dimension '{dim}' — every persona config "
|
|
295
|
+
f"must cover RAW + SPE + CUS"
|
|
296
|
+
)
|
|
297
|
+
return errors
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _check_providers(slug: str, cfg: dict) -> list[str]:
|
|
301
|
+
errors: list[str] = []
|
|
302
|
+
providers = cfg.get("providers")
|
|
303
|
+
if not isinstance(providers, list) or not providers:
|
|
304
|
+
return [f"{slug}: `providers:` must be a non-empty list"]
|
|
305
|
+
first = providers[0]
|
|
306
|
+
if not isinstance(first, dict) or not first.get("id"):
|
|
307
|
+
errors.append(f"{slug}: providers[0] must be a mapping with an `id`")
|
|
308
|
+
# default judge provider
|
|
309
|
+
default_test = cfg.get("defaultTest", {})
|
|
310
|
+
judge = (
|
|
311
|
+
default_test.get("options", {}).get("provider")
|
|
312
|
+
if isinstance(default_test, dict) else None
|
|
313
|
+
)
|
|
314
|
+
if not judge:
|
|
315
|
+
errors.append(
|
|
316
|
+
f"{slug}: defaultTest.options.provider must be set "
|
|
317
|
+
f"(the llm-rubric judge model)"
|
|
318
|
+
)
|
|
319
|
+
return errors
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# -----------------------------------------------------------------------------
|
|
323
|
+
# Entry point
|
|
324
|
+
# -----------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
def validate(persona_dir: Path = PERSONA_DIR) -> list[str]:
|
|
327
|
+
"""Return a flat list of error strings; empty list means OK."""
|
|
328
|
+
errors: list[str] = []
|
|
329
|
+
if not persona_dir.exists():
|
|
330
|
+
return [f"tests/persona/ directory does not exist at {persona_dir}"]
|
|
331
|
+
shared_path = persona_dir / "shared.yaml"
|
|
332
|
+
if not shared_path.exists():
|
|
333
|
+
errors.append("tests/persona/shared.yaml is missing")
|
|
334
|
+
shared: dict = {}
|
|
335
|
+
else:
|
|
336
|
+
try:
|
|
337
|
+
shared = _load_yaml(shared_path)
|
|
338
|
+
except Exception as exc:
|
|
339
|
+
errors.append(f"shared.yaml: failed to parse — {exc}")
|
|
340
|
+
shared = {}
|
|
341
|
+
configs = sorted(persona_dir.glob("*.promptfooconfig.yaml"))
|
|
342
|
+
if not configs:
|
|
343
|
+
errors.append(
|
|
344
|
+
"tests/persona/: no *.promptfooconfig.yaml files found"
|
|
345
|
+
)
|
|
346
|
+
return errors
|
|
347
|
+
for cfg_path in configs:
|
|
348
|
+
slug, fname_errs = _check_filename_and_slug(cfg_path)
|
|
349
|
+
errors.extend(fname_errs)
|
|
350
|
+
if slug is None or fname_errs:
|
|
351
|
+
continue
|
|
352
|
+
meta = _load_master_meta(slug)
|
|
353
|
+
if meta is None:
|
|
354
|
+
errors.append(
|
|
355
|
+
f"{slug}: meta.json not found at prebuilt/master-{slug}/"
|
|
356
|
+
)
|
|
357
|
+
continue
|
|
358
|
+
try:
|
|
359
|
+
cfg = _load_yaml(cfg_path)
|
|
360
|
+
except Exception as exc:
|
|
361
|
+
errors.append(f"{slug}: failed to parse YAML — {exc}")
|
|
362
|
+
continue
|
|
363
|
+
allowed = _allowed_contains_values(slug, meta)
|
|
364
|
+
errors.extend(_check_providers(slug, cfg))
|
|
365
|
+
errors.extend(_check_tests(slug, cfg, allowed))
|
|
366
|
+
if shared:
|
|
367
|
+
errors.extend(_check_prompt_sync(slug, cfg, shared))
|
|
368
|
+
return errors
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def main() -> int:
|
|
372
|
+
errors = validate()
|
|
373
|
+
if errors:
|
|
374
|
+
print(f"{len(errors)} promptfoo-config error(s):")
|
|
375
|
+
for e in errors:
|
|
376
|
+
print(f" ERROR: {e}")
|
|
377
|
+
return 1
|
|
378
|
+
print("promptfoo configs OK")
|
|
379
|
+
return 0
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
if __name__ == "__main__":
|
|
383
|
+
sys.exit(main())
|
package/scripts/validate.py
CHANGED
|
@@ -161,11 +161,144 @@ def lint_master(master_dir: Path, strict: bool = False) -> list[str]:
|
|
|
161
161
|
return issues
|
|
162
162
|
|
|
163
163
|
|
|
164
|
+
def _run_persona_fidelity_subcheck() -> list[str]:
|
|
165
|
+
"""Run the persona-fidelity validator as a sub-check.
|
|
166
|
+
|
|
167
|
+
Imported lazily so that --master single-target lints stay fast and to
|
|
168
|
+
avoid hard-coupling the two scripts at module load time.
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
import importlib.util
|
|
172
|
+
|
|
173
|
+
spec_path = Path(__file__).resolve().parent / "validate-persona-fidelity.py"
|
|
174
|
+
spec = importlib.util.spec_from_file_location("vpf", spec_path)
|
|
175
|
+
mod = importlib.util.module_from_spec(spec)
|
|
176
|
+
spec.loader.exec_module(mod)
|
|
177
|
+
return mod.validate(PREBUILT_DIR)
|
|
178
|
+
except Exception as exc: # pragma: no cover — surfaces to user
|
|
179
|
+
return [f"persona-fidelity sub-check failed to run: {exc}"]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _run_manifest_versions_subcheck() -> list[str]:
|
|
183
|
+
"""Run the platform-manifest version-drift gate as a sub-check.
|
|
184
|
+
|
|
185
|
+
Hard gate (not advisory): any mismatch returns a non-empty error list
|
|
186
|
+
and the parent validate.py will exit non-zero. Drift between the
|
|
187
|
+
platform manifests must be fixed before release.
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
import importlib.util
|
|
191
|
+
|
|
192
|
+
spec_path = (
|
|
193
|
+
Path(__file__).resolve().parent / "check-manifest-versions.py"
|
|
194
|
+
)
|
|
195
|
+
spec = importlib.util.spec_from_file_location("cmv", spec_path)
|
|
196
|
+
mod = importlib.util.module_from_spec(spec)
|
|
197
|
+
spec.loader.exec_module(mod)
|
|
198
|
+
versions = mod.collect_versions()
|
|
199
|
+
if not versions:
|
|
200
|
+
return []
|
|
201
|
+
unique = set(versions.values())
|
|
202
|
+
if len(unique) <= 1:
|
|
203
|
+
return []
|
|
204
|
+
lines = ["manifest version drift detected:"]
|
|
205
|
+
for path, v in sorted(versions.items()):
|
|
206
|
+
lines.append(f" {path}: {v}")
|
|
207
|
+
return lines
|
|
208
|
+
except Exception as exc: # pragma: no cover
|
|
209
|
+
return [f"manifest-versions sub-check failed to run: {exc}"]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _run_lore_triggers_content_subcheck() -> list[str]:
|
|
213
|
+
"""Run the lore_triggers-content advisory validator as a sub-check.
|
|
214
|
+
|
|
215
|
+
ADVISORY for v0.8.x: this collects warning lines but never causes
|
|
216
|
+
validate.py to exit non-zero. It will become a hard gate in v0.9.
|
|
217
|
+
Callers receive a list of warning strings to print.
|
|
218
|
+
"""
|
|
219
|
+
try:
|
|
220
|
+
import importlib.util
|
|
221
|
+
|
|
222
|
+
spec_path = (
|
|
223
|
+
Path(__file__).resolve().parent
|
|
224
|
+
/ "validate-lore-triggers-content.py"
|
|
225
|
+
)
|
|
226
|
+
spec = importlib.util.spec_from_file_location("vltc", spec_path)
|
|
227
|
+
mod = importlib.util.module_from_spec(spec)
|
|
228
|
+
spec.loader.exec_module(mod)
|
|
229
|
+
results = mod.validate(PREBUILT_DIR)
|
|
230
|
+
warnings: list[str] = []
|
|
231
|
+
for r in results:
|
|
232
|
+
if not r["passed"]:
|
|
233
|
+
warnings.append(
|
|
234
|
+
f"{r['master']} entry[{r['entry_idx']}] "
|
|
235
|
+
f"source_ref={r['source_ref']}: no high-similarity "
|
|
236
|
+
f"match (best file={r['best_file']}, "
|
|
237
|
+
f"lcs={r['best_lcs']} need {r['needed_lcs']}, "
|
|
238
|
+
f"ratio={r['best_ratio']})"
|
|
239
|
+
)
|
|
240
|
+
elif r.get("in_references_only"):
|
|
241
|
+
warnings.append(
|
|
242
|
+
f"{r['master']} entry[{r['entry_idx']}] "
|
|
243
|
+
f"source_ref={r['source_ref']}: matched only in "
|
|
244
|
+
f"references/{r['ref_file']} — consider extending "
|
|
245
|
+
f"sources/excerpts to cover this quote"
|
|
246
|
+
)
|
|
247
|
+
return warnings
|
|
248
|
+
except Exception as exc: # pragma: no cover
|
|
249
|
+
return [f"lore-triggers-content sub-check failed to run: {exc}"]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _run_promptfoo_configs_subcheck() -> list[str]:
|
|
253
|
+
"""Run the persona promptfoo-config validator as a sub-check.
|
|
254
|
+
|
|
255
|
+
Skips silently if tests/persona/ does not exist (older branch state
|
|
256
|
+
predating v0.8 promptfoo work). Returns a list of error strings.
|
|
257
|
+
"""
|
|
258
|
+
persona_dir = (
|
|
259
|
+
Path(__file__).resolve().parent.parent / "tests" / "persona"
|
|
260
|
+
)
|
|
261
|
+
if not persona_dir.exists():
|
|
262
|
+
return []
|
|
263
|
+
try:
|
|
264
|
+
import importlib.util
|
|
265
|
+
|
|
266
|
+
spec_path = (
|
|
267
|
+
Path(__file__).resolve().parent / "validate-promptfoo-configs.py"
|
|
268
|
+
)
|
|
269
|
+
spec = importlib.util.spec_from_file_location("vppc", spec_path)
|
|
270
|
+
mod = importlib.util.module_from_spec(spec)
|
|
271
|
+
spec.loader.exec_module(mod)
|
|
272
|
+
return mod.validate(persona_dir)
|
|
273
|
+
except Exception as exc: # pragma: no cover — surfaces to user
|
|
274
|
+
return [f"promptfoo-configs sub-check failed to run: {exc}"]
|
|
275
|
+
|
|
276
|
+
|
|
164
277
|
def main():
|
|
165
278
|
parser = argparse.ArgumentParser(description="Master-skill SKILL.md linter")
|
|
166
279
|
parser.add_argument("--master", type=str, help="Lint a specific master only")
|
|
167
280
|
parser.add_argument("--strict", action="store_true", help="Treat warnings as errors")
|
|
168
281
|
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
282
|
+
parser.add_argument(
|
|
283
|
+
"--skip-persona-fidelity",
|
|
284
|
+
action="store_true",
|
|
285
|
+
help="Skip the v0.8 persona-fidelity sub-check",
|
|
286
|
+
)
|
|
287
|
+
parser.add_argument(
|
|
288
|
+
"--skip-promptfoo-configs",
|
|
289
|
+
action="store_true",
|
|
290
|
+
help="Skip the v0.8 promptfoo-configs sub-check",
|
|
291
|
+
)
|
|
292
|
+
parser.add_argument(
|
|
293
|
+
"--skip-manifest-versions",
|
|
294
|
+
action="store_true",
|
|
295
|
+
help="Skip the v0.8 manifest version-drift gate",
|
|
296
|
+
)
|
|
297
|
+
parser.add_argument(
|
|
298
|
+
"--skip-lore-triggers-content",
|
|
299
|
+
action="store_true",
|
|
300
|
+
help="Skip the v0.8 advisory lore_triggers-content validator",
|
|
301
|
+
)
|
|
169
302
|
args = parser.parse_args()
|
|
170
303
|
|
|
171
304
|
if args.master:
|
|
@@ -186,19 +319,94 @@ def main():
|
|
|
186
319
|
if any("[ERROR]" in i for i in issues):
|
|
187
320
|
has_errors = True
|
|
188
321
|
|
|
322
|
+
# --- v0.8 persona-fidelity sub-check (runs only on full-tree lints) ---
|
|
323
|
+
persona_errors: list[str] = []
|
|
324
|
+
if not args.master and not args.skip_persona_fidelity:
|
|
325
|
+
persona_errors = _run_persona_fidelity_subcheck()
|
|
326
|
+
if persona_errors:
|
|
327
|
+
has_errors = True
|
|
328
|
+
|
|
329
|
+
# --- v0.8 promptfoo-configs sub-check (also full-tree only) ---
|
|
330
|
+
promptfoo_errors: list[str] = []
|
|
331
|
+
if not args.master and not args.skip_promptfoo_configs:
|
|
332
|
+
promptfoo_errors = _run_promptfoo_configs_subcheck()
|
|
333
|
+
if promptfoo_errors:
|
|
334
|
+
has_errors = True
|
|
335
|
+
|
|
336
|
+
# --- v0.8 manifest version-drift gate (full-tree only, HARD gate) ---
|
|
337
|
+
manifest_errors: list[str] = []
|
|
338
|
+
if not args.master and not args.skip_manifest_versions:
|
|
339
|
+
manifest_errors = _run_manifest_versions_subcheck()
|
|
340
|
+
if manifest_errors:
|
|
341
|
+
has_errors = True
|
|
342
|
+
|
|
343
|
+
# --- v0.8 lore_triggers-content advisory sub-check ---
|
|
344
|
+
# ADVISORY ONLY: warnings printed but never affect has_errors.
|
|
345
|
+
lore_warnings: list[str] = []
|
|
346
|
+
if not args.master and not args.skip_lore_triggers_content:
|
|
347
|
+
lore_warnings = _run_lore_triggers_content_subcheck()
|
|
348
|
+
|
|
189
349
|
if args.json:
|
|
190
|
-
|
|
350
|
+
out = {"skills": all_issues}
|
|
351
|
+
if persona_errors:
|
|
352
|
+
out["persona_fidelity"] = persona_errors
|
|
353
|
+
if promptfoo_errors:
|
|
354
|
+
out["promptfoo_configs"] = promptfoo_errors
|
|
355
|
+
if manifest_errors:
|
|
356
|
+
out["manifest_versions"] = manifest_errors
|
|
357
|
+
if lore_warnings:
|
|
358
|
+
out["lore_triggers_content_advisory"] = lore_warnings
|
|
359
|
+
print(json.dumps(out, indent=2, ensure_ascii=False))
|
|
191
360
|
else:
|
|
192
|
-
|
|
193
|
-
|
|
361
|
+
nothing_to_report = (
|
|
362
|
+
not all_issues
|
|
363
|
+
and not persona_errors
|
|
364
|
+
and not promptfoo_errors
|
|
365
|
+
and not manifest_errors
|
|
366
|
+
and not lore_warnings
|
|
367
|
+
)
|
|
368
|
+
if nothing_to_report:
|
|
369
|
+
print(f"✅ All {len(dirs)} skills pass validation.")
|
|
194
370
|
else:
|
|
195
371
|
for name, issues in all_issues.items():
|
|
196
372
|
for issue in issues:
|
|
197
373
|
print(issue)
|
|
374
|
+
if persona_errors:
|
|
375
|
+
print()
|
|
376
|
+
print("Persona-fidelity sub-check (v0.8):")
|
|
377
|
+
for e in persona_errors:
|
|
378
|
+
print(f" [ERROR] {e}")
|
|
379
|
+
if promptfoo_errors:
|
|
380
|
+
print()
|
|
381
|
+
print("Promptfoo-configs sub-check (v0.8):")
|
|
382
|
+
for e in promptfoo_errors:
|
|
383
|
+
print(f" [ERROR] {e}")
|
|
384
|
+
if manifest_errors:
|
|
385
|
+
print()
|
|
386
|
+
print("Manifest version-drift gate (v0.8):")
|
|
387
|
+
for e in manifest_errors:
|
|
388
|
+
print(f" [ERROR] {e}")
|
|
389
|
+
if lore_warnings:
|
|
390
|
+
print()
|
|
391
|
+
print(
|
|
392
|
+
"Lore-triggers content sub-check (v0.8, ADVISORY — "
|
|
393
|
+
"hard gate in v0.9):"
|
|
394
|
+
)
|
|
395
|
+
for w in lore_warnings:
|
|
396
|
+
print(f" [WARN] {w}")
|
|
198
397
|
print()
|
|
199
398
|
total_errors = sum(1 for issues in all_issues.values() for i in issues if "[ERROR]" in i)
|
|
200
399
|
total_warns = sum(1 for issues in all_issues.values() for i in issues if "[WARN]" in i)
|
|
201
|
-
|
|
400
|
+
total_errors += (
|
|
401
|
+
len(persona_errors)
|
|
402
|
+
+ len(promptfoo_errors)
|
|
403
|
+
+ len(manifest_errors)
|
|
404
|
+
)
|
|
405
|
+
total_warns += len(lore_warnings)
|
|
406
|
+
print(
|
|
407
|
+
f"Summary: {total_errors} error(s), {total_warns} warning(s) "
|
|
408
|
+
f"across {len(all_issues)} master(s)"
|
|
409
|
+
)
|
|
202
410
|
|
|
203
411
|
sys.exit(1 if has_errors else 0)
|
|
204
412
|
|