diff --git a/AGENTS.md b/AGENTS.md
index ed3cd31..e98bff4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -159,6 +159,82 @@ else?" after a direct-edit pass, do one more full systematic read and batch agai
---
+## Workflow C: Batch Translate / Review with Herdr(批量翻译/审阅)
+
+Run one book/article per omp session in its own herdr pane. Proven on the
+9-book batch (2026-08-07): 9 review panes (`omp --model slow`), each moved to
+its own tab, then one apply pass per pane, all 9 green on the check suite.
+
+### Setup
+
+1. One herdr workspace; the main omp session in the root pane orchestrates.
+2. Rename each book dir with a shared batch prefix so they sort and zip
+ together: `batch0-21【《心经》的人生智慧】`, `batch0-55【…】`, …
+3. Split one pane per book. `herdr pane split --cwd
` does NOT stick
+ (panes launch in the workspace root) — pass the absolute book dir to omp's
+ own `--cwd` at launch instead.
+
+```fish
+# 5 right of the main pane, then 4 below it
+r=$(herdr pane split --current --direction right --no-focus)
+p=$(echo "$r" | jq -r '.result.pane.pane_id')
+# repeat: herdr pane split --pane $prev --direction down --no-focus
+herdr pane rename review- # label each pane
+```
+
+4. Launch the slow model in every pane (background all, then `wait`):
+
+```fish
+herdr pane run w7:p2 "omp --model slow --cwd /abs/path/to/book-dir" &
+# ... one line per book
+wait
+```
+
+Verify each pane landed in its book dir:
+`herdr pane read --source recent-unwrapped --lines 8` — the TUI title
+shows the dir.
+
+5. Submit the review prompt (B below) to all panes at once. Poll
+ `herdr pane get | jq -r '.result.pane.agent_status'` until every
+ pane is `idle`/`done` (allow ~1 h for long books).
+6. Move each pane to its own tab so the batch is watchable while it runs:
+ `herdr pane move --new-tab --label --no-focus`.
+7. After all reviews finish, submit the apply prompt (C below) to every pane
+ again, poll to completion, then gate with `check-translation.py` and
+ spot-check that fixes actually landed in `target.dj`.
+8. Package: `zip -r -reviewed.zip batch0-*/` and verify the entry count
+ (9 books × 6 files each = 63 entries).
+
+### The three prompts
+
+**A — Translate a book** (one session per book, Workflow A):
+
+> Translate the book (file: .docx) from Chinese to English for the MPI translation project. Follow Workflow A in ../../toolkit/AGENTS.md: (1) load the mpi-translation and mpi-terms-search skills from ../../toolkit/skills/; (2) extract the Chinese source with ../../toolkit/scripts/docx2dj.fish '.docx' into source.dj; (3) translate the ENTIRE book into target.dj (English; line count matches source; you ARE the model — no external translation APIs; look up key Buddhist terms with ../../toolkit/terms-database/search.py); (4) generate bilingual.dj: ../../toolkit/scripts/gen-bilingual.py source.dj target.dj > bilingual.dj; (5) self-review with mpi-translation-review (self mode), edit target.dj, and write edit-suggestions.dj for terminology issues; (6) regenerate bilingual.dj and verify source/target line counts match. Deliverables in this folder: source.dj, target.dj, bilingual.dj, edit-suggestions.dj. Do not commit binaries or bilingual.dj. Report when done.
+
+**B — Review a book** (herdr pane, slow model; writes `review-findings.dj` only):
+
+> Review the translation in this directory (your cwd is the book dir). Files: source.dj (Chinese source), target.dj (English translation), bilingual.dj (bilingual), edit-suggestions.dj (prior edit suggestions, may be stale). Read source and target fully and review the English translation for: (1) accuracy vs source — mistranslations, omissions, additions, meaning drift; (2) Buddhist terminology — consistent, standard renderings; (3) fluency and register — natural, idiomatic English appropriate to the genre; (4) completeness — every source section covered. Write findings to review-findings.dj in this directory, organized by severity (critical/major/minor), each with location and a concrete fix. Do NOT modify source.dj, target.dj, or bilingual.dj. End your final message with a one-paragraph summary.
+
+**C — Apply findings** (same pane, direct-edit mode):
+
+> Apply your review findings now. This is direct-edit mode per project convention. 1) Read review-findings.dj and target.dj fully. 2) Apply EVERY actionable finding (all must-fix and considerations) to target.dj with exact-string replacements, batched in one pass. 3) CRITICAL: do not add or remove any line — source.dj and target.dj line counts must remain identical. 4) Regenerate bilingual.dj: /gen-bilingual.py source.dj target.dj > bilingual.dj 5) Run /check-translation.py . and report which checks pass/fail. Report what you changed and the check result.
+
+### Gotchas(踩过的坑)
+
+- `herdr pane split --cwd ` doesn't stick — launch omp with `--cwd ` instead.
+- Write the poll loop carefully: wait for `agent_status` to reach `idle`/`done` with a deadline. The naive first version inverted the logic and reported "done" instantly.
+- Line-count parity is load-bearing: `gen-bilingual.py` pairs lines by index and the check gate FAILs on drift. Apply fixes with exact-string replacements; never insert or delete lines (a blank-line fix was deliberately skipped in the batch for exactly this reason).
+- `check-translation.py` calibration facts (all learned on the 9-book run):
+ - CJK leakage ignores djot anchors/links (`{#...}`, `(...)`) — structural markup legitimately contains Chinese.
+ - Only strictly-Chinese punctuation flags (`,。、;:?!《》【】()`); `—` `“”` `’` `·` are legitimate English.
+ - Emphasis = preservation on the same line (source `*…*` must survive in target), not count parity — targets legitimately add italics for titles/Sanskrit.
+ - Digit fidelity understands 万/亿 scaling, 多, word and comma forms (180亿 → "18 billion", 1300多万 → "13+ million"), and excludes TOC page numbers (`[N](#...)`), which the convention drops.
+ - Use `--allow-cjk 人` for intentional Chinese (e.g. a character whose strokes the text explains) and `--term-map term-map.md` to check terminology fidelity; without a term map that check is skipped.
+- `--model slow` is omp's model-role flag for the slow/reasoning model; confirm the exact flag with `omp --help` if unsure.
+- Reviews are the quality gate: the apply pass is what lands findings in `target.dj` (book 21 alone took 26 exact-string replacements). The gate proves mechanics, not quality.
+
+---
+
## Djot
- Comments: `{% ... %}`
@@ -190,6 +266,7 @@ regenerating the same Python in execute_code each turn.
- `toolkit/scripts/dj2docx.fish ` — pandoc .dj → .docx in `/tmp/`
- `toolkit/scripts/proofread-pdf.py ` — word-level diff between manuscript and typeset PDF
- `toolkit/scripts/gen-bilingual.py ` — produce `bilingual.dj` on stdout; run as `gen-bilingual.py source.dj target.dj > bilingual.dj`
+- `toolkit/scripts/check-translation.py ` — deterministic translation gate: line/paragraph/heading parity, emphasis preservation, CJK & Chinese-punctuation leakage, digit fidelity (万/亿-aware), terminology vs term map, bilingual freshness. Exit 1 on any FAIL. Run before delivering a translation; keep green as a regression suite.
- `toolkit/scripts/gen-bilingual--.py` — article-specific extraction from DOCX or source/target pairing
- `toolkit/scripts/compile-typst.fish ` — compile a Typst file to PDF
diff --git a/readme.md b/readme.md
index 54dcbf7..6dd1cf6 100644
--- a/readme.md
+++ b/readme.md
@@ -99,6 +99,7 @@ generated; do not edit it by hand or commit it.
| Convert `.docx` to `.dj` | `./scripts/docx2dj.fish input.docx output.dj` |
| Convert `target.dj` to `.docx` | `./scripts/dj2docx.fish ../translate-files/展示/target.dj` |
| Compile a Typst file to PDF | `./scripts/compile-typst.fish ../translate-files/my-article/my-article.typ` |
+| Run the translation gate | `./scripts/check-translation.py ../translate-files/my-article/` — 11 deterministic checks; exit 1 on any FAIL |
| Search the term database | `./terms-database/search.py 空性` |
| Run the term database UI | `./terms-database/server.py` then open |
@@ -110,7 +111,7 @@ Docs, as arranged by your project coordinator.
## Learn more
-- `AGENTS.md` — full MPI project conventions, translation workflows, and review rules.
+- `AGENTS.md` — full MPI project conventions, translation workflows, review rules, and Workflow C: the herdr batch workflow for translating or reviewing many books in parallel (one omp pane per book).
- `skills/readme.dj` — how the skills are organized.
- `references/` — design notes, formatting guides, and other reference materials.
diff --git a/scripts/check-translation.py b/scripts/check-translation.py
new file mode 100755
index 0000000..74fa64a
--- /dev/null
+++ b/scripts/check-translation.py
@@ -0,0 +1,411 @@
+#!/usr/bin/env python3
+"""Deterministic translation checks for the MPI project (TDD-style gate).
+
+Checks the mechanical invariants of a Chinese->English djot translation.
+Semantic quality (fluency, register, tone) is NOT checked here — that is the
+LLM review layer. This script is the hard gate: it must go green before a
+translation is delivered, and it stays in toolkit/scripts/ as a regression
+suite so later edits cannot silently break parity.
+
+Severity:
+ FAIL — hard invariant broken; gate is red.
+ WARN — possible drift worth a reviewer's eye; does not fail the gate.
+ PASS — clean.
+
+Checks:
+ 1. line-count parity — source.dj and target.dj must have equal lines
+ 2. paragraph parity — equal number of blank-separated blocks
+ 3. heading parity — equal # of lines starting with each heading level
+ 4. emphasis preservation — every *...* in a source line survives on the
+ same-index target line (D5). Target may ADD
+ italics (titles, Sanskrit) — that is fine.
+ 5. comment parity — equal {% ... %} blocks (D5)
+ 6. CJK leakage — no Chinese characters in target (whitelistable)
+ 7. Chinese punctuation — no strictly-Chinese punctuation in target
+ (,。、;:?!《》【】()). Em dash, curly
+ quotes, middot are legal English — not flagged.
+ 8. bold leakage — no Markdown ** in target (D5)
+ 9. digit fidelity — every arabic number in source appears in target
+ 10. terminology — source terms from a term map must appear in target
+ with an allowed English rendering (A3 / terms DB).
+ FAIL: term present in source, none of its
+ renderings found in target. WARN: renderings
+ found but fewer times than the source term.
+ 11. bilingual freshness — bilingual.dj, if present, equals a regeneration
+ from source+target
+
+Usage:
+ check-translation.py
+ Uses /source.dj and /target.dj; auto-detects
+ bilingual.dj and term-map.md in the same directory.
+ check-translation.py [--bilingual FILE]
+ Explicit files.
+
+Options:
+ --term-map FILE Term map (default: /term-map.md if present).
+ Accepted formats:
+ - markdown table rows: | 菩提心 | bodhicitta |
+ - plain lines: CNEN or CN|EN1|EN2
+ Multiple Chinese terms separated by "/" or "、" share
+ one English side; English renderings separated by "/"
+ are alternatives, any of which satisfies the check.
+ --allow-cjk LIST Comma-separated CJK strings permitted in target
+ (e.g. quoted book titles like 《心经》).
+ --json Emit machine-readable JSON results.
+
+Exit code: 0 when no check FAILs (WARNs allowed), 1 otherwise.
+"""
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+
+CJK_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]")
+# Strictly-Chinese punctuation only. Em dash (—), curly quotes (“” ‘’),
+# middot (·), and ellipsis are legitimate in English prose.
+CN_PUNCT_RE = re.compile(r"[,。、;:?!《》【】()]")
+EMPHASIS_RE = re.compile(r"\*[^*\n]+\*")
+COMMENT_RE = re.compile(r"\{%[\s\S]*?%\}")
+HEADING_RE = re.compile(r"^(#{1,6})(?:\s|$)")
+BOLD_RE = re.compile(r"\*\*")
+DIGIT_RE = re.compile(r"\d+")
+
+PASS, WARN, FAIL = "PASS", "WARN", "FAIL"
+
+
+def read_lines(path):
+ return Path(path).read_text(encoding="utf-8").splitlines()
+
+
+def count_paras(lines):
+ """Blank-separated blocks; leading/trailing blanks ignored."""
+ count = 0
+ in_block = False
+ for line in lines:
+ if line.strip():
+ if not in_block:
+ count += 1
+ in_block = True
+ else:
+ in_block = False
+ return count
+
+
+def heading_counts(lines):
+ counts = {i: 0 for i in range(1, 7)}
+ for line in lines:
+ m = HEADING_RE.match(line)
+ if m:
+ counts[len(m.group(1))] += 1
+ return counts
+
+
+def term_map_from_markdown(text):
+ """Parse a term map into {chinese_term: [english_renderings]}."""
+ terms = {}
+ for raw in text.splitlines():
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ if "\t" in line:
+ cn, _, en = line.partition("\t")
+ terms[cn.strip()] = [r.strip() for r in en.split("/") if r.strip()]
+ continue
+ if "|" not in line:
+ continue
+ cells = [c.strip() for c in line.strip("|").split("|")]
+ if len(cells) < 2:
+ continue
+ cn_cell, en_cell = cells[0], cells[1]
+ if not cn_cell or not en_cell or set(cn_cell) <= {"-", " "}:
+ continue
+ for cn in (c.strip() for c in re.split(r"[/、]", cn_cell) if c.strip()):
+ terms[cn] = [r.strip() for r in en_cell.split("/") if r.strip()]
+ return terms
+
+
+def check_line_count(src, tgt):
+ ok = len(src) == len(tgt)
+ return (PASS if ok else FAIL,
+ f"source={len(src)} target={len(tgt)}", [])
+
+
+def check_paras(src, tgt):
+ s, t = count_paras(src), count_paras(tgt)
+ return (PASS if s == t else FAIL,
+ f"source={s} target={t}", [])
+
+
+def check_headings(src, tgt):
+ s, t = heading_counts(src), heading_counts(tgt)
+ diffs = [f"H{i}: {s[i]} vs {t[i]}" for i in range(1, 7) if s[i] != t[i]]
+ return (PASS if not diffs else FAIL,
+ "; ".join(diffs) if diffs else "all levels match", diffs)
+
+
+def check_emphasis(src, tgt):
+ """D5 preservation: source emphasis must survive on the same line.
+
+ Target may add emphasis for titles/Sanskrit, so counts need not match.
+ """
+ missing = []
+ for i, (s, t) in enumerate(zip(src, tgt), 1):
+ if EMPHASIS_RE.search(s) and not EMPHASIS_RE.search(t):
+ missing.append((i, s, t))
+ return (PASS if not missing else FAIL,
+ "all source emphasis preserved"
+ if not missing else
+ f"{len(missing)} source line(s) lost emphasis: "
+ + ", ".join(f"S{i}" for i, _, _ in missing[:10]),
+ missing)
+
+
+def check_comments(src, tgt):
+ s, t = len(COMMENT_RE.findall("\n".join(src))), len(COMMENT_RE.findall("\n".join(tgt)))
+ return (PASS if s == t else FAIL,
+ f"source={s} target={t}", [])
+
+
+ANCHOR_RE = re.compile(r"\{#[^{}]*\}|\(\s*#?[^{}\n]*\)")
+LINK_TGT_RE = re.compile(r"\[\d+\]\(\s*#")
+
+
+def strip_structural(text):
+ """Remove djot anchors {#...}, link destinations (...), and image paths —
+ structural markup that may legitimately contain Chinese."""
+ return ANCHOR_RE.sub("", text)
+
+
+def check_cjk(tgt, allow=()):
+ bad = []
+ for i, line in enumerate(tgt, 1):
+ stripped = strip_structural(line)
+ for token in allow:
+ stripped = stripped.replace(token, "")
+ if CJK_RE.search(stripped):
+ bad.append((i, line))
+ return (PASS if not bad else FAIL,
+ "clean" if not bad else f"{len(bad)} line(s) contain CJK outside anchors/links: "
+ + ", ".join(f"L{i}" for i, _ in bad[:10]),
+ bad)
+
+
+def check_cn_punct(tgt):
+ bad = []
+ for i, line in enumerate(tgt, 1):
+ if CN_PUNCT_RE.search(line):
+ bad.append((i, line))
+ return (PASS if not bad else FAIL,
+ "clean" if not bad else f"{len(bad)} line(s) contain Chinese punctuation: "
+ + ", ".join(f"L{i}" for i, _ in bad[:10]),
+ bad)
+
+
+def check_bold(tgt):
+ bad = []
+ for i, line in enumerate(tgt, 1):
+ if BOLD_RE.search(line):
+ bad.append((i, line))
+ return (PASS if not bad else FAIL,
+ "clean" if not bad else f"{len(bad)} line(s) contain ** : "
+ + ", ".join(f"L{i}" for i, _ in bad[:10]),
+ bad)
+
+
+_ONES = ["", "one", "two", "three", "four", "five", "six", "seven", "eight",
+ "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen",
+ "sixteen", "seventeen", "eighteen", "nineteen"]
+_TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
+ "eighty", "ninety"]
+
+
+def number_to_words(n):
+ if n < 20:
+ return _ONES[n]
+ if n < 100:
+ return (_TENS[n // 10] + ("-" + _ONES[n % 10] if n % 10 else ""))
+ if n < 1000:
+ return _ONES[n // 100] + " hundred" + (
+ (" " + number_to_words(n % 100)) if n % 100 else "")
+ if n < 1000000:
+ return number_to_words(n // 1000) + " thousand" + (
+ (" " + number_to_words(n % 1000)) if n % 1000 else "")
+ return number_to_words(n // 1000000) + " million" + (
+ (" " + number_to_words(n % 1000000)) if n % 1000000 else "")
+
+
+def accepted_number_spellings(n, unit):
+ """All English spellings that legitimately render source number n.
+
+ unit: "" (plain), "万" (×10^4), or "亿" (×10^8). The target may keep the
+ digits ("1,200"), spell them ("twelve hundred"), or scale the unit
+ ("13 million" for 1300万, "18 billion" for 180亿).
+ """
+ cands = {str(n), number_to_words(n)}
+ if 100 <= n < 10000 and n % 100 == 0: # "twelve hundred"
+ cands.add(f"{n // 100} hundred")
+ cands.add(number_to_words(n // 100) + " hundred")
+ value = n * (10 ** 4 if unit == "万" else 10 ** 8 if unit == "亿" else 1)
+ if value != n:
+ cands.add(str(value))
+ cands.add(number_to_words(value))
+ for divisor, suffix in ((10 ** 9, "billion"), (10 ** 6, "million"),
+ (10 ** 3, "thousand")):
+ if value % divisor == 0 and value // divisor > 0:
+ cands.add(f"{value // divisor} {suffix}")
+ cands.add(number_to_words(value // divisor) + " " + suffix)
+ return cands
+
+
+def source_content_nums(src_text):
+ """(number, unit) pairs from the source, excluding TOC page numbers
+ ([N](#...)) that the project convention intentionally drops."""
+ stripped = LINK_TGT_RE.sub("", src_text)
+ out = []
+ for m in re.finditer(r"\d+(?:\s*(?:多\s*)?[万亿])?", stripped):
+ tok = m.group(0)
+ unit = tok[-1] if tok[-1] in "万亿" else ""
+ out.append((int(re.sub(r"\D", "", tok)), unit))
+ return out
+
+
+def check_digits(src, tgt):
+ src_nums = source_content_nums("\n".join(src))
+ tgt_text = " ".join(tgt).lower().replace(",", "")
+ missing = []
+ for n, unit in src_nums:
+ if any(s.lower() in tgt_text
+ for s in accepted_number_spellings(n, unit)):
+ continue
+ missing.append(str(n) + unit)
+ return (PASS if not missing else FAIL,
+ "all present" if not missing else f"missing in target: {', '.join(missing)}",
+ missing)
+
+
+def check_terminology(src, tgt, terms):
+ """A3: source term present -> some allowed rendering present in target.
+
+ FAIL when no rendering is found at all; WARN when found but under-counted
+ (inflections, line wraps, or a genuine drift the reviewer should verify).
+ """
+ if not terms:
+ return PASS, "no term map provided; skipped", []
+ src_text = "\n".join(src)
+ tgt_text = " ".join(tgt).lower()
+ fails, warns = [], []
+ checked = 0
+ for cn, renderings in sorted(terms.items()):
+ n = src_text.count(cn)
+ if n == 0:
+ continue
+ checked += 1
+ hits = sum(tgt_text.count(r.lower()) for r in renderings)
+ if hits == 0:
+ fails.append(f"{cn} ({n}× in source) — none of {renderings} found in target")
+ elif hits < n:
+ warns.append(f"{cn} ({n}× in source, {hits}× rendered) — verify")
+ if fails:
+ status, detail = FAIL, f"{checked} term(s) checked; " + "; ".join(fails)
+ elif warns:
+ status, detail = WARN, f"{checked} term(s) checked; " + "; ".join(warns)
+ else:
+ status, detail = PASS, f"{checked} term(s) checked; all consistent"
+ return status, detail, fails + warns
+
+
+def check_bilingual(src, tgt, bilingual_path):
+ if bilingual_path is None or not Path(bilingual_path).exists():
+ return PASS, "no bilingual.dj present; skipped", []
+ actual = Path(bilingual_path).read_text(encoding="utf-8").splitlines()
+ expected = []
+ for s, t in zip(src, tgt):
+ if s == "":
+ expected.append("")
+ else:
+ expected.extend([s, t, ""])
+ if expected and expected[-1] != "":
+ expected.append("")
+ if actual == expected:
+ return PASS, f"{len(actual)} lines match a regeneration", []
+ return FAIL, f"stale: {Path(bilingual_path)} differs from source+target regeneration", []
+
+
+def run_checks(src, tgt, bilingual=None, term_map=None, allow_cjk=()):
+ return [
+ ("line-count parity", *check_line_count(src, tgt)),
+ ("paragraph parity", *check_paras(src, tgt)),
+ ("heading parity", *check_headings(src, tgt)),
+ ("emphasis preservation", *check_emphasis(src, tgt)),
+ ("comment parity", *check_comments(src, tgt)),
+ ("CJK leakage", *check_cjk(tgt, allow_cjk)),
+ ("Chinese punctuation", *check_cn_punct(tgt)),
+ ("bold leakage", *check_bold(tgt)),
+ ("digit fidelity", *check_digits(src, tgt)),
+ ("terminology", *check_terminology(src, tgt, term_map)),
+ ("bilingual freshness", *check_bilingual(src, tgt, bilingual)),
+ ]
+
+
+def main():
+ ap = argparse.ArgumentParser(description="Deterministic translation checks")
+ ap.add_argument("paths", nargs="+", help="book dir, or source.dj target.dj")
+ ap.add_argument("--bilingual", default=None, help="bilingual.dj to verify")
+ ap.add_argument("--term-map", default=None, help="term map file")
+ ap.add_argument("--allow-cjk", default="", help="comma-separated CJK whitelist")
+ ap.add_argument("--json", action="store_true")
+ args = ap.parse_args()
+
+ if len(args.paths) == 1 and Path(args.paths[0]).is_dir():
+ d = Path(args.paths[0])
+ src, tgt = d / "source.dj", d / "target.dj"
+ bilingual = args.bilingual or d / "bilingual.dj"
+ term_map = args.term_map or d / "term-map.md"
+ label = str(d)
+ elif len(args.paths) == 2:
+ src, tgt = Path(args.paths[0]), Path(args.paths[1])
+ bilingual = Path(args.bilingual) if args.bilingual else None
+ term_map = Path(args.term_map) if args.term_map else None
+ label = f"{src} -> {tgt}"
+ else:
+ ap.error("pass a book directory, or source.dj target.dj")
+
+ if not src.exists() or not tgt.exists():
+ ap.error(f"missing source or target: {src} / {tgt}")
+
+ src_lines = read_lines(src)
+ tgt_lines = read_lines(tgt)
+ allow = [t for t in args.allow_cjk.split(",") if t.strip()]
+ terms = {}
+ if term_map and Path(term_map).exists():
+ terms = term_map_from_markdown(Path(term_map).read_text(encoding="utf-8"))
+
+ checks = run_checks(src_lines, tgt_lines, bilingual, terms, allow)
+ failed = [name for name, status, *_ in checks if status == FAIL]
+ warned = [name for name, status, *_ in checks if status == WARN]
+
+ if args.json:
+ print(json.dumps({
+ "target": label,
+ "passed": [c[0] for c in checks if c[1] == PASS],
+ "warned": warned,
+ "failed": failed,
+ "details": {c[0]: {"status": c[1], "detail": c[2]} for c in checks},
+ }, ensure_ascii=False, indent=2))
+ else:
+ print(f"check-translation.py — {label}\n")
+ for name, status, detail, *_ in checks:
+ print(f"[{status:4}] {name}: {detail}")
+ summary = "ALL CHECKS PASSED"
+ if warned:
+ summary = f"PASSED with warnings: {', '.join(warned)}"
+ if failed:
+ summary = f"FAILED: {', '.join(failed)}"
+ print(f"\n{summary}")
+
+ sys.exit(0 if not failed else 1)
+
+
+if __name__ == "__main__":
+ main()