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,393 @@
1
+ #!/usr/bin/env python3
2
+ """Validate that lore_triggers[].content quotes can be located verbatim
3
+ (or with high similarity) in the master's own sources/*-excerpts.md.
4
+
5
+ Background
6
+ ----------
7
+ PR #32 introduced the lore_triggers schema. During self-review one entry
8
+ was caught and removed where the `content` quote was fabricated and falsely
9
+ attributed to T48n2008. This validator exists so the next fabrication is
10
+ caught by CI, not by luck.
11
+
12
+ Algorithm
13
+ ---------
14
+ For each prebuilt/master-<slug>/meta.json:
15
+ - For each lore_triggers[] entry:
16
+ - Extract the quotation portion of `content` (strip the editorial
17
+ gloss that follows the em-dash "——" if present)
18
+ - Identify candidate excerpts files in this master's sources/ dir
19
+ (preferring those whose name or body contains a normalized form
20
+ of the source_ref CBETA id; otherwise scan all *-excerpts.md)
21
+ - Normalize both texts (drop punctuation/whitespace, T<->S Han, …)
22
+ - PASS if any of:
23
+ * longest common substring >= 60 chars
24
+ * difflib.SequenceMatcher ratio >= 0.75
25
+ Otherwise FAIL and report best candidate file + best ratio.
26
+
27
+ Mode
28
+ ----
29
+ By default this is *advisory* — it prints findings but exits 0 so it does
30
+ not block CI on day one. Run with `--strict` to make failures hard exits
31
+ (used locally and from a follow-up CI gate after the grace window).
32
+
33
+ Plan: advisory through v0.8.x, hard gate from v0.9.0.
34
+
35
+ Usage
36
+ -----
37
+ python scripts/validate-lore-triggers-content.py
38
+ python scripts/validate-lore-triggers-content.py --strict
39
+ python scripts/validate-lore-triggers-content.py --master master-huineng
40
+ python scripts/validate-lore-triggers-content.py --json
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import argparse
45
+ import json
46
+ import re
47
+ import sys
48
+ from difflib import SequenceMatcher
49
+ from pathlib import Path
50
+
51
+ PREBUILT_DIR = Path(__file__).resolve().parent.parent / "prebuilt"
52
+
53
+ # Thresholds — calibrated against the 7 lore_triggers shipped in PR #32.
54
+ # A pass requires EITHER an absolute LCS floor OR a relative LCS coverage
55
+ # OR a high SequenceMatcher ratio. Short quotes (≈45 chars) need to match
56
+ # nearly verbatim; longer quotes (80+) just need a 40-char window.
57
+ MIN_LCS_ABS = 40 # absolute floor — longest common substring chars
58
+ MIN_LCS_FRAC = 0.85 # OR: LCS must cover ≥85% of the normalized quote
59
+ MIN_RATIO = 0.75 # OR: SequenceMatcher ratio across the file
60
+
61
+ # Drop these chars during normalization. Includes ASCII + common CJK
62
+ # punctuation, half- and full-width spaces, and a few special markers.
63
+ PUNCT_PATTERN = re.compile(
64
+ r"[\s  "
65
+ r",。、;:!?「」『』()【】《》〈〉…—–\-·"
66
+ r",.;:!?()\[\]{}<>\"'`~@#\$%\^&\*_+=|\\/]"
67
+ )
68
+
69
+ # Minimal Traditional -> Simplified table covering the chars that actually
70
+ # show up in our excerpts vs meta quotes. Keeping this hand-curated avoids
71
+ # adding opencc as a dep. Extend as new false-negatives surface.
72
+ T2S = {
73
+ "於": "于", "爾": "尔", "後": "后", "個": "个", "們": "们",
74
+ "這": "这", "麼": "么", "說": "说", "話": "话", "對": "对",
75
+ "問": "问", "見": "见", "現": "现", "實": "实", "經": "经",
76
+ "聖": "圣", "賢": "贤", "覺": "觉", "悟": "悟", "靈": "灵",
77
+ "魂": "魂", "體": "体", "氣": "气", "風": "风", "雲": "云",
78
+ "電": "电", "話": "话", "書": "书", "讀": "读", "寫": "写",
79
+ "聽": "听", "聞": "闻", "嗎": "吗", "啟": "启", "輪": "轮",
80
+ "華": "华", "藏": "藏", "識": "识", "種": "种", "葉": "叶",
81
+ "場": "场", "進": "进", "達": "达", "過": "过", "來": "来",
82
+ "車": "车", "間": "间", "問": "问", "離": "离", "邊": "边",
83
+ "處": "处", "顯": "显", "標": "标", "點": "点", "稱": "称",
84
+ "讚": "赞", "勸": "劝", "請": "请", "餘": "余", "禪": "禅",
85
+ "閉": "闭", "開": "开", "關": "关", "鎖": "锁", "頓": "顿",
86
+ "漸": "渐", "悟": "悟", "戒": "戒", "戀": "恋", "夢": "梦",
87
+ "斷": "断", "點": "点", "靜": "静", "動": "动", "亂": "乱",
88
+ "歲": "岁", "時": "时", "節": "节", "義": "义", "禮": "礼",
89
+ "孫": "孙", "誰": "谁", "與": "与", "從": "从", "貴": "贵",
90
+ "賤": "贱", "誤": "误", "繁": "繁", "簡": "简", "歸": "归",
91
+ "鐘": "钟", "響": "响", "陽": "阳", "陰": "阴",
92
+ }
93
+
94
+
95
+ def normalize(text: str) -> str:
96
+ """Drop punctuation + whitespace, fold trad->simp."""
97
+ text = "".join(T2S.get(c, c) for c in text)
98
+ text = PUNCT_PATTERN.sub("", text)
99
+ return text
100
+
101
+
102
+ def extract_quotation(content: str) -> str:
103
+ """Lore_triggers content is typically: <quote>——<editorial gloss>
104
+
105
+ Strip everything from the em-dash onwards so we only compare the
106
+ actual scripture quotation against the excerpts file.
107
+ """
108
+ # The em-dash separator may be a single 「——」 or 「——」 followed by
109
+ # 《text》 etc. Some entries lack the gloss; in that case return the
110
+ # whole content untouched.
111
+ if "——" in content:
112
+ return content.split("——", 1)[0]
113
+ if "──" in content:
114
+ return content.split("──", 1)[0]
115
+ return content
116
+
117
+
118
+ def longest_common_substring_len(a: str, b: str) -> int:
119
+ """Return length of the longest common substring of normalized a,b.
120
+
121
+ Uses SequenceMatcher's find_longest_match which runs in roughly
122
+ O(len(a)*len(b)) — fine for our hundred-char strings.
123
+ """
124
+ if not a or not b:
125
+ return 0
126
+ sm = SequenceMatcher(None, a, b, autojunk=False)
127
+ m = sm.find_longest_match(0, len(a), 0, len(b))
128
+ return m.size
129
+
130
+
131
+ def similarity_ratio(a: str, b: str) -> float:
132
+ """SequenceMatcher ratio on normalized strings (windowed for speed).
133
+
134
+ `b` (the haystack / whole excerpts file) is typically much larger
135
+ than `a`. Compare against a sliding window of size ~2x|a| so the
136
+ ratio isn't drowned by the rest of the file.
137
+ """
138
+ if not a or not b:
139
+ return 0.0
140
+ if len(b) <= 2 * len(a):
141
+ return SequenceMatcher(None, a, b, autojunk=False).ratio()
142
+ best = 0.0
143
+ win = 2 * len(a)
144
+ step = max(1, len(a) // 2)
145
+ for i in range(0, len(b) - win + 1, step):
146
+ r = SequenceMatcher(None, a, b[i : i + win], autojunk=False).ratio()
147
+ if r > best:
148
+ best = r
149
+ # Early exit if we already pass the bar comfortably
150
+ if best >= 0.95:
151
+ break
152
+ return best
153
+
154
+
155
+ def _candidate_files(
156
+ master_dir: Path, source_ref: str, *, include_references: bool = False
157
+ ) -> list[Path]:
158
+ """Return excerpts files ranked by likely relevance to source_ref.
159
+
160
+ Strategy: prefer files in sources/ whose name or body contains a
161
+ normalized form of the CBETA id (e.g. T46n1911 -> also try T1911
162
+ short form). Fall back to all *-excerpts.md files. If
163
+ include_references=True, also append references/*.md as a soft
164
+ secondary corpus (used when the primary excerpts check fails — a
165
+ match there means the quote is real but excerpts/ is incomplete).
166
+ """
167
+ sources = master_dir / "sources"
168
+ if not sources.exists():
169
+ return []
170
+ all_files = sorted(sources.glob("*-excerpts.md"))
171
+
172
+ cbeta_prefix = source_ref.split("#", 1)[0]
173
+ # Build keys to look for in file body: long form (T46n1911) and a
174
+ # short form stripping the "n<digits>" segment (T1911).
175
+ keys = {cbeta_prefix}
176
+ m = re.match(r"^(T)\d+(n\d+)$", cbeta_prefix)
177
+ if m:
178
+ # T46n1911 -> T1911
179
+ keys.add(m.group(1) + cbeta_prefix.split("n", 1)[1])
180
+
181
+ ranked: list[tuple[int, Path]] = []
182
+ for f in all_files:
183
+ body = f.read_text(encoding="utf-8", errors="ignore")
184
+ score = 0
185
+ for k in keys:
186
+ if k in body:
187
+ score += 2
188
+ if k in f.name:
189
+ score += 1
190
+ ranked.append((score, f))
191
+ ranked.sort(key=lambda x: -x[0])
192
+ sources_list = [p for _, p in ranked] or all_files
193
+
194
+ if include_references:
195
+ refs = master_dir / "references"
196
+ if refs.exists():
197
+ return sources_list + sorted(refs.glob("*.md"))
198
+ return sources_list
199
+
200
+
201
+ def check_entry(
202
+ master_dir: Path,
203
+ entry: dict,
204
+ entry_idx: int,
205
+ ) -> dict:
206
+ """Check a single lore_triggers entry. Returns a result dict."""
207
+ content = entry.get("content", "") or ""
208
+ source_ref = entry.get("source_ref", "") or ""
209
+ quote = extract_quotation(content)
210
+ quote_norm = normalize(quote)
211
+
212
+ # Pass thresholds depend on quote length: a short quote must be matched
213
+ # almost verbatim (frac of its own length); a long quote can satisfy
214
+ # the absolute window of MIN_LCS_ABS chars.
215
+ needed_lcs = min(MIN_LCS_ABS, max(1, int(len(quote_norm) * MIN_LCS_FRAC)))
216
+
217
+ def _scan(files: list[Path]) -> tuple[Path | None, int, float]:
218
+ bf, blcs, br = None, 0, 0.0
219
+ for f in files:
220
+ body = f.read_text(encoding="utf-8", errors="ignore")
221
+ body_norm = normalize(body)
222
+ lcs = longest_common_substring_len(quote_norm, body_norm)
223
+ ratio = similarity_ratio(quote_norm, body_norm)
224
+ if lcs > blcs or (lcs == blcs and ratio > br):
225
+ blcs, br, bf = lcs, ratio, f
226
+ if lcs >= needed_lcs or ratio >= MIN_RATIO:
227
+ break
228
+ return bf, blcs, br
229
+
230
+ # Primary check: only files inside sources/
231
+ primary_files = _candidate_files(master_dir, source_ref)
232
+ best_file, best_lcs, best_ratio = _scan(primary_files)
233
+ passed_in_sources = (best_lcs >= needed_lcs) or (best_ratio >= MIN_RATIO)
234
+
235
+ # Secondary check: also look in references/. A match here means the
236
+ # quote is real but the sources/ excerpts file does not contain it;
237
+ # advisory output should flag this so the author can extend excerpts.
238
+ found_in_references = False
239
+ ref_file = None
240
+ if not passed_in_sources:
241
+ secondary_files = _candidate_files(
242
+ master_dir, source_ref, include_references=True
243
+ )
244
+ # Only scan the references-side files we did not already check
245
+ new_files = [f for f in secondary_files if f not in primary_files]
246
+ rf, rlcs, rr = _scan(new_files)
247
+ if (rlcs >= needed_lcs) or (rr >= MIN_RATIO):
248
+ found_in_references = True
249
+ ref_file = rf
250
+ if rlcs > best_lcs or (rlcs == best_lcs and rr > best_ratio):
251
+ best_file, best_lcs, best_ratio = rf, rlcs, rr
252
+
253
+ # "passed" gating: true if quote is real (in either sources or
254
+ # references). The "in_sources_only" sub-field lets CI distinguish
255
+ # strict-pass (sources) from advisory-pass (references-only).
256
+ passed = passed_in_sources or found_in_references
257
+ return {
258
+ "master": master_dir.name,
259
+ "entry_idx": entry_idx,
260
+ "source_ref": source_ref,
261
+ "quote_len": len(quote_norm),
262
+ "needed_lcs": needed_lcs,
263
+ "best_file": best_file.name if best_file else None,
264
+ "best_lcs": best_lcs,
265
+ "best_ratio": round(best_ratio, 3),
266
+ "passed": passed,
267
+ "passed_in_sources": passed_in_sources,
268
+ "in_references_only": found_in_references and not passed_in_sources,
269
+ "ref_file": ref_file.name if ref_file else None,
270
+ }
271
+
272
+
273
+ def validate(
274
+ prebuilt_dir: Path = PREBUILT_DIR,
275
+ master_filter: str | None = None,
276
+ ) -> list[dict]:
277
+ """Return list of result dicts (one per lore_triggers entry checked)."""
278
+ results: list[dict] = []
279
+ for d in sorted(prebuilt_dir.iterdir()):
280
+ if not d.is_dir():
281
+ continue
282
+ if master_filter and d.name != master_filter:
283
+ continue
284
+ meta_path = d / "meta.json"
285
+ if not meta_path.exists():
286
+ continue
287
+ try:
288
+ data = json.loads(meta_path.read_text(encoding="utf-8"))
289
+ except json.JSONDecodeError:
290
+ continue
291
+ if data.get("kind") == "meta-skill":
292
+ continue
293
+ lore = data.get("lore_triggers", []) or []
294
+ for i, entry in enumerate(lore):
295
+ results.append(check_entry(d, entry, i))
296
+ return results
297
+
298
+
299
+ def _format_human(results: list[dict]) -> tuple[str, int]:
300
+ fails = [r for r in results if not r["passed"]]
301
+ soft = [r for r in results if r.get("in_references_only")]
302
+ lines: list[str] = []
303
+ if not results:
304
+ lines.append("No lore_triggers entries found to check.")
305
+ return "\n".join(lines), 0
306
+
307
+ lines.append(
308
+ f"Checked {len(results)} lore_triggers entries across "
309
+ f"{len({r['master'] for r in results})} masters."
310
+ )
311
+ lines.append("")
312
+ lines.append("Similarity distribution (per entry):")
313
+ for r in results:
314
+ if not r["passed"]:
315
+ marker = "FAIL"
316
+ elif r.get("in_references_only"):
317
+ marker = "WARN"
318
+ else:
319
+ marker = "PASS"
320
+ lines.append(
321
+ f" [{marker}] {r['master']} #{r['entry_idx']} "
322
+ f"({r['source_ref']}) — file={r['best_file']} "
323
+ f"quote_len={r['quote_len']} lcs={r['best_lcs']} "
324
+ f"(need {r['needed_lcs']}) ratio={r['best_ratio']}"
325
+ )
326
+ if soft:
327
+ lines.append("")
328
+ lines.append(
329
+ f"{len(soft)} entry/entries matched only in references/ "
330
+ f"(quote is real but sources/ excerpts file does not contain it; "
331
+ f"consider extending the excerpts):"
332
+ )
333
+ for r in soft:
334
+ lines.append(
335
+ f" - {r['master']} entry[{r['entry_idx']}] source_ref="
336
+ f"{r['source_ref']}: match in references/{r['ref_file']}"
337
+ )
338
+ if fails:
339
+ lines.append("")
340
+ lines.append(f"{len(fails)} entry/entries did not meet thresholds:")
341
+ lines.append(
342
+ f" thresholds: longest_common_substring >= "
343
+ f"min({MIN_LCS_ABS}, {MIN_LCS_FRAC}*quote_len) chars "
344
+ f"OR SequenceMatcher ratio >= {MIN_RATIO}"
345
+ )
346
+ for r in fails:
347
+ lines.append(
348
+ f" - {r['master']} entry[{r['entry_idx']}] source_ref="
349
+ f"{r['source_ref']}: no high-similarity match "
350
+ f"(best file={r['best_file']}, lcs={r['best_lcs']} "
351
+ f"need {r['needed_lcs']}, ratio={r['best_ratio']})"
352
+ )
353
+ return "\n".join(lines), len(fails)
354
+
355
+
356
+ def main() -> int:
357
+ parser = argparse.ArgumentParser(
358
+ description="Validate lore_triggers content against sources excerpts."
359
+ )
360
+ parser.add_argument("--master", type=str, help="Only check one master dir")
361
+ parser.add_argument(
362
+ "--strict",
363
+ action="store_true",
364
+ help="Exit non-zero on any FAIL (default: advisory, always exit 0)",
365
+ )
366
+ parser.add_argument("--json", action="store_true", help="JSON output")
367
+ args = parser.parse_args()
368
+
369
+ results = validate(PREBUILT_DIR, master_filter=args.master)
370
+
371
+ if args.json:
372
+ print(json.dumps({"results": results}, ensure_ascii=False, indent=2))
373
+ else:
374
+ text, _ = _format_human(results)
375
+ print(text)
376
+
377
+ fails = [r for r in results if not r["passed"]]
378
+ if fails:
379
+ if args.strict:
380
+ return 1
381
+ # Advisory mode: print a banner and exit 0 so CI does not block.
382
+ if not args.json:
383
+ print()
384
+ print(
385
+ "ADVISORY: lore_triggers content validator is in advisory "
386
+ "mode for v0.8.x. It will become a hard CI gate in v0.9. "
387
+ "Rerun with --strict locally to reproduce a hard failure."
388
+ )
389
+ return 0
390
+
391
+
392
+ if __name__ == "__main__":
393
+ sys.exit(main())
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env python3
2
+ """Validate the persona-fidelity schema across master meta.json files.
3
+
4
+ v0.8 introduces three new fields on each single-master meta.json:
5
+
6
+ - signature_phrases (required) : list[str], 3-7 non-blank entries — anchors
7
+ used by fidelity tests to detect off-voice drift.
8
+ - style (required) : dict with exactly three string keys
9
+ {all, qa, monologue}, each 30-80 chars (zh-Hans expected).
10
+ - lore_triggers (optional) : list[dict] of conditional snippet
11
+ injections. Each entry needs `keys` (non-empty list) + `content`
12
+ (80-300 chars) + `source_ref` (must resolve to a real id in this
13
+ master's own sources[].id, optionally with a `#anchor` suffix).
14
+ If `secondary_keys` is present, `selective` must be true.
15
+
16
+ Meta-skills (compare-masters / master-debate / master-curriculum) carry
17
+ no meta.json so they are naturally skipped.
18
+
19
+ Usage:
20
+ python3 scripts/validate-persona-fidelity.py
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ PREBUILT_DIR = Path(__file__).resolve().parent.parent / "prebuilt"
29
+
30
+ SIGNATURE_MIN = 3
31
+ SIGNATURE_MAX = 7
32
+
33
+ STYLE_KEYS = {"all", "qa", "monologue"}
34
+ STYLE_MIN = 30
35
+ STYLE_MAX = 80
36
+
37
+ LORE_CONTENT_MIN = 80
38
+ LORE_CONTENT_MAX = 300
39
+ LORE_REQUIRED_KEYS = {"keys", "content", "source_ref"}
40
+ LORE_OPTIONAL_KEYS = {"secondary_keys", "selective"}
41
+
42
+
43
+ def _load(meta_path: Path) -> dict:
44
+ try:
45
+ return json.loads(meta_path.read_text(encoding="utf-8"))
46
+ except json.JSONDecodeError:
47
+ return {}
48
+
49
+
50
+ def _check_signature_phrases(slug: str, data: dict) -> list[str]:
51
+ errors: list[str] = []
52
+ if "signature_phrases" not in data:
53
+ return [f"{slug}: missing required field 'signature_phrases'"]
54
+ phrases = data["signature_phrases"]
55
+ if not isinstance(phrases, list):
56
+ return [f"{slug}: signature_phrases must be a list, got {type(phrases).__name__}"]
57
+ n = len(phrases)
58
+ if n < SIGNATURE_MIN or n > SIGNATURE_MAX:
59
+ errors.append(
60
+ f"{slug}: signature_phrases length {n} out of "
61
+ f"[{SIGNATURE_MIN}, {SIGNATURE_MAX}]"
62
+ )
63
+ for i, p in enumerate(phrases):
64
+ if not isinstance(p, str) or not p.strip():
65
+ errors.append(f"{slug}: signature_phrases[{i}] must be a non-blank string")
66
+ return errors
67
+
68
+
69
+ def _check_style(slug: str, data: dict) -> list[str]:
70
+ errors: list[str] = []
71
+ if "style" not in data:
72
+ return [f"{slug}: missing required field 'style'"]
73
+ style = data["style"]
74
+ if not isinstance(style, dict):
75
+ return [f"{slug}: style must be an object, got {type(style).__name__}"]
76
+ keys = set(style.keys())
77
+ missing = STYLE_KEYS - keys
78
+ extra = keys - STYLE_KEYS
79
+ for k in sorted(missing):
80
+ errors.append(f"{slug}: style missing required key '{k}'")
81
+ for k in sorted(extra):
82
+ errors.append(f"{slug}: style has unexpected key '{k}'")
83
+ for k in sorted(STYLE_KEYS & keys):
84
+ v = style[k]
85
+ if not isinstance(v, str):
86
+ errors.append(
87
+ f"{slug}: style.{k} must be a string, got {type(v).__name__}"
88
+ )
89
+ continue
90
+ length = len(v)
91
+ if length < STYLE_MIN or length > STYLE_MAX:
92
+ errors.append(
93
+ f"{slug}: style.{k} length {length} out of [{STYLE_MIN}, {STYLE_MAX}]"
94
+ )
95
+ return errors
96
+
97
+
98
+ def _check_lore_triggers(slug: str, data: dict, source_ids: set[str]) -> list[str]:
99
+ errors: list[str] = []
100
+ if "lore_triggers" not in data:
101
+ return errors
102
+ triggers = data["lore_triggers"]
103
+ if not isinstance(triggers, list):
104
+ return [f"{slug}: lore_triggers must be a list, got {type(triggers).__name__}"]
105
+ for i, entry in enumerate(triggers):
106
+ prefix = f"{slug}.lore_triggers[{i}]"
107
+ if not isinstance(entry, dict):
108
+ errors.append(f"{prefix}: entry must be an object")
109
+ continue
110
+ # required keys
111
+ for k in sorted(LORE_REQUIRED_KEYS):
112
+ if k not in entry:
113
+ errors.append(f"{prefix}: missing required key '{k}'")
114
+ # unknown keys (allow required + optional)
115
+ for k in sorted(entry.keys() - LORE_REQUIRED_KEYS - LORE_OPTIONAL_KEYS):
116
+ errors.append(f"{prefix}: unexpected key '{k}'")
117
+ # keys
118
+ keys = entry.get("keys")
119
+ if keys is not None:
120
+ if not isinstance(keys, list) or not keys:
121
+ errors.append(f"{prefix}: keys must be a non-empty list")
122
+ else:
123
+ for j, k in enumerate(keys):
124
+ if not isinstance(k, str) or not k.strip():
125
+ errors.append(
126
+ f"{prefix}: keys[{j}] must be a non-blank string"
127
+ )
128
+ # secondary_keys + selective
129
+ sec = entry.get("secondary_keys")
130
+ if sec is not None:
131
+ if not isinstance(sec, list):
132
+ errors.append(f"{prefix}: secondary_keys must be a list")
133
+ else:
134
+ for j, k in enumerate(sec):
135
+ if not isinstance(k, str) or not k.strip():
136
+ errors.append(
137
+ f"{prefix}: secondary_keys[{j}] must be a non-blank string"
138
+ )
139
+ if entry.get("selective") is not True:
140
+ errors.append(
141
+ f"{prefix}: secondary_keys requires 'selective: true'"
142
+ )
143
+ # selective standalone
144
+ if "selective" in entry and not isinstance(entry["selective"], bool):
145
+ errors.append(f"{prefix}: selective must be a boolean")
146
+ # content
147
+ content = entry.get("content")
148
+ if content is not None:
149
+ if not isinstance(content, str):
150
+ errors.append(f"{prefix}: content must be a string")
151
+ else:
152
+ length = len(content)
153
+ if length < LORE_CONTENT_MIN or length > LORE_CONTENT_MAX:
154
+ errors.append(
155
+ f"{prefix}: content length {length} out of "
156
+ f"[{LORE_CONTENT_MIN}, {LORE_CONTENT_MAX}]"
157
+ )
158
+ # source_ref
159
+ source_ref = entry.get("source_ref")
160
+ if source_ref is not None:
161
+ if not isinstance(source_ref, str) or not source_ref.strip():
162
+ errors.append(f"{prefix}: source_ref must be a non-blank string")
163
+ else:
164
+ # Strip optional anchor (e.g. "T48n2008#般若品")
165
+ base = source_ref.split("#", 1)[0].strip()
166
+ if base not in source_ids:
167
+ errors.append(
168
+ f"{prefix}: source_ref '{source_ref}' not found in "
169
+ f"{slug}'s sources[].id"
170
+ )
171
+ return errors
172
+
173
+
174
+ def validate(prebuilt: Path) -> list[str]:
175
+ """Run all persona-fidelity checks over the given prebuilt tree.
176
+
177
+ Meta-skills (`kind: "meta-skill"`) carry their own protocol meta.json
178
+ (e.g. master-debate's `debate_protocol` block) and are not single-master
179
+ personas — they are skipped.
180
+ """
181
+ errors: list[str] = []
182
+ for meta_path in sorted(prebuilt.glob("master-*/meta.json")):
183
+ slug = meta_path.parent.name.removeprefix("master-")
184
+ data = _load(meta_path)
185
+ # Skip meta-skills (debate, curriculum, etc.) — they don't carry persona fields.
186
+ if data.get("kind") == "meta-skill":
187
+ continue
188
+ source_ids = {
189
+ s.get("id") for s in data.get("sources", []) if isinstance(s, dict)
190
+ }
191
+ source_ids.discard(None)
192
+ errors.extend(_check_signature_phrases(slug, data))
193
+ errors.extend(_check_style(slug, data))
194
+ errors.extend(_check_lore_triggers(slug, data, source_ids))
195
+ return errors
196
+
197
+
198
+ def main() -> int:
199
+ errors = validate(PREBUILT_DIR)
200
+ if errors:
201
+ print(f"{len(errors)} persona-fidelity error(s):")
202
+ for e in errors:
203
+ print(f" ERROR: {e}")
204
+ return 1
205
+ print("persona-fidelity OK")
206
+ return 0
207
+
208
+
209
+ if __name__ == "__main__":
210
+ sys.exit(main())