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.
Files changed (70) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.cursor-plugin/plugin.json +2 -2
  4. package/README.md +60 -12
  5. package/README_EN.md +57 -11
  6. package/SKILL.md +55 -298
  7. package/gemini-extension.json +2 -2
  8. package/hooks/session-start +40 -2
  9. package/hooks/tests/test_session_start.sh +149 -0
  10. package/package.json +7 -3
  11. package/prebuilt/compare/SKILL.md +1 -1
  12. package/prebuilt/compare/tests/fidelity.jsonl +13 -0
  13. package/prebuilt/master-ajahn-chah/meta.json +17 -1
  14. package/prebuilt/master-atisha/meta.json +17 -1
  15. package/prebuilt/master-buddhaghosa/meta.json +26 -1
  16. package/prebuilt/master-curriculum/SKILL.md +87 -0
  17. package/prebuilt/master-curriculum/references/chan.md +29 -0
  18. package/prebuilt/master-curriculum/references/gelug-madhyamaka.md +33 -0
  19. package/prebuilt/master-curriculum/references/huayan.md +28 -0
  20. package/prebuilt/master-curriculum/references/jingtu.md +29 -0
  21. package/prebuilt/master-curriculum/references/sanlun-zhongguan.md +30 -0
  22. package/prebuilt/master-curriculum/references/theravada-vipassana.md +39 -0
  23. package/prebuilt/master-curriculum/references/tiantai.md +30 -0
  24. package/prebuilt/master-curriculum/references/weishi.md +31 -0
  25. package/prebuilt/master-curriculum/tests/fidelity.jsonl +8 -0
  26. package/prebuilt/master-debate/SKILL.md +274 -0
  27. package/prebuilt/master-debate/meta.json +25 -0
  28. package/prebuilt/master-debate/tests/fidelity.jsonl +8 -0
  29. package/prebuilt/master-fazang/meta.json +25 -0
  30. package/prebuilt/master-huineng/meta.json +37 -0
  31. package/prebuilt/master-huineng/references/teaching.md +1 -1
  32. package/prebuilt/master-huineng/sources/INDEX.md +1 -1
  33. package/prebuilt/master-huineng/sources/tanjing-excerpts.md +17 -1
  34. package/prebuilt/master-kumarajiva/meta.json +16 -0
  35. package/prebuilt/master-kumarajiva/references/teaching.md +2 -2
  36. package/prebuilt/master-mahasi-sayadaw/meta.json +17 -1
  37. package/prebuilt/master-milarepa/meta.json +26 -1
  38. package/prebuilt/master-ouyi/meta.json +17 -0
  39. package/prebuilt/master-ouyi/references/teaching.md +4 -4
  40. package/prebuilt/master-ouyi/sources/INDEX.md +1 -1
  41. package/prebuilt/master-ouyi/sources/jiaoguan-gangzong-excerpts.md +3 -3
  42. package/prebuilt/master-tsongkhapa/meta.json +18 -1
  43. package/prebuilt/master-xuanzang/meta.json +16 -0
  44. package/prebuilt/master-xuanzang/references/teaching.md +5 -5
  45. package/prebuilt/master-xuanzang/references/voice.md +1 -1
  46. package/prebuilt/master-xuyun/meta.json +39 -0
  47. package/prebuilt/master-yinguang/SKILL.md +1 -1
  48. package/prebuilt/master-yinguang/meta.json +17 -0
  49. package/prebuilt/master-yinguang/references/teaching.md +7 -7
  50. package/prebuilt/master-yinguang/references/voice.md +1 -1
  51. package/prebuilt/master-yinguang/sources/INDEX.md +3 -3
  52. package/prebuilt/master-yinguang/sources/wenchao-excerpts.md +4 -4
  53. package/prebuilt/master-yinguang/sources/yihanbianfu-excerpts.md +3 -3
  54. package/prebuilt/master-zhiyi/meta.json +28 -0
  55. package/prebuilt/master-zhiyi/references/teaching.md +2 -2
  56. package/scripts/check-manifest-versions.py +142 -0
  57. package/scripts/tests/test_check_manifest_versions.py +217 -0
  58. package/scripts/tests/test_debate_protocol.py +159 -0
  59. package/scripts/tests/test_validate_cross_critique.py +149 -0
  60. package/scripts/tests/test_validate_curriculum_sources.py +144 -0
  61. package/scripts/tests/test_validate_lore_triggers_content.py +372 -0
  62. package/scripts/tests/test_validate_persona_fidelity.py +317 -0
  63. package/scripts/tests/test_validate_promptfoo_configs.py +386 -0
  64. package/scripts/validate-cross-critique.py +137 -0
  65. package/scripts/validate-curriculum-sources.py +111 -0
  66. package/scripts/validate-fidelity.py +16 -1
  67. package/scripts/validate-lore-triggers-content.py +393 -0
  68. package/scripts/validate-persona-fidelity.py +210 -0
  69. package/scripts/validate-promptfoo-configs.py +383 -0
  70. 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())
@@ -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
- print(json.dumps(all_issues, indent=2, ensure_ascii=False))
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
- if not all_issues:
193
- print(f"✅ All {len(dirs)} masters pass validation.")
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
- print(f"Summary: {total_errors} error(s), {total_warns} warning(s) across {len(all_issues)} master(s)")
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