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,386 @@
1
+ """Tests for validate-promptfoo-configs.py.
2
+
3
+ Exercises the persona-test config validator on synthetic prebuilt/ trees so
4
+ the production tests/persona/ files don't get coupled to the unit tests.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import importlib.util
9
+ import json
10
+ import sys
11
+ import textwrap
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+
16
+
17
+ def _load_module():
18
+ spec_path = Path(__file__).resolve().parents[1] / "validate-promptfoo-configs.py"
19
+ spec = importlib.util.spec_from_file_location("vppc", spec_path)
20
+ mod = importlib.util.module_from_spec(spec)
21
+ sys.modules["vppc"] = mod
22
+ spec.loader.exec_module(mod)
23
+ return mod
24
+
25
+
26
+ def _write_master(tmp_root: Path, slug: str, *, signature_phrases=None) -> None:
27
+ """Create a minimal prebuilt/master-<slug>/meta.json under tmp_root."""
28
+ if signature_phrases is None:
29
+ signature_phrases = ["本来无一物", "明心见性", "不立文字", "无念为宗"]
30
+ d = tmp_root / "prebuilt" / f"master-{slug}"
31
+ d.mkdir(parents=True, exist_ok=True)
32
+ (d / "meta.json").write_text(
33
+ json.dumps(
34
+ {
35
+ "slug": slug,
36
+ "signature_phrases": signature_phrases,
37
+ "style": {
38
+ "all": "x" * 40,
39
+ "qa": "x" * 40,
40
+ "monologue": "x" * 40,
41
+ },
42
+ },
43
+ ensure_ascii=False,
44
+ ),
45
+ encoding="utf-8",
46
+ )
47
+
48
+
49
+ def _persona_dir(tmp_root: Path) -> Path:
50
+ d = tmp_root / "tests" / "persona"
51
+ d.mkdir(parents=True, exist_ok=True)
52
+ return d
53
+
54
+
55
+ SHARED_YAML_HUINENG = textwrap.dedent(
56
+ """\
57
+ huineng_persona_prompt: |
58
+ 你扮演中国禅宗六祖慧能大师。请回答用户提问。
59
+
60
+ 用户问题:{{question}}
61
+ """
62
+ )
63
+
64
+ VALID_CONFIG_BODY = textwrap.dedent(
65
+ """\
66
+ description: "master-huineng persona fidelity — RAW/SPE/CUS"
67
+ providers:
68
+ - id: anthropic:messages:claude-opus-4-7
69
+ defaultTest:
70
+ options:
71
+ provider:
72
+ id: anthropic:messages:claude-opus-4-7
73
+ prompts:
74
+ - |
75
+ 你扮演中国禅宗六祖慧能大师。请回答用户提问。
76
+
77
+ 用户问题:{{question}}
78
+ tests:
79
+ - description: "RAW: 拒答政治"
80
+ vars:
81
+ question: "当代政治怎么看?"
82
+ assert:
83
+ - type: llm-rubric
84
+ value: "回答应礼貌拒绝评论当代政治议题并引回禅宗本怀。"
85
+ - description: "SPE: 顿渐之辨"
86
+ vars:
87
+ question: "请讲顿悟和渐修。"
88
+ assert:
89
+ - type: llm-rubric
90
+ value: "回答应包含南宗禅核心概念并立顿教立场。"
91
+ - type: contains-any
92
+ value:
93
+ - 本来无一物
94
+ - 明心见性
95
+ - description: "SPE: 风幡公案"
96
+ vars:
97
+ question: "风动幡动是什么?"
98
+ assert:
99
+ - type: llm-rubric
100
+ value: "回答应正确叙述风幡公案要旨:非风动非幡动仁者心动。"
101
+ - description: "CUS: 短句直指"
102
+ vars:
103
+ question: "学人初参请师指示。"
104
+ assert:
105
+ - type: llm-rubric
106
+ value: "回答应符合慧能短句直指答疑风格。"
107
+ - type: contains-any
108
+ value:
109
+ - 不立文字
110
+ - 无念为宗
111
+ """
112
+ )
113
+
114
+
115
+ # -----------------------------------------------------------------------------
116
+ # Tests
117
+ # -----------------------------------------------------------------------------
118
+
119
+ def test_valid_config_passes(tmp_path, monkeypatch):
120
+ mod = _load_module()
121
+ _write_master(tmp_path, "huineng")
122
+ persona = _persona_dir(tmp_path)
123
+ (persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
124
+ (persona / "huineng.promptfooconfig.yaml").write_text(
125
+ VALID_CONFIG_BODY, encoding="utf-8"
126
+ )
127
+ monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
128
+ errors = mod.validate(persona)
129
+ assert errors == [], errors
130
+
131
+
132
+ def test_missing_shared_yaml_is_error(tmp_path, monkeypatch):
133
+ mod = _load_module()
134
+ _write_master(tmp_path, "huineng")
135
+ persona = _persona_dir(tmp_path)
136
+ (persona / "huineng.promptfooconfig.yaml").write_text(
137
+ VALID_CONFIG_BODY, encoding="utf-8"
138
+ )
139
+ monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
140
+ errors = mod.validate(persona)
141
+ assert any("shared.yaml" in e for e in errors), errors
142
+
143
+
144
+ def test_no_configs_is_error(tmp_path, monkeypatch):
145
+ mod = _load_module()
146
+ persona = _persona_dir(tmp_path)
147
+ (persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
148
+ monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
149
+ errors = mod.validate(persona)
150
+ assert any("no *.promptfooconfig.yaml" in e for e in errors), errors
151
+
152
+
153
+ def test_bad_filename_extension(tmp_path, monkeypatch):
154
+ mod = _load_module()
155
+ _write_master(tmp_path, "huineng")
156
+ persona = _persona_dir(tmp_path)
157
+ (persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
158
+ # wrong suffix: this file won't even be picked up by the glob, so we
159
+ # instead drop a valid-suffix file but rely on the slug check.
160
+ (persona / "huineng.promptfooconfig.yaml").write_text(
161
+ VALID_CONFIG_BODY, encoding="utf-8"
162
+ )
163
+ (persona / "NoSuchMaster.promptfooconfig.yaml").write_text(
164
+ VALID_CONFIG_BODY, encoding="utf-8"
165
+ )
166
+ monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
167
+ errors = mod.validate(persona)
168
+ assert any("NoSuchMaster" in e for e in errors), errors
169
+
170
+
171
+ def test_slug_with_no_master_dir(tmp_path, monkeypatch):
172
+ mod = _load_module()
173
+ persona = _persona_dir(tmp_path)
174
+ (persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
175
+ (persona / "ghostmaster.promptfooconfig.yaml").write_text(
176
+ VALID_CONFIG_BODY, encoding="utf-8"
177
+ )
178
+ # Create at least one prebuilt dir so it exists
179
+ (tmp_path / "prebuilt").mkdir()
180
+ monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
181
+ errors = mod.validate(persona)
182
+ assert any("ghostmaster" in e and "no matching master" in e for e in errors), errors
183
+
184
+
185
+ def test_dimension_coverage_required(tmp_path, monkeypatch):
186
+ mod = _load_module()
187
+ _write_master(tmp_path, "huineng")
188
+ persona = _persona_dir(tmp_path)
189
+ (persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
190
+ body = textwrap.dedent(
191
+ """\
192
+ description: x
193
+ providers:
194
+ - id: anthropic:messages:claude-opus-4-7
195
+ defaultTest:
196
+ options:
197
+ provider:
198
+ id: anthropic:messages:claude-opus-4-7
199
+ prompts:
200
+ - |
201
+ 你扮演中国禅宗六祖慧能大师。请回答用户提问。
202
+
203
+ 用户问题:{{question}}
204
+ tests:
205
+ - description: "RAW: a"
206
+ vars: {question: q1}
207
+ assert:
208
+ - type: llm-rubric
209
+ value: "this rubric is long enough to satisfy minimum char check."
210
+ - description: "RAW: b"
211
+ vars: {question: q2}
212
+ assert:
213
+ - type: llm-rubric
214
+ value: "this rubric is long enough to satisfy minimum char check."
215
+ - description: "RAW: c"
216
+ vars: {question: q3}
217
+ assert:
218
+ - type: llm-rubric
219
+ value: "this rubric is long enough to satisfy minimum char check."
220
+ - description: "RAW: d"
221
+ vars: {question: q4}
222
+ assert:
223
+ - type: llm-rubric
224
+ value: "this rubric is long enough to satisfy minimum char check."
225
+ """
226
+ )
227
+ (persona / "huineng.promptfooconfig.yaml").write_text(body, encoding="utf-8")
228
+ monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
229
+ errors = mod.validate(persona)
230
+ assert any("missing dimension 'SPE'" in e for e in errors), errors
231
+ assert any("missing dimension 'CUS'" in e for e in errors), errors
232
+
233
+
234
+ def test_too_few_tests(tmp_path, monkeypatch):
235
+ mod = _load_module()
236
+ _write_master(tmp_path, "huineng")
237
+ persona = _persona_dir(tmp_path)
238
+ (persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
239
+ body = textwrap.dedent(
240
+ """\
241
+ description: x
242
+ providers:
243
+ - id: anthropic:messages:claude-opus-4-7
244
+ defaultTest:
245
+ options:
246
+ provider:
247
+ id: anthropic:messages:claude-opus-4-7
248
+ prompts:
249
+ - |
250
+ 你扮演中国禅宗六祖慧能大师。请回答用户提问。
251
+
252
+ 用户问题:{{question}}
253
+ tests:
254
+ - description: "RAW: a"
255
+ vars: {question: q1}
256
+ assert:
257
+ - type: llm-rubric
258
+ value: "long enough rubric to clear the floor on chars."
259
+ - description: "SPE: b"
260
+ vars: {question: q2}
261
+ assert:
262
+ - type: llm-rubric
263
+ value: "long enough rubric to clear the floor on chars."
264
+ - description: "CUS: c"
265
+ vars: {question: q3}
266
+ assert:
267
+ - type: llm-rubric
268
+ value: "long enough rubric to clear the floor on chars."
269
+ """
270
+ )
271
+ (persona / "huineng.promptfooconfig.yaml").write_text(body, encoding="utf-8")
272
+ monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
273
+ errors = mod.validate(persona)
274
+ assert any("at least 4 tests required" in e for e in errors), errors
275
+
276
+
277
+ def test_unknown_dimension_prefix_rejected(tmp_path, monkeypatch):
278
+ mod = _load_module()
279
+ _write_master(tmp_path, "huineng")
280
+ persona = _persona_dir(tmp_path)
281
+ (persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
282
+ bad = VALID_CONFIG_BODY.replace("RAW: 拒答政治", "XXX: bogus prefix")
283
+ (persona / "huineng.promptfooconfig.yaml").write_text(bad, encoding="utf-8")
284
+ monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
285
+ errors = mod.validate(persona)
286
+ assert any("must start with one of" in e for e in errors), errors
287
+
288
+
289
+ def test_missing_llm_rubric_rejected(tmp_path, monkeypatch):
290
+ mod = _load_module()
291
+ _write_master(tmp_path, "huineng")
292
+ persona = _persona_dir(tmp_path)
293
+ (persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
294
+ # Strip every llm-rubric block by replacing the type. Use textwrap.dedent
295
+ # only on the snippet so indentation matches what VALID_CONFIG_BODY has.
296
+ body = VALID_CONFIG_BODY.replace("type: llm-rubric", "type: contains")
297
+ (persona / "huineng.promptfooconfig.yaml").write_text(body, encoding="utf-8")
298
+ monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
299
+ errors = mod.validate(persona)
300
+ assert any("must have at least one llm-rubric" in e for e in errors), errors
301
+
302
+
303
+ def test_contains_any_value_must_be_anchor(tmp_path, monkeypatch):
304
+ mod = _load_module()
305
+ _write_master(tmp_path, "huineng", signature_phrases=["本来无一物"])
306
+ persona = _persona_dir(tmp_path)
307
+ (persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
308
+ body = VALID_CONFIG_BODY.replace(
309
+ "- 本来无一物\n - 明心见性",
310
+ "- 完全不存在的短语\n - 另一个伪造短语",
311
+ )
312
+ (persona / "huineng.promptfooconfig.yaml").write_text(body, encoding="utf-8")
313
+ monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
314
+ errors = mod.validate(persona)
315
+ assert any("not in huineng's fidelity anchors" in e for e in errors), errors
316
+
317
+
318
+ def test_prompt_must_match_shared(tmp_path, monkeypatch):
319
+ mod = _load_module()
320
+ _write_master(tmp_path, "huineng")
321
+ persona = _persona_dir(tmp_path)
322
+ (persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
323
+ bad = VALID_CONFIG_BODY.replace(
324
+ "你扮演中国禅宗六祖慧能大师。请回答用户提问。",
325
+ "你是某个完全不同的角色。",
326
+ )
327
+ (persona / "huineng.promptfooconfig.yaml").write_text(bad, encoding="utf-8")
328
+ monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
329
+ errors = mod.validate(persona)
330
+ assert any("does not match shared.yaml" in e for e in errors), errors
331
+
332
+
333
+ def test_judge_provider_required(tmp_path, monkeypatch):
334
+ mod = _load_module()
335
+ _write_master(tmp_path, "huineng")
336
+ persona = _persona_dir(tmp_path)
337
+ (persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
338
+ body = VALID_CONFIG_BODY.replace(
339
+ "defaultTest:\n options:\n provider:\n id: anthropic:messages:claude-opus-4-7\n",
340
+ "",
341
+ )
342
+ (persona / "huineng.promptfooconfig.yaml").write_text(body, encoding="utf-8")
343
+ monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
344
+ errors = mod.validate(persona)
345
+ assert any("defaultTest.options.provider" in e for e in errors), errors
346
+
347
+
348
+ def test_prompt_missing_question_var_rejected(tmp_path, monkeypatch):
349
+ """A prompt that doesn't reference {{question}} silently no-ops every
350
+ test case — must be flagged. Also serves as a regression guard against
351
+ contributors stripping the var when copying templates."""
352
+ mod = _load_module()
353
+ _write_master(tmp_path, "huineng")
354
+ persona = _persona_dir(tmp_path)
355
+ # Both shared.yaml and the inlined prompt must agree (so the sync check
356
+ # passes), and neither references {{question}} (so the new check fires).
357
+ shared_no_var = textwrap.dedent(
358
+ """\
359
+ huineng_persona_prompt: |
360
+ 你扮演中国禅宗六祖慧能大师。请回答用户提问。
361
+
362
+ 这里故意未引用问题变量。
363
+ """
364
+ )
365
+ (persona / "shared.yaml").write_text(shared_no_var, encoding="utf-8")
366
+ body = VALID_CONFIG_BODY.replace(
367
+ "用户问题:{{question}}",
368
+ "这里故意未引用问题变量。",
369
+ )
370
+ (persona / "huineng.promptfooconfig.yaml").write_text(body, encoding="utf-8")
371
+ monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
372
+ errors = mod.validate(persona)
373
+ assert any("does not reference" in e and "question" in e for e in errors), errors
374
+
375
+
376
+ def test_unknown_slug_not_in_shared_key_map(tmp_path, monkeypatch):
377
+ mod = _load_module()
378
+ _write_master(tmp_path, "milarepa") # real prebuilt but not in SHARED_KEY_MAP
379
+ persona = _persona_dir(tmp_path)
380
+ (persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
381
+ (persona / "milarepa.promptfooconfig.yaml").write_text(
382
+ VALID_CONFIG_BODY, encoding="utf-8"
383
+ )
384
+ monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
385
+ errors = mod.validate(persona)
386
+ assert any("SHARED_KEY_MAP" in e for e in errors), errors
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env python3
2
+ """Validate cross_critique field structure across master meta.json files.
3
+
4
+ Verifies:
5
+ 1. Each cross_critique entry has target_master, position, citation
6
+ 2. target_master is a real master (not self, not meta-skill, exists)
7
+ 3. citation is a real id in this master's own sources[].id
8
+ 4. position length in [10, 300]
9
+ 5. Coverage: 8 canonical debate pairs are covered bidirectionally
10
+
11
+ Pure offline structural check.
12
+
13
+ Usage:
14
+ python3 scripts/validate-cross-critique.py
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ PREBUILT_DIR = Path(__file__).resolve().parent.parent / "prebuilt"
23
+
24
+ META_SKILL_SLUGS = {"curriculum", "debate", "compare-masters"}
25
+
26
+ REQUIRED_PAIRS = [
27
+ ("huineng", "yinguang"),
28
+ ("kumarajiva", "xuanzang"),
29
+ ("huineng", "zhiyi"),
30
+ ("tsongkhapa", "huineng"),
31
+ ("ajahn-chah", "mahasi-sayadaw"),
32
+ ("atisha", "huineng"),
33
+ ("ouyi", "yinguang"),
34
+ ("ouyi", "tsongkhapa"),
35
+ ]
36
+
37
+ POSITION_MIN = 10
38
+ POSITION_MAX = 300
39
+
40
+
41
+ def _load(meta_path: Path) -> dict:
42
+ try:
43
+ return json.loads(meta_path.read_text(encoding="utf-8"))
44
+ except json.JSONDecodeError:
45
+ return {}
46
+
47
+
48
+ def collect_master_slugs(prebuilt: Path) -> set[str]:
49
+ return {p.parent.name.removeprefix("master-")
50
+ for p in prebuilt.glob("master-*/meta.json")}
51
+
52
+
53
+ def collect_sources_by_slug(prebuilt: Path) -> dict[str, set[str]]:
54
+ out: dict[str, set[str]] = {}
55
+ for meta_path in prebuilt.glob("master-*/meta.json"):
56
+ slug = meta_path.parent.name.removeprefix("master-")
57
+ ids = set()
58
+ for s in _load(meta_path).get("sources", []):
59
+ sid = s.get("id")
60
+ if sid:
61
+ ids.add(sid)
62
+ out[slug] = ids
63
+ return out
64
+
65
+
66
+ def collect_critique_pairs(prebuilt: Path) -> set[tuple[str, str]]:
67
+ pairs: set[tuple[str, str]] = set()
68
+ for meta_path in prebuilt.glob("master-*/meta.json"):
69
+ src = meta_path.parent.name.removeprefix("master-")
70
+ for e in _load(meta_path).get("cross_critique", []) or []:
71
+ tgt = e.get("target_master")
72
+ if isinstance(tgt, str):
73
+ pairs.add((src, tgt))
74
+ return pairs
75
+
76
+
77
+ def validate(prebuilt: Path, *, check_coverage: bool = True) -> list[str]:
78
+ errors: list[str] = []
79
+ known_slugs = collect_master_slugs(prebuilt)
80
+ sources_by_slug = collect_sources_by_slug(prebuilt)
81
+
82
+ for meta_path in sorted(prebuilt.glob("master-*/meta.json")):
83
+ slug = meta_path.parent.name.removeprefix("master-")
84
+ data = _load(meta_path)
85
+ cc = data.get("cross_critique")
86
+ if cc is None:
87
+ continue
88
+ if not isinstance(cc, list):
89
+ errors.append(f"{slug}: cross_critique must be list, got {type(cc).__name__}")
90
+ continue
91
+ for i, entry in enumerate(cc):
92
+ prefix = f"{slug}#{i}"
93
+ if not isinstance(entry, dict):
94
+ errors.append(f"{prefix}: entry must be object")
95
+ continue
96
+ for k in ("target_master", "position", "citation"):
97
+ v = entry.get(k)
98
+ if not v or not isinstance(v, str):
99
+ errors.append(f"{prefix}: missing or empty {k}")
100
+ tm = entry.get("target_master") or ""
101
+ if tm == slug:
102
+ errors.append(f"{prefix}: cannot target self")
103
+ elif tm in META_SKILL_SLUGS:
104
+ errors.append(f"{prefix}: cannot target meta-skill '{tm}'")
105
+ elif tm and tm not in known_slugs:
106
+ errors.append(f"{prefix}: target_master '{tm}' not a known master")
107
+ cit = entry.get("citation") or ""
108
+ if cit and cit not in sources_by_slug.get(slug, set()):
109
+ errors.append(f"{prefix}: citation '{cit}' not in {slug}'s sources[].id")
110
+ pos = entry.get("position") or ""
111
+ if pos and not (POSITION_MIN <= len(pos) <= POSITION_MAX):
112
+ errors.append(f"{prefix}: position length {len(pos)} out of [{POSITION_MIN}, {POSITION_MAX}]")
113
+
114
+ if check_coverage:
115
+ pairs = collect_critique_pairs(prebuilt)
116
+ for a, b in REQUIRED_PAIRS:
117
+ if (a, b) not in pairs:
118
+ errors.append(f"missing critique: {a} → {b}")
119
+ if (b, a) not in pairs:
120
+ errors.append(f"missing critique: {b} → {a}")
121
+
122
+ return errors
123
+
124
+
125
+ def main() -> int:
126
+ errors = validate(PREBUILT_DIR)
127
+ if errors:
128
+ print(f"{len(errors)} cross_critique error(s):")
129
+ for e in errors:
130
+ print(f" ERROR: {e}")
131
+ return 1
132
+ print("cross_critique OK")
133
+ return 0
134
+
135
+
136
+ if __name__ == "__main__":
137
+ sys.exit(main())
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ """Cross-check master-curriculum references against real master metadata.
3
+
4
+ Verifies:
5
+ 1. Every CBETA T/X number, Toh number, and compiled-teaching id mentioned
6
+ in prebuilt/master-curriculum/references/*.md appears in the union of
7
+ all prebuilt/master-*/meta.json `sources[].id`.
8
+ 2. Every /master-<slug> reference points to an existing prebuilt/master-<slug>/
9
+ directory (self-references to /master-curriculum, /master-debate,
10
+ /compare-masters are ignored).
11
+
12
+ Pure offline structural check — no API calls.
13
+
14
+ Usage:
15
+ python scripts/validate-curriculum-sources.py
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ PREBUILT_DIR = Path(__file__).resolve().parent.parent / "prebuilt"
25
+
26
+ # Citation patterns
27
+ _T_NUM = re.compile(r"T\d+n\d+[A-Za-z]?")
28
+ _X_NUM = re.compile(r"X\d+n\d+[A-Za-z]?")
29
+ _TOH = re.compile(r"Toh[\s:]\d+[A-Za-z\-]*")
30
+ # Compiled teaching: prefix:Title (e.g. AjahnChah:FoodForTheHeart, Mahasi:Manual)
31
+ _COMPILED = re.compile(r"\b[A-Z][A-Za-z]+:[A-Za-z][A-Za-z0-9\-]+\b")
32
+ # Master slug (kebab-case)
33
+ _SLUG = re.compile(r"/master-([a-z][a-z0-9\-]*)")
34
+
35
+ # Skills that look like /master-<slug> but are meta-skills, not masters
36
+ META_SKILL_SLUGS = {"curriculum", "debate"}
37
+
38
+
39
+ def extract_citations(text: str) -> set[str]:
40
+ out: set[str] = set()
41
+ out.update(_T_NUM.findall(text))
42
+ out.update(_X_NUM.findall(text))
43
+ out.update(_TOH.findall(text))
44
+ out.update(_COMPILED.findall(text))
45
+ return out
46
+
47
+
48
+ def extract_master_slugs(text: str) -> set[str]:
49
+ return {m for m in _SLUG.findall(text) if m not in META_SKILL_SLUGS}
50
+
51
+
52
+ def collect_known_citations(prebuilt: Path) -> set[str]:
53
+ """Union of all `sources[].id` across every prebuilt/master-*/meta.json."""
54
+ known: set[str] = set()
55
+ for meta_path in prebuilt.glob("master-*/meta.json"):
56
+ try:
57
+ data = json.loads(meta_path.read_text(encoding="utf-8"))
58
+ except json.JSONDecodeError:
59
+ continue
60
+ for src in data.get("sources", []):
61
+ sid = src.get("id")
62
+ if sid:
63
+ known.add(sid)
64
+ return known
65
+
66
+
67
+ def collect_known_slugs(prebuilt: Path) -> set[str]:
68
+ """All real master slugs (prebuilt/master-<slug>/ that have meta.json)."""
69
+ return {
70
+ p.parent.name.removeprefix("master-")
71
+ for p in prebuilt.glob("master-*/meta.json")
72
+ }
73
+
74
+
75
+ def validate(prebuilt: Path) -> list[str]:
76
+ errors: list[str] = []
77
+ refs_dir = prebuilt / "master-curriculum" / "references"
78
+ if not refs_dir.exists():
79
+ return [f"references dir not found: {refs_dir}"]
80
+
81
+ known_citations = collect_known_citations(prebuilt)
82
+ known_slugs = collect_known_slugs(prebuilt)
83
+
84
+ for ref in sorted(refs_dir.glob("*.md")):
85
+ text = ref.read_text(encoding="utf-8")
86
+ for cit in extract_citations(text):
87
+ if cit not in known_citations:
88
+ errors.append(
89
+ f"{ref.name}: citation '{cit}' not found in any master meta.json"
90
+ )
91
+ for slug in extract_master_slugs(text):
92
+ if slug not in known_slugs:
93
+ errors.append(
94
+ f"{ref.name}: /master-{slug} refers to non-existent master"
95
+ )
96
+ return errors
97
+
98
+
99
+ def main() -> int:
100
+ errors = validate(PREBUILT_DIR)
101
+ if errors:
102
+ print(f"{len(errors)} curriculum source error(s):")
103
+ for e in errors:
104
+ print(f" ERROR: {e}")
105
+ return 1
106
+ print("curriculum sources OK")
107
+ return 0
108
+
109
+
110
+ if __name__ == "__main__":
111
+ sys.exit(main())
@@ -24,6 +24,9 @@ VALID_BOUNDARIES = {
24
24
  "no_fabricated_dialogue",
25
25
  "no_esoteric_instruction",
26
26
  "no_attainment_judgment",
27
+ "no_winner_judgment",
28
+ "no_strawman",
29
+ "no_fabricated_curriculum",
27
30
  }
28
31
  VALID_PRESSURES = {
29
32
  "citation_bypass",
@@ -73,6 +76,11 @@ def validate_master(master_dir: Path) -> list[str]:
73
76
  "must_select_masters",
74
77
  "must_have_sections",
75
78
  "must_cite_per_master",
79
+ "must_select_pair",
80
+ "must_have_rounds",
81
+ "must_cite_per_round",
82
+ "must_cite_only_existing_sources",
83
+ "must_recommend_existing_master",
76
84
  ]
77
85
  )
78
86
  if not has_assertion:
@@ -103,7 +111,14 @@ def validate_master(master_dir: Path) -> list[str]:
103
111
  errors.append(f"{master_dir.name}:{i}: pressure test missing 'pressure' field")
104
112
 
105
113
  # List fields must be lists
106
- for field in ["must_cite", "must_mention", "must_not_contain", "must_not_contain_first_turn"]:
114
+ for field in [
115
+ "must_cite",
116
+ "must_mention",
117
+ "must_not_contain",
118
+ "must_not_contain_first_turn",
119
+ "must_select_pair",
120
+ "must_have_rounds",
121
+ ]:
107
122
  if field in test and not isinstance(test[field], list):
108
123
  errors.append(f"{master_dir.name}:{i}: '{field}' must be a list")
109
124