Przeglądaj źródła

Merge pull request #2119 from deepseek-harness/worktree/gate-package-subsystem-pages

Require package groups to declare subsystem documentation
Tianyi Cui 3 tygodni temu
rodzic
commit
4ba47e665b

+ 2 - 2
.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.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 .agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md
-2026-08-03-package-anchored-subsystem-pages.md: 71e4c2a28f3f556e0d3df2242b410899df763e48
-2026-08-03-package-anchored-subsystem-pages.zh.md: 0f29a26e456ad7883d00ad51ae60aadc4acb5358
+2026-08-03-package-anchored-subsystem-pages.md: e5337fba0c9a69dd786fb156572e978f87435b9b
+2026-08-03-package-anchored-subsystem-pages.zh.md: 25afe84faa43af59d4366b5cc0b6c2d2ad4da1ed

+ 4 - 1
.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md

@@ -14,7 +14,9 @@ Every `docs/subsystems/` page anchors to the package or package group that decla
 
 Every type a generated signature references must resolve somewhere in the folder: the agent ownership vocabulary moved from the generator's `TYPE_LINK_EXEMPTIONS` into `LINK_MAP → core.md`, so exemptions are reserved for genuinely service-local or vendored shapes. Each pasted declaration has one home (`SessionEvent` lives on [session.md](../../../../docs/subsystems/session.md); core.md summarizes and links).
 
-Every `packages/<group>/README.md` pair is a thin entry point in one shape: a why-first intro paragraph, a package table (Package / Role / ctx key), and a closing pointer to the owning subsystems page. Load-bearing prose that outgrows that shape relocates to the owning subsystems page rather than being deleted.
+Every `packages/<group>/README.md` pair is a thin entry point in one shape: a why-first intro paragraph, a package table (Package / Role / ctx key), and a closing pointer to the owning subsystems page. A group that declares no standalone subsystem reference is instead classified with a non-empty rationale in `GROUPS_WITHOUT_SUBSYSTEM_PAGE`. Load-bearing prose that outgrows that shape relocates to the owning subsystems page rather than being deleted.
+
+`verify-subsystem-pages` discovers groups from both group READMEs and child package manifests. It rejects a missing group README, a group with neither a reader-visible direct link to one English file under `docs/subsystems/` nor an explicit exemption, a blank or orphaned exemption, an exempt group that gains a link, and a link whose page is absent; code, comments, images, nested paths, and traversal do not satisfy ownership. The check runs as an independent `doc-sync` leaf, so adding a package group cannot silently omit its documentation owner.
 
 The [subsystems README](../../../../docs/subsystems/README.md) indexes every page in the folder on both language sides; `scripts/project-doc-site.spec.ts` enforces one table row per page, so an added or merged page cannot silently miss the index.
 
@@ -29,6 +31,7 @@ The [subsystems README](../../../../docs/subsystems/README.md) indexes every pag
 ## Consequences
 
 - Which page documents a type is predictable from `packages/<group>/`; the subsystems README is a complete index enforced by test.
+- Every package group makes its subsystem owner or justified absence reviewable, and `verify-subsystem-pages` rejects unclassified additions and stale exemptions.
 - Generated signature footers link the agent ownership vocabulary instead of silently exempting it.
 - `verify-type-equiv`'s 1:1 manifest keeps each paste single-homed; the duplicate `SessionEvent` paste is gone.
 - The [original catalog note](2026-06-20-core-data-structures-catalog.md) remains the owner of the `ts type-equiv` drift-gate mechanism; only its page-scoping rule is superseded here.

+ 5 - 2
.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md

@@ -14,7 +14,9 @@ Status: implemented
 
 生成签名引用的每个类型都必须能在目录中某处解析:agent 所有权词汇从生成器的 `TYPE_LINK_EXEMPTIONS` 移入 `LINK_MAP → core.md`,因此豁免只留给确实仅用于服务内部或来自 vendored 代码的类型结构。每个粘贴的声明只有一个家(`SessionEvent` 位于 [session.md](../../../../docs/subsystems/session.zh.md);core.md 概括并链接)。
 
-每个 `packages/<group>/README.md` 配对都是统一形状的精简入口:一段先说明「为什么」的介绍、一张包表格(包 / 角色 / ctx 键)、一个指向对应子系统页面的收尾链接。如果承载关键信息的正文超出这一结构所能容纳的范围,就将其迁移到对应的子系统页面,而非删除。
+每个 `packages/<group>/README.md` 配对都是统一形状的精简入口:一段先说明「为什么」的介绍、一张包表格(包 / 角色 / ctx 键)、一个指向对应子系统页面的收尾链接。未声明独立子系统参考资料的分组会在 `GROUPS_WITHOUT_SUBSYSTEM_PAGE` 中附上非空理由。如果承载关键信息的正文超出这一结构所能容纳的范围,就将其迁移到对应的子系统页面,而非删除。
+
+`verify-subsystem-pages` 同时从分组 README 和子包 manifest(元数据清单)发现分组。它会拒绝缺少分组 README、分组既没有面向读者且直接指向 `docs/subsystems/` 下某一个英文文件的链接也没有显式豁免、豁免为空或成为孤立项、已豁免分组新增链接,以及链接指向的页面不存在;代码、注释、图片、嵌套路径和路径穿越都不能满足所有权声明。该检查作为独立的 `doc-sync`(文档同步)叶节点运行,因此新增包分组时不能悄悄遗漏其文档拥有方。
 
 [子系统 README](../../../../docs/subsystems/README.zh.md) 在中英文两侧索引目录中的每一页;`scripts/project-doc-site.spec.ts` 强制每个页面对应一个表格行,因此新增或合并吸收的页面无法悄悄缺席索引。
 
@@ -29,6 +31,7 @@ Status: implemented
 ## 后果
 
 - 哪一页记录某类型可由 `packages/<group>/` 预测;子系统 README 是由测试强制的完整索引。
+- 每个包分组都会将其子系统拥有方或合理的缺席原因暴露给评审,且 `verify-subsystem-pages` 会拒绝未分类的新增项和陈旧豁免。
 - 生成的签名页脚链接 agent 所有权词汇,而不是静默豁免。
 - `verify-type-equiv` 的 1:1 manifest(元数据清单)保证每个粘贴单一归属;重复的 `SessionEvent` 粘贴已移除。
-- [原目录 Agent Note](2026-06-20-core-data-structures-catalog.zh.md) 仍拥有 `ts type-equiv` 漂移门禁机制;此处仅取代其页面范围界定规则。
+- [原目录 Agent Note](2026-06-20-core-data-structures-catalog.zh.md) 仍拥有 `ts type-equiv` 漂移检查机制;此处仅取代其页面范围界定规则。

+ 1 - 0
package.json

@@ -72,6 +72,7 @@
     "verify-doc-site-fragments": "tsx scripts/verify-doc-site-fragments.ts",
     "verify-public-repository-links": "tsx scripts/verify-public-repository-links.ts",
     "verify-doc-refs": "tsx scripts/verify-doc-refs.ts",
+    "verify-subsystem-pages": "tsx scripts/verify-subsystem-pages.ts",
     "verify-package-paths": "tsx scripts/verify-package-paths.ts",
     "verify-dsh-package-licenses": "tsx scripts/verify-dsh-package-licenses.ts",
     "verify-config-source-ownership": "tsx scripts/verify-config-source-ownership.ts",

+ 1 - 1
packages/AGENTS.md

@@ -22,6 +22,6 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md
 - **Package tsconfig:** extends `tsconfig.base.json` (Client: `tsconfig.base.client.json`), uses `rootDir: src`, `outDir: lib/types`, and references each workspace dependency plus `runtime-diagnostics/invariants`; registers in exactly one aggregate. Only `api/remotes` splits for generated contracts; ordinary two-entry Client plugins do not ([layout](../docs/development.md#typescript-project-layout)).
 - `src/types.ts` contains only types — no runtime code.
 - Tests live at package level under `tests/`, not `src/__tests__/`.
-- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code.
+- Update package README and JSDoc contracts in the same commit as behavior, and verify them against code with [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md). Group READMEs declare subsystem ownership through a canonical English page link or justified [exemption](../scripts/verify-subsystem-pages.ts).
 - Package READMEs document model, token, and KV-cache effects using the [canonical Model Experience format](../docs/cookbook/adding-a-package.md#4-write-the-package-readme).
 - Package READMEs put durable consumer gaps and non-obvious maintainer constraints under `## Known Limitations and Deferred Work`; ordinary cleanup stays in its TODO or Agent Note. Packages with none use a justified [allowlist entry](../scripts/verify-package-readme-limitations.ts) ([rationale](../.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.md)).

+ 2 - 2
packages/attachment/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 packages/attachment/README.md
-README.md: 61b4e5c602f475f85bbe859b8483e30b518e06c8
-README.zh.md: ac93f4870a714d3131fc47789dac9cb5ae926b66
+README.md: 556c98b40f601a631e65ccb2121d30fa74b97399
+README.zh.md: 2915e8e207acfdc389b04c609d86ec2ef9c083b9

+ 2 - 0
packages/attachment/README.md

@@ -10,3 +10,5 @@ The durable binary attachment seam and its local filesystem implementation. Both
 | `attachment-local/` | Content-addressed private storage below `DSH_HOME` | (registers on `ctx.attachments`) |
 
 Unsent browser drafts are intentionally outside this capability. Bytes enter durable storage only when a user prompt is submitted or when a provider adapter commits structured model output.
+
+See [durable image attachments](../../docs/subsystems/attachment.md) for reference validation, storage, and verified-read contracts.

+ 2 - 0
packages/attachment/README.zh.md

@@ -10,3 +10,5 @@
 | `attachment-local/` | `DSH_HOME` 下的私有内容寻址存储 | (注册至 `ctx.attachments`) |
 
 未发送的浏览器草稿刻意位于这项能力之外。只有用户提交提示词,或提供方适配器提交结构化模型输出时,字节才进入持久存储。
+
+有关引用校验、存储和经过校验的读取约定,参见[持久图片附件](../../docs/subsystems/attachment.zh.md)。

+ 2 - 2
packages/extensions/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 packages/extensions/README.md
-README.md: 12717c43b0cf0a2a7958ed2e18f608da23c67963
-README.zh.md: df2d70d3e534b0dfefafe6e1192f20eb5f237d6a
+README.md: 27957a4683edf840cbd7bad00a809ceac8944c20
+README.zh.md: 173ec99b9418015fced23e72453bcd4d814d7009

+ 2 - 0
packages/extensions/README.md

@@ -10,3 +10,5 @@ Model-facing tools over the live cordis runtime the agent itself runs inside: in
 | [`cordis-host-runner/`](cordis-host-runner/README.md) | Definition registry, the `node:vm` sandbox for host halves, and the request-run round trip | provides `ctx.dynamicCordisRunner` |
 | [`cordis-client-runner/`](cordis-client-runner/README.md) | Browser half of a dual-half package: evaluates the definition into a live browser plugin and answers the run request | client face; provides the browser `ctx.dynamicCordisRunner` |
 | [`ui-cordis/`](ui-cordis/README.md) | Browser surfaces: the frame-wide panel that operates every definition, and the read-only define card | client face; registers slots |
+
+See [Extensions](../../docs/subsystems/extensions.md) for package lifecycle, sandbox behavior, and the generated Cordis API.

+ 2 - 0
packages/extensions/README.zh.md

@@ -10,3 +10,5 @@ agent 修改自身运行时:检查已加载的插件与服务接口、定义
 | [`cordis-host-runner/`](cordis-host-runner/README.zh.md) | 定义注册表、host 半的 `node:vm` 沙箱,以及 request-run 往返 | 提供 `ctx.dynamicCordisRunner` |
 | [`cordis-client-runner/`](cordis-client-runner/README.zh.md) | 双半包的浏览器半:把定义求值成活的浏览器插件,并应答运行请求 | client 面;提供浏览器侧 `ctx.dynamicCordisRunner` |
 | [`ui-cordis/`](ui-cordis/README.zh.md) | 浏览器面:操作全部定义的全局面板,与只读的 define 卡片 | client 面;注册 slot |
+
+有关包生命周期、沙箱行为和生成的 Cordis API,参见[扩展](../../docs/subsystems/extensions.zh.md)。

+ 2 - 2
packages/preset/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 packages/preset/README.md
-README.md: 5e7805eaa303b78c4d8a1a8972e1d031ee304fae
-README.zh.md: 217d807f40abc0908fb0c67fa0c94dc4d0806592
+README.md: 24d13075281689f5e3df8f91c4f13035fdb4acb9
+README.zh.md: a529eea6667da50204ccd4fa958b8c443af65e99

+ 2 - 0
packages/preset/README.md

@@ -14,3 +14,5 @@ The presets the deployment ships live in [`apps/cli/config/agent-presets/`](../.
 The composition split this group assumes: registries and cross-session facilities are process singletons and stay in the host composition, while a preset carries what one agent contributes to them. A preset that names a row publishing a process-global service is rejected at mount rather than allowed to collide with the next session.
 
 Design: [the per-session agent-preset note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md).
+
+See the [`AgentPresets` reference](../../docs/subsystems/core.md#ctxagentpresets--agentpresets) for discovery, mounting, inheritance, and recomposition.

+ 2 - 0
packages/preset/README.zh.md

@@ -14,3 +14,5 @@
 本组假定的组装划分是:注册表与跨会话设施是进程单例,留在宿主组装中;preset 只承载单个 agent 对它们的贡献。若 preset 中某一行发布了进程级全局服务,挂载时即被拒绝,而不是留到与下一个会话相撞。
 
 设计详见 [按会话组装 agent preset 的 Agent Note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md)。
+
+有关发现、挂载、继承和重新组装的约定,参见 [`AgentPresets` 参考](../../docs/subsystems/core.zh.md#ctxagentpresets--agentpresets)。

+ 6 - 0
packages/runtime-diagnostics/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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 packages/runtime-diagnostics/README.md
+README.md: f8df688f2dc053d579437dfdd04cee790973c1db
+README.zh.md: 294bf3369c4049c9c350713ed497ca4374a99c2f

+ 11 - 0
packages/runtime-diagnostics/README.md

@@ -0,0 +1,11 @@
+# runtime-diagnostics/ — package-owned runtime checks
+
+English | [中文](README.zh.md)
+
+Configurable diagnostics that check package-owned runtime relationships without changing product behavior.
+
+| Package | Role | ctx key |
+|---|---|---|
+| [`invariants/`](invariants/README.md) | Registers and runs package-owned runtime invariant companions | `ctx.invariants` |
+
+See [runtime invariants](../../docs/subsystems/invariants.md) for selection, lifecycle, and package-attributed failure semantics.

+ 11 - 0
packages/runtime-diagnostics/README.zh.md

@@ -0,0 +1,11 @@
+# runtime-diagnostics/:包自有运行时检查
+
+[English](README.md) | 中文
+
+可配置诊断用于检查包自有的运行时关系,而不改变产品行为。
+
+| 包 | 角色 | ctx 键 |
+|---|---|---|
+| [`invariants/`](invariants/README.zh.md) | 注册并运行包自有的运行时不变式配套入口 | `ctx.invariants` |
+
+有关选择、生命周期和归因到包的失败语义,参见[运行时不变式](../../docs/subsystems/invariants.zh.md)。

+ 2 - 2
packages/typert/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 packages/typert/README.md
-README.md: ad9f843e48be0e3be85921ed8fd3ca4e2c327160
-README.zh.md: 9ebd00f71a509e24a0b6c674c56d87c411dd7794
+README.md: 36b97510ceb6524b396e53eb05b6d0e702fce9fe
+README.zh.md: 0bf6b83a606f7cd812772cdd116fd65825d66fd9

+ 2 - 0
packages/typert/README.md

@@ -9,3 +9,5 @@ Typert separates source analysis, runtime storage, and Loader discovery.
 | [`registry/`](registry/README.md) | Stores runtime package reflection and schemas | `ctx.typert` |
 | [`loader/`](loader/README.md) | Discovers Loader entries and registers generated host artifacts | consumes `ctx.loader` and `ctx.typert` |
 | [`generator/`](generator/README.md) | Generates runtime artifacts from source types | build-time library |
+
+See [TypeRT remote calls](../../docs/subsystems/typert.md) for the generated invocation, schema, and transport contracts.

+ 2 - 0
packages/typert/README.zh.md

@@ -9,3 +9,5 @@ Typert 将源代码分析、运行时存储和 Loader 发现机制分离。
 | [`registry/`](registry/README.zh.md) | 存储运行时包反射和 schema | `ctx.typert` |
 | [`loader/`](loader/README.zh.md) | 发现 Loader 条目并注册生成的宿主产物 | 使用 `ctx.loader`、`ctx.typert` |
 | [`generator/`](generator/README.zh.md) | 从源代码类型生成运行时产物 | 构建时库 |
+
+有关所生成的调用、schema 和传输三方面的约定,参见 [TypeRT 远程调用](../../docs/subsystems/typert.zh.md)。

+ 6 - 0
scripts/run-gates.spec.ts

@@ -84,6 +84,12 @@ describe('gate graph validation', () => {
     expect(ids).toContain('public-repository-links')
   })
 
+  it('keeps package-group subsystem ownership in the documentation gate', () => {
+    const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
+
+    expect(ids).toContain('subsystem-pages')
+  })
+
   it('keeps the hygiene aggregate aligned with the package script checks', () => {
     const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id))
 

+ 1 - 0
scripts/run-gates.ts

@@ -674,6 +674,7 @@ function docSyncLeafGates(options: {
     pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
     pnpmScript('public-repository-links', 'verify-public-repository-links', { label: 'public repository links' }),
     pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }),
+    pnpmScript('subsystem-pages', 'verify-subsystem-pages', { label: 'subsystem pages' }),
     pnpmScript('package-paths', 'verify-package-paths', { label: 'package paths' }),
     pnpmScript('config-source-ownership', 'verify-config-source-ownership', { label: 'config source ownership' }),
     pnpmScript('package-readme-model-experience', 'verify-package-readme-model-experience', { label: 'package README model experience' }),

+ 123 - 0
scripts/verify-subsystem-pages.spec.ts

@@ -0,0 +1,123 @@
+/** Regression coverage for package-group subsystem-page ownership. */
+
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { describe, expect, it, onTestFinished } from 'vitest'
+import { auditSubsystemPages } from './verify-subsystem-pages.ts'
+
+function fixture(): string {
+  const root = mkdtempSync(join(tmpdir(), 'dsh-subsystem-pages-'))
+  onTestFinished(() => {
+    rmSync(root, { recursive: true, force: true })
+  })
+  return root
+}
+
+function write(root: string, path: string, source: string): void {
+  const absolute = join(root, path)
+  mkdirSync(dirname(absolute), { recursive: true })
+  writeFileSync(absolute, source)
+}
+
+describe('package-group subsystem pages', () => {
+  it('accepts a direct page link and a justified no-page group', () => {
+    const root = fixture()
+    write(root, 'packages/alpha/README.md', '[types](../../docs/subsystems/alpha.md#contract)\n')
+    write(root, 'packages/alpha/alpha/package.json', '{}\n')
+    write(root, 'docs/subsystems/alpha.md', '# Alpha\n')
+    write(root, 'packages/adapter/README.md', '# Adapter\n')
+
+    expect(auditSubsystemPages(root, { adapter: 'Adapter over an existing subsystem.' })).toEqual({
+      groups: 2,
+      linked: 1,
+      exempt: 1,
+      violations: [],
+    })
+  })
+
+  it('rejects a new group whose README never declares subsystem ownership', () => {
+    const root = fixture()
+    write(root, 'packages/schedule/README.md', '# Schedule\n')
+    write(root, 'packages/schedule/tool-schedule/package.json', '{}\n')
+
+    expect(auditSubsystemPages(root, {}).violations).toEqual([
+      'packages/schedule/README.md: no reader-visible direct docs/subsystems/*.md link; add the owning page and link, or add a justified GROUPS_WITHOUT_SUBSYSTEM_PAGE entry',
+    ])
+  })
+
+  it('does not treat the subsystem index or a Chinese counterpart as an owning page', () => {
+    const root = fixture()
+    write(
+      root,
+      'packages/wrong/README.md',
+      '[index](../../docs/subsystems/README.md) [Chinese](../../docs/subsystems/wrong.zh.md)\n',
+    )
+    write(root, 'docs/subsystems/README.md', '# Subsystems\n')
+    write(root, 'docs/subsystems/wrong.zh.md', '# Wrong\n')
+
+    expect(auditSubsystemPages(root, {}).violations).toEqual([
+      'packages/wrong/README.md: no reader-visible direct docs/subsystems/*.md link; add the owning page and link, or add a justified GROUPS_WITHOUT_SUBSYSTEM_PAGE entry',
+    ])
+  })
+
+  it('does not count links hidden in code, comments, or image syntax', () => {
+    const root = fixture()
+    write(
+      root,
+      'packages/hidden/README.md',
+      [
+        '`[inline](../../docs/subsystems/hidden.md)`',
+        '```md',
+        '[fenced](../../docs/subsystems/hidden.md)',
+        '```',
+        '<!-- [comment](../../docs/subsystems/hidden.md) -->',
+        '![image](../../docs/subsystems/hidden.md)',
+        '',
+      ].join('\n'),
+    )
+    write(root, 'docs/subsystems/hidden.md', '# Hidden\n')
+
+    expect(auditSubsystemPages(root, {}).violations).toEqual([
+      'packages/hidden/README.md: no reader-visible direct docs/subsystems/*.md link; add the owning page and link, or add a justified GROUPS_WITHOUT_SUBSYSTEM_PAGE entry',
+    ])
+  })
+
+  it('rejects a link that escapes the subsystem directory', () => {
+    const root = fixture()
+    write(root, 'packages/escape/README.md', '[escape](../../docs/subsystems/../architecture.md)\n')
+    write(root, 'docs/architecture.md', '# Architecture\n')
+
+    expect(auditSubsystemPages(root, {}).violations).toEqual([
+      'packages/escape/README.md: no reader-visible direct docs/subsystems/*.md link; add the owning page and link, or add a justified GROUPS_WITHOUT_SUBSYSTEM_PAGE entry',
+    ])
+  })
+
+  it('rejects missing group READMEs and missing linked pages', () => {
+    const root = fixture()
+    write(root, 'packages/no-readme/pkg/package.json', '{}\n')
+    write(root, 'packages/broken/README.md', '[missing](../../docs/subsystems/missing.md)\n')
+
+    expect(auditSubsystemPages(root, {}).violations).toEqual([
+      'packages/broken/README.md: linked subsystem page does not exist: docs/subsystems/missing.md',
+      'packages/no-readme/README.md: package group has no group README declaring subsystem ownership',
+    ])
+  })
+
+  it('rejects blank, orphaned, and stale exemptions', () => {
+    const root = fixture()
+    write(root, 'packages/linked/README.md', '[types](../../docs/subsystems/linked.md)\n')
+    write(root, 'docs/subsystems/linked.md', '# Linked\n')
+    write(root, 'packages/blank/README.md', '# Blank\n')
+
+    expect(auditSubsystemPages(root, {
+      blank: ' ',
+      linked: 'No page.',
+      orphan: 'Removed group.',
+    }).violations).toEqual([
+      'exemption blank: missing justification for omitting a subsystem page',
+      'exemption orphan: no matching package group; remove the stale entry',
+      'packages/linked/README.md: links a subsystem page but remains exempt; remove the stale exemption',
+    ])
+  })
+})

+ 145 - 0
scripts/verify-subsystem-pages.ts

@@ -0,0 +1,145 @@
+/**
+ * Doc-sync gate for package-group subsystem references. Every package group
+ * either links at least one existing `docs/subsystems/` page from its English
+ * group README or carries an explicit, justified exemption below.
+ */
+
+import { existsSync, globSync, readFileSync } from 'node:fs'
+import { resolve, sep } from 'node:path'
+import { parseMarkdown, visitMarkdown } from './markdown.ts'
+
+const root = resolve(import.meta.dirname, '..')
+
+/**
+ * Package groups that do not own a standalone subsystem reference. Reasons
+ * are reviewable policy: a new group cannot silently inherit an exemption.
+ */
+export const GROUPS_WITHOUT_SUBSYSTEM_PAGE: Readonly<Record<string, string>> = {
+  acp: 'Protocol transport entry point; the server package README owns its interoperability contract.',
+  api: 'Remote transport and BFF assembly; Typert and the package READMEs own the underlying contracts.',
+  boot: 'Shared application-bin boot library rather than a runtime subsystem.',
+  bundle: 'Composition patch carriers whose mounted packages own all runtime contracts.',
+  e2b: 'Provider implementations of the filesystem and subprocess subsystems, not a new capability contract.',
+  examples: 'Non-product demonstration compositions whose mounted packages own all runtime contracts.',
+  experimental: 'Empty staging group; promoted packages move to their product-role group before release.',
+  feedback: 'One command producer and inline log-event payload; its package README and persistence catalog own the complete contract.',
+  hooks: 'External hook-protocol bridges over existing interception points, not a new Harness service.',
+  identity: 'Shared anonymous correlation values rather than authenticated account or authorization behavior.',
+  mcp: 'Integration adapter that contributes external tools through the existing tool registry.',
+  sdk: 'Out-of-process protocol and client packages whose package READMEs own the SDK contracts.',
+  util: 'Low-level primitives whose business semantics remain with their consuming subsystems.',
+}
+
+/** Result of auditing package-group subsystem documentation. */
+export interface SubsystemPageAudit {
+  /** Package groups discovered from group READMEs or child package manifests. */
+  readonly groups: number
+  /** Groups carrying at least one direct subsystem-page link. */
+  readonly linked: number
+  /** Groups covered by an explicit no-page policy. */
+  readonly exempt: number
+  /** Actionable contract violations. */
+  readonly violations: readonly string[]
+}
+
+/** Normalize one filesystem glob result to repository slash form. */
+function normalize(path: string): string {
+  return path.split(sep).join('/')
+}
+
+/** Extract the package-group segment from a repository-relative path. */
+function groupOf(path: string): string {
+  const group = path.split('/')[1]
+  if (group === undefined || group.length === 0) throw new Error(`invalid package path: ${path}`)
+  return group
+}
+
+/** Return canonical subsystem-page targets linked by one group README. */
+function subsystemLinks(source: string): string[] {
+  const links = new Set<string>()
+  visitMarkdown(parseMarkdown(source), (node) => {
+    if (node.type !== 'link') return
+    const match = /^\.\.\/\.\.\/docs\/subsystems\/([^/#?]+\.md)(?:#[^?#]*)?$/.exec(node.url)
+    const page = match?.[1]
+    if (page !== undefined && page !== 'README.md' && !page.endsWith('.zh.md')) links.add(`docs/subsystems/${page}`)
+  })
+  return [...links].sort()
+}
+
+/**
+ * Audit package-group subsystem ownership for one repository tree.
+ * @param scanRoot - repository root containing `packages/` and `docs/`.
+ * @param exemptions - groups intentionally carrying no subsystem-page link.
+ * @returns counts plus every actionable violation.
+ */
+export function auditSubsystemPages(
+  scanRoot: string = root,
+  exemptions: Readonly<Record<string, string>> = GROUPS_WITHOUT_SUBSYSTEM_PAGE,
+): SubsystemPageAudit {
+  const readmes = globSync('packages/*/README.md', { cwd: scanRoot }).map(normalize).sort()
+  const manifests = globSync('packages/*/*/package.json', { cwd: scanRoot }).map(normalize).sort()
+  const groups = new Set([...readmes, ...manifests].map(groupOf))
+  const violations: string[] = []
+  let linked = 0
+  let exempt = 0
+
+  for (const [group, reason] of Object.entries(exemptions)) {
+    if (!groups.has(group)) {
+      violations.push(`exemption ${group}: no matching package group; remove the stale entry`)
+    }
+    if (reason.trim().length === 0) {
+      violations.push(`exemption ${group}: missing justification for omitting a subsystem page`)
+    }
+  }
+
+  for (const group of [...groups].sort()) {
+    const readme = `packages/${group}/README.md`
+    const readmePath = resolve(scanRoot, readme)
+    if (!existsSync(readmePath)) {
+      violations.push(`${readme}: package group has no group README declaring subsystem ownership`)
+      continue
+    }
+
+    const links = subsystemLinks(readFileSync(readmePath, 'utf8'))
+    const isExempt = Object.hasOwn(exemptions, group)
+    if (links.length === 0) {
+      if (isExempt) {
+        exempt += 1
+      } else {
+        violations.push(
+          `${readme}: no reader-visible direct docs/subsystems/*.md link; add the owning page and link,`
+          + ' or add a justified GROUPS_WITHOUT_SUBSYSTEM_PAGE entry',
+        )
+      }
+      continue
+    }
+
+    linked += 1
+    if (isExempt) {
+      violations.push(`${readme}: links a subsystem page but remains exempt; remove the stale exemption`)
+    }
+    for (const page of links) {
+      if (!existsSync(resolve(scanRoot, page))) {
+        violations.push(`${readme}: linked subsystem page does not exist: ${page}`)
+      }
+    }
+  }
+
+  return { groups: groups.size, linked, exempt, violations }
+}
+
+/** Run the repository audit as a standalone doc-sync gate. */
+function main(): void {
+  const audit = auditSubsystemPages()
+  if (audit.violations.length > 0) {
+    console.error('verify-subsystem-pages: package-group documentation violations found:')
+    for (const violation of audit.violations) console.error(`  ${violation}`)
+    process.exit(1)
+  }
+  console.log(
+    `verify-subsystem-pages: ${String(audit.groups)} group(s) checked`
+    + ` (${String(audit.linked)} linked, ${String(audit.exempt)} explicitly exempt), all conform.`,
+  )
+}
+
+if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) main()