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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/ETHICS.md +23 -17
- package/GEMINI.md +1 -1
- package/README.md +57 -295
- package/README_EN.md +59 -276
- package/SKILL.md +5 -5
- package/bin/cli.mjs +545 -78
- package/gemini-extension.json +1 -1
- package/hooks/run-hook.cmd +18 -5
- package/hooks/session-start +4 -1
- package/hooks/tests/test_run_hook.sh +114 -0
- package/hooks/tests/test_run_hook_cmd.sh +94 -0
- package/masters/.gitkeep +0 -0
- package/package.json +10 -3
- package/prebuilt/{compare → compare-masters}/SKILL.md +5 -5
- package/prebuilt/master-ajahn-chah/SKILL.md +13 -11
- package/prebuilt/master-ajahn-chah/meta.json +8 -0
- package/prebuilt/master-ajahn-chah/references/voice.md +1 -1
- package/prebuilt/master-atisha/SKILL.md +13 -11
- package/prebuilt/master-atisha/meta.json +8 -0
- package/prebuilt/master-atisha/references/voice.md +1 -1
- package/prebuilt/master-buddhaghosa/SKILL.md +13 -11
- package/prebuilt/master-buddhaghosa/meta.json +8 -0
- package/prebuilt/master-buddhaghosa/references/voice.md +1 -1
- package/prebuilt/master-curriculum/SKILL.md +1 -1
- package/prebuilt/master-debate/SKILL.md +1 -1
- package/prebuilt/master-fazang/SKILL.md +3 -3
- package/prebuilt/master-fazang/meta.json +8 -0
- package/prebuilt/master-help/SKILL.md +86 -0
- package/prebuilt/master-help/tests/fidelity.jsonl +10 -0
- package/prebuilt/master-huineng/SKILL.md +3 -3
- package/prebuilt/master-huineng/meta.json +8 -0
- package/prebuilt/master-kumarajiva/SKILL.md +3 -3
- package/prebuilt/master-kumarajiva/meta.json +20 -1
- package/prebuilt/master-mahasi-sayadaw/SKILL.md +13 -11
- package/prebuilt/master-mahasi-sayadaw/meta.json +8 -0
- package/prebuilt/master-mahasi-sayadaw/references/voice.md +2 -2
- package/prebuilt/master-milarepa/SKILL.md +13 -11
- package/prebuilt/master-milarepa/meta.json +8 -0
- package/prebuilt/master-milarepa/references/voice.md +1 -1
- package/prebuilt/master-nagarjuna/SKILL.md +3 -3
- package/prebuilt/master-nagarjuna/meta.json +25 -2
- package/prebuilt/master-ouyi/SKILL.md +3 -3
- package/prebuilt/master-ouyi/meta.json +8 -0
- package/prebuilt/master-tsongkhapa/SKILL.md +13 -11
- package/prebuilt/master-tsongkhapa/meta.json +32 -3
- package/prebuilt/master-tsongkhapa/references/voice.md +1 -1
- package/prebuilt/master-xuanzang/SKILL.md +3 -3
- package/prebuilt/master-xuanzang/meta.json +8 -0
- package/prebuilt/master-xuyun/SKILL.md +3 -3
- package/prebuilt/master-xuyun/meta.json +8 -0
- package/prebuilt/master-yinguang/SKILL.md +3 -3
- package/prebuilt/master-yinguang/meta.json +8 -0
- package/prebuilt/master-zhiyi/SKILL.md +3 -3
- package/prebuilt/master-zhiyi/meta.json +8 -0
- package/prompts/correction_handler.md +104 -0
- package/prompts/doctrine_reviewer.md +61 -0
- package/prompts/intake.md +62 -0
- package/prompts/merger.md +62 -0
- package/prompts/rag_instructions.md +54 -0
- package/prompts/sutra_analyzer.md +83 -0
- package/prompts/teaching_builder.md +41 -0
- package/prompts/voice_analyzer.md +92 -0
- package/prompts/voice_builder.md +48 -0
- package/prompts/voice_reviewer.md +66 -0
- package/references/README.md +12 -0
- package/references/ethics-runtime.md +112 -0
- package/references/fojin-api.md +223 -0
- package/references/source-conventions.md +129 -0
- package/references/teaching-modes.md +91 -0
- package/references/traditions.md +72 -0
- package/references/workflow-details.md +361 -0
- package/requirements.txt +6 -0
- package/routing.json +209 -0
- package/scripts/check-gate-liveness.py +222 -0
- package/scripts/select-fidelity-smoke.py +78 -0
- package/scripts/test-fidelity.py +339 -51
- 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 +142 -0
- package/scripts/tests/test_validate.py +145 -0
- package/scripts/tests/test_validate_citation_contract.py +408 -0
- package/scripts/tests/test_validate_fidelity.py +2 -2
- package/scripts/tests/test_validate_workflow.py +284 -0
- package/scripts/validate-citation-contract.py +193 -0
- package/scripts/validate-fidelity.py +6 -1
- package/scripts/validate-routing.py +254 -0
- package/scripts/validate.py +63 -36
- package/scripts/verify_citations.py +8 -1
- package/skill-catalog.json +210 -0
- package/tools/cross_reference.py +365 -0
- package/tools/fojin_bridge.py +146 -0
- package/tools/master_builder.py +341 -0
- package/tools/rag_query.py +336 -0
- package/tools/skill_writer.py +230 -0
- package/tools/sutra_collector.py +237 -0
- package/tools/verify_sources.py +512 -0
- package/tools/version_manager.py +88 -0
- /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:
|