Просмотр исходного кода

docs: require bilingual non-README documentation

Tianyi Cui 1 месяц назад
Родитель
Сommit
f32d3f207f

+ 2 - 2
.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write
-2026-07-02-bilingual-docs-and-pairing-gate.md: 4bc02878a0ea3f998e411ecc2b064c1626eacf3c
-2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 90a0c2f07f68b0fb4e26cd1c4b537a30a829b10f
+2026-07-02-bilingual-docs-and-pairing-gate.md: 08e149ccc2342695d6dae4f1845896def6bf4388
+2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 91f25f33c20337ea688257829552795085e0a8c0

+ 3 - 3
.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md

@@ -12,8 +12,8 @@ This repo's README and docs tree are read by people and agents inside and outsid
 
 - **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).
 - **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.
-- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical), excluded (generated or bilingual-by-construction) files stay unpaired, and date-named documents on or after the manifest's `requiredSince` cutoff have complete pairs. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows.
-- **The enforcement frontier advances in coherent review batches.** A related set enters `required` only when reviewers can evaluate it as a unit. The core frontier groups [architecture](../../../../docs/architecture.md), the [Cordis primer](../../../../docs/cordis-primer.md), [defensive patterns](../../../../docs/defensive-patterns.md), the [glossary](../../../../docs/glossary.md), and [testing](../../../../docs/testing.md) because their terminology, links, and contributor contracts inform one another; admitting only part would leave the enforced corpus internally inconsistent. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.
+- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: sources selected explicitly, by document class, or by the manifest's `requiredSince` cutoff have complete pairs; every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. The `requiredClasses` set in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) closes a translated class for all present and future files instead of relying on an enumerated snapshot.
+- **The enforcement frontier advances in coherent review batches, then closes by class.** Explicit `required` entries admit related files while their back-catalog is still being reviewed; after that catalog is complete, its `non-readme` or `readme` class enters `requiredClasses` and can no longer grow a backlog. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.
 - **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.
 - **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent.
 
@@ -40,5 +40,5 @@ Paired sibling files with locale suffixes are the dominant Chinese big-tech conv
 - Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, "who confirmed these consistent, and when" is answerable from git blame on the yaml.
 - When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.
 - Generated docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list.
-- Rollout is incremental by design: documents outside `required` are visible backlog (`--list`), not red CI, so pairs land in reviewable batches without a big-bang PR. A date-named document dated on or after the manifest's `requiredSince` cutoff merges bilingual or not at all, so new date-named Agent Notes do not enlarge that backlog.
+- Rollout remains incremental until a document class is complete: explicit `required` entries and the date cutoff prevent regression during review batches, while a closed class makes every current and future member mandatory. The non-README class is closed, so only the README class can still appear as backlog.
 - The recorded hashes double as the update tool (`git cat-file -p <hash>` recovers either side's last-confirmed text for a minimal diff-based update), so re-translation of whole files is never forced by the mechanism.

+ 3 - 3
.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md

@@ -12,8 +12,8 @@ Status: implemented
 
 - **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。
 - **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。
-- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:required 的配对必须存在;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)不得配对;凡文件名以日期开头且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,也必须有完整配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单只进不退:每个合并的翻译批次将自己的文件加入其中,覆盖面只增不减
-- **执行红线按连贯的评审批次推进。** 一组相关文档只有在评审者能够将其作为整体评估时,才进入 `required`。核心红线将[架构](../../../../docs/architecture.md)、[Cordis 入门](../../../../docs/cordis-primer.md)、[防御性模式](../../../../docs/defensive-patterns.md)、[术语表](../../../../docs/glossary.md)和[测试](../../../../docs/testing.md)归为一组,因为它们的术语、链接和贡献者契约相互关联;只纳入其中一部分会使受门禁约束的文档集合内部不一致。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。
+- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:通过显式指定、文档类别或 manifest(元数据清单)的 `requiredSince` 分界日期选中源文档必须有完整配对;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `requiredClasses` 集合会将已完成翻译的类别纳入强制范围,对其当前及今后所有文件强制执行契约,而不再依赖一份枚举式快照
+- **执行红线按连贯的评审批次推进,再以类别为单位完成强制覆盖。** 在存量文档仍处于评审阶段时,显式 `required` 条目会纳入相关文件;存量文档全部完成后,其 `non-readme` 或 `readme` 类别进入 `requiredClasses`,不再产生新的 backlog。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。
 - **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。
 - **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。
 
@@ -40,5 +40,5 @@ Status: implemented
 - 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。
 - 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。
 - 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。
-- 推进天然是渐进的:`required` 之外的文档是可见的 backlog(待翻清单,`--list`),而非红色的 CI;因此配对按可评审的批次落地,无需一个巨型 PR。凡文件名以日期开头且日期不早于 manifest 中 `requiredSince` 分界日期的文档,都必须配齐双语文件,因此新建的日期命名 Agent Note 不会增加这份 backlog
+- 在文档类别全部完成之前,推进仍然是渐进的:显式 `required` 条目与日期分界可在评审批次期间防止回退,已纳入强制范围的类别则将其当前及今后的每个成员都列为必选项。非 README 类别已纳入强制范围,因此只有 README 类别仍可能出现 backlog(待翻清单)
 - 记录的 hash 兼作更新工具(`git cat-file -p <hash>` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),因此这套机制从不强迫整篇重译。

+ 2 - 2
docs/i18n/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write
-README.md: 77d7b3210216c7c12d7d06b1ed16396d02ef1d16
-README.zh.md: de15fc3b5f30c1280ce6b38c1afd2475be7f9671
+README.md: a60572b0691702c949b44b82a7b1d732a888ed93
+README.zh.md: 04ae032233dfa01c145b5d6bdbe7353a366e11f1

+ 3 - 3
docs/i18n/README.md

@@ -23,7 +23,7 @@ This repo's documentation is read by people and agents both inside and outside t
 
 `pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:
 
-1. Every file listed as `required` in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair.
+1. Every file listed as `required`, and every document whose class appears in `requiredClasses`, in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair. The classes are `non-readme` and `readme`; class matching is case-insensitive on the basename, so `missions/readme.md` is a README.
 2. Every pair that exists at all — required or not — is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.
 3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all.
 4. Every date-named document (`yyyy-mm-dd-*.md`) dated on or after the manifest's `requiredSince` cutoff has a complete pair — new date-named Agent Notes merge bilingual from birth.
@@ -41,11 +41,11 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co
 **Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):
 
 - `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list.
-- `docs/AGENTS.md` and `.agents/notes/**/AGENTS.md` — agent instructions, maintained in English only like the root `AGENTS.md`.
+- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.
 - `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.
 - [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.
 
-**Rollout**: a date-named document (`yyyy-mm-dd-*.md`, i.e. an Agent Note) dated on or after the manifest's `requiredSince` cutoff must merge with its pair. Earlier dates are backlog, including files created on the cutoff's eve. An Agent Note filename records its first-proposed date, so backdating past the cutoff is a review-visible violation. The manifest's `required` list is the current enforcement frontier, not the goal of full coverage. Translation batches add paths to `required`, ratcheting the gate forward. Unlisted documents remain visible in `--list`, while every existing pair is governed by the full contract. Because later edits must update both sides, expand `required` only as fast as translation review can support.
+**Enforcement frontier**: `requiredClasses` closes a whole document class after its back-catalog has been translated. `non-readme` is closed: every current or future in-scope non-README document must merge bilingual. README coverage remains an explicit-file rollout until `readme` joins the closed set. The manifest's `required` list retains already-admitted files, and a date-named document (`yyyy-mm-dd-*.md`, i.e. an Agent Note) dated on or after `requiredSince` must merge with its pair regardless of class. `--list` reports any unclosed-class backlog while every existing pair remains governed by the full contract.
 
 ## Division of labor
 

+ 3 - 3
docs/i18n/README.zh.md

@@ -23,7 +23,7 @@
 
 `pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份契约:
 
-1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个文件都有完整配对。
+1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个文件,以及所属文档类别出现在 `requiredClasses` 中的每篇文档,都有完整配对。类别分为 `non-readme` 和 `readme`;判断类别时,basename 不区分大小写,因此 `missions/readme.md` 也属于 README。
 2. 任何已存在的配对(无论是否 required)都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。
 3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。
 4. 凡文件名符合 `yyyy-mm-dd-*.md` 且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,都必须有完整配对;新建的日期命名 Agent Note 从创建起便须配齐中英文。
@@ -41,11 +41,11 @@
 **排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):
 
 - `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。
-- `docs/AGENTS.md` 与 `.agents/notes/**/AGENTS.md`:agent 指令,与根 `AGENTS.md` 一样只以英文维护。
+- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。
 - `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。
 - [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。
 
-**推进**:以日期命名的文档(`yyyy-mm-dd-*.md`,即 Agent Note),只要标注日期等于或晚于 manifest 的 `requiredSince` 分界日期,合并时就必须配齐双语文件。更早日期的文件属于 backlog(待翻清单),包括分界前夜创建的文件。Agent Note 文件名记录首次提出日期,因此倒填日期绕过分界属于评审可见的违规。manifest 中的 `required` 列表是当前执行红线,并非全量覆盖这一最终目标。翻译批次将路径加入 `required`,使门禁只向前收紧。未列入的文档仍可通过 `--list` 查看,而任何已存在的配对都受完整契约约束。后续修改必须同步更新两侧,因此 `required` 的扩展速度不能超过翻译评审的承载能力
+**执行红线**:某个文档类别的存量文档全部翻译完成后,`requiredClasses` 会将整个类别纳入强制范围。`non-readme` 已纳入强制范围:当前及今后所有纳入范围的非 README 文档,合并时都必须配齐双语文件。README 覆盖仍按显式文件逐步推进,直到 `readme` 加入这一强制范围。manifest 的 `required` 列表保留已纳入的文件;以日期命名的文档(`yyyy-mm-dd-*.md`,即 Agent Note)只要日期不早于 `requiredSince`,就无论所属类别都必须与对侧文件一同合并。`--list` 会报告尚未纳入强制范围的类别中的任何 backlog(待翻清单),而每个已存在的配对仍受完整契约约束
 
 ## 分工
 

+ 2 - 2
docs/i18n/style-samples.md

@@ -70,9 +70,9 @@
 
 ## ⑦ 推进策略(长段拆分示范)
 
-> **Rollout**: date-named Agent Notes don't wait for a batch — one dated on or after the manifest's `requiredSince` cutoff must merge with its pair, so each new date-named Agent Note is bilingual from birth. For the back-catalog, the `required` list in the manifest is the enforcement frontier, not the goal. […] Pairing a document is a commitment: every later edit to either side must carry the counterpart along, so grow the frontier at the pace translation review is actually resourced, not ahead of it.
+> **Enforcement frontier**: a document class enters the manifest's `requiredClasses` set only after its back-catalog has been translated and reviewed. The `non-readme` class is closed, so every current or future in-scope non-README document must merge bilingual; README coverage remains an explicit-file rollout until that class is ready to close. […] Pairing a document is a commitment: every later edit to either side must carry the counterpart along, so close a class only when translation review can sustain it.
 
-**推进**:日期命名的 Agent Note 无需等待批量翻译。只要文件名中的日期不早于 manifest(元数据清单)的 `requiredSince` 分界日期,合入时就必须配齐中英文,因此此类 Agent Note 从创建起就要求双语齐备。对于存量文档,manifest 中的 `required` 列表只是当前的执行红线,并非最终目标。(……)一旦文档完成配对,后续修改任一侧都必须同步更新另一侧。因此,应根据实际可投入的翻译评审能力逐步扩展执行红线,不能超前
+**执行红线**:只有在某个文档类别的存量文档全部完成翻译和评审后,该类别才会进入 manifest(元数据清单)的 `requiredClasses` 集合。`non-readme` 类别已纳入强制范围,因此当前及今后所有纳入范围的非 README 文档,合入时都必须配齐双语文件;README 覆盖仍按显式文件逐步推进,直到该类别具备整体纳入强制范围的条件。(……)一旦文档完成配对,后续修改任一侧都必须同步更新另一侧。因此,只有在翻译评审能力足以持续支撑时,才应将整个类别纳入强制范围
 
 ## 从样例提炼的要点
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
scripts/snapshots/translation-prompt-v4/request-response.expected.json


+ 4 - 0
scripts/translation-pairing.manifest.json

@@ -1,4 +1,7 @@
 {
+  "requiredClasses": [
+    "non-readme"
+  ],
   "requiredSince": "2026-07-14",
   "required": [
     ".agents/notes/README.md",
@@ -215,6 +218,7 @@
   "excluded": [
     ".agents/notes/AGENTS.md",
     ".agents/notes/implemented/AGENTS.md",
+    ".agents/notes/implemented/CLAUDE.md",
     "docs/AGENTS.md",
     "docs/agent-lifecycle.md",
     "docs/capability-seams.md",

+ 38 - 0
scripts/translation-pairing.spec.ts

@@ -7,6 +7,8 @@ import {
   parseTranslationMarkdown,
   parseTranslationPairingManifest,
   requiresPairByDate,
+  requiresTranslationPair,
+  translationDocumentClass,
   translationStructureDiff,
   translationStructureSignature,
 } from './translation-pairing.ts'
@@ -20,10 +22,12 @@ describe('translation pairing manifest', () => {
     expect(parseTranslationPairingManifest(JSON.stringify({
       requiredSince: '2026-07-14',
       required: ['README.md'],
+      requiredClasses: ['non-readme'],
       excluded: ['docs/generated/'],
     }))).toEqual({
       requiredSince: '2026-07-14',
       required: ['README.md'],
+      requiredClasses: ['non-readme'],
       excluded: ['docs/generated/'],
     })
   })
@@ -33,6 +37,7 @@ describe('translation pairing manifest', () => {
     expect(() => parseTranslationPairingManifest(JSON.stringify({
       requiredSince: cutoff,
       required: [],
+      requiredClasses: [],
       excluded: [],
     }))).toThrow('requiredSince must be a valid YYYY-MM-DD date')
   })
@@ -41,9 +46,42 @@ describe('translation pairing manifest', () => {
     expect(() => parseTranslationPairingManifest(JSON.stringify({
       requiredSince: '2026-07-14',
       required: [42],
+      requiredClasses: [],
       excluded: [],
     }))).toThrow('required must be an array of strings')
   })
+
+  it('rejects unknown and duplicate document classes', () => {
+    const manifest = (requiredClasses: string[]) => JSON.stringify({
+      requiredSince: '2026-07-14',
+      required: [],
+      requiredClasses,
+      excluded: [],
+    })
+    expect(() => parseTranslationPairingManifest(manifest(['guide']))).toThrow('requiredClasses must contain only')
+    expect(() => parseTranslationPairingManifest(manifest(['readme', 'readme']))).toThrow('requiredClasses must not contain duplicates')
+  })
+})
+
+describe('document-class pairing frontier', () => {
+  const manifest = parseTranslationPairingManifest(JSON.stringify({
+    requiredSince: '2026-07-14',
+    required: ['docs/legacy/README.md'],
+    requiredClasses: ['non-readme'],
+    excluded: [],
+  }))
+
+  it('classifies README basenames case-insensitively', () => {
+    expect(translationDocumentClass('packages/core/README.md')).toBe('readme')
+    expect(translationDocumentClass('missions/readme.md')).toBe('readme')
+    expect(translationDocumentClass('docs/readme-guide.md')).toBe('non-readme')
+  })
+
+  it('requires every non-README while retaining explicit README entries', () => {
+    expect(requiresTranslationPair('docs/guide.md', manifest)).toBe(true)
+    expect(requiresTranslationPair('docs/legacy/README.md', manifest)).toBe(true)
+    expect(requiresTranslationPair('docs/new/README.md', manifest)).toBe(false)
+  })
 })
 
 describe('date-based pairing frontier', () => {

+ 33 - 0
scripts/translation-pairing.ts

@@ -12,11 +12,18 @@ import type { Nodes } from 'mdast'
 /** Validated shape of `scripts/translation-pairing.manifest.json`. */
 export interface TranslationPairingManifest {
   required: string[]
+  /** Document classes whose complete in-scope population must be paired. */
+  requiredClasses: TranslationDocumentClass[]
   excluded: string[]
   /** Date-named documents on or after this day must merge bilingual. */
   requiredSince: string
 }
 
+/** Stable classes used to close one translation rollout without enumerating files. */
+export type TranslationDocumentClass = 'readme' | 'non-readme'
+
+const TRANSLATION_DOCUMENT_CLASSES: TranslationDocumentClass[] = ['readme', 'non-readme']
+
 const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
 const DATED_DOCUMENT = /(?:^|\/)(\d{4}-\d{2}-\d{2})-[^/]*\.md$/
 
@@ -40,6 +47,19 @@ function stringArrayField(record: Record<string, unknown>, field: 'required' | '
   return entries
 }
 
+/** Read and validate the manifest's closed document-class set. */
+function requiredClassesField(record: Record<string, unknown>): TranslationDocumentClass[] {
+  const value = record.requiredClasses
+  if (!Array.isArray(value) || !value.every((entry): entry is TranslationDocumentClass =>
+    typeof entry === 'string' && TRANSLATION_DOCUMENT_CLASSES.includes(entry as TranslationDocumentClass))) {
+    throw new Error('translation-pairing.manifest.json: requiredClasses must contain only "readme" and "non-readme"')
+  }
+  if (new Set(value).size !== value.length) {
+    throw new Error('translation-pairing.manifest.json: requiredClasses must not contain duplicates')
+  }
+  return value
+}
+
 /** Parse and validate the checked-in bilingual manifest. */
 export function parseTranslationPairingManifest(content: string): TranslationPairingManifest {
   const value: unknown = JSON.parse(content)
@@ -53,11 +73,24 @@ export function parseTranslationPairingManifest(content: string): TranslationPai
   }
   return {
     required: stringArrayField(record, 'required'),
+    requiredClasses: requiredClassesField(record),
     excluded: stringArrayField(record, 'excluded'),
     requiredSince,
   }
 }
 
+/** Classify a Markdown source by whether its basename is README, case-insensitively. */
+export function translationDocumentClass(file: string): TranslationDocumentClass {
+  return /(?:^|\/)readme\.md$/i.test(file) ? 'readme' : 'non-readme'
+}
+
+/** Whether the manifest requires this in-scope source to have a complete pair. */
+export function requiresTranslationPair(file: string, manifest: TranslationPairingManifest): boolean {
+  return manifest.required.includes(file)
+    || manifest.requiredClasses.includes(translationDocumentClass(file))
+    || requiresPairByDate(file, manifest.requiredSince)
+}
+
 /** Return the leading date of a `yyyy-mm-dd-*.md` basename, if present. */
 export function datedDocumentDate(file: string): string | undefined {
   return DATED_DOCUMENT.exec(file)?.[1]

+ 14 - 21
scripts/verify-translation-pairing.ts

@@ -1,9 +1,10 @@
 /**
  * Enforce complete English/Chinese pairs, matching structure, and recorded git
  * blob hashes under the bilingual manifest. Required files and date-named docs
- * at or after `requiredSince` must be paired; excluded docs may have neither a
- * counterpart nor sidecar. `--list` reports state and `--write` records both
- * sides after human review. Translation quality remains a review responsibility.
+ * at or after `requiredSince`, plus every source in a required document class,
+ * must be paired; excluded docs may have neither a counterpart nor sidecar.
+ * `--list` reports state and `--write` records both sides after human review.
+ * Translation quality remains a review responsibility.
  * See `docs/i18n/README.md` for the owning contract.
  */
 
@@ -11,11 +12,11 @@ import { createHash } from 'node:crypto'
 import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
 import { basename, join, resolve, sep } from 'node:path'
 import {
-  datedDocumentDate,
   linksTo,
   parseTranslationMarkdown,
   parseTranslationPairingManifest,
-  requiresPairByDate,
+  requiresTranslationPair,
+  translationDocumentClass,
   translationStructureDiff,
   translationStructureSignature,
 } from './translation-pairing.ts'
@@ -118,29 +119,21 @@ if (writeMode) {
 const errors: string[] = []
 const state = new Map<string, 'ok' | 'out-of-sync' | 'missing'>()
 
-// 1. Required pairs exist.
+// 1. Explicit manifest entries name existing source documents.
 for (const req of manifest.required) {
   if (!existsSync(join(root, req))) {
     errors.push(`${req}: listed in translation-pairing.manifest.json \`required\` but the file does not exist`)
-    continue
-  }
-  const { zh } = pairPaths(req)
-  if (!existsSync(join(root, zh))) {
-    errors.push(`${req}: required to have a translation, but ${zh} does not exist`)
-    state.set(req, 'missing')
   }
 }
 
-// 2. Date-named documents (Agent Notes) dated on/after the requiredSince cutoff merge
-// bilingual: a new Agent Note lands with its pair or not at all. Deterministic from
-// the filename alone — no git history, so it holds on shallow CI checkouts.
+// 2. Every source selected explicitly, by document class, or by the dated-document
+// cutoff merges bilingual. Class enforcement closes a rollout for future files too.
 for (const source of sources) {
   if (isExcluded(source)) continue
-  const date = datedDocumentDate(source)
-  if (!requiresPairByDate(source, manifest.requiredSince) || date === undefined) continue
+  if (!requiresTranslationPair(source, manifest)) continue
   const { zh } = pairPaths(source)
   if (!existsSync(join(root, zh))) {
-    errors.push(`${source}: dated ${date} — documents dated on/after ${manifest.requiredSince} merge bilingual (docs/i18n/README.md); add the counterpart and record the pair`)
+    errors.push(`${source}: required to merge bilingual as a ${translationDocumentClass(source)} document (docs/i18n/README.md); add the counterpart and record the pair`)
     state.set(source, 'missing')
   }
 }
@@ -214,8 +207,8 @@ if (listMode) {
   const order = { 'out-of-sync': 0, missing: 1, ok: 2 } as const
   const rows = [...state.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0].localeCompare(b[0]))
   for (const [file, status] of rows) {
-    const required = manifest.required.includes(file)
-    const tag = required ? '  (required)' : requiresPairByDate(file, manifest.requiredSince) ? '  (required by date)' : '  (backlog)'
+    const required = requiresTranslationPair(file, manifest)
+    const tag = required ? `  (required ${translationDocumentClass(file)})` : '  (backlog)'
     console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? tag : ''}`)
   }
   const counts = { 'ok': 0, 'out-of-sync': 0, 'missing': 0 }
@@ -225,7 +218,7 @@ if (listMode) {
 }
 
 if (errors.length === 0) {
-  console.log(`verify-translation-pairing: ${pairAnchors.size} pair(s) checked against ${manifest.required.length} required, all consistent.`)
+  console.log(`verify-translation-pairing: ${pairAnchors.size} pair(s) checked against ${manifest.required.length} explicit requirements and required classes [${manifest.requiredClasses.join(', ')}], all consistent.`)
   process.exit(0)
 }
 

Некоторые файлы не были показаны из-за большого количества измененных файлов