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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/GEMINI.md +1 -1
- package/README.md +52 -297
- package/README_EN.md +52 -278
- package/bin/cli.mjs +237 -2
- package/gemini-extension.json +1 -1
- package/hooks/session-start +4 -1
- package/package.json +3 -2
- package/prebuilt/master-curriculum/SKILL.md +1 -1
- package/prebuilt/master-debate/SKILL.md +1 -1
- package/prebuilt/master-help/SKILL.md +86 -0
- package/prebuilt/master-help/tests/fidelity.jsonl +10 -0
- package/prebuilt/master-kumarajiva/meta.json +14 -3
- package/prebuilt/master-nagarjuna/meta.json +19 -4
- package/prebuilt/master-tsongkhapa/meta.json +26 -5
- package/references/teaching-modes.md +8 -1
- package/routing.json +209 -0
- package/scripts/check-gate-liveness.py +222 -0
- package/scripts/test-fidelity.py +320 -49
- package/scripts/tests/test_check_gate_liveness.py +232 -0
- package/scripts/tests/test_check_response.py +190 -0
- package/scripts/tests/test_fidelity_providers.py +202 -0
- package/scripts/tests/test_select_fidelity_smoke.py +2 -2
- package/scripts/tests/test_validate.py +145 -0
- package/scripts/tests/test_validate_citation_contract.py +1 -1
- package/scripts/tests/test_validate_fidelity.py +2 -2
- package/scripts/tests/test_validate_workflow.py +21 -2
- package/scripts/validate-fidelity.py +6 -1
- package/scripts/validate-routing.py +254 -0
- package/scripts/validate.py +63 -36
- package/skill-catalog.json +83 -20
- /package/prebuilt/{compare → compare-masters}/SKILL.md +0 -0
- /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
|
-
|
|
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
|
-
}
|
|
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())
|
package/scripts/validate.py
CHANGED
|
@@ -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
|
-
#
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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(
|