DSH / Atlas
2026-06-20implementedprocess

Subsystems catalog and the `ts type-equiv` drift gate

子系统目录与 `ts type-equiv` 漂移门禁

A reader trying to understand the harness could find its *behavior* in [architecture.md](../../../../docs/architecture.md) (the service map, the session/turn/step lifecycle, the event taxonomy) but had no single place describing its *vocabulary* — the data structures that behavior moves around. The type shapes lived only in source, scattered across `packages/*/src/types.ts`, so understanding "what is a `Message`, a `

English

Problem

A reader trying to understand the harness could find its behavior in architecture.md (the service map, the session/turn/step lifecycle, the event taxonomy) but had no single place describing its vocabulary — the data structures that behavior moves around. The type shapes lived only in source, scattered across packages/*/src/types.ts, so understanding "what is a Message, a SessionEvent, a StreamChunk" meant reading the declarations directly. A prose catalog would help, but a catalog that paraphrases or paste-copies type definitions rots the instant a field changes — and an out-of-sync type doc is worse than none, because a reader trusts it.

So the work had two intertwined questions: what belongs in such a catalog (the scoping problem — a harness has dozens of cross-package types and dumping all of them helps no one), and how to keep pasted type definitions from drifting (the durability problem). This Agent Note records both decisions. Its historical sibling, the archived generated Cordis events + services catalog decision, is the wiring-axis complement: this one catalogs the data structures, that one the events and services that move them.

Decision

A new docs/subsystems/ folder catalogs the vocabulary, with a new verify-type-equiv doc-sync gate that keeps every pasted type declaration and its JSDoc synchronized with source.

What counts as "core" — the spine-vs-subsystem line

Superseded as the page-scoping rule by package-anchored subsystem pages: each page now anchors to the package group that declares its vocabulary. The ts type-equiv mechanism below remains current.

The decisive test for the scoping line is ShellExecRequest/ShellExecSpec/ShellRunResult: bash is a capability seam, not part of the agent-loop spine, so if those are "core" then "core" means all cross-package vocabulary and the catalog is a flat dump; if they are not, "core" means the central spine and bash vocabulary belongs on its own subsystem page. The latter won, which set the whole structure: a tiered folder, not a flat document.

The rule that settled the remaining cases: the type you write, hold, or receive is core; the machinery that types it, renders it, or persists it is a subsystem-page detail. Worked through:

  • A data structure is core if it flows through the agent-loop spine — the loop holds, derives, streams, or logs it on every turn regardless of which plugins load (Message, StreamChunk, SessionEvent, the Agent handle) — or it is the single headline type a plugin author writes against a pipeline (ToolDefinition).
  • ToolDefinition is core (it is what every tool author writes) even though the loop never holds one — authoring-importance overrides the strict flows-through-spine rule for this one headline type. But its typing machinery — ValueSchemaSpec, ParameterSchemaSpec, InferValue, and InferArgs — is a subsystem-page detail. That is the spine-vs-subsystem line made sharp.
  • ToolSchema is core (it is a field of GenerateOptions, the model request that flows through every step) even though it is conceptually part of the tool pipeline — flows through the spine wins over conceptual home when they conflict.
  • The tool-presentation vocabulary (ToolCallView/ToolResultView, …), the SessionPersistence durability seam, and bash vocabulary belong on subsystem pages.

core.md is a self-contained spine doc: it states the exact type definition of each spine structure with minimal prose and links to sibling subsystem pages for package-owned detail; the folder's README indexes every page. The original subsystem pages are llm-streaming.md, session.md, persistence.md (split from session along the in-memory-model vs. durability-seam line), tools.md, and shell.md.

The ts type-equiv mechanism — literal AND drift-proof

The durability requirement was specific: the doc shows the literal current type declaration and original JSDoc (so a reader sees the real shape and source contract, not a paraphrase) and is mechanically guaranteed to match source. The repo already compiles fenced ```ts blocks (doc-typecheck), but a real typechecked block needs import noise and proves only assignability — a renamed field or changed JSDoc can pass. So:

  • Complete type declarations and their JSDoc are pasted verbatim into a dedicated ```ts type-equiv fence. A concise ```ts public-api fence carries the source-equivalent ambient projection for a class whose implementation bodies do not belong in the catalog. doc-typecheck recognizes both and skips them (the bare declarations are not standalone-compilable), and excludes them from the opt-out ratio — they are a separately-checked category, not unchecked sketches.
  • A new scripts/verify-type-equiv.ts extracts each block via the TypeScript parser and asserts that its declaration structure and every JSDoc comment match the declared symbol, ignoring only formatting whitespace and non-JSDoc comments. Ordinary blocks retain the complete declaration. A public-api projection retains a class's public fields, constructor, accessors, and methods with their original JSDoc while removing implementation bodies and private or protected members. This is chosen over a compiled _Check assertion because source names and documentation identity, not assignability, are the properties the catalog preserves.
  • Each type block's document, symbol, and source file are recorded in scripts/type-equiv.manifest.json ({ doc, symbol, source } entries), not in directive comments in the prose. The script enforces a 1:1 correspondence between each primary type-equiv block and one manifest entry, so a block can never be silently unchecked and an entry can never rot. A paired .zh.md block reuses the unsuffixed sibling's entry only when the complete tracked fence sequence matches in order, kind, and byte-exact body; otherwise the gate checks it independently, finds no manifest entry, and fails.
  • Wired into doc-sync, so relevant documentation changes run it locally and CI runs it with the other documentation checks.

Maintenance is the author's job, with a gate backstop

verify-type-equiv catches a drifted paste of an already-documented type, but it cannot tell you a brand-new core type went undocumented. So AGENTS.md and the dsh-code-review skill were updated to require keeping the catalog in sync when a change adds or reshapes a documented type — the gate handles drift, the human handles new types.

Alternatives considered

  • A flat dump of all cross-package vocabulary — the ShellExecRequest test case killed it: if seam vocabulary is "core", the catalog helps no one; the tiered spine-vs-subsystem structure won.
  • A compiled _Check assignability assertion instead of the source match — rejected because assignability does not preserve names or JSDoc: a renamed field with the same type or a changed contract comment would pass.
  • Put each type block's source in a directive comment — rejected for the central manifest, whose enforced 1:1 correspondence means a block can never be silently unchecked and an entry can never rot.

Verification lesson

verify-type-equiv must scan the complete Markdown scope, not only manifest-named documents. Otherwise an unmanifested type-equiv block escapes the claimed one-to-one check. The gate therefore reports such blocks as orphans. This Agent Note records that fail-closed scan rule together with the spine-vs-subsystem and verbatim-match decisions; the generated Cordis catalog has the symmetric design record in its archived Agent Note.

Consequences

  • The vocabulary now has a single home that cannot silently drift: a field or public class-member change in source fails verify-type-equiv in doc-sync and CI until the paste is refreshed. Cordis service methods remain owned by the generated services catalog rather than being duplicated here.
  • The spine-vs-subsystem line is a reusable scoping tool, not a one-off: the same "the thing you write/hold/receive is core; the machinery that types/renders/persists it is a detail" rule is what later scoped the events/services catalog's harness-vs-inherited tiering.
  • The ts type-equiv fence is a third doc-block category alongside ```ts (compiled) and ```ts ignore-check (sketch). A later sibling added a fourth, ```ts cordis-catalog (generated signature), reusing the same skip-and-exclude treatment.
  • Adding or reshaping a core type now carries a documentation obligation the author must honor (the gate cannot detect a missing new type), backstopped by the dsh-code-review checklist.
  • Since 2026-07-27 the subsystem-page tier spans every service-bearing subsystem: nine lean pages (permission presets, plan mode, runtime invariants, the HTTP carrier, storage — owning both ctx.storage and ctx.storageDomain — TUI extensions, workspaces, client modules, telemetry) cover the ten ctx services that had none, so each harness service and event scope has exactly one owning subsystems page — the precondition for generating per-subsystem service/event reference into these pages instead of flat catalogs.

中文

问题

试图理解 harness 的读者可以在 architecture.md 中找到它的行为(服务图、会话/轮次/步骤生命周期、事件分类体系),却找不到一个统一描述其词汇的地方,也就是这些行为所传递的数据结构。类型定义只存在于源码中,散落在 packages/*/src/types.ts 各处,因此要理解「什么是 MessageSessionEventStreamChunk」,就必须直接阅读声明。文字目录会有所帮助,但复述或复制粘贴类型定义的目录会在字段发生变化时立即腐化,而不同步的类型文档比没有文档更糟,因为读者会信任它。

因此,这项工作有两个相互交织的问题:这样的目录应包含什么(范围问题——harness 有数十种跨包边界的类型,把它们全部罗列出来对谁都没有帮助),以及如何避免粘贴的类型定义发生漂移(持久性问题)。本 Agent Note 记下了这两项决策。与它历史上配套的已归档的 Cordis 事件与服务目录自动生成决策接线维度形成补充:本文对数据结构编目,另一篇则对传递这些结构的事件和服务编目。

决策

新增的 docs/subsystems/ 目录对这些词汇编目,并配有新的 verify-type-equiv doc-sync(文档同步门禁),使每个粘贴的类型声明及其 JSDoc 与源码保持同步。

何为「核心」——主干与子系统的分界线

作为页面范围界定规则已被取代,见按包锚定的子系统页面:每页现在锚定到声明其词汇的包分组。下文的 ts type-equiv 机制仍然有效。

范围界定的决定性测试是 ShellExecRequest/ShellExecSpec/ShellRunResult:bash 是一个能力 seam,不属于 agent loop(智能体循环)主干;如果这些算「核心」,那么「核心」就意味着所有跨包词汇,目录沦为平铺罗列;如果不算,「核心」就意味着中央主干,bash 词汇归入其自身的子系统页面。后者胜出,由此确定了整体结构:一个分层文件夹,而非一份平铺文档。

确定其余案例的规则是:你编写、持有或接收的类型是核心;为其提供类型推导、渲染或持久化的机制是子系统页面细节。 逐一验证如下:

  • 一个数据结构是核心的,如果它流经 agent loop 主干——无论加载了哪些插件,循环在每个轮次都会持有、派生、流式输出或记录它(MessageStreamChunkSessionEventAgent 句柄)——或者它是插件作者面对某条流水线时编写的唯一标志性类型(ToolDefinition)。
  • ToolDefinition 是核心(它是每个工具作者编写的东西),即使循环从不持有它——对于这一个标志性类型,编写层面的重要性压过了严格的「流经主干」规则。但它的类型推导机制——ValueSchemaSpecParameterSchemaSpecInferValueInferArgs——是子系统页面细节。这就是主干与子系统分界线的精确表述。
  • ToolSchema 是核心(它是流经每个步骤的模型请求 GenerateOptions 的一个字段),即使它在概念上属于工具流水线——当流经主干概念归属冲突时,前者胜出。
  • 工具展示词汇(ToolCallView/ToolResultView 等)、SessionPersistence 持久性 seam 以及 bash 词汇归入子系统页面。

core.md 是一份自包含的主干文档:它给出每个主干结构的确切类型定义,辅以最少的行文,并链接到同级子系统页面获取包所拥有的细节;目录的 README 索引全部页面。最初的子系统页面包括 llm-streaming.mdsession.mdpersistence.md(沿内存模型与持久性 seam 的分界线从会话页面拆出)、tools.mdshell.md

ts type-equiv 机制——既逐字又防漂移

持久性要求很具体:文档展示当前类型声明与原始 JSDoc 的逐字内容(让读者看到真实形状和源码约定,而非复述),并且以机械方式保证其与源码匹配。仓库已经会编译 ```ts 围栏块(doc-typecheck),但真正接受类型检查的块需要导入噪音,而且只能证明可赋值性——字段改名或 JSDoc 变化仍可能通过。因此:

  • 完整的类型声明及其 JSDoc 会逐字粘贴到专用的 ```ts type-equiv 围栏中。简洁的 ```ts public-api 围栏承载类的源码等价环境声明投影,用于实现体不应进入目录的类。doc-typecheck 会识别并跳过这两种围栏(裸声明无法独立编译),并且将它们排除在 opt-out 比例之外——它们是单独受检的类别,而不是未经检查的草图。
  • 新增的 scripts/verify-type-equiv.ts 通过 TypeScript 解析器提取每个块,并断言其声明结构和每条 JSDoc 注释都与所声明的符号匹配,只忽略格式空白和非 JSDoc 注释。普通块保留完整声明。public-api 投影保留类的公共字段、构造函数、访问器和方法及其原始 JSDoc,同时移除实现体以及私有或受保护成员。之所以选择它而非编译式 _Check 断言,是因为目录所保留的是源码名称与文档一致性,而不是可赋值性。
  • 每个类型块的文档、symbol 和源文件都记录在集中的 scripts/type-equiv.manifest.json{ doc, symbol, source } 条目)中,而非行文中的指令注释。脚本在每个主 type-equiv 块与一条 manifest(元数据清单)条目之间强制执行 1:1 对应,因此一个块永远不会被静默漏检,一条条目也永远不会腐烂。只有当配对 .zh.md 块的完整受跟踪围栏序列在顺序、类型和按字节精确的正文上均与无后缀兄弟文件匹配时,才会复用后者的条目;否则门禁会独立检查该块,发现没有 manifest 条目后失败。
  • 接入 doc-sync,因此相关文档变更时会在本地运行它,CI 也会与其他文档检查一起运行它。

维护是作者的职责,门禁作为兜底

verify-type-equiv 能捕获已记录类型的粘贴漂移,但无法告诉你一个全新的核心类型没有被记录。因此 AGENTS.md 和 dsh-code-review skill(技能)已更新,要求在变更添加或重塑已记录类型时同步更新目录——门禁处理漂移,人处理新类型。

曾考虑的替代方案

  • 平铺罗列所有跨包词汇ShellExecRequest 测试案例否决了它。如果 seam 词汇算「核心」,目录对谁都没帮助;分层的主干与子系统结构胜出。
  • 用编译式 _Check 可赋值性断言代替源码匹配:否决。可赋值性不会保留名称或 JSDoc;同类型字段改名或约定注释变化仍会通过。
  • 将每个类型块的源文件写进指令注释:否决,改用集中 manifest;其强制的 1:1 对应确保一个块永远不会被静默漏检,一条条目也永远不会腐烂。

验证教训

verify-type-equiv 必须扫描完整的 Markdown 范围,而不仅是 manifest 点名的文档。否则,未列入清单的 type-equiv 块就会逃过所宣称的一一检查。因此,门禁会将此类块报告为未列入清单的块。本 Agent Note 将这条默认拒绝放行的扫描规则,连同主干与子系统的分界决策及逐字匹配决策一并记录;生成的 Cordis 目录在其已归档的 Agent Note 中有对称的设计记录。

后果

  • 这些词汇现在有一个无法悄然漂移的唯一归属:源码中的字段或公共类成员发生变化后,doc-sync 和 CI 中的 verify-type-equiv 会持续失败,直至粘贴内容刷新。Cordis 服务方法仍由生成的服务目录负责,而不会在此重复。
  • 主干与子系统分界线是一个可复用的范围界定工具,而非一次性的:同一条「你编写/持有/接收的东西是核心;为其提供类型推导/渲染/持久化的机制是细节」规则,后来也被用于界定事件/服务目录的 harness 层与继承层分层。
  • ts type-equiv 围栏是继 ```ts(编译)和 ```ts ignore-check(草稿)之后的第三种文档块类别。后续的姊妹门禁又增加了第四种 ```ts cordis-catalog(生成签名),复用了相同的跳过并排除处理。
  • 添加或重塑核心类型现在附带一项文档义务,作者必须履行(门禁无法检测缺失的类型),由 dsh-code-review 检查清单兜底。
  • 自 2026-07-27 起,子系统页面层级覆盖每个承载服务的子系统:九个精简页面(权限预设、计划模式、运行时不变式、HTTP 载体、存储——同时拥有 ctx.storagectx.storageDomain——终端扩展、工作区、客户端模块、遥测)覆盖了原先没有页面的十个 ctx 服务,于是每个 harness 服务和事件作用域都有恰好一个所属的 subsystems 页面——这是把按子系统生成的服务/事件参考写入这些页面(而非平铺目录)的前提。