master-skill 0.9.1 → 0.10.1

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 (88) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.cursor-plugin/plugin.json +2 -2
  4. package/ETHICS.md +23 -17
  5. package/README.md +57 -14
  6. package/README_EN.md +60 -16
  7. package/SKILL.md +5 -5
  8. package/bin/cli.mjs +497 -62
  9. package/gemini-extension.json +2 -2
  10. package/hooks/run-hook.cmd +18 -5
  11. package/hooks/tests/test_run_hook.sh +114 -0
  12. package/hooks/tests/test_run_hook_cmd.sh +94 -0
  13. package/masters/.gitkeep +0 -0
  14. package/package.json +9 -3
  15. package/prebuilt/compare/SKILL.md +48 -14
  16. package/prebuilt/compare/tests/fidelity.jsonl +12 -12
  17. package/prebuilt/master-ajahn-chah/SKILL.md +13 -11
  18. package/prebuilt/master-ajahn-chah/meta.json +8 -0
  19. package/prebuilt/master-ajahn-chah/references/voice.md +1 -1
  20. package/prebuilt/master-atisha/SKILL.md +13 -11
  21. package/prebuilt/master-atisha/meta.json +8 -0
  22. package/prebuilt/master-atisha/references/voice.md +1 -1
  23. package/prebuilt/master-buddhaghosa/SKILL.md +13 -11
  24. package/prebuilt/master-buddhaghosa/meta.json +8 -0
  25. package/prebuilt/master-buddhaghosa/references/voice.md +1 -1
  26. package/prebuilt/master-fazang/SKILL.md +3 -3
  27. package/prebuilt/master-fazang/meta.json +8 -0
  28. package/prebuilt/master-huineng/SKILL.md +3 -3
  29. package/prebuilt/master-huineng/meta.json +8 -0
  30. package/prebuilt/master-kumarajiva/SKILL.md +3 -3
  31. package/prebuilt/master-kumarajiva/meta.json +8 -0
  32. package/prebuilt/master-mahasi-sayadaw/SKILL.md +13 -11
  33. package/prebuilt/master-mahasi-sayadaw/meta.json +8 -0
  34. package/prebuilt/master-mahasi-sayadaw/references/voice.md +2 -2
  35. package/prebuilt/master-milarepa/SKILL.md +13 -11
  36. package/prebuilt/master-milarepa/meta.json +8 -0
  37. package/prebuilt/master-milarepa/references/voice.md +1 -1
  38. package/prebuilt/master-nagarjuna/SKILL.md +3 -3
  39. package/prebuilt/master-nagarjuna/meta.json +8 -0
  40. package/prebuilt/master-ouyi/SKILL.md +3 -3
  41. package/prebuilt/master-ouyi/meta.json +8 -0
  42. package/prebuilt/master-tsongkhapa/SKILL.md +13 -11
  43. package/prebuilt/master-tsongkhapa/meta.json +8 -0
  44. package/prebuilt/master-tsongkhapa/references/voice.md +1 -1
  45. package/prebuilt/master-xuanzang/SKILL.md +3 -3
  46. package/prebuilt/master-xuanzang/meta.json +8 -0
  47. package/prebuilt/master-xuyun/SKILL.md +3 -3
  48. package/prebuilt/master-xuyun/meta.json +8 -0
  49. package/prebuilt/master-yinguang/SKILL.md +3 -3
  50. package/prebuilt/master-yinguang/meta.json +8 -0
  51. package/prebuilt/master-zhiyi/SKILL.md +3 -3
  52. package/prebuilt/master-zhiyi/meta.json +8 -0
  53. package/prompts/correction_handler.md +104 -0
  54. package/prompts/doctrine_reviewer.md +61 -0
  55. package/prompts/intake.md +62 -0
  56. package/prompts/merger.md +62 -0
  57. package/prompts/rag_instructions.md +54 -0
  58. package/prompts/sutra_analyzer.md +83 -0
  59. package/prompts/teaching_builder.md +41 -0
  60. package/prompts/voice_analyzer.md +92 -0
  61. package/prompts/voice_builder.md +48 -0
  62. package/prompts/voice_reviewer.md +66 -0
  63. package/references/README.md +12 -0
  64. package/references/ethics-runtime.md +112 -0
  65. package/references/fojin-api.md +223 -0
  66. package/references/source-conventions.md +129 -0
  67. package/references/teaching-modes.md +84 -0
  68. package/references/traditions.md +72 -0
  69. package/references/workflow-details.md +361 -0
  70. package/requirements.txt +6 -0
  71. package/scripts/select-fidelity-smoke.py +78 -0
  72. package/scripts/test-fidelity.py +40 -10
  73. package/scripts/tests/test_select_fidelity_smoke.py +142 -0
  74. package/scripts/tests/test_validate_citation_contract.py +408 -0
  75. package/scripts/tests/test_validate_fidelity.py +59 -0
  76. package/scripts/tests/test_validate_workflow.py +265 -0
  77. package/scripts/validate-citation-contract.py +193 -0
  78. package/scripts/validate-fidelity.py +20 -0
  79. package/scripts/verify_citations.py +8 -1
  80. package/skill-catalog.json +147 -0
  81. package/tools/cross_reference.py +365 -0
  82. package/tools/fojin_bridge.py +146 -0
  83. package/tools/master_builder.py +341 -0
  84. package/tools/rag_query.py +336 -0
  85. package/tools/skill_writer.py +230 -0
  86. package/tools/sutra_collector.py +237 -0
  87. package/tools/verify_sources.py +512 -0
  88. package/tools/version_manager.py +88 -0
@@ -138,6 +138,7 @@ def run_tests(
138
138
  dry_run: bool = False,
139
139
  model: str = "claude-sonnet-4-6",
140
140
  max_tests: int | None = None,
141
+ quiet: bool = False,
141
142
  ) -> dict:
142
143
  """Run fidelity tests for a master. Returns summary."""
143
144
  master_dir = PREBUILT_DIR / master_name
@@ -197,7 +198,8 @@ def run_tests(
197
198
  failed = 0
198
199
 
199
200
  for i, test in enumerate(tests):
200
- print(f" [{i+1}/{len(tests)}] {test['q'][:50]}...", end=" ", flush=True)
201
+ if not quiet:
202
+ print(f" [{i+1}/{len(tests)}] {test['q'][:50]}...", end=" ", flush=True)
201
203
 
202
204
  try:
203
205
  message = client.messages.create(
@@ -215,7 +217,8 @@ def run_tests(
215
217
  "error": str(e),
216
218
  })
217
219
  failed += 1
218
- print("API ERROR")
220
+ if not quiet:
221
+ print("API ERROR")
219
222
  continue
220
223
 
221
224
  check = check_response(
@@ -240,12 +243,14 @@ def run_tests(
240
243
 
241
244
  if check["passed"]:
242
245
  passed += 1
243
- print("PASS")
246
+ if not quiet:
247
+ print("PASS")
244
248
  else:
245
249
  failed += 1
246
250
  failures = (check["missing_cites"] + check["missing_mentions"]
247
251
  + check["forbidden_found"] + check["boundary_violations"])
248
- print(f"FAIL ({failures})")
252
+ if not quiet:
253
+ print(f"FAIL ({failures})")
249
254
 
250
255
  return {
251
256
  "master": master_name,
@@ -258,7 +263,22 @@ def run_tests(
258
263
  }
259
264
 
260
265
 
261
- def main():
266
+ def results_failed(results: list[dict], dry_run: bool) -> bool:
267
+ """Return whether collected fidelity results require a failing exit status."""
268
+ if dry_run:
269
+ return any("error" in suite for suite in results)
270
+ return any(
271
+ "error" in suite
272
+ or suite.get("failed", 0) > 0
273
+ or any(
274
+ case.get("status") in {"FAIL", "api_error"}
275
+ for case in suite.get("results", [])
276
+ )
277
+ for suite in results
278
+ )
279
+
280
+
281
+ def main() -> int:
262
282
  parser = argparse.ArgumentParser(description="Master-skill fidelity test runner")
263
283
  parser.add_argument("--master", type=str, help="Test a specific master")
264
284
  parser.add_argument("--all", action="store_true", help="Test all masters with fidelity.jsonl")
@@ -276,6 +296,9 @@ def main():
276
296
  if not args.master and not args.all:
277
297
  parser.error("Specify --master <name> or --all")
278
298
 
299
+ if args.json and hasattr(sys.stdout, "reconfigure"):
300
+ sys.stdout.reconfigure(encoding="utf-8")
301
+
279
302
  if args.all:
280
303
  masters = sorted(
281
304
  d.name for d in PREBUILT_DIR.iterdir()
@@ -286,11 +309,16 @@ def main():
286
309
 
287
310
  all_results = []
288
311
  for master in masters:
289
- print(f"\n{'='*50}")
290
- print(f"Testing: {master}")
291
- print(f"{'='*50}")
312
+ if not args.json:
313
+ print(f"\n{'='*50}")
314
+ print(f"Testing: {master}")
315
+ print(f"{'='*50}")
292
316
  result = run_tests(
293
- master, dry_run=args.dry_run, model=args.model, max_tests=args.max_tests
317
+ master,
318
+ dry_run=args.dry_run,
319
+ model=args.model,
320
+ max_tests=args.max_tests,
321
+ quiet=args.json,
294
322
  )
295
323
  all_results.append(result)
296
324
 
@@ -309,6 +337,8 @@ def main():
309
337
  else:
310
338
  print(f" {r['master']}: {r.get('passed', 0)}/{r['total']} ({r.get('pass_rate', 'N/A')})")
311
339
 
340
+ return 1 if results_failed(all_results, args.dry_run) else 0
341
+
312
342
 
313
343
  if __name__ == "__main__":
314
- main()
344
+ raise SystemExit(main())
@@ -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", "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", [{"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,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" / "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