master-skill 0.10.1 → 0.11.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 (35) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.cursor-plugin/plugin.json +1 -1
  4. package/GEMINI.md +1 -1
  5. package/README.md +52 -297
  6. package/README_EN.md +52 -278
  7. package/bin/cli.mjs +237 -2
  8. package/gemini-extension.json +1 -1
  9. package/hooks/session-start +4 -1
  10. package/package.json +3 -2
  11. package/prebuilt/master-curriculum/SKILL.md +1 -1
  12. package/prebuilt/master-debate/SKILL.md +1 -1
  13. package/prebuilt/master-help/SKILL.md +86 -0
  14. package/prebuilt/master-help/tests/fidelity.jsonl +10 -0
  15. package/prebuilt/master-kumarajiva/meta.json +14 -3
  16. package/prebuilt/master-nagarjuna/meta.json +19 -4
  17. package/prebuilt/master-tsongkhapa/meta.json +26 -5
  18. package/references/teaching-modes.md +8 -1
  19. package/routing.json +209 -0
  20. package/scripts/check-gate-liveness.py +222 -0
  21. package/scripts/test-fidelity.py +320 -49
  22. package/scripts/tests/test_check_gate_liveness.py +232 -0
  23. package/scripts/tests/test_check_response.py +190 -0
  24. package/scripts/tests/test_fidelity_providers.py +202 -0
  25. package/scripts/tests/test_select_fidelity_smoke.py +2 -2
  26. package/scripts/tests/test_validate.py +145 -0
  27. package/scripts/tests/test_validate_citation_contract.py +1 -1
  28. package/scripts/tests/test_validate_fidelity.py +2 -2
  29. package/scripts/tests/test_validate_workflow.py +21 -2
  30. package/scripts/validate-fidelity.py +6 -1
  31. package/scripts/validate-routing.py +254 -0
  32. package/scripts/validate.py +63 -36
  33. package/skill-catalog.json +83 -20
  34. /package/prebuilt/{compare → compare-masters}/SKILL.md +0 -0
  35. /package/prebuilt/{compare → compare-masters}/tests/fidelity.jsonl +0 -0
@@ -0,0 +1,145 @@
1
+ """Tests for scripts/validate.py — the strict gate `npm run validate` runs.
2
+
3
+ parse_frontmatter had no tests, which is how it kept only the last entry of
4
+ `sources:` and dropped every `cbeta_id`. The sources[] checks in lint_master
5
+ were reading a shape the file never had.
6
+ """
7
+
8
+ import importlib.util
9
+ from pathlib import Path
10
+
11
+ MODULE_PATH = Path(__file__).resolve().parents[1] / "validate.py"
12
+ SPEC = importlib.util.spec_from_file_location("validate_module", MODULE_PATH)
13
+ validate_module = importlib.util.module_from_spec(SPEC)
14
+ SPEC.loader.exec_module(validate_module)
15
+
16
+ parse_frontmatter = validate_module.parse_frontmatter
17
+
18
+ FRONTMATTER = """---
19
+ name: master-test
20
+ description: Test master.
21
+ version: 0.5.0
22
+ license: MIT
23
+ lineage: 测试宗
24
+ dates: 638-713
25
+ sources:
26
+ - title: 六祖大师法宝坛经
27
+ cbeta_id: T48n2008
28
+ fojin_text_id: 58
29
+ - title: 金刚般若波罗蜜经
30
+ cbeta_id: T08n0235
31
+ fojin_text_id: 7
32
+ - title: 维摩诘所说经
33
+ cbeta_id: T14n0475
34
+ fojin_text_id: 28
35
+ citation_format: "【《{title}》{section},{cbeta_id}】"
36
+ verified_by: xr843
37
+ verified_at: 2026-04-06
38
+ ---
39
+
40
+ # Body
41
+ """
42
+
43
+
44
+ def _write(tmp_path: Path, text: str) -> Path:
45
+ path = tmp_path / "SKILL.md"
46
+ path.write_text(text, encoding="utf-8")
47
+ return path
48
+
49
+
50
+ def test_parse_frontmatter_keeps_every_source(tmp_path):
51
+ """All three declared sources survive, not just the last one."""
52
+ fm, _, _ = parse_frontmatter(_write(tmp_path, FRONTMATTER))
53
+
54
+ titles = [s["title"] for s in fm["sources"]]
55
+ assert titles == ["六祖大师法宝坛经", "金刚般若波罗蜜经", "维摩诘所说经"]
56
+
57
+
58
+ def test_parse_frontmatter_keeps_continuation_keys(tmp_path):
59
+ """cbeta_id and fojin_text_id sit on continuation lines and must survive.
60
+
61
+ Every citation check downstream keys off cbeta_id; dropping it silently
62
+ empties the data the sources[] rules are supposed to inspect.
63
+ """
64
+ fm, _, _ = parse_frontmatter(_write(tmp_path, FRONTMATTER))
65
+
66
+ assert [s.get("cbeta_id") for s in fm["sources"]] == [
67
+ "T48n2008",
68
+ "T08n0235",
69
+ "T14n0475",
70
+ ]
71
+ assert fm["sources"][0].get("fojin_text_id") == 58
72
+
73
+
74
+ def test_parse_frontmatter_reads_scalar_keys(tmp_path):
75
+ """Scalar keys around the list keep working."""
76
+ fm, _, _ = parse_frontmatter(_write(tmp_path, FRONTMATTER))
77
+
78
+ assert fm["name"] == "master-test"
79
+ assert fm["lineage"] == "测试宗"
80
+ assert fm["citation_format"] == "【《{title}》{section},{cbeta_id}】"
81
+
82
+
83
+ def test_parse_frontmatter_surfaces_a_malformed_source_before_the_last(tmp_path):
84
+ """A source with neither title nor cbeta_id must reach lint_master.
85
+
86
+ lint_master flags `sources[i] missing 'title' or 'cbeta_id'`, but only
87
+ ever saw the final entry — a malformed one anywhere earlier was invisible.
88
+ """
89
+ text = FRONTMATTER.replace(
90
+ " - title: 六祖大师法宝坛经\n cbeta_id: T48n2008\n fojin_text_id: 58\n",
91
+ " - fojin_text_id: 58\n note: no title and no cbeta_id\n",
92
+ )
93
+ fm, _, _ = parse_frontmatter(_write(tmp_path, text))
94
+
95
+ assert len(fm["sources"]) == 3
96
+ bad = fm["sources"][0]
97
+ assert "title" not in bad and "cbeta_id" not in bad
98
+
99
+
100
+ def test_parse_frontmatter_without_frontmatter(tmp_path):
101
+ """A file with no frontmatter yields an empty dict, not a crash."""
102
+ fm, body, _ = parse_frontmatter(_write(tmp_path, "# Just a body\n"))
103
+
104
+ assert fm == {}
105
+ assert "Just a body" in body
106
+
107
+
108
+ def test_parse_frontmatter_rejects_invalid_yaml(tmp_path):
109
+ """Malformed frontmatter raises instead of being silently mis-parsed.
110
+
111
+ The old parser accepted anything, which let two skills ship a description
112
+ holding a bare `: ` — YAML reads that as a nested mapping and rejects the
113
+ whole block. Clients that parse frontmatter strictly see no frontmatter
114
+ at all, so this must fail loudly here.
115
+ """
116
+ text = "---\nname: master-test\ndescription: keyed on 时序: staged plan\n---\n"
117
+ try:
118
+ parse_frontmatter(_write(tmp_path, text))
119
+ except ValueError as exc:
120
+ assert "invalid YAML frontmatter" in str(exc)
121
+ else:
122
+ raise AssertionError("invalid YAML frontmatter was accepted")
123
+
124
+
125
+ def test_curriculum_sources_subcheck_is_wired_in():
126
+ """The curriculum gate must be reachable from validate.py.
127
+
128
+ master-curriculum/SKILL.md claims CI enforces it, but the script was in no
129
+ workflow, no npm script and no sub-check — only unit tests over synthetic
130
+ trees. It passed against the real references/ by luck, never by check.
131
+ """
132
+ assert hasattr(validate_module, "_run_curriculum_sources_subcheck")
133
+ assert validate_module._run_curriculum_sources_subcheck() == []
134
+
135
+
136
+ def test_every_prebuilt_skill_has_parseable_frontmatter():
137
+ """Every shipped SKILL.md must parse — this is the case that would have
138
+ caught master-curriculum and master-debate before they shipped."""
139
+ prebuilt = Path(__file__).resolve().parents[2] / "prebuilt"
140
+ skills = sorted(prebuilt.glob("*/SKILL.md"))
141
+ assert skills, f"no SKILL.md found under {prebuilt}"
142
+
143
+ for skill in skills:
144
+ fm, _, _ = parse_frontmatter(skill)
145
+ assert fm.get("name"), f"{skill.parent.name}: frontmatter has no name"
@@ -235,7 +235,7 @@ def test_repository_wording_uses_declared_source_contract():
235
235
  repository = Path(__file__).resolve().parents[2]
236
236
  runtime_paths = [
237
237
  repository / "SKILL.md",
238
- repository / "prebuilt" / "compare" / "SKILL.md",
238
+ repository / "prebuilt" / "compare-masters" / "SKILL.md",
239
239
  repository / "prompts" / "doctrine_reviewer.md",
240
240
  repository / "references" / "ethics-runtime.md",
241
241
  repository / "references" / "source-conventions.md",
@@ -20,7 +20,7 @@ def _write_fixture(tmp_path: Path, master_name: str, cases: list[dict]) -> Path:
20
20
  def test_compare_requires_framework_output_sections(tmp_path):
21
21
  master_dir = _write_fixture(
22
22
  tmp_path,
23
- "compare",
23
+ "compare-masters",
24
24
  [
25
25
  {
26
26
  "q": "禅和净怎么比较?",
@@ -52,7 +52,7 @@ def test_compare_accepts_required_framework_output_sections(tmp_path):
52
52
  "must_not_contain": ["更好"],
53
53
  }
54
54
  )
55
- master_dir = _write_fixture(tmp_path, "compare", cases)
55
+ master_dir = _write_fixture(tmp_path, "compare-masters", cases)
56
56
 
57
57
  errors = validate_fidelity.validate_master(master_dir)
58
58
 
@@ -140,11 +140,28 @@ def test_python39_job_compiles_and_runs_the_four_generator_cli_steps():
140
140
  _assert_hard(smoke)
141
141
 
142
142
 
143
+ def _covered(target: str, patterns: set[str]) -> bool:
144
+ """Whether a push-paths pattern set actually triggers on `target`.
145
+
146
+ The contract is "editing this file runs CI", not "this literal string
147
+ appears in the list" — so a broader glob that consolidates several entries
148
+ (docs/PRD.md + docs/v1-framework-roadmap.md -> docs/**) still satisfies it,
149
+ while dropping the coverage entirely still fails.
150
+ """
151
+ if target in patterns:
152
+ return True
153
+ return any(
154
+ target.startswith(pattern[: -len("**")])
155
+ for pattern in patterns
156
+ if pattern.endswith("**")
157
+ )
158
+
159
+
143
160
  def test_push_paths_include_distribution_and_generator_runtime():
144
161
  triggers = WORKFLOW.get("on", WORKFLOW.get(True))
145
162
  assert isinstance(triggers, dict)
146
163
  paths = set(triggers["push"]["paths"])
147
- assert {
164
+ required = {
148
165
  "skill-catalog.json",
149
166
  "SKILL.md",
150
167
  "references/**",
@@ -162,7 +179,9 @@ def test_push_paths_include_distribution_and_generator_runtime():
162
179
  "gemini-extension.json",
163
180
  ".github/PULL_REQUEST_TEMPLATE.md",
164
181
  ".github/ISSUE_TEMPLATE/**",
165
- } <= paths
182
+ }
183
+ uncovered = sorted(t for t in required if not _covered(t, paths))
184
+ assert not uncovered, f"push trigger does not cover: {uncovered}"
166
185
 
167
186
 
168
187
  def test_pick_step_uses_checked_selector_without_fixed_roster():
@@ -27,6 +27,11 @@ VALID_BOUNDARIES = {
27
27
  "no_winner_judgment",
28
28
  "no_strawman",
29
29
  "no_fabricated_curriculum",
30
+ # Router skills (/master-help) name a destination and stop. If a router
31
+ # answers the doctrinal question itself, it does so with none of the
32
+ # citation_contract / HARD-GATE machinery each persona carries — so
33
+ # "teaching instead of routing" is a boundary breach, not a shortcut.
34
+ "router_must_not_teach",
30
35
  }
31
36
  VALID_PRESSURES = {
32
37
  "citation_bypass",
@@ -133,7 +138,7 @@ def validate_master(master_dir: Path) -> list[str]:
133
138
  if field in test and not isinstance(test[field], list):
134
139
  errors.append(f"{master_dir.name}:{i}: '{field}' must be a list")
135
140
 
136
- if master_dir.name == "compare" and test_type not in {"boundary", "pressure"}:
141
+ if master_dir.name == "compare-masters" and test_type not in {"boundary", "pressure"}:
137
142
  sections = set(test.get("must_have_sections", []))
138
143
  missing = sorted(COMPARE_REQUIRED_SECTIONS - sections)
139
144
  if missing:
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env python3
2
+ """Validate routing.json — the machine-readable master/mode routing table.
3
+
4
+ `master-skill recommend` and the `/master-help` skill both route off this
5
+ file. It exists because the routing knowledge used to live only as prose:
6
+ a weighted-match paragraph and a 24-row pairing table inside
7
+ prebuilt/compare-masters/SKILL.md, plus a decision tree in
8
+ references/teaching-modes.md. Prose cannot be executed and cannot drift-check
9
+ itself — the original pairing table shipped three key collisions (`戒律`,
10
+ `道次第`, `中观/空性` each matched two or three rows), so which pairing a
11
+ query landed on depended on iteration order.
12
+
13
+ The central invariant this script enforces is therefore **pairwise
14
+ disjointness**: within `mode_rules`, and within `topic_pairings`, no keyword
15
+ may appear in two rows, and no keyword may be a substring of a keyword in
16
+ another row. Substring matters because `recommend` matches by containment —
17
+ if row A had `道次第` and row B had `菩提道次第`, a query mentioning the
18
+ latter would match both and the winner would be positional. Making that a CI
19
+ error forces collisions to be resolved when the data is authored.
20
+
21
+ Keyword data for personas is deliberately NOT duplicated into routing.json;
22
+ it stays in each prebuilt/<slug>/meta.json `search_scope.keywords`, so this
23
+ script also checks that every persona still carries usable keywords.
24
+
25
+ Checks
26
+ ------
27
+ 1. version == 1 and the three top-level sections are well-formed
28
+ 2. every mode in `mode_rules` is a `kind: teaching-mode` skill in
29
+ skill-catalog.json
30
+ 3. every master slug in `topic_pairings` / `default_pairing` is a
31
+ `kind: persona` skill in skill-catalog.json
32
+ 4. `mode_rules` keyword sets are pairwise disjoint (incl. substrings)
33
+ 5. `topic_pairings` keyword sets are pairwise disjoint (incl. substrings)
34
+ 6. `mode_rules` `order` values are exactly 1..N with no gaps or ties
35
+ 7. every catalog persona is reachable from at least one pairing or the
36
+ default pairing (no master can become unrecommendable)
37
+ 8. every catalog persona has a non-empty search_scope.keywords
38
+
39
+ Usage
40
+ -----
41
+ python scripts/validate-routing.py # exit 1 on any problem
42
+ python scripts/validate-routing.py --json
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import argparse
47
+ import json
48
+ import sys
49
+ from pathlib import Path
50
+
51
+ ROOT = Path(__file__).resolve().parent.parent
52
+ ROUTING_PATH = ROOT / "routing.json"
53
+ CATALOG_PATH = ROOT / "skill-catalog.json"
54
+ PREBUILT = ROOT / "prebuilt"
55
+
56
+
57
+ def _read_json(p: Path):
58
+ try:
59
+ return json.loads(p.read_text(encoding="utf-8"))
60
+ except (json.JSONDecodeError, OSError) as err:
61
+ return {"__error__": f"{p.name}: {err}"}
62
+
63
+
64
+ def _disjoint_problems(section: str, rows: list) -> list:
65
+ """Report keyword collisions across rows.
66
+
67
+ Two keywords collide when they are equal or one contains the other.
68
+ Collisions inside a single row are fine (`观智` alongside `十六观智`
69
+ is a deliberate broadening); collisions across rows are not, because
70
+ they make the match order-dependent.
71
+ """
72
+ problems = []
73
+ flat = []
74
+ for row in rows:
75
+ label = row.get("id") or row.get("mode") or "<unnamed>"
76
+ for kw in row.get("keywords", []):
77
+ flat.append((label, kw))
78
+
79
+ for i, (label_a, kw_a) in enumerate(flat):
80
+ for label_b, kw_b in flat[i + 1:]:
81
+ if label_a == label_b:
82
+ continue
83
+ if kw_a == kw_b:
84
+ problems.append(
85
+ f"{section}: keyword {kw_a!r} appears in both "
86
+ f"{label_a!r} and {label_b!r}"
87
+ )
88
+ elif kw_a in kw_b or kw_b in kw_a:
89
+ shorter, longer = sorted((kw_a, kw_b), key=len)
90
+ problems.append(
91
+ f"{section}: keyword {shorter!r} ({label_a!r}) is a "
92
+ f"substring of {longer!r} ({label_b!r}) — a query "
93
+ f"matching the longer one would match both"
94
+ )
95
+ return problems
96
+
97
+
98
+ def validate(root: Path = ROOT) -> list:
99
+ problems = []
100
+
101
+ routing = _read_json(root / "routing.json")
102
+ if "__error__" in routing:
103
+ return [f"cannot read routing.json ({routing['__error__']})"]
104
+ catalog = _read_json(root / "skill-catalog.json")
105
+ if "__error__" in catalog:
106
+ return [f"cannot read skill-catalog.json ({catalog['__error__']})"]
107
+
108
+ # 1 — shape
109
+ if routing.get("version") != 1:
110
+ problems.append("routing.json: version must be 1")
111
+
112
+ mode_rules = routing.get("mode_rules")
113
+ pairings = routing.get("topic_pairings")
114
+ default_pairing = routing.get("default_pairing")
115
+ if not isinstance(mode_rules, list) or not mode_rules:
116
+ problems.append("routing.json: mode_rules must be a non-empty array")
117
+ mode_rules = []
118
+ if not isinstance(pairings, list) or not pairings:
119
+ problems.append("routing.json: topic_pairings must be a non-empty array")
120
+ pairings = []
121
+ if not isinstance(default_pairing, list) or not default_pairing:
122
+ problems.append("routing.json: default_pairing must be a non-empty array")
123
+ default_pairing = []
124
+
125
+ skills = catalog.get("skills", [])
126
+ personas = {s["name"] for s in skills if s.get("kind") == "persona"}
127
+ modes = {s["name"] for s in skills if s.get("kind") == "teaching-mode"}
128
+
129
+ # 2 — modes resolve
130
+ for rule in mode_rules:
131
+ mode = rule.get("mode")
132
+ if mode not in modes:
133
+ problems.append(
134
+ f"mode_rules: {mode!r} is not a kind:teaching-mode skill in "
135
+ f"skill-catalog.json (known: {sorted(modes)})"
136
+ )
137
+ if not rule.get("keywords"):
138
+ problems.append(f"mode_rules: {mode!r} has no keywords")
139
+
140
+ # 3 — masters resolve
141
+ situations = routing.get("situations") or []
142
+ if not isinstance(situations, list):
143
+ problems.append("routing.json: situations must be an array")
144
+ situations = []
145
+
146
+ referenced = set()
147
+ for section, rows in (("topic_pairings", pairings), ("situations", situations)):
148
+ for row in rows:
149
+ rid = row.get("id", "<unnamed>")
150
+ if not row.get("keywords"):
151
+ problems.append(f"{section}: {rid!r} has no keywords")
152
+ row_masters = row.get("masters", [])
153
+ if not row_masters:
154
+ problems.append(f"{section}: {rid!r} has no masters")
155
+ for slug in row_masters:
156
+ referenced.add(slug)
157
+ if slug not in personas:
158
+ problems.append(
159
+ f"{section}: {rid!r} references {slug!r}, which is "
160
+ f"not a kind:persona skill in skill-catalog.json"
161
+ )
162
+ for slug in default_pairing:
163
+ referenced.add(slug)
164
+ if slug not in personas:
165
+ problems.append(
166
+ f"default_pairing: {slug!r} is not a kind:persona skill in "
167
+ f"skill-catalog.json"
168
+ )
169
+
170
+ # 4 / 5 — disjointness within each section
171
+ problems += _disjoint_problems("mode_rules", mode_rules)
172
+ problems += _disjoint_problems("topic_pairings", pairings)
173
+ problems += _disjoint_problems("situations", situations)
174
+
175
+ # 5b — a situation keyword that also triggers a mode is dead code: the
176
+ # mode layer short-circuits first, so the situation row can never fire.
177
+ mode_kws = {kw for r in mode_rules for kw in r.get("keywords", [])}
178
+ for row in situations:
179
+ rid = row.get("id", "<unnamed>")
180
+ for kw in row.get("keywords", []):
181
+ for mkw in mode_kws:
182
+ if kw == mkw or kw in mkw or mkw in kw:
183
+ problems.append(
184
+ f"situations: {rid!r} keyword {kw!r} collides with "
185
+ f"mode_rules keyword {mkw!r} — mode_rules is evaluated "
186
+ f"first, so this situation row is unreachable"
187
+ )
188
+
189
+ # 6 — order is a clean 1..N
190
+ orders = [r.get("order") for r in mode_rules]
191
+ if sorted(o for o in orders if isinstance(o, int)) != list(
192
+ range(1, len(mode_rules) + 1)
193
+ ):
194
+ problems.append(
195
+ f"mode_rules: order values must be exactly 1..{len(mode_rules)} "
196
+ f"with no gaps or ties (got {orders})"
197
+ )
198
+
199
+ # 7 — no unreachable persona
200
+ for slug in sorted(personas - referenced):
201
+ problems.append(
202
+ f"coverage: persona {slug!r} appears in no topic_pairing or "
203
+ f"situation and is not in default_pairing — it can never be "
204
+ f"recommended"
205
+ )
206
+
207
+ # 8 — persona keywords still exist (recommend scores off them)
208
+ for slug in sorted(personas):
209
+ meta_path = PREBUILT / slug / "meta.json"
210
+ if not meta_path.exists():
211
+ problems.append(f"keywords: {slug} has no meta.json")
212
+ continue
213
+ meta = _read_json(meta_path)
214
+ if "__error__" in meta:
215
+ problems.append(f"keywords: {slug} meta.json unreadable")
216
+ continue
217
+ kws = (meta.get("search_scope") or {}).get("keywords")
218
+ if not kws:
219
+ problems.append(
220
+ f"keywords: {slug} has empty search_scope.keywords — "
221
+ f"`recommend` cannot score it"
222
+ )
223
+
224
+ return problems
225
+
226
+
227
+ def main() -> int:
228
+ ap = argparse.ArgumentParser(description=__doc__)
229
+ ap.add_argument("--json", action="store_true", help="machine-readable output")
230
+ args = ap.parse_args()
231
+
232
+ problems = validate()
233
+
234
+ if args.json:
235
+ print(json.dumps({"ok": not problems, "problems": problems}, indent=2))
236
+ elif problems:
237
+ print(f"routing.json validation failed ({len(problems)} problem(s)):\n")
238
+ for p in problems:
239
+ print(f" ✗ {p}")
240
+ print()
241
+ else:
242
+ routing = _read_json(ROUTING_PATH)
243
+ print(
244
+ f"routing.json ok — {len(routing.get('mode_rules', []))} mode rules, "
245
+ f"{len(routing.get('situations', []))} situations, "
246
+ f"{len(routing.get('topic_pairings', []))} topic pairings, "
247
+ f"all keyword sets pairwise disjoint."
248
+ )
249
+
250
+ return 1 if problems else 0
251
+
252
+
253
+ if __name__ == "__main__":
254
+ sys.exit(main())
@@ -14,10 +14,11 @@ from __future__ import annotations
14
14
 
15
15
  import argparse
16
16
  import json
17
- import re
18
17
  import sys
19
18
  from pathlib import Path
20
19
 
20
+ import yaml
21
+
21
22
  PREBUILT_DIR = Path(__file__).resolve().parent.parent / "prebuilt"
22
23
 
23
24
  # --- Required and recommended fields ---
@@ -48,41 +49,20 @@ def parse_frontmatter(path: Path) -> tuple[dict, str, list[str]]:
48
49
  if end is None:
49
50
  return {}, text, lines
50
51
 
51
- # Minimal YAML parse (no pyyaml dependency)
52
- fm: dict = {}
53
- current_key = None
54
- current_list: list | None = None
55
- for line in lines[1:end]:
56
- # list item
57
- if line.startswith(" - ") and current_key:
58
- if current_list is None:
59
- current_list = []
60
- item = line.strip().lstrip("- ").strip()
61
- # Try inline dict (title: xxx)
62
- if ":" in item:
63
- parts = item.split(":", 1)
64
- if current_list and isinstance(current_list[-1], dict):
65
- current_list[-1][parts[0].strip()] = parts[1].strip()
66
- else:
67
- current_list.append({parts[0].strip(): parts[1].strip()})
68
- else:
69
- current_list.append(item)
70
- continue
71
- # Save accumulated list
72
- if current_list is not None and current_key:
73
- fm[current_key] = current_list
74
- current_list = None
75
- # key: value
76
- match = re.match(r"^(\w[\w_-]*):\s*(.*)", line)
77
- if match:
78
- current_key = match.group(1)
79
- value = match.group(2).strip().strip('"').strip("'")
80
- if value:
81
- fm[current_key] = value
82
- # If empty value, might be a list starting next line
83
- # Flush last list
84
- if current_list is not None and current_key:
85
- fm[current_key] = current_list
52
+ # This was a hand-rolled parser, from back when pyyaml was not a
53
+ # dependency. It matched list items only as ` - `, so a 4-space
54
+ # continuation line like ` cbeta_id: T48n2008` matched neither that nor
55
+ # the `key:` regex (which is anchored at column 0) and fell through to the
56
+ # list-flush branch — clearing the accumulated list on every continuation
57
+ # and letting the next ` - ` overwrite it. Every master kept exactly one
58
+ # source and no cbeta_id at all, so the sources[] rules below inspected
59
+ # data that was never in the file.
60
+ try:
61
+ fm = yaml.safe_load("\n".join(lines[1:end])) or {}
62
+ except yaml.YAMLError as exc:
63
+ raise ValueError(f"{path}: invalid YAML frontmatter — {exc}") from exc
64
+ if not isinstance(fm, dict):
65
+ raise ValueError(f"{path}: frontmatter is not a mapping")
86
66
 
87
67
  body = "\n".join(lines[end + 1 :])
88
68
  return fm, body, lines
@@ -274,6 +254,32 @@ def _run_promptfoo_configs_subcheck() -> list[str]:
274
254
  return [f"promptfoo-configs sub-check failed to run: {exc}"]
275
255
 
276
256
 
257
+ def _run_curriculum_sources_subcheck() -> list[str]:
258
+ """Run the curriculum source validator as a sub-check.
259
+
260
+ master-curriculum/SKILL.md claims CI enforces this, but the script was
261
+ wired into no workflow, no npm script and no sub-check — only its own unit
262
+ tests, which build synthetic trees under tmp_path. It has never run against
263
+ the real references/, so a curriculum recommending a sutra no master
264
+ declares would have shipped unnoticed. Returns a list of error strings.
265
+ """
266
+ curriculum_dir = PREBUILT_DIR / "master-curriculum"
267
+ if not curriculum_dir.exists():
268
+ return []
269
+ try:
270
+ import importlib.util
271
+
272
+ spec_path = (
273
+ Path(__file__).resolve().parent / "validate-curriculum-sources.py"
274
+ )
275
+ spec = importlib.util.spec_from_file_location("vcs", spec_path)
276
+ mod = importlib.util.module_from_spec(spec)
277
+ spec.loader.exec_module(mod)
278
+ return mod.validate(PREBUILT_DIR)
279
+ except Exception as exc: # pragma: no cover — surfaces to user
280
+ return [f"curriculum-sources sub-check failed to run: {exc}"]
281
+
282
+
277
283
  def main():
278
284
  parser = argparse.ArgumentParser(description="Master-skill SKILL.md linter")
279
285
  parser.add_argument("--master", type=str, help="Lint a specific master only")
@@ -294,6 +300,11 @@ def main():
294
300
  action="store_true",
295
301
  help="Skip the v0.8 manifest version-drift gate",
296
302
  )
303
+ parser.add_argument(
304
+ "--skip-curriculum-sources",
305
+ action="store_true",
306
+ help="Skip the curriculum source gate",
307
+ )
297
308
  parser.add_argument(
298
309
  "--skip-lore-triggers-content",
299
310
  action="store_true",
@@ -340,6 +351,13 @@ def main():
340
351
  if manifest_errors:
341
352
  has_errors = True
342
353
 
354
+ # --- curriculum source gate (full-tree only, HARD gate) ---
355
+ curriculum_errors: list[str] = []
356
+ if not args.master and not args.skip_curriculum_sources:
357
+ curriculum_errors = _run_curriculum_sources_subcheck()
358
+ if curriculum_errors:
359
+ has_errors = True
360
+
343
361
  # --- v0.8 lore_triggers-content advisory sub-check ---
344
362
  # ADVISORY ONLY: warnings printed but never affect has_errors.
345
363
  lore_warnings: list[str] = []
@@ -354,6 +372,8 @@ def main():
354
372
  out["promptfoo_configs"] = promptfoo_errors
355
373
  if manifest_errors:
356
374
  out["manifest_versions"] = manifest_errors
375
+ if curriculum_errors:
376
+ out["curriculum_sources"] = curriculum_errors
357
377
  if lore_warnings:
358
378
  out["lore_triggers_content_advisory"] = lore_warnings
359
379
  print(json.dumps(out, indent=2, ensure_ascii=False))
@@ -363,6 +383,7 @@ def main():
363
383
  and not persona_errors
364
384
  and not promptfoo_errors
365
385
  and not manifest_errors
386
+ and not curriculum_errors
366
387
  and not lore_warnings
367
388
  )
368
389
  if nothing_to_report:
@@ -386,6 +407,11 @@ def main():
386
407
  print("Manifest version-drift gate (v0.8):")
387
408
  for e in manifest_errors:
388
409
  print(f" [ERROR] {e}")
410
+ if curriculum_errors:
411
+ print()
412
+ print("Curriculum source gate:")
413
+ for e in curriculum_errors:
414
+ print(f" [ERROR] {e}")
389
415
  if lore_warnings:
390
416
  print()
391
417
  print(
@@ -401,6 +427,7 @@ def main():
401
427
  len(persona_errors)
402
428
  + len(promptfoo_errors)
403
429
  + len(manifest_errors)
430
+ + len(curriculum_errors)
404
431
  )
405
432
  total_warns += len(lore_warnings)
406
433
  print(