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,512 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Validate declared persona source manifests, or audit legacy FoJin links.
4
+
5
+ Discovers CBETA IDs from meta.json sources and fojin.app URLs in markdown
6
+ files, then verifies each against FoJin's API and maps to internal text_ids.
7
+
8
+ Key insight: meta.json and URLs use the full CBETA catalog format (e.g.
9
+ T08n0235) while FoJin internally uses a shorter cbeta_id (e.g. T0235).
10
+ This script handles the conversion.
11
+
12
+ Offline modes validate family identifiers, declared membership, and citation
13
+ contracts. They do not parse free-text citations or check HTTP reachability.
14
+ The legacy no-argument / --fix modes only audit repository CBETA/FoJin URLs.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import os
22
+ import re
23
+ import sys
24
+ import time
25
+ from pathlib import Path
26
+
27
+ # Allow importing fojin_bridge from tools/
28
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
29
+
30
+ from fojin_bridge import create_bridge
31
+ from skill_writer import derive_citation_contract
32
+
33
+
34
+ PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
35
+ PREBUILT_DIR = os.path.join(PROJECT_ROOT, "prebuilt")
36
+
37
+ # Matches fojin.app/texts/<ID> in URLs — ID can be CBETA-style or numeric
38
+ FOJIN_URL_RE = re.compile(r"(https?://fojin\.app/texts/)([A-Za-z0-9n]+)")
39
+
40
+ # Full CBETA catalog ID pattern: T08n0235, X62n1182, J36n0348
41
+ FULL_CBETA_RE = re.compile(r"^([A-Z])(\d+)n(\d+[a-z]?)$")
42
+
43
+ SOURCE_ID_PATTERNS = {
44
+ "cbeta": FULL_CBETA_RE,
45
+ "tibetan_canon": re.compile(r"^(?:Toh[: ]\d+[A-Za-z-]*|BDRC:[A-Za-z0-9][A-Za-z0-9-]*)$"),
46
+ "kadam_corpus": re.compile(r"^BDRC:[A-Za-z0-9][A-Za-z0-9-]*$"),
47
+ "tibetan_treatise": re.compile(r"^[A-Za-z][A-Za-z0-9'-]*(?:-[A-Za-z0-9'-]+)*$"),
48
+ "pali_canon": re.compile(r"^(?:SuttaCentral|SC[: ][A-Za-z0-9. -]+|(?:DN|MN|SN|AN|KN) ?\d+(?:\.\d+)?)$"),
49
+ "pali_commentary": re.compile(r"^PTS:[A-Za-z0-9][A-Za-z0-9-]*$"),
50
+ "pali_treatise": re.compile(r"^PTS:[A-Za-z0-9][A-Za-z0-9-]*$"),
51
+ "compiled_teaching": re.compile(r"^[^:\s]+:[^:\s].+$"),
52
+ }
53
+
54
+
55
+ def validate_source_document(document: dict) -> list[str]:
56
+ """Validate declared source families, identifiers, contract, and citations."""
57
+ errors: list[str] = []
58
+ sources = document.get("sources") if isinstance(document, dict) else None
59
+ if not isinstance(sources, list) or not sources:
60
+ return ["sources must be a non-empty list"]
61
+
62
+ declared: set[tuple[str, str]] = set()
63
+ for index, source in enumerate(sources):
64
+ if not isinstance(source, dict):
65
+ errors.append(f"sources[{index}] must be an object")
66
+ continue
67
+ source_type = source.get("type")
68
+ source_id = source.get("id")
69
+ if source_type not in SOURCE_ID_PATTERNS:
70
+ errors.append(f"sources[{index}].type is unsupported: {source_type!r}")
71
+ continue
72
+ if not isinstance(source_id, str) or not SOURCE_ID_PATTERNS[source_type].fullmatch(source_id):
73
+ errors.append(
74
+ f"sources[{index}] {source_type} identifier is invalid: {source_id!r}"
75
+ )
76
+ continue
77
+ member = (source_type, source_id)
78
+ if member in declared:
79
+ errors.append(
80
+ f"sources[{index}] duplicates declared source {source_type}:{source_id}"
81
+ )
82
+ declared.add(member)
83
+
84
+ try:
85
+ expected_contract = derive_citation_contract(sources)
86
+ except ValueError as exc:
87
+ errors.append(str(exc))
88
+ else:
89
+ if document.get("citation_contract") != expected_contract:
90
+ errors.append(
91
+ "citation_contract must equal the contract derived from sources[].type"
92
+ )
93
+
94
+ citations = document.get("citations", [])
95
+ if not isinstance(citations, list):
96
+ errors.append("citations must be a list when present")
97
+ else:
98
+ for index, citation in enumerate(citations):
99
+ if not isinstance(citation, dict):
100
+ errors.append(f"citations[{index}] must be an object")
101
+ continue
102
+ member = (citation.get("type"), citation.get("id"))
103
+ if member not in declared:
104
+ errors.append(
105
+ f"citations[{index}] {member[0]}:{member[1]} is not declared in sources[]"
106
+ )
107
+
108
+ return errors
109
+
110
+
111
+ def _load_declared_source_target(target: str, *, final: bool) -> tuple[dict | None, list[str]]:
112
+ path = Path(target)
113
+ errors: list[str] = []
114
+ if final:
115
+ if not path.is_dir():
116
+ return None, [f"final-check target is not a persona directory: {path}"]
117
+ missing = [
118
+ name
119
+ for name in ("SKILL.md", "teaching.md", "voice.md", "meta.json")
120
+ if not (path / name).is_file()
121
+ ]
122
+ if missing:
123
+ return None, [f"final-check target is missing required files: {missing}"]
124
+ if not re.fullmatch(r"master-[a-z0-9][a-z0-9-]*", path.name):
125
+ errors.append(
126
+ "final-check persona directory must use master-<slug>: "
127
+ f"{path.name}"
128
+ )
129
+ try:
130
+ skill_text = (path / "SKILL.md").read_text(encoding="utf-8")
131
+ except OSError as exc:
132
+ errors.append(f"cannot read final SKILL.md: {exc}")
133
+ else:
134
+ name_match = re.search(r"(?m)^name:\s*([^\s]+)\s*$", skill_text)
135
+ skill_name = name_match.group(1) if name_match else None
136
+ if skill_name != path.name:
137
+ errors.append(
138
+ f"SKILL.md name {skill_name!r} must equal directory "
139
+ f"{path.name!r}"
140
+ )
141
+ path = path / "meta.json"
142
+ elif not path.is_file():
143
+ return None, [f"check-links input file not found: {path}"]
144
+
145
+ try:
146
+ return json.loads(path.read_text(encoding="utf-8")), errors
147
+ except (OSError, json.JSONDecodeError) as exc:
148
+ errors.append(f"cannot read source manifest {path}: {exc}")
149
+ return None, errors
150
+
151
+
152
+ def _run_declared_source_check(target: str, *, final: bool) -> int:
153
+ document, errors = _load_declared_source_target(target, final=final)
154
+ if document is not None:
155
+ errors.extend(validate_source_document(document))
156
+ if errors:
157
+ for error in errors:
158
+ print(f"ERROR: {error}", file=sys.stderr)
159
+ return 1
160
+ label = "final source check" if final else "declared sources"
161
+ print(f"{label} OK ({len(document['sources'])} sources)")
162
+ return 0
163
+
164
+
165
+ def full_to_short_cbeta(full_id: str) -> str | None:
166
+ """Convert full CBETA ID (T08n0235) to FoJin short format (T0235).
167
+
168
+ FoJin stores cbeta_id as the collection prefix + text number,
169
+ dropping the volume number. E.g.:
170
+ T08n0235 -> T0235
171
+ X62n1182 -> X1182
172
+ J36n0348 -> J0348
173
+ T34n1718 -> T1718
174
+ """
175
+ m = FULL_CBETA_RE.match(full_id)
176
+ if not m:
177
+ return None
178
+ prefix = m.group(1) # T, X, J, etc.
179
+ text_num = m.group(3) # 0235, 1182, etc.
180
+ return f"{prefix}{text_num}"
181
+
182
+
183
+ def collect_cbeta_ids() -> dict[str, list[str]]:
184
+ """Scan all meta.json and return {full_cbeta_id: [teacher_slugs]}."""
185
+ cbeta_map: dict[str, list[str]] = {}
186
+ for teacher in sorted(os.listdir(PREBUILT_DIR)):
187
+ meta_path = os.path.join(PREBUILT_DIR, teacher, "meta.json")
188
+ if not os.path.isfile(meta_path):
189
+ continue
190
+ with open(meta_path, encoding="utf-8") as f:
191
+ meta = json.load(f)
192
+ for src in meta.get("sources", []):
193
+ if src.get("type") == "cbeta" and src.get("id"):
194
+ cbeta_id = src["id"]
195
+ cbeta_map.setdefault(cbeta_id, []).append(teacher)
196
+ return cbeta_map
197
+
198
+
199
+ def collect_all_fojin_urls() -> dict[str, list[tuple[str, int]]]:
200
+ """Scan all md/py files and return {id_in_url: [(filepath, line_num)]}."""
201
+ url_map: dict[str, list[tuple[str, int]]] = {}
202
+
203
+ scan_dirs = [PREBUILT_DIR, os.path.join(PROJECT_ROOT, "prompts")]
204
+ extensions = {".md", ".py"}
205
+
206
+ for scan_dir in scan_dirs:
207
+ if not os.path.isdir(scan_dir):
208
+ continue
209
+ for root, _dirs, files in os.walk(scan_dir):
210
+ for fname in files:
211
+ if os.path.splitext(fname)[1] not in extensions:
212
+ continue
213
+ fpath = os.path.join(root, fname)
214
+ with open(fpath, encoding="utf-8") as f:
215
+ for line_num, line in enumerate(f, 1):
216
+ for m in FOJIN_URL_RE.finditer(line):
217
+ text_id_in_url = m.group(2)
218
+ url_map.setdefault(text_id_in_url, []).append(
219
+ (fpath, line_num)
220
+ )
221
+ return url_map
222
+
223
+
224
+ def verify_via_search(bridge, title: str, short_cbeta_id: str) -> dict | None:
225
+ """Search FoJin for a text by title, return the matching result with cbeta_id match."""
226
+ try:
227
+ resp = bridge.search_texts(title, sources="cbeta", page=1, size=5)
228
+ for r in resp.get("results", []):
229
+ if r.get("cbeta_id") == short_cbeta_id:
230
+ return r
231
+ # Also try without source filter
232
+ resp = bridge.search_texts(title, page=1, size=5)
233
+ for r in resp.get("results", []):
234
+ if r.get("cbeta_id") == short_cbeta_id:
235
+ return r
236
+ except Exception:
237
+ pass
238
+ return None
239
+
240
+
241
+ def verify_via_lookup(bridge, short_ids: list[str]) -> dict:
242
+ """Try the batch lookup-cbeta endpoint. Returns {short_cbeta_id: internal_id}."""
243
+ result = {}
244
+ try:
245
+ ids_str = ",".join(short_ids)
246
+ resp = bridge.lookup_cbeta_ids(ids_str)
247
+ if isinstance(resp, dict):
248
+ mapping = resp.get("results") or resp.get("data") or resp
249
+ for sid in short_ids:
250
+ entry = mapping.get(sid)
251
+ if entry and isinstance(entry, dict):
252
+ result[sid] = entry.get("text_id") or entry.get("id")
253
+ elif entry and isinstance(entry, int):
254
+ result[sid] = entry
255
+ except Exception:
256
+ pass # Endpoint may not be implemented; fall back to search
257
+ return result
258
+
259
+
260
+ def verify_ids(bridge, cbeta_map: dict[str, list[str]], titles: dict[str, str]) -> dict[str, dict]:
261
+ """Verify all CBETA IDs and return {full_cbeta_id: {text_id, short_id, title, ...}}.
262
+
263
+ Args:
264
+ cbeta_map: {full_cbeta_id: [teacher_slugs]}
265
+ titles: {full_cbeta_id: title_from_meta} for search fallback
266
+ """
267
+ results: dict[str, dict] = {}
268
+
269
+ # Build full -> short mapping
270
+ full_to_short = {}
271
+ short_to_full = {}
272
+ for full_id in cbeta_map:
273
+ short = full_to_short_cbeta(full_id)
274
+ if short:
275
+ full_to_short[full_id] = short
276
+ short_to_full[short] = full_id
277
+
278
+ # Try batch lookup first
279
+ short_ids = list(full_to_short.values())
280
+ lookup_result = verify_via_lookup(bridge, short_ids)
281
+
282
+ for full_id, short_id in sorted(full_to_short.items()):
283
+ if short_id in lookup_result:
284
+ results[full_id] = {
285
+ "text_id": lookup_result[short_id],
286
+ "short_cbeta_id": short_id,
287
+ "method": "lookup",
288
+ }
289
+ continue
290
+
291
+ # Fallback: search by title
292
+ title = titles.get(full_id, "")
293
+ if title:
294
+ time.sleep(0.2) # Rate limit
295
+ match = verify_via_search(bridge, title, short_id)
296
+ if match:
297
+ results[full_id] = {
298
+ "text_id": match["id"],
299
+ "short_cbeta_id": short_id,
300
+ "title_zh": match.get("title_zh", ""),
301
+ "method": "search",
302
+ }
303
+ continue
304
+
305
+ # Try direct get with the internal ID if it's numeric-ish
306
+ # Last resort: not found
307
+ results[full_id] = {
308
+ "text_id": None,
309
+ "short_cbeta_id": short_id,
310
+ "method": "not_found",
311
+ }
312
+
313
+ return results
314
+
315
+
316
+ def collect_titles_from_meta() -> dict[str, str]:
317
+ """Collect {full_cbeta_id: title} from meta.json sources."""
318
+ titles = {}
319
+ for teacher in os.listdir(PREBUILT_DIR):
320
+ meta_path = os.path.join(PREBUILT_DIR, teacher, "meta.json")
321
+ if not os.path.isfile(meta_path):
322
+ continue
323
+ with open(meta_path, encoding="utf-8") as f:
324
+ meta = json.load(f)
325
+ for src in meta.get("sources", []):
326
+ if src.get("type") == "cbeta" and src.get("id") and src.get("title"):
327
+ titles[src["id"]] = src["title"]
328
+ return titles
329
+
330
+
331
+ def fix_urls_in_file(
332
+ filepath: str, id_map: dict[str, str], dry_run: bool
333
+ ) -> list[str]:
334
+ """Replace CBETA IDs with internal text_ids in URLs. Returns list of changes."""
335
+ changes = []
336
+ with open(filepath, encoding="utf-8") as f:
337
+ content = f.read()
338
+
339
+ def replacer(m):
340
+ prefix = m.group(1)
341
+ old_id = m.group(2)
342
+ if old_id in id_map:
343
+ new_id = id_map[old_id]
344
+ rel = os.path.relpath(filepath, PROJECT_ROOT)
345
+ changes.append(f" {rel}: {old_id} -> {new_id}")
346
+ return prefix + new_id
347
+ return m.group(0)
348
+
349
+ new_content = FOJIN_URL_RE.sub(replacer, content)
350
+
351
+ if not dry_run and new_content != content:
352
+ with open(filepath, "w", encoding="utf-8") as f:
353
+ f.write(new_content)
354
+
355
+ return changes
356
+
357
+
358
+ def _run_legacy_link_verification(*, fix: bool) -> int:
359
+ """Preserve the historical FoJin CBETA URL audit and optional fixer."""
360
+ dry_run = not fix
361
+
362
+ print("=" * 60)
363
+ print("FoJin Source Verification Report")
364
+ print("=" * 60)
365
+ if dry_run:
366
+ print("Mode: DRY RUN (use --fix to apply changes)\n")
367
+ else:
368
+ print("Mode: FIX (writing changes to files)\n")
369
+
370
+ # Step 1: Collect CBETA IDs from meta.json
371
+ cbeta_map = collect_cbeta_ids()
372
+ all_cbeta_ids = sorted(cbeta_map.keys())
373
+ teacher_count = len(set(t for ts in cbeta_map.values() for t in ts))
374
+ print(f"[1/4] Found {len(all_cbeta_ids)} unique CBETA IDs across {teacher_count} teachers")
375
+ for cid in all_cbeta_ids:
376
+ short = full_to_short_cbeta(cid) or "?"
377
+ print(f" {cid} (-> {short}) <- {', '.join(cbeta_map[cid])}")
378
+
379
+ # Step 2: Collect all fojin.app URLs from files
380
+ url_map = collect_all_fojin_urls()
381
+ all_url_ids = sorted(url_map.keys())
382
+ total_urls = sum(len(v) for v in url_map.values())
383
+ print(f"\n[2/4] Found {total_urls} fojin.app URLs using {len(all_url_ids)} unique IDs")
384
+
385
+ # IDs in URLs but not in meta.json sources
386
+ extra_url_ids = set(all_url_ids) - set(all_cbeta_ids)
387
+ if extra_url_ids:
388
+ print(f" Extra IDs in URLs (not in meta.json sources):")
389
+ for eid in sorted(extra_url_ids):
390
+ locs = url_map[eid]
391
+ files = set(os.path.relpath(f, PROJECT_ROOT) for f, _ in locs)
392
+ print(f" {eid} in {', '.join(sorted(files))}")
393
+
394
+ # Combine: all unique CBETA-style IDs from both meta.json and URLs
395
+ all_ids = set(all_cbeta_ids)
396
+ for uid in all_url_ids:
397
+ if FULL_CBETA_RE.match(uid):
398
+ all_ids.add(uid)
399
+
400
+ # Non-CBETA IDs in URLs (e.g. suttacentral IDs, placeholder "123")
401
+ non_cbeta_url_ids = [uid for uid in all_url_ids if not FULL_CBETA_RE.match(uid)]
402
+ if non_cbeta_url_ids:
403
+ print(f" Non-CBETA IDs in URLs (skipped): {', '.join(non_cbeta_url_ids)}")
404
+
405
+ # Step 3: Verify with FoJin API
406
+ print(f"\n[3/4] Verifying {len(all_ids)} CBETA IDs against FoJin API...")
407
+ bridge = create_bridge()
408
+
409
+ if not bridge.test_connection():
410
+ print(" [ERROR] Cannot connect to FoJin API.")
411
+ print(" Set FOJIN_URL environment variable if using a custom instance.")
412
+ sys.exit(1)
413
+ print(" API connection OK")
414
+
415
+ # Build combined cbeta_map (include URL-only IDs)
416
+ combined_map = dict(cbeta_map)
417
+ for uid in all_url_ids:
418
+ if FULL_CBETA_RE.match(uid) and uid not in combined_map:
419
+ combined_map[uid] = ["(URL only)"]
420
+
421
+ titles = collect_titles_from_meta()
422
+ verified = verify_ids(bridge, combined_map, titles)
423
+
424
+ found = {k: v for k, v in verified.items() if v["text_id"] is not None}
425
+ not_found = {k: v for k, v in verified.items() if v["text_id"] is None}
426
+
427
+ print(f"\n Verified: {len(found)}/{len(verified)}")
428
+ for cid in sorted(found):
429
+ info = found[cid]
430
+ title = info.get("title_zh", titles.get(cid, ""))
431
+ print(f" [OK] {cid} -> text_id={info['text_id']} {title} ({info['method']})")
432
+
433
+ if not_found:
434
+ print(f"\n Not found in FoJin ({len(not_found)}):")
435
+ for cid in sorted(not_found):
436
+ teachers = combined_map.get(cid, ["?"])
437
+ print(f" [MISS] {cid} (-> {not_found[cid]['short_cbeta_id']}) used by: {', '.join(teachers)}")
438
+
439
+ # Step 4: Update URLs
440
+ # Build replacement map: full_cbeta_id -> str(internal_text_id)
441
+ id_replacement_map: dict[str, str] = {}
442
+ for full_id, info in found.items():
443
+ id_replacement_map[full_id] = str(info["text_id"])
444
+
445
+ action = "Would update" if dry_run else "Updating"
446
+ print(f"\n[4/4] {action} URLs...")
447
+ all_changes = []
448
+
449
+ files_to_fix = set()
450
+ for locations in url_map.values():
451
+ for fpath, _ in locations:
452
+ files_to_fix.add(fpath)
453
+
454
+ for fpath in sorted(files_to_fix):
455
+ changes = fix_urls_in_file(fpath, id_replacement_map, dry_run)
456
+ all_changes.extend(changes)
457
+
458
+ if all_changes:
459
+ for c in all_changes:
460
+ print(c)
461
+ verb = "would be made" if dry_run else "applied"
462
+ print(f"\n Total: {len(all_changes)} URL replacements {verb}")
463
+ else:
464
+ print(" No URL replacements needed")
465
+
466
+ # Summary
467
+ print("\n" + "=" * 60)
468
+ print("Summary")
469
+ print("=" * 60)
470
+ print(f" CBETA IDs in meta.json: {len(all_cbeta_ids)}")
471
+ print(f" CBETA IDs in URLs: {len([u for u in all_url_ids if FULL_CBETA_RE.match(u)])}")
472
+ print(f" Total unique CBETA IDs: {len(all_ids)}")
473
+ print(f" Verified in FoJin: {len(found)}")
474
+ print(f" Not found in FoJin: {len(not_found)}")
475
+ print(f" URL replacements: {len(all_changes)}")
476
+ if dry_run and all_changes:
477
+ print("\n Run with --fix to apply changes.")
478
+
479
+ return 0
480
+
481
+
482
+ def main(argv: list[str] | None = None) -> int:
483
+ parser = argparse.ArgumentParser(
484
+ description="Validate declared source manifests or audit legacy FoJin links"
485
+ )
486
+ modes = parser.add_mutually_exclusive_group()
487
+ modes.add_argument(
488
+ "--check-links",
489
+ metavar="JSON",
490
+ help="offline-check a collected JSON source manifest",
491
+ )
492
+ modes.add_argument(
493
+ "--final-check",
494
+ metavar="PERSONA_DIR",
495
+ help="offline-check a generated persona and its final meta.json",
496
+ )
497
+ modes.add_argument(
498
+ "--fix",
499
+ action="store_true",
500
+ help="run the legacy online CBETA URL audit and apply replacements",
501
+ )
502
+ args = parser.parse_args(argv)
503
+
504
+ if args.check_links:
505
+ return _run_declared_source_check(args.check_links, final=False)
506
+ if args.final_check:
507
+ return _run_declared_source_check(args.final_check, final=True)
508
+ return _run_legacy_link_verification(fix=args.fix)
509
+
510
+
511
+ if __name__ == "__main__":
512
+ sys.exit(main())
@@ -0,0 +1,88 @@
1
+ """
2
+ Version Manager — archives and rolls back teacher skill versions.
3
+ Adapted from colleague-skill's version_manager.py.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import shutil
9
+ from datetime import datetime
10
+
11
+ MAX_VERSIONS = 10
12
+
13
+
14
+ def list_versions(teacher_dir: str) -> list:
15
+ """List all archived versions of a teacher skill."""
16
+ versions_dir = os.path.join(teacher_dir, "versions")
17
+ if not os.path.exists(versions_dir):
18
+ return []
19
+
20
+ versions = []
21
+ for entry in sorted(os.listdir(versions_dir)):
22
+ entry_path = os.path.join(versions_dir, entry)
23
+ if os.path.isdir(entry_path) and entry.startswith("v"):
24
+ files = os.listdir(entry_path)
25
+ mtime = os.path.getmtime(entry_path)
26
+ versions.append({
27
+ "version": entry[1:],
28
+ "archived_at": datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M"),
29
+ "files": files,
30
+ })
31
+ return versions
32
+
33
+
34
+ def rollback(teacher_dir: str, target_version: str) -> bool:
35
+ """Roll back a teacher skill to a previous version. Backs up current state first."""
36
+ target_dir = os.path.join(teacher_dir, "versions", f"v{target_version}")
37
+ if not os.path.isdir(target_dir):
38
+ return False
39
+
40
+ meta_path = os.path.join(teacher_dir, "meta.json")
41
+ current_version = "unknown"
42
+ if os.path.exists(meta_path):
43
+ with open(meta_path, "r", encoding="utf-8") as f:
44
+ meta = json.load(f)
45
+ current_version = meta.get("version", "unknown")
46
+ backup_dir = os.path.join(teacher_dir, "versions", f"v{current_version}_before_rollback")
47
+ os.makedirs(backup_dir, exist_ok=True)
48
+ for fname in ["SKILL.md", "teaching.md", "voice.md", "meta.json"]:
49
+ src = os.path.join(teacher_dir, fname)
50
+ if os.path.exists(src):
51
+ shutil.copy2(src, backup_dir)
52
+
53
+ for fname in ["SKILL.md", "teaching.md", "voice.md", "meta.json"]:
54
+ src = os.path.join(target_dir, fname)
55
+ if os.path.exists(src):
56
+ shutil.copy2(src, os.path.join(teacher_dir, fname))
57
+
58
+ if os.path.exists(meta_path):
59
+ with open(meta_path, "r", encoding="utf-8") as f:
60
+ meta = json.load(f)
61
+ meta["rollback_from"] = current_version
62
+ meta["updated_at"] = datetime.now().strftime("%Y-%m-%d")
63
+ with open(meta_path, "w", encoding="utf-8") as f:
64
+ json.dump(meta, f, ensure_ascii=False, indent=2)
65
+
66
+ return True
67
+
68
+
69
+ def cleanup_old_versions(teacher_dir: str) -> int:
70
+ """Remove old versions beyond MAX_VERSIONS limit."""
71
+ versions_dir = os.path.join(teacher_dir, "versions")
72
+ if not os.path.exists(versions_dir):
73
+ return 0
74
+
75
+ entries = []
76
+ for entry in os.listdir(versions_dir):
77
+ entry_path = os.path.join(versions_dir, entry)
78
+ if os.path.isdir(entry_path):
79
+ entries.append((entry_path, os.path.getmtime(entry_path)))
80
+
81
+ entries.sort(key=lambda x: x[1], reverse=True)
82
+
83
+ removed = 0
84
+ for path, _ in entries[MAX_VERSIONS:]:
85
+ shutil.rmtree(path)
86
+ removed += 1
87
+
88
+ return removed