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,230 @@
1
+ """
2
+ Skill Writer — creates and updates teacher skill directories.
3
+ Adapted from colleague-skill's skill_writer.py for Buddhist master context.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import shutil
10
+ from datetime import datetime
11
+ from typing import Optional
12
+
13
+ try:
14
+ from pypinyin import lazy_pinyin, Style
15
+ HAS_PYPINYIN = True
16
+ except ImportError:
17
+ HAS_PYPINYIN = False
18
+
19
+
20
+ # Generated teaching/voice content is LLM output derived from external FoJin
21
+ # data. Before it is written into SKILL.md (which the agent later loads and
22
+ # follows as instructions) strip C0/C1 control chars except \n and \t: ANSI
23
+ # escapes / NULs serve no purpose in Buddhist-text markdown and could be used
24
+ # to hide content or spoof a terminal. Defense-in-depth alongside the prompt-
25
+ # level injection guards (prompts/sutra_analyzer.md, rag_instructions.md).
26
+ _CONTROL_CHARS = re.compile(
27
+ r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f"
28
+ r"\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]"
29
+ )
30
+
31
+ REQUIRED_CITATION_CLAIMS = [
32
+ "doctrinal_claim",
33
+ "practice_guidance",
34
+ "text_interpretation",
35
+ ]
36
+
37
+
38
+ def sanitize_generated(content: str) -> str:
39
+ """Strip control characters from generated content before persisting."""
40
+ if not content:
41
+ return content
42
+ return _CONTROL_CHARS.sub("", content)
43
+
44
+
45
+ def derive_citation_contract(sources: list[dict]) -> dict:
46
+ """Build the exact version-1 contract for a non-empty source manifest."""
47
+ if not isinstance(sources, list) or not sources:
48
+ raise ValueError("sources must be a non-empty list")
49
+
50
+ source_types: list[str] = []
51
+ for index, source in enumerate(sources):
52
+ source_type = source.get("type") if isinstance(source, dict) else None
53
+ if not isinstance(source_type, str) or not source_type.strip():
54
+ raise ValueError(
55
+ f"sources[{index}].type must be a non-empty string"
56
+ )
57
+ source_types.append(source_type)
58
+
59
+ return {
60
+ "version": 1,
61
+ "claim_policy": "declared_sources_only",
62
+ "required_for": list(REQUIRED_CITATION_CLAIMS),
63
+ "allowed_source_types": sorted(set(source_types)),
64
+ "minimum_claim_coverage": 0.9,
65
+ "live_retrieval_allowed": True,
66
+ }
67
+
68
+
69
+ SKILL_MD_TEMPLATE = """---
70
+ name: master-{slug}
71
+ description: 依据{name}({tradition}{school})的教学风格与教义体系
72
+ user-invocable: true
73
+ ---
74
+
75
+ # {name}
76
+
77
+ {disclaimer}
78
+
79
+ ---
80
+
81
+ ## PART A — 教义体系
82
+
83
+ {teaching_content}
84
+
85
+ ## PART B — 说法风格
86
+
87
+ {voice_content}
88
+
89
+ ## 运行规则
90
+
91
+ 1. 收到提问后,先依据 voice.md Layer 0 硬规则检查
92
+ 2. 依据 voice.md Layer 1-3 确定回答的风格和方式
93
+ 3. 依据 teaching.md 检索相关教义内容
94
+ 4. 以该法师的风格组织回答
95
+ 5. 教义断言、修行指导、文本解释必须引用 `meta.json.sources[]` 中的 `source_id`,且来源类型须列于 `citation_contract.allowed_source_types`
96
+ 6. live 结果只有在返回的来源类型与标识可解析到上述声明来源时才可引用;否则剥离相关断言
97
+ 7. 遇到超出范围的问题,坦诚说明并建议查阅相关传承
98
+ """
99
+
100
+ DISCLAIMER = "本内容依据历史佛教文献生成,仅供参考学习。如需正式修行指导,请亲近善知识。所有回答均须附 persona 已声明且可核验的来源。"
101
+
102
+
103
+ def slugify(name: str) -> str:
104
+ """Convert teacher name to URL-safe slug."""
105
+ if HAS_PYPINYIN:
106
+ pinyin_list = lazy_pinyin(name, style=Style.NORMAL)
107
+ slug = "-".join(pinyin_list)
108
+ else:
109
+ slug = name
110
+ slug = slug.lower().replace(" ", "-")
111
+ slug = "".join(c for c in slug if c.isalnum() or c == "-")
112
+ slug = slug.strip("-")
113
+ return slug
114
+
115
+
116
+ def create_teacher(
117
+ base_dir: str,
118
+ name: str,
119
+ tradition: str,
120
+ school: str,
121
+ era: str,
122
+ languages: list,
123
+ teaching_content: str,
124
+ voice_content: str,
125
+ fojin_entity_id: Optional[str] = None,
126
+ sources: Optional[list] = None,
127
+ citation_contract: Optional[dict] = None,
128
+ ) -> str:
129
+ """Create a new teacher skill directory."""
130
+ source_manifest = sources if sources is not None else []
131
+ expected_contract = derive_citation_contract(source_manifest)
132
+ if citation_contract is None:
133
+ citation_contract = expected_contract
134
+ elif citation_contract != expected_contract:
135
+ raise ValueError(
136
+ "citation_contract must equal the contract derived from sources[].type"
137
+ )
138
+
139
+ teaching_content = sanitize_generated(teaching_content)
140
+ voice_content = sanitize_generated(voice_content)
141
+ slug = slugify(name)
142
+ teacher_dir = os.path.join(base_dir, f"master-{slug}")
143
+ os.makedirs(teacher_dir, exist_ok=True)
144
+ os.makedirs(os.path.join(teacher_dir, "versions"), exist_ok=True)
145
+
146
+ with open(os.path.join(teacher_dir, "teaching.md"), "w", encoding="utf-8") as f:
147
+ f.write(teaching_content)
148
+
149
+ with open(os.path.join(teacher_dir, "voice.md"), "w", encoding="utf-8") as f:
150
+ f.write(voice_content)
151
+
152
+ skill_content = SKILL_MD_TEMPLATE.format(
153
+ slug=slug, name=name, tradition=tradition, school=school,
154
+ disclaimer=DISCLAIMER, teaching_content=teaching_content,
155
+ voice_content=voice_content,
156
+ )
157
+ with open(os.path.join(teacher_dir, "SKILL.md"), "w", encoding="utf-8") as f:
158
+ f.write(skill_content)
159
+
160
+ meta = {
161
+ "name": name, "slug": slug, "tradition": tradition, "school": school,
162
+ "era": era, "languages": languages, "fojin_entity_id": fojin_entity_id,
163
+ "sources": source_manifest, "citation_contract": citation_contract,
164
+ "version": "1.0.0",
165
+ "created_at": datetime.now().strftime("%Y-%m-%d"),
166
+ "updated_at": datetime.now().strftime("%Y-%m-%d"),
167
+ "disclaimer": DISCLAIMER,
168
+ }
169
+ with open(os.path.join(teacher_dir, "meta.json"), "w", encoding="utf-8") as f:
170
+ json.dump(meta, f, ensure_ascii=False, indent=2)
171
+
172
+ return teacher_dir
173
+
174
+
175
+ def update_teacher(teacher_dir: str, teaching_patch: Optional[str] = None, voice_patch: Optional[str] = None) -> str:
176
+ """Update an existing teacher skill with new content. Archives current version before updating."""
177
+ meta_path = os.path.join(teacher_dir, "meta.json")
178
+ with open(meta_path, "r", encoding="utf-8") as f:
179
+ meta = json.load(f)
180
+
181
+ version = meta.get("version", "1.0.0")
182
+ version_dir = os.path.join(teacher_dir, "versions", f"v{version}")
183
+ os.makedirs(version_dir, exist_ok=True)
184
+ for fname in ["SKILL.md", "teaching.md", "voice.md", "meta.json"]:
185
+ src = os.path.join(teacher_dir, fname)
186
+ if os.path.exists(src):
187
+ shutil.copy2(src, version_dir)
188
+
189
+ if teaching_patch:
190
+ with open(os.path.join(teacher_dir, "teaching.md"), "a", encoding="utf-8") as f:
191
+ f.write("\n\n" + sanitize_generated(teaching_patch))
192
+
193
+ if voice_patch:
194
+ with open(os.path.join(teacher_dir, "voice.md"), "a", encoding="utf-8") as f:
195
+ f.write("\n\n" + sanitize_generated(voice_patch))
196
+
197
+ parts = version.split(".")
198
+ parts[1] = str(int(parts[1]) + 1)
199
+ new_version = ".".join(parts)
200
+
201
+ meta["version"] = new_version
202
+ meta["updated_at"] = datetime.now().strftime("%Y-%m-%d")
203
+ with open(meta_path, "w", encoding="utf-8") as f:
204
+ json.dump(meta, f, ensure_ascii=False, indent=2)
205
+
206
+ teaching_content = open(os.path.join(teacher_dir, "teaching.md"), encoding="utf-8").read()
207
+ voice_content = open(os.path.join(teacher_dir, "voice.md"), encoding="utf-8").read()
208
+ skill_content = SKILL_MD_TEMPLATE.format(
209
+ slug=meta["slug"], name=meta["name"], tradition=meta["tradition"],
210
+ school=meta["school"], disclaimer=DISCLAIMER,
211
+ teaching_content=teaching_content, voice_content=voice_content,
212
+ )
213
+ with open(os.path.join(teacher_dir, "SKILL.md"), "w", encoding="utf-8") as f:
214
+ f.write(skill_content)
215
+
216
+ return new_version
217
+
218
+
219
+ def list_teachers(base_dir: str) -> list:
220
+ """List all teacher skills in a directory."""
221
+ teachers = []
222
+ if not os.path.exists(base_dir):
223
+ return teachers
224
+ for entry in sorted(os.listdir(base_dir)):
225
+ meta_path = os.path.join(base_dir, entry, "meta.json")
226
+ if os.path.isfile(meta_path):
227
+ with open(meta_path, "r", encoding="utf-8") as f:
228
+ meta = json.load(f)
229
+ teachers.append(meta)
230
+ return teachers
@@ -0,0 +1,237 @@
1
+ """
2
+ Sutra Collector — gathers Buddhist texts and metadata for a specific teacher.
3
+
4
+ Orchestrates FoJin Bridge calls to collect:
5
+ 1. Teacher's KG entity and relations (lineage, school, texts)
6
+ 2. Core texts associated with the teacher
7
+ 3. Text content (selected juans)
8
+ 4. Dictionary terms related to the teacher's tradition
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import sys
17
+ from typing import Optional
18
+
19
+ from fojin_bridge import FojinBridge, create_bridge
20
+ from skill_writer import derive_citation_contract
21
+
22
+
23
+ def _source_identity_from_text(text: dict) -> tuple[str, str] | None:
24
+ """Normalize one FoJin text record to the source-neutral identity schema."""
25
+ source_type = text.get("source_type")
26
+ source_id = text.get("source_id")
27
+ if not source_type and text.get("cbeta_id"):
28
+ source_type = "cbeta"
29
+ source_id = text["cbeta_id"]
30
+ if not isinstance(source_type, str) or not isinstance(source_id, str):
31
+ return None
32
+ if not source_type.strip() or not source_id.strip():
33
+ return None
34
+ return source_type.strip(), source_id.strip()
35
+
36
+
37
+ def _declared_sources_from_texts(texts: list[dict]) -> list[dict]:
38
+ """Extract only canonical source identifiers returned by FoJin."""
39
+ sources: list[dict] = []
40
+ seen: set[tuple[str, str]] = set()
41
+ for text in texts:
42
+ identity = _source_identity_from_text(text)
43
+ if identity is None:
44
+ continue
45
+ source_type, source_id = identity
46
+ member = identity
47
+ if member in seen:
48
+ continue
49
+ seen.add(member)
50
+ sources.append(
51
+ {
52
+ "type": source_type,
53
+ "id": source_id,
54
+ "title": text.get("title") or text.get("title_zh") or source_id,
55
+ }
56
+ )
57
+ return sources
58
+
59
+
60
+ def collect_teacher_data(
61
+ teacher_name: str,
62
+ tradition: Optional[str] = None,
63
+ bridge: Optional[FojinBridge] = None,
64
+ ) -> dict:
65
+ """Collect all available data about a teacher from FoJin."""
66
+ if bridge is None:
67
+ bridge = create_bridge()
68
+
69
+ result = {
70
+ "entity": None,
71
+ "lineage": [],
72
+ "texts": [],
73
+ "content_samples": [],
74
+ "terms": [],
75
+ "sources": [],
76
+ }
77
+
78
+ # Step 1: Find teacher in KG
79
+ kg_results = bridge.search_kg_entities(teacher_name, entity_type="person")
80
+ if kg_results.get("results"):
81
+ entity = kg_results["results"][0]
82
+ result["entity"] = entity
83
+ detail = bridge.get_kg_entity(entity["id"])
84
+ result["entity"] = detail
85
+ for rel in detail.get("relations", []):
86
+ if rel["predicate"] in (
87
+ "teacher_of", "student_of", "lineage_holder",
88
+ "transmitted_to", "received_from",
89
+ ):
90
+ result["lineage"].append(rel)
91
+
92
+ # Step 2: Search for associated texts
93
+ text_results = bridge.search_texts(teacher_name, size=50)
94
+ if text_results.get("results"):
95
+ result["texts"] = text_results["results"]
96
+ result["sources"] = _declared_sources_from_texts(result["texts"])
97
+ if result["sources"]:
98
+ result["citation_contract"] = derive_citation_contract(result["sources"])
99
+
100
+ # Step 3: Collect content samples from top texts
101
+ for text in result["texts"][:5]:
102
+ text_id = text.get("id")
103
+ if not text_id:
104
+ continue
105
+ try:
106
+ content = bridge.get_text_content(text_id, juan_num=1)
107
+ sample = {
108
+ "text_id": text_id,
109
+ "title": text.get("title_zh", ""),
110
+ "content": content.get("content", "")[:3000],
111
+ }
112
+ identity = _source_identity_from_text(text)
113
+ if identity is not None:
114
+ sample["source_type"], sample["source_id"] = identity
115
+ result["content_samples"].append(sample)
116
+ except Exception:
117
+ continue
118
+
119
+ # Step 4: Collect tradition-specific terms
120
+ if tradition:
121
+ tradition_terms = {
122
+ "汉传": ["净土", "禅", "般若", "菩提", "念佛"],
123
+ "南传": ["vipassana", "satipatthana", "anicca", "dukkha", "anatta"],
124
+ "藏传": ["菩提道次第", "空性", "菩提心", "止观", "三主要道"],
125
+ }
126
+ for term in tradition_terms.get(tradition, []):
127
+ dict_results = bridge.search_dictionary(term, size=5)
128
+ if dict_results.get("results"):
129
+ result["terms"].extend(dict_results["results"])
130
+
131
+ return result
132
+
133
+
134
+ def collect_specific_texts(
135
+ cbeta_ids: list,
136
+ bridge: Optional[FojinBridge] = None,
137
+ ) -> list:
138
+ """Collect full content for specific texts by CBETA ID."""
139
+ if bridge is None:
140
+ bridge = create_bridge()
141
+
142
+ texts = []
143
+ id_map = bridge.lookup_cbeta_ids(",".join(cbeta_ids))
144
+
145
+ for cbeta_id, text_id in id_map.items():
146
+ if not text_id:
147
+ continue
148
+ text_meta = bridge.get_text(text_id)
149
+ juan_list = bridge.get_text_juans(text_id)
150
+
151
+ text_data = {
152
+ "cbeta_id": cbeta_id,
153
+ "text_id": text_id,
154
+ "title": text_meta.get("title_zh", ""),
155
+ "juans": [],
156
+ }
157
+
158
+ for juan in juan_list.get("juans", [])[:10]:
159
+ content = bridge.get_text_content(text_id, juan["juan_num"])
160
+ text_data["juans"].append({
161
+ "juan_num": juan["juan_num"],
162
+ "content": content.get("content", ""),
163
+ })
164
+
165
+ texts.append(text_data)
166
+
167
+ return texts
168
+
169
+
170
+ def save_collected_data(data: dict, output_path: str) -> str:
171
+ """Save collected data to JSON file."""
172
+ parent = os.path.dirname(output_path)
173
+ if parent:
174
+ os.makedirs(parent, exist_ok=True)
175
+ with open(output_path, "w", encoding="utf-8") as f:
176
+ json.dump(data, f, ensure_ascii=False, indent=2)
177
+ return os.path.abspath(output_path)
178
+
179
+
180
+ def offline_smoke_data(name: str, tradition: str | None) -> dict:
181
+ """Return deterministic no-network data for installed-runtime smoke tests."""
182
+ sources = [
183
+ {
184
+ "type": "compiled_teaching",
185
+ "id": "OfflineSmoke:Deterministic",
186
+ "title": "Deterministic offline smoke source",
187
+ }
188
+ ]
189
+ return {
190
+ "entity": {
191
+ "name_zh": name,
192
+ "entity_type": "person",
193
+ "description": "deterministic offline smoke",
194
+ },
195
+ "tradition": tradition,
196
+ "lineage": [],
197
+ "texts": [],
198
+ "content_samples": [],
199
+ "terms": [],
200
+ "sources": sources,
201
+ "citation_contract": derive_citation_contract(sources),
202
+ }
203
+
204
+
205
+ def main(argv: list[str] | None = None) -> int:
206
+ parser = argparse.ArgumentParser(
207
+ description="Collect declared Buddhist source data for create-master"
208
+ )
209
+ parser.add_argument("--name", required=True, help="historical teacher name")
210
+ parser.add_argument("--tradition", default=None, help="汉传 / 藏传 / 南传")
211
+ parser.add_argument("--output", required=True, help="output JSON manifest")
212
+ parser.add_argument(
213
+ "--offline-smoke",
214
+ action="store_true",
215
+ help="write deterministic source data without network access",
216
+ )
217
+ args = parser.parse_args(argv)
218
+
219
+ if args.offline_smoke:
220
+ data = offline_smoke_data(args.name, args.tradition)
221
+ else:
222
+ data = collect_teacher_data(args.name, args.tradition)
223
+ if not data.get("sources"):
224
+ print(
225
+ "ERROR: FoJin returned no canonical declared source identifiers; "
226
+ "curate sources[] before review",
227
+ file=sys.stderr,
228
+ )
229
+ return 1
230
+
231
+ destination = save_collected_data(data, args.output)
232
+ print(f"collected data written: {destination}")
233
+ return 0
234
+
235
+
236
+ if __name__ == "__main__":
237
+ sys.exit(main())