master-skill 0.10.0 → 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 (102) 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/ETHICS.md +23 -17
  5. package/GEMINI.md +1 -1
  6. package/README.md +57 -295
  7. package/README_EN.md +59 -276
  8. package/SKILL.md +5 -5
  9. package/bin/cli.mjs +545 -78
  10. package/gemini-extension.json +1 -1
  11. package/hooks/run-hook.cmd +18 -5
  12. package/hooks/session-start +4 -1
  13. package/hooks/tests/test_run_hook.sh +114 -0
  14. package/hooks/tests/test_run_hook_cmd.sh +94 -0
  15. package/masters/.gitkeep +0 -0
  16. package/package.json +10 -3
  17. package/prebuilt/{compare → compare-masters}/SKILL.md +5 -5
  18. package/prebuilt/master-ajahn-chah/SKILL.md +13 -11
  19. package/prebuilt/master-ajahn-chah/meta.json +8 -0
  20. package/prebuilt/master-ajahn-chah/references/voice.md +1 -1
  21. package/prebuilt/master-atisha/SKILL.md +13 -11
  22. package/prebuilt/master-atisha/meta.json +8 -0
  23. package/prebuilt/master-atisha/references/voice.md +1 -1
  24. package/prebuilt/master-buddhaghosa/SKILL.md +13 -11
  25. package/prebuilt/master-buddhaghosa/meta.json +8 -0
  26. package/prebuilt/master-buddhaghosa/references/voice.md +1 -1
  27. package/prebuilt/master-curriculum/SKILL.md +1 -1
  28. package/prebuilt/master-debate/SKILL.md +1 -1
  29. package/prebuilt/master-fazang/SKILL.md +3 -3
  30. package/prebuilt/master-fazang/meta.json +8 -0
  31. package/prebuilt/master-help/SKILL.md +86 -0
  32. package/prebuilt/master-help/tests/fidelity.jsonl +10 -0
  33. package/prebuilt/master-huineng/SKILL.md +3 -3
  34. package/prebuilt/master-huineng/meta.json +8 -0
  35. package/prebuilt/master-kumarajiva/SKILL.md +3 -3
  36. package/prebuilt/master-kumarajiva/meta.json +20 -1
  37. package/prebuilt/master-mahasi-sayadaw/SKILL.md +13 -11
  38. package/prebuilt/master-mahasi-sayadaw/meta.json +8 -0
  39. package/prebuilt/master-mahasi-sayadaw/references/voice.md +2 -2
  40. package/prebuilt/master-milarepa/SKILL.md +13 -11
  41. package/prebuilt/master-milarepa/meta.json +8 -0
  42. package/prebuilt/master-milarepa/references/voice.md +1 -1
  43. package/prebuilt/master-nagarjuna/SKILL.md +3 -3
  44. package/prebuilt/master-nagarjuna/meta.json +25 -2
  45. package/prebuilt/master-ouyi/SKILL.md +3 -3
  46. package/prebuilt/master-ouyi/meta.json +8 -0
  47. package/prebuilt/master-tsongkhapa/SKILL.md +13 -11
  48. package/prebuilt/master-tsongkhapa/meta.json +32 -3
  49. package/prebuilt/master-tsongkhapa/references/voice.md +1 -1
  50. package/prebuilt/master-xuanzang/SKILL.md +3 -3
  51. package/prebuilt/master-xuanzang/meta.json +8 -0
  52. package/prebuilt/master-xuyun/SKILL.md +3 -3
  53. package/prebuilt/master-xuyun/meta.json +8 -0
  54. package/prebuilt/master-yinguang/SKILL.md +3 -3
  55. package/prebuilt/master-yinguang/meta.json +8 -0
  56. package/prebuilt/master-zhiyi/SKILL.md +3 -3
  57. package/prebuilt/master-zhiyi/meta.json +8 -0
  58. package/prompts/correction_handler.md +104 -0
  59. package/prompts/doctrine_reviewer.md +61 -0
  60. package/prompts/intake.md +62 -0
  61. package/prompts/merger.md +62 -0
  62. package/prompts/rag_instructions.md +54 -0
  63. package/prompts/sutra_analyzer.md +83 -0
  64. package/prompts/teaching_builder.md +41 -0
  65. package/prompts/voice_analyzer.md +92 -0
  66. package/prompts/voice_builder.md +48 -0
  67. package/prompts/voice_reviewer.md +66 -0
  68. package/references/README.md +12 -0
  69. package/references/ethics-runtime.md +112 -0
  70. package/references/fojin-api.md +223 -0
  71. package/references/source-conventions.md +129 -0
  72. package/references/teaching-modes.md +91 -0
  73. package/references/traditions.md +72 -0
  74. package/references/workflow-details.md +361 -0
  75. package/requirements.txt +6 -0
  76. package/routing.json +209 -0
  77. package/scripts/check-gate-liveness.py +222 -0
  78. package/scripts/select-fidelity-smoke.py +78 -0
  79. package/scripts/test-fidelity.py +339 -51
  80. package/scripts/tests/test_check_gate_liveness.py +232 -0
  81. package/scripts/tests/test_check_response.py +190 -0
  82. package/scripts/tests/test_fidelity_providers.py +202 -0
  83. package/scripts/tests/test_select_fidelity_smoke.py +142 -0
  84. package/scripts/tests/test_validate.py +145 -0
  85. package/scripts/tests/test_validate_citation_contract.py +408 -0
  86. package/scripts/tests/test_validate_fidelity.py +2 -2
  87. package/scripts/tests/test_validate_workflow.py +284 -0
  88. package/scripts/validate-citation-contract.py +193 -0
  89. package/scripts/validate-fidelity.py +6 -1
  90. package/scripts/validate-routing.py +254 -0
  91. package/scripts/validate.py +63 -36
  92. package/scripts/verify_citations.py +8 -1
  93. package/skill-catalog.json +210 -0
  94. package/tools/cross_reference.py +365 -0
  95. package/tools/fojin_bridge.py +146 -0
  96. package/tools/master_builder.py +341 -0
  97. package/tools/rag_query.py +336 -0
  98. package/tools/skill_writer.py +230 -0
  99. package/tools/sutra_collector.py +237 -0
  100. package/tools/verify_sources.py +512 -0
  101. package/tools/version_manager.py +88 -0
  102. /package/prebuilt/{compare → compare-masters}/tests/fidelity.jsonl +0 -0
@@ -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
 
@@ -0,0 +1,284 @@
1
+ """Structural and behavior checks for the repository validation workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+ import yaml
11
+
12
+
13
+ ROOT = Path(__file__).resolve().parents[2]
14
+ WORKFLOW_PATH = ROOT / ".github" / "workflows" / "validate-and-test.yml"
15
+ WORKFLOW_TEXT = WORKFLOW_PATH.read_text(encoding="utf-8")
16
+
17
+
18
+ def _load_workflow_text(text: str) -> dict:
19
+ workflow = yaml.safe_load(text)
20
+ assert isinstance(workflow, dict)
21
+ assert isinstance(workflow.get("jobs"), dict)
22
+ return workflow
23
+
24
+
25
+ def _job(workflow: dict, job_name: str) -> dict:
26
+ job = workflow["jobs"].get(job_name)
27
+ assert isinstance(job, dict), f"missing workflow job: {job_name}"
28
+ assert isinstance(job.get("steps"), list), f"job has no steps: {job_name}"
29
+ return job
30
+
31
+
32
+ def _step(workflow: dict, job_name: str, step_name: str) -> dict:
33
+ matches = [
34
+ step
35
+ for step in _job(workflow, job_name)["steps"]
36
+ if step.get("name") == step_name
37
+ ]
38
+ assert len(matches) == 1, f"expected one {job_name}/{step_name} step, got {len(matches)}"
39
+ return matches[0]
40
+
41
+
42
+ def _assert_hard(step: dict) -> None:
43
+ assert step.get("continue-on-error") not in (True, "true")
44
+
45
+
46
+ WORKFLOW = _load_workflow_text(WORKFLOW_TEXT)
47
+
48
+
49
+ def test_free_text_tokens_cannot_substitute_for_workflow_structure():
50
+ fake = _load_workflow_text(
51
+ """\
52
+ name: fake
53
+ jobs:
54
+ validate:
55
+ steps:
56
+ - name: comments only
57
+ run: echo 'python -m pytest tests/ scripts/tests/ -v'
58
+ """
59
+ )
60
+
61
+ with pytest.raises(AssertionError, match="Run Python tests"):
62
+ _step(fake, "validate", "Run Python tests")
63
+
64
+
65
+ def test_workflow_has_no_softened_steps():
66
+ for job_name, job in WORKFLOW["jobs"].items():
67
+ for step in job.get("steps", []):
68
+ assert step.get("continue-on-error") not in (True, "true"), (
69
+ f"softened workflow step in {job_name}: {step.get('name', step.get('uses'))}"
70
+ )
71
+
72
+
73
+ @pytest.mark.parametrize(
74
+ ("step_name", "command"),
75
+ [
76
+ ("Run Python tests", "python -m pytest tests/ scripts/tests/ -v"),
77
+ ("Validate citation contracts", "python scripts/validate-citation-contract.py"),
78
+ (
79
+ "Validate lore_triggers content (v0.8 — hard gate)",
80
+ "python scripts/validate-lore-triggers-content.py --strict",
81
+ ),
82
+ ],
83
+ )
84
+ def test_validate_job_contains_hard_gate_commands(step_name: str, command: str):
85
+ step = _step(WORKFLOW, "validate", step_name)
86
+ assert step.get("run") == command
87
+ _assert_hard(step)
88
+
89
+
90
+ @pytest.mark.parametrize(
91
+ ("step_name", "command"),
92
+ [
93
+ ("Check desktop formatting", "cargo fmt --manifest-path desktop/Cargo.toml -- --check"),
94
+ (
95
+ "Lint desktop app",
96
+ "cargo clippy --locked --manifest-path desktop/Cargo.toml "
97
+ "--all-targets -- -D warnings",
98
+ ),
99
+ ("Test desktop app", "cargo test --locked --manifest-path desktop/Cargo.toml"),
100
+ ("Build desktop app", "cargo build --locked --manifest-path desktop/Cargo.toml"),
101
+ ],
102
+ )
103
+ def test_desktop_job_contains_hard_gate_commands(step_name: str, command: str):
104
+ step = _step(WORKFLOW, "desktop-rust", step_name)
105
+ assert step.get("run") == command
106
+ _assert_hard(step)
107
+
108
+
109
+ def test_desktop_quality_gates_run_before_tests_and_build():
110
+ step_names = [step.get("name") for step in _job(WORKFLOW, "desktop-rust")["steps"]]
111
+ assert step_names.index("Check desktop formatting") < step_names.index("Lint desktop app")
112
+ assert step_names.index("Lint desktop app") < step_names.index("Test desktop app")
113
+ assert step_names.index("Test desktop app") < step_names.index("Build desktop app")
114
+
115
+
116
+ def test_windows_cli_job_installs_the_generator_python_runtime():
117
+ job = _job(WORKFLOW, "cli-windows")
118
+ uses = [step.get("uses", "") for step in job["steps"]]
119
+ assert any(use.startswith("actions/setup-python@") for use in uses)
120
+ install = _step(WORKFLOW, "cli-windows", "Install generator dependencies")
121
+ assert install.get("run") == "python -m pip install -r requirements.txt"
122
+ _assert_hard(install)
123
+
124
+
125
+ def test_python39_job_compiles_and_runs_the_four_generator_cli_steps():
126
+ job = _job(WORKFLOW, "python-compat")
127
+ setup = next(
128
+ step
129
+ for step in job["steps"]
130
+ if str(step.get("uses", "")).startswith("actions/setup-python@")
131
+ )
132
+ assert setup.get("with", {}).get("python-version") == "3.9"
133
+ smoke = _step(WORKFLOW, "python-compat", "Run Python 3.9 generator smoke")
134
+ command = smoke["run"]
135
+ assert "python -m compileall -q tools scripts" in command
136
+ assert "sutra_collector.py" in command
137
+ assert "verify_sources.py --check-links" in command
138
+ assert "master_builder.py" in command
139
+ assert "verify_sources.py --final-check" in command
140
+ _assert_hard(smoke)
141
+
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
+
160
+ def test_push_paths_include_distribution_and_generator_runtime():
161
+ triggers = WORKFLOW.get("on", WORKFLOW.get(True))
162
+ assert isinstance(triggers, dict)
163
+ paths = set(triggers["push"]["paths"])
164
+ required = {
165
+ "skill-catalog.json",
166
+ "SKILL.md",
167
+ "references/**",
168
+ "ETHICS.md",
169
+ "requirements.txt",
170
+ "README.md",
171
+ "README_EN.md",
172
+ "CONTRIBUTING.md",
173
+ "CHANGELOG.md",
174
+ "docs/PRD.md",
175
+ "docs/v1-framework-roadmap.md",
176
+ "masters/**",
177
+ ".claude-plugin/**",
178
+ ".cursor-plugin/**",
179
+ "gemini-extension.json",
180
+ ".github/PULL_REQUEST_TEMPLATE.md",
181
+ ".github/ISSUE_TEMPLATE/**",
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}"
185
+
186
+
187
+ def test_pick_step_uses_checked_selector_without_fixed_roster():
188
+ checkout = next(
189
+ step
190
+ for step in _job(WORKFLOW, "fidelity-smoke")["steps"]
191
+ if str(step.get("uses", "")).startswith("actions/checkout@")
192
+ )
193
+ assert checkout.get("with", {}).get("fetch-depth") == 0
194
+ step = _step(WORKFLOW, "fidelity-smoke", "Pick smoke target")
195
+ script = step["run"]
196
+ assert "if ! CHANGED=$(python scripts/select-fidelity-smoke.py" in script
197
+ assert "if ! DIFF_OUTPUT=$(git diff --name-only" in script
198
+ assert "CHANGED_CANDIDATES" in script
199
+ assert "--prebuilt prebuilt" in script
200
+ assert '--day-of-year "$(date +%j)"' in script
201
+ assert "MASTERS=(" not in script
202
+ _assert_hard(step)
203
+
204
+
205
+ def test_smoke_selector_producer_failure_is_not_masked(tmp_path: Path):
206
+ pick_step = _step(WORKFLOW, "fidelity-smoke", "Pick smoke target")
207
+ script = pick_step["run"].replace("${{ github.base_ref || 'main' }}", "main")
208
+
209
+ bin_dir = tmp_path / "bin"
210
+ bin_dir.mkdir()
211
+ fake_python = bin_dir / "python"
212
+ fake_python.write_text(
213
+ "#!/bin/sh\nprintf 'master-partial\\n'\nexit 7\n",
214
+ encoding="utf-8",
215
+ )
216
+ fake_python.chmod(0o755)
217
+ output_path = tmp_path / "github-output"
218
+ env = os.environ.copy()
219
+ env["PATH"] = f"{bin_dir}:{env['PATH']}"
220
+ env["GITHUB_OUTPUT"] = str(output_path)
221
+
222
+ result = subprocess.run(
223
+ ["bash", "-e", "-o", "pipefail", "-c", script],
224
+ cwd=ROOT,
225
+ env=env,
226
+ text=True,
227
+ capture_output=True,
228
+ check=False,
229
+ )
230
+
231
+ assert result.returncode != 0
232
+ assert not output_path.exists()
233
+
234
+
235
+ def test_smoke_git_diff_failure_is_not_masked(tmp_path: Path):
236
+ pick_step = _step(WORKFLOW, "fidelity-smoke", "Pick smoke target")
237
+ script = pick_step["run"].replace("${{ github.base_ref || 'main' }}", "main")
238
+
239
+ bin_dir = tmp_path / "bin"
240
+ bin_dir.mkdir()
241
+ fake_git = bin_dir / "git"
242
+ fake_git.write_text("#!/bin/sh\nexit 7\n", encoding="utf-8")
243
+ fake_git.chmod(0o755)
244
+ fake_python = bin_dir / "python"
245
+ fake_python.write_text(
246
+ "#!/bin/sh\nprintf 'master-alpha\\n'\n",
247
+ encoding="utf-8",
248
+ )
249
+ fake_python.chmod(0o755)
250
+ output_path = tmp_path / "github-output"
251
+ env = os.environ.copy()
252
+ env["PATH"] = f"{bin_dir}:{env['PATH']}"
253
+ env["GITHUB_OUTPUT"] = str(output_path)
254
+
255
+ result = subprocess.run(
256
+ ["bash", "-e", "-o", "pipefail", "-c", script],
257
+ cwd=ROOT,
258
+ env=env,
259
+ text=True,
260
+ capture_output=True,
261
+ check=False,
262
+ )
263
+
264
+ assert result.returncode != 0
265
+ assert "git diff failed" in result.stdout
266
+ assert not output_path.exists()
267
+
268
+
269
+ @pytest.mark.parametrize(
270
+ ("job_name", "step_name"),
271
+ [
272
+ ("fidelity-smoke", "Run fidelity smoke"),
273
+ ("fidelity-full", "Run fidelity tests"),
274
+ ],
275
+ )
276
+ def test_each_fidelity_no_key_branch_records_step_summary(job_name: str, step_name: str):
277
+ job = _job(WORKFLOW, job_name)
278
+ assert job.get("needs") == "validate"
279
+ step = _step(WORKFLOW, job_name, step_name)
280
+ script = step["run"]
281
+ assert 'if [ -z "${ANTHROPIC_API_KEY:-}" ]; then' in script
282
+ assert script.count('echo "### Fidelity grading skipped"') == 1
283
+ assert '} >> "$GITHUB_STEP_SUMMARY"' in script
284
+ _assert_hard(step)
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env python3
2
+ """Validate source-neutral citation contracts in persona metadata."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+
10
+ PREBUILT_DIR = Path(__file__).resolve().parent.parent / "prebuilt"
11
+
12
+ EXPECTED_PERSONA_SLUGS = (
13
+ "ajahn-chah",
14
+ "atisha",
15
+ "buddhaghosa",
16
+ "fazang",
17
+ "huineng",
18
+ "kumarajiva",
19
+ "mahasi-sayadaw",
20
+ "milarepa",
21
+ "nagarjuna",
22
+ "ouyi",
23
+ "tsongkhapa",
24
+ "xuanzang",
25
+ "xuyun",
26
+ "yinguang",
27
+ "zhiyi",
28
+ )
29
+
30
+ EXPECTED_REQUIRED_FOR = [
31
+ "doctrinal_claim",
32
+ "practice_guidance",
33
+ "text_interpretation",
34
+ ]
35
+ EXPECTED_KEYS = {
36
+ "version",
37
+ "claim_policy",
38
+ "required_for",
39
+ "allowed_source_types",
40
+ "minimum_claim_coverage",
41
+ "live_retrieval_allowed",
42
+ }
43
+
44
+
45
+ def _display_path(meta_path: Path) -> str:
46
+ return f"{meta_path.parent.name}/meta.json"
47
+
48
+
49
+ def _persona_count(prebuilt_dir: Path) -> int:
50
+ count = 0
51
+ for meta_path in prebuilt_dir.glob("master-*/meta.json"):
52
+ try:
53
+ data = json.loads(meta_path.read_text(encoding="utf-8"))
54
+ except (OSError, json.JSONDecodeError):
55
+ continue
56
+ if data.get("kind") != "meta-skill":
57
+ count += 1
58
+ return count
59
+
60
+
61
+ def validate(prebuilt_dir: Path) -> list[str]:
62
+ """Return deterministic citation-contract errors for persona metadata."""
63
+ errors: list[str] = []
64
+
65
+ for meta_path in sorted(prebuilt_dir.glob("master-*/meta.json")):
66
+ path = _display_path(meta_path)
67
+ try:
68
+ data = json.loads(meta_path.read_text(encoding="utf-8"))
69
+ except (OSError, json.JSONDecodeError) as exc:
70
+ errors.append(f"{path}: invalid metadata: {exc}")
71
+ continue
72
+
73
+ if data.get("kind") == "meta-skill":
74
+ if "citation_contract" in data:
75
+ errors.append(
76
+ f"{path}: meta-skill must not declare citation_contract"
77
+ )
78
+ continue
79
+
80
+ sources = data.get("sources")
81
+ if not isinstance(sources, list) or not sources:
82
+ errors.append(f"{path}: persona sources must be a non-empty list")
83
+ continue
84
+
85
+ source_types: list[str] = []
86
+ for index, source in enumerate(sources):
87
+ source_type = source.get("type") if isinstance(source, dict) else None
88
+ if not isinstance(source_type, str) or not source_type.strip():
89
+ errors.append(
90
+ f"{path}: sources[].type at index {index} must be a non-empty string"
91
+ )
92
+ continue
93
+ source_types.append(source_type)
94
+
95
+ contract = data.get("citation_contract")
96
+ if not isinstance(contract, dict):
97
+ errors.append(f"{path}: citation_contract must be an object")
98
+ continue
99
+
100
+ contract_keys = set(contract)
101
+ if contract_keys != EXPECTED_KEYS:
102
+ missing = sorted(EXPECTED_KEYS - contract_keys)
103
+ extra = sorted(contract_keys - EXPECTED_KEYS)
104
+ errors.append(
105
+ f"{path}: citation_contract keys must be exact; "
106
+ f"missing={missing}, extra={extra}"
107
+ )
108
+
109
+ version = contract.get("version")
110
+ if type(version) is not int or version != 1:
111
+ errors.append(f"{path}: version must be integer 1")
112
+
113
+ if contract.get("claim_policy") != "declared_sources_only":
114
+ errors.append(
115
+ f"{path}: claim_policy must be 'declared_sources_only'"
116
+ )
117
+
118
+ if contract.get("required_for") != EXPECTED_REQUIRED_FOR:
119
+ errors.append(
120
+ f"{path}: required_for must equal {EXPECTED_REQUIRED_FOR}"
121
+ )
122
+
123
+ allowed_source_types = contract.get("allowed_source_types")
124
+ expected_source_types = sorted(set(source_types))
125
+ if (
126
+ not isinstance(allowed_source_types, list)
127
+ or any(
128
+ not isinstance(source_type, str) or not source_type.strip()
129
+ for source_type in allowed_source_types
130
+ )
131
+ or allowed_source_types != expected_source_types
132
+ ):
133
+ errors.append(
134
+ f"{path}: allowed_source_types must equal {expected_source_types}"
135
+ )
136
+
137
+ coverage = contract.get("minimum_claim_coverage")
138
+ if (
139
+ isinstance(coverage, bool)
140
+ or not isinstance(coverage, (int, float))
141
+ or coverage != 0.9
142
+ ):
143
+ errors.append(f"{path}: minimum_claim_coverage must be numeric 0.9")
144
+
145
+ if contract.get("live_retrieval_allowed") is not True:
146
+ errors.append(f"{path}: live_retrieval_allowed must be true")
147
+
148
+ return errors
149
+
150
+
151
+ def validate_repository(prebuilt_dir: Path) -> list[str]:
152
+ """Validate contracts plus the repository's fixed 15-persona roster."""
153
+ if not prebuilt_dir.is_dir():
154
+ return [f"{prebuilt_dir}: prebuilt directory not found"]
155
+
156
+ errors = validate(prebuilt_dir)
157
+ discovered: set[str] = set()
158
+ for meta_path in sorted(prebuilt_dir.glob("master-*/meta.json")):
159
+ try:
160
+ data = json.loads(meta_path.read_text(encoding="utf-8"))
161
+ except (OSError, json.JSONDecodeError):
162
+ continue
163
+ if data.get("kind") == "meta-skill":
164
+ continue
165
+ discovered.add(meta_path.parent.name.removeprefix("master-"))
166
+
167
+ expected = set(EXPECTED_PERSONA_SLUGS)
168
+ missing = sorted(expected - discovered)
169
+ unexpected = sorted(discovered - expected)
170
+ if missing or unexpected or len(discovered) != len(EXPECTED_PERSONA_SLUGS):
171
+ errors.append(
172
+ "persona roster must contain exactly "
173
+ f"{len(EXPECTED_PERSONA_SLUGS)} declared personas; "
174
+ f"missing={[f'master-{slug}' for slug in missing]}, "
175
+ f"unexpected={[f'master-{slug}' for slug in unexpected]}"
176
+ )
177
+
178
+ return errors
179
+
180
+
181
+ def main() -> int:
182
+ errors = validate_repository(PREBUILT_DIR)
183
+ if errors:
184
+ for error in errors:
185
+ print(error, file=sys.stderr)
186
+ return 1
187
+
188
+ print(f"citation contracts OK ({_persona_count(PREBUILT_DIR)} personas)")
189
+ return 0
190
+
191
+
192
+ if __name__ == "__main__":
193
+ sys.exit(main())
@@ -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: