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
@@ -0,0 +1,142 @@
1
+ """Behavior tests for deterministic fidelity smoke-target selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+
13
+ ROOT = Path(__file__).resolve().parents[2]
14
+ SELECTOR = ROOT / "scripts" / "select-fidelity-smoke.py"
15
+
16
+
17
+ def _write_meta(prebuilt: Path, slug: str, sources: object) -> None:
18
+ master_dir = prebuilt / slug
19
+ master_dir.mkdir(parents=True)
20
+ (master_dir / "meta.json").write_text(
21
+ json.dumps({"sources": sources}),
22
+ encoding="utf-8",
23
+ )
24
+
25
+
26
+ def _run_selector(
27
+ prebuilt: Path,
28
+ day_of_year: str,
29
+ changed: str | list[str] | None = None,
30
+ ) -> subprocess.CompletedProcess[str]:
31
+ command = [
32
+ sys.executable,
33
+ str(SELECTOR),
34
+ "--prebuilt",
35
+ str(prebuilt),
36
+ "--day-of-year",
37
+ day_of_year,
38
+ ]
39
+ if changed is not None:
40
+ changed_values = [changed] if isinstance(changed, str) else changed
41
+ for candidate in changed_values:
42
+ command.extend(["--changed", candidate])
43
+ return subprocess.run(command, text=True, capture_output=True, check=False)
44
+
45
+
46
+ @pytest.mark.parametrize(
47
+ ("day_of_year", "expected"),
48
+ [("008", "master-gamma"), ("099", "master-alpha"), ("100", "master-beta")],
49
+ )
50
+ def test_rotation_parses_zero_padded_days_as_decimal(
51
+ tmp_path: Path,
52
+ day_of_year: str,
53
+ expected: str,
54
+ ):
55
+ prebuilt = tmp_path / "prebuilt"
56
+ _write_meta(prebuilt, "master-gamma", [{"id": "g"}])
57
+ _write_meta(prebuilt, "master-alpha", [{"id": "a"}])
58
+ _write_meta(prebuilt, "master-beta", [{"id": "b"}])
59
+
60
+ result = _run_selector(prebuilt, day_of_year)
61
+
62
+ assert result.returncode == 0, result.stderr
63
+ assert result.stdout == f"{expected}\n"
64
+ assert result.stderr == ""
65
+
66
+
67
+ def test_changed_persona_wins_only_when_in_discovered_roster(tmp_path: Path):
68
+ prebuilt = tmp_path / "prebuilt"
69
+ _write_meta(prebuilt, "master-alpha", [{"id": "a"}])
70
+ _write_meta(prebuilt, "master-beta", [{"id": "b"}])
71
+
72
+ selected = _run_selector(prebuilt, "008", "master-beta")
73
+ rotated = _run_selector(prebuilt, "008", "master-not-discovered")
74
+
75
+ assert selected.returncode == 0, selected.stderr
76
+ assert selected.stdout == "master-beta\n"
77
+ assert rotated.returncode == 0, rotated.stderr
78
+ assert rotated.stdout == "master-alpha\n"
79
+
80
+
81
+ def test_meta_skill_before_persona_does_not_hide_changed_persona(tmp_path: Path):
82
+ prebuilt = tmp_path / "prebuilt"
83
+ _write_meta(prebuilt, "master-alpha", [{"id": "a"}])
84
+ _write_meta(prebuilt, "master-beta", [{"id": "b"}])
85
+
86
+ result = _run_selector(
87
+ prebuilt,
88
+ "008",
89
+ ["compare-masters", "master-beta", "master-alpha"],
90
+ )
91
+
92
+ assert result.returncode == 0, result.stderr
93
+ assert result.stdout == "master-beta\n"
94
+
95
+
96
+ def test_discovery_ignores_meta_skills_and_empty_sources(tmp_path: Path):
97
+ prebuilt = tmp_path / "prebuilt"
98
+ _write_meta(prebuilt, "compare-masters", [{"id": "meta"}])
99
+ _write_meta(prebuilt, "master-empty", [])
100
+ _write_meta(prebuilt, "master-valid", [{"id": "source"}])
101
+
102
+ result = _run_selector(prebuilt, "100")
103
+
104
+ assert result.returncode == 0, result.stderr
105
+ assert result.stdout == "master-valid\n"
106
+
107
+
108
+ def test_empty_roster_fails_closed(tmp_path: Path):
109
+ prebuilt = tmp_path / "prebuilt"
110
+ _write_meta(prebuilt, "master-empty", [])
111
+
112
+ result = _run_selector(prebuilt, "008")
113
+
114
+ assert result.returncode != 0
115
+ assert result.stdout == ""
116
+ assert "No persona smoke targets" in result.stderr
117
+
118
+
119
+ def test_invalid_json_after_valid_metadata_fails_without_partial_stdout(tmp_path: Path):
120
+ prebuilt = tmp_path / "prebuilt"
121
+ _write_meta(prebuilt, "master-alpha", [{"id": "a"}])
122
+ invalid_dir = prebuilt / "master-zeta"
123
+ invalid_dir.mkdir(parents=True)
124
+ (invalid_dir / "meta.json").write_text("{not-json", encoding="utf-8")
125
+
126
+ result = _run_selector(prebuilt, "008")
127
+
128
+ assert result.returncode != 0
129
+ assert result.stdout == ""
130
+ assert "master-zeta/meta.json" in result.stderr
131
+
132
+
133
+ @pytest.mark.parametrize("day_of_year", ["0", "367", "not-a-day"])
134
+ def test_invalid_day_fails_closed(tmp_path: Path, day_of_year: str):
135
+ prebuilt = tmp_path / "prebuilt"
136
+ _write_meta(prebuilt, "master-alpha", [{"id": "a"}])
137
+
138
+ result = _run_selector(prebuilt, day_of_year)
139
+
140
+ assert result.returncode != 0
141
+ assert result.stdout == ""
142
+ assert "day-of-year" in result.stderr
@@ -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"
@@ -0,0 +1,408 @@
1
+ """Tests for the cross-tradition citation contract validator."""
2
+ from __future__ import annotations
3
+
4
+ import importlib.util
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+
12
+ @pytest.fixture
13
+ def validator():
14
+ script_path = Path(__file__).resolve().parents[1] / "validate-citation-contract.py"
15
+ spec = importlib.util.spec_from_file_location("validate_citation_contract", script_path)
16
+ module = importlib.util.module_from_spec(spec)
17
+ sys.modules["validate_citation_contract"] = module
18
+ spec.loader.exec_module(module)
19
+ return module
20
+
21
+
22
+ @pytest.fixture
23
+ def fake_tree(tmp_path: Path) -> Path:
24
+ return tmp_path / "prebuilt"
25
+
26
+
27
+ def contract(*types: str) -> dict:
28
+ return {
29
+ "version": 1,
30
+ "claim_policy": "declared_sources_only",
31
+ "required_for": [
32
+ "doctrinal_claim",
33
+ "practice_guidance",
34
+ "text_interpretation",
35
+ ],
36
+ "allowed_source_types": sorted(types),
37
+ "minimum_claim_coverage": 0.9,
38
+ "live_retrieval_allowed": True,
39
+ }
40
+
41
+
42
+ def write_meta(
43
+ prebuilt: Path,
44
+ slug: str,
45
+ source_types: list[str],
46
+ value: dict | None,
47
+ *,
48
+ kind: str | None = None,
49
+ ) -> None:
50
+ directory = prebuilt / f"master-{slug}"
51
+ directory.mkdir(parents=True, exist_ok=True)
52
+ data = {
53
+ "slug": slug,
54
+ "sources": [
55
+ {"type": source_type, "id": f"id-{index}", "title": f"source-{index}"}
56
+ for index, source_type in enumerate(source_types)
57
+ ],
58
+ }
59
+ if kind is not None:
60
+ data["kind"] = kind
61
+ if value is not None:
62
+ data["citation_contract"] = value
63
+ (directory / "meta.json").write_text(json.dumps(data), encoding="utf-8")
64
+
65
+
66
+ @pytest.mark.parametrize(
67
+ "source_types",
68
+ [
69
+ ["cbeta"],
70
+ ["tibetan_canon", "tibetan_treatise"],
71
+ ["compiled_teaching", "pali_canon", "pali_treatise"],
72
+ ],
73
+ )
74
+ def test_valid_source_family_contracts_pass(validator, fake_tree, source_types):
75
+ write_meta(fake_tree, "demo", source_types, contract(*source_types))
76
+ assert validator.validate(fake_tree) == []
77
+
78
+
79
+ def test_missing_contract_fails(validator, fake_tree):
80
+ write_meta(fake_tree, "demo", ["cbeta"], None)
81
+ errors = validator.validate(fake_tree)
82
+ assert any(
83
+ "master-demo/meta.json" in error and "citation_contract" in error
84
+ for error in errors
85
+ )
86
+
87
+
88
+ @pytest.mark.parametrize("allowed", [[], ["cbeta", "pali_canon"]])
89
+ def test_source_type_drift_fails(validator, fake_tree, allowed):
90
+ value = contract("cbeta")
91
+ value["allowed_source_types"] = allowed
92
+ write_meta(fake_tree, "demo", ["cbeta"], value)
93
+ errors = validator.validate(fake_tree)
94
+ assert any(
95
+ "master-demo/meta.json" in error and "allowed_source_types" in error
96
+ for error in errors
97
+ )
98
+
99
+
100
+ @pytest.mark.parametrize(
101
+ ("field", "invalid"),
102
+ [
103
+ ("version", 2),
104
+ ("claim_policy", "any_source"),
105
+ ("required_for", ["doctrinal_claim"]),
106
+ ("minimum_claim_coverage", True),
107
+ ("minimum_claim_coverage", 0.8),
108
+ ("live_retrieval_allowed", "yes"),
109
+ ],
110
+ )
111
+ def test_fixed_contract_values_are_enforced(
112
+ validator,
113
+ fake_tree,
114
+ field,
115
+ invalid,
116
+ ):
117
+ value = contract("cbeta")
118
+ value[field] = invalid
119
+ write_meta(fake_tree, "demo", ["cbeta"], value)
120
+ errors = validator.validate(fake_tree)
121
+ assert any(
122
+ "master-demo/meta.json" in error and field in error for error in errors
123
+ )
124
+
125
+
126
+ def test_meta_skill_without_sources_is_ignored(validator, fake_tree):
127
+ write_meta(fake_tree, "debate", [], None, kind="meta-skill")
128
+ assert validator.validate(fake_tree) == []
129
+
130
+
131
+ def test_meta_skill_must_not_declare_persona_contract(validator, fake_tree):
132
+ write_meta(
133
+ fake_tree,
134
+ "debate",
135
+ ["cbeta"],
136
+ contract("cbeta"),
137
+ kind="meta-skill",
138
+ )
139
+ errors = validator.validate(fake_tree)
140
+ assert any(
141
+ "master-debate/meta.json" in error and "meta-skill" in error
142
+ for error in errors
143
+ )
144
+
145
+
146
+ def test_persona_without_sources_is_not_treated_as_meta_skill(validator, fake_tree):
147
+ write_meta(fake_tree, "demo", [], None)
148
+ errors = validator.validate(fake_tree)
149
+ assert any(
150
+ "master-demo/meta.json" in error and "sources" in error for error in errors
151
+ )
152
+
153
+
154
+ @pytest.mark.parametrize("mutation", ["missing", "extra"])
155
+ def test_contract_keys_are_exact(validator, fake_tree, mutation):
156
+ value = contract("cbeta")
157
+ if mutation == "missing":
158
+ del value["claim_policy"]
159
+ else:
160
+ value["unexpected"] = "value"
161
+ write_meta(fake_tree, "demo", ["cbeta"], value)
162
+ errors = validator.validate(fake_tree)
163
+ assert any(
164
+ "master-demo/meta.json" in error and "citation_contract" in error
165
+ for error in errors
166
+ )
167
+
168
+
169
+ @pytest.mark.parametrize("source_type", ["", " ", None, True])
170
+ def test_source_types_must_be_non_empty_strings(
171
+ validator,
172
+ fake_tree,
173
+ source_type,
174
+ ):
175
+ write_meta(fake_tree, "demo", [source_type], contract("cbeta"))
176
+ errors = validator.validate(fake_tree)
177
+ assert any(
178
+ "master-demo/meta.json" in error and "sources[].type" in error
179
+ for error in errors
180
+ )
181
+
182
+
183
+ def test_cli_exit_codes_and_persona_count(
184
+ validator,
185
+ fake_tree,
186
+ monkeypatch,
187
+ capsys,
188
+ ):
189
+ monkeypatch.setattr(validator, "PREBUILT_DIR", fake_tree)
190
+ monkeypatch.setattr(validator, "EXPECTED_PERSONA_SLUGS", ("demo",))
191
+ write_meta(fake_tree, "demo", ["cbeta"], None)
192
+
193
+ assert validator.main() == 1
194
+ assert "master-demo/meta.json" in capsys.readouterr().err
195
+
196
+ write_meta(fake_tree, "demo", ["cbeta"], contract("cbeta"))
197
+ assert validator.main() == 0
198
+ captured = capsys.readouterr()
199
+ assert captured.err == ""
200
+ assert captured.out.strip() == "citation contracts OK (1 personas)"
201
+
202
+
203
+ def test_repository_mode_fails_when_prebuilt_directory_is_missing(
204
+ validator,
205
+ tmp_path,
206
+ ):
207
+ errors = validator.validate_repository(tmp_path / "missing")
208
+ assert any("prebuilt directory" in error for error in errors)
209
+
210
+
211
+ def test_repository_mode_fails_with_zero_personas(validator, fake_tree):
212
+ write_meta(fake_tree, "debate", [], None, kind="meta-skill")
213
+ errors = validator.validate_repository(fake_tree)
214
+ assert any("persona roster" in error and "missing" in error for error in errors)
215
+
216
+
217
+ def test_repository_mode_fails_with_fourteen_personas(validator, fake_tree):
218
+ for slug in validator.EXPECTED_PERSONA_SLUGS[:-1]:
219
+ write_meta(fake_tree, slug, ["cbeta"], contract("cbeta"))
220
+
221
+ errors = validator.validate_repository(fake_tree)
222
+ missing = validator.EXPECTED_PERSONA_SLUGS[-1]
223
+ assert any(
224
+ "persona roster" in error and f"master-{missing}" in error
225
+ for error in errors
226
+ )
227
+
228
+
229
+ def test_repository_mode_accepts_the_real_fifteen_persona_roster(validator):
230
+ repository = Path(__file__).resolve().parents[2]
231
+ assert validator.validate_repository(repository / "prebuilt") == []
232
+
233
+
234
+ def test_repository_wording_uses_declared_source_contract():
235
+ repository = Path(__file__).resolve().parents[2]
236
+ runtime_paths = [
237
+ repository / "SKILL.md",
238
+ repository / "prebuilt" / "compare-masters" / "SKILL.md",
239
+ repository / "prompts" / "doctrine_reviewer.md",
240
+ repository / "references" / "ethics-runtime.md",
241
+ repository / "references" / "source-conventions.md",
242
+ repository / "docs" / "PRD.md",
243
+ repository / "docs" / "v1-framework-roadmap.md",
244
+ repository / "ETHICS.md",
245
+ repository / "README.md",
246
+ repository / "README_EN.md",
247
+ repository / "CONTRIBUTING.md",
248
+ ]
249
+ forbidden = [
250
+ "NO DOCTRINAL CLAIM WITHOUT CBETA CITATION",
251
+ "每位祖师的回答必须附 CBETA 引用",
252
+ "CBETA 经证覆盖率 ≥ 90%",
253
+ "primary_cbeta_ids 过滤结果",
254
+ "当前版本仅汉传",
255
+ "无 CBETA 经号的教义断言不得写入",
256
+ "每一条教义断言必须附一个**真实**的 CBETA 经号",
257
+ ]
258
+ for path in runtime_paths:
259
+ content = path.read_text(encoding="utf-8")
260
+ for phrase in forbidden:
261
+ assert phrase not in content, (
262
+ f"{path}: stale source-family rule: {phrase}"
263
+ )
264
+
265
+ root_text = runtime_paths[0].read_text(encoding="utf-8")
266
+ compare_text = runtime_paths[1].read_text(encoding="utf-8")
267
+ reviewer_text = runtime_paths[2].read_text(encoding="utf-8")
268
+ conventions_text = runtime_paths[4].read_text(encoding="utf-8")
269
+
270
+ for text in (root_text, compare_text):
271
+ assert "NO DOCTRINAL CLAIM WITHOUT A DECLARED SOURCE CITATION" in text
272
+ assert "citation_contract.allowed_source_types" in text
273
+ assert "meta.json.sources[]" in text
274
+ assert "live_retrieval_allowed" in text
275
+
276
+ assert "primary_cbeta_ids" not in compare_text
277
+ assert "minimum_claim_coverage" in reviewer_text
278
+ assert "同等适用 citation contract" in conventions_text
279
+ contributing_text = runtime_paths[-1].read_text(encoding="utf-8")
280
+ assert "validate-citation-contract.py" in contributing_text
281
+ assert '"citation_contract"' in contributing_text
282
+ assert "meta.json.sources[]" in contributing_text
283
+ for source_family in (
284
+ "CBETA",
285
+ "BDRC / Toh",
286
+ "PTS / SuttaCentral",
287
+ "compiled teachings",
288
+ ):
289
+ assert source_family in conventions_text
290
+
291
+
292
+ def test_non_cbeta_runtime_instructions_are_source_family_aware():
293
+ repository = Path(__file__).resolve().parents[2]
294
+ slugs = (
295
+ "atisha",
296
+ "tsongkhapa",
297
+ "milarepa",
298
+ "buddhaghosa",
299
+ "mahasi-sayadaw",
300
+ "ajahn-chah",
301
+ )
302
+ for slug in slugs:
303
+ persona = repository / "prebuilt" / f"master-{slug}"
304
+ meta = json.loads((persona / "meta.json").read_text(encoding="utf-8"))
305
+ assert "cbeta" not in meta["citation_contract"]["allowed_source_types"]
306
+ instructions = (persona / "SKILL.md").read_text(encoding="utf-8")
307
+ for stale in ("cbeta_id", "--sources cbeta", "以 CBETA 汉文为主"):
308
+ assert stale not in instructions, f"master-{slug}: stale {stale}"
309
+ for required in (
310
+ "source_type",
311
+ "source_id",
312
+ "meta.json.sources[]",
313
+ "citation_contract.allowed_source_types",
314
+ ):
315
+ assert required in instructions, f"master-{slug}: missing {required}"
316
+
317
+ rag = (repository / "prompts" / "rag_instructions.md").read_text(
318
+ encoding="utf-8"
319
+ )
320
+ assert "--sources cbeta" not in rag
321
+ for required in (
322
+ "source_type",
323
+ "source_id",
324
+ "meta.json.sources[]",
325
+ "citation_contract.allowed_source_types",
326
+ ):
327
+ assert required in rag
328
+
329
+
330
+ def test_live_retrieval_never_uses_sources_outside_the_declared_contract():
331
+ repository = Path(__file__).resolve().parents[2]
332
+ persona_skills = []
333
+ for path in sorted((repository / "prebuilt").glob("master-*/SKILL.md")):
334
+ meta_path = path.parent / "meta.json"
335
+ if not meta_path.is_file():
336
+ continue
337
+ metadata = json.loads(meta_path.read_text(encoding="utf-8"))
338
+ if isinstance(metadata.get("sources"), list) and metadata["sources"]:
339
+ persona_skills.append(path)
340
+ assert len(persona_skills) == 15
341
+ for path in persona_skills:
342
+ content = path.read_text(encoding="utf-8")
343
+ for stale in (
344
+ "声明经典之外",
345
+ "所列经典之外",
346
+ "三部经之外",
347
+ "上述三部经之外",
348
+ ):
349
+ assert stale not in content, f"{path}: contradictory live trigger {stale}"
350
+ assert "先人工扩充 `sources[]` / citation contract" in content
351
+ assert "已声明来源" in content
352
+
353
+
354
+ def test_create_master_docs_match_the_real_generator_cli():
355
+ repository = Path(__file__).resolve().parents[2]
356
+ root = (repository / "SKILL.md").read_text(encoding="utf-8")
357
+ workflow = (repository / "references" / "workflow-details.md").read_text(
358
+ encoding="utf-8"
359
+ )
360
+ conventions = (repository / "references" / "source-conventions.md").read_text(
361
+ encoding="utf-8"
362
+ )
363
+ combined = root + workflow + conventions
364
+ assert "sutra_collector.py --name" in combined
365
+ assert "--output collected_data.json" in combined
366
+ assert "verify_sources.py --check-links collected_data.json" in combined
367
+ assert "master_builder.py --spec generated-master.json" in combined
368
+ assert "verify_sources.py --final-check" in combined
369
+ assert "masters/master-{slug}/" in root
370
+ assert "masters/master-{slug}/" in workflow
371
+ assert "masters/master-{slug}/" in conventions
372
+ assert "masters/{slug}/" not in combined
373
+ assert "生成器内存" in root
374
+ assert "master_builder.py --name" not in combined
375
+
376
+
377
+ def test_create_master_prompts_and_contribution_paths_are_source_neutral():
378
+ repository = Path(__file__).resolve().parents[2]
379
+ source_prompts = [
380
+ repository / "prompts" / "sutra_analyzer.md",
381
+ repository / "prompts" / "teaching_builder.md",
382
+ repository / "prompts" / "voice_builder.md",
383
+ ]
384
+ for path in source_prompts:
385
+ content = path.read_text(encoding="utf-8")
386
+ for stale in (
387
+ "`cbeta_id`",
388
+ "`fojin_url`",
389
+ "所有经文引用必须附 FoJin 链接",
390
+ "https://fojin.app/texts/{text_id}",
391
+ ):
392
+ assert stale not in content, f"{path}: stale {stale}"
393
+ assert "source_id" in content, f"{path}: missing source_id"
394
+
395
+ intake = (repository / "prompts" / "intake.md").read_text(encoding="utf-8")
396
+ for tradition in ("印度", "汉传", "藏传", "南传"):
397
+ assert tradition in intake
398
+ assert "当前版本仅汉传" not in intake
399
+
400
+ pull_request = (
401
+ repository / ".github" / "PULL_REQUEST_TEMPLATE.md"
402
+ ).read_text(encoding="utf-8")
403
+ issue = (
404
+ repository / ".github" / "ISSUE_TEMPLATE" / "new_master.yml"
405
+ ).read_text(encoding="utf-8")
406
+ for content in (pull_request, issue):
407
+ assert "声明来源" in content
408
+ assert "validate-citation-contract.py" in pull_request