Sfoglia il codice sorgente

Merge pull request #318 from deepseek-harness/worktree-ts-gen

refactor: derive event graphs and scope invariants from TypeScript
Tianyi Cui 2 mesi fa
parent
commit
34bb75b2d2

+ 1 - 1
AGENTS.md

@@ -92,7 +92,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
 - Every npm package is `@deepseek-ai/dsh-<name>`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package.
 - ESM everywhere (`"type": "module"`). Cross-package imports use package names, never relative paths; in-package relative imports use explicit `.ts` extensions. Dev/test/demo run unbuilt via tsx + the root tsconfig `paths` map; builds are for outside consumers only.
 - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
-- **Typed events use declaration merging**; extensible unions use merge-extensible maps. Event JSDoc needs `@mode` and payload `@param` tags; public service methods document parameters and non-void returns. Catalog gates enforce this.
+- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns.
 - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default.
 - **Waterfall listeners MUST call `next()`** to delegate; returning without it is the veto ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)).
 - **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event.

+ 1 - 1
docs/architecture.md

@@ -110,7 +110,7 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the
 
 ### Agent Scope
 
-Every live agent owns a scoped `agent.ctx`. Its registrations shadow same-named globals, receive only that agent's dispatches, and unwind with the agent. `CreateAgentOptions.setup(agentCtx)` composes the scope before publication. See the [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md); subagent composition controls are documented [separately](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md).
+Every live agent owns a scoped `agent.ctx`. Its registrations shadow same-named globals, receive only that agent's dispatches, and unwind with the agent. `CreateAgentOptions.setup(agentCtx)` composes the scope before publication. The [semantic-gates RFC](rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md) defines typed resolvers that derive carrier checks from merged `Events` signatures and `scopeTarget`, eliminating the handwritten event table. See the [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md); subagent composition controls are documented [separately](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md).
 
 ## State
 

+ 6 - 6
docs/cordis-catalog/events.md

@@ -245,7 +245,7 @@ Creation announcement during session publication. A synchronous throw vetoes and
 'session/created'(this: Scoped<Session>, session: Session): void
 ```
 
-Source: [`packages/core/session/src/index.ts:46`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts)
 
 ### `session/disposed` — emit
 
@@ -255,7 +255,7 @@ Emitted once when an announced session leaves the store, including publication r
 'session/disposed'(this: Scoped<Session>, session: Session): void
 ```
 
-Source: [`packages/core/session/src/index.ts:55`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:57`](../../packages/core/session/src/index.ts)
 
 ### `session/event` — emit
 
@@ -267,7 +267,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before
 
 Types: [SessionEvent](../core-data-structures/core.md)
 
-Source: [`packages/core/session/src/index.ts:66`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:69`](../../packages/core/session/src/index.ts)
 
 ### `session/flush` — parallel
 
@@ -277,7 +277,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await
 'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void
 ```
 
-Source: [`packages/core/session/src/index.ts:75`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts)
 
 ## `skill/*`
 
@@ -311,7 +311,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c
 'subagent/end'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void
 ```
 
-Source: [`packages/subagent/subagent/src/index.ts:90`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:92`](../../packages/subagent/subagent/src/index.ts)
 
 ### `subagent/provider-added` — emit
 
@@ -341,7 +341,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get(
 'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
 ```
 
-Source: [`packages/subagent/subagent/src/index.ts:82`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:83`](../../packages/subagent/subagent/src/index.ts)
 
 ## `system-prompt/*`
 

+ 2 - 2
docs/cordis-catalog/services.md

@@ -200,7 +200,7 @@ list(): Session[]
 fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
 ```
 
-Source: [`packages/core/session/src/index.ts:560`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:564`](../../packages/core/session/src/index.ts)
 
 ## `ctx.skills` — `SkillService`
 
@@ -226,7 +226,7 @@ list(): string[]
 async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
 ```
 
-Source: [`packages/subagent/subagent/src/index.ts:123`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:125`](../../packages/subagent/subagent/src/index.ts)
 
 ## `ctx.systemPrompt` — `SystemPrompt`
 

+ 9 - 9
docs/event-producer-consumer.md

@@ -3,7 +3,7 @@
 
 # Event Producer And Consumer Matrix
 
-This matrix shows which packages dispatch each harness-owned event and which packages listen to it. It is intentionally a table rather than one large graph: events are many-to-many, and dense relation data is easier to review in rows. Dynamic dispatch overrides cover sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment.
+This matrix shows which packages dispatch each harness-owned event and which packages listen to it. It is intentionally a table rather than one large graph: events are many-to-many, and dense relation data is easier to review in rows. Receiver and event-name types also cover contained dispatch sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment.
 
 | Event | Mode | Declared in | Dispatchers | Listeners |
 | --- | --- | --- | --- | --- |
@@ -15,7 +15,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
 | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
 | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
-| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:180`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) |
+| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:180`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) |
 | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
 | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
@@ -25,16 +25,16 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:68`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:51`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:46`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:66`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
-| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:75`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
+| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
 | `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:131`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - |
 | `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:137`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - |
-| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:90`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) |
+| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) |
 | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:66`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:82`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
+| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
 | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - |
 | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
 | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
@@ -55,4 +55,4 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | --- | --- | --- |
 | `internal/dispatch` | - | [`invariants`](../packages/support/invariants) |
 
-Maintenance mode: hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`.
+Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.

+ 1 - 0
docs/rfc/INDEX.md

@@ -174,6 +174,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
 | [Parallel pre-push gates](implemented/process/2026-07-06-parallel-pre-push-gates.md) | 2026-07-06 |
 | [A gated Known-Limitations section in every package README](implemented/process/2026-07-10-readme-known-limitations-gate.md) | 2026-07-10 |
 | [Package Model Experience contract](implemented/process/2026-07-12-package-model-experience-contract.md) | 2026-07-12 |
+| [TypeScript Program-backed semantic gates](implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md) | 2026-07-14 |
 
 ### Testing
 

+ 1 - 1
docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md

@@ -328,7 +328,7 @@ The plugin does not police trusted setup by scanning registries or reject prompt
 
 ### Generated artifacts keep public contracts aligned
 
-The event catalog, service catalog, producer/consumer matrix, configuration catalog, module graph, tool catalog, and type-equivalence blocks are generated or freshness-gated from source. `verify-scoped-dispatch` keeps the declared scoped-event set aligned with runtime invariant coverage.
+The event catalog, service catalog, producer/consumer matrix, configuration catalog, module graph, tool catalog, type-equivalence blocks, and scoped-event resolver map are generated or freshness-gated from source. The [TypeScript semantic-gates RFC](../process/2026-07-14-typescript-program-backed-semantic-gates.md) owns Program construction, semantic event discovery, and resolver-generation rules.
 
 Behavioral tests pin scoped routing and disposal, final-entry collision cleanup, publication rollback, ordered quiescence, durable pre/post-commit behavior, live tool filtering across presentation and execution, cooperative prompt assembly, structured-output commit in native and Code Mode, async subagent startup and signal cancellation, worker terminal arbitration, ACP settlement, and process teardown.
 

+ 6 - 0
docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.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
+2026-07-14-typescript-program-backed-semantic-gates.md: 3e7a76e86d83080ae1a4f91ca97cc9749c90ef29
+2026-07-14-typescript-program-backed-semantic-gates.zh.md: 0a13452012e7f6cbd3ad7994845ba1985355c089

+ 63 - 0
docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md

@@ -0,0 +1,63 @@
+# RFC: TypeScript Program-backed semantic gates
+
+Status: implemented
+
+English | [中文](2026-07-14-typescript-program-backed-semantic-gates.zh.md)
+
+## Problem
+
+Repository gates sometimes need facts that TypeScript syntax does not carry by itself: whether a receiver is a Cordis `Context`, which concrete event names reach a forwarding helper, and whether declaration merging changed an event signature.
+
+The existing gates use TypeScript's single-file syntax model and maintain these facts through naming conventions, handwritten tables, and JSDoc.
+
+The repository needs one semantic source of truth without introducing runtime package cycles, broad fallback heuristics, or machine-readable annotations that restate information already available to TypeScript.
+
+## Decision
+
+Repository gates can combine project-wide type information through `ts.Program` and use `TypeChecker` to extract **strongly typed** facts, reducing their reliance on naming conventions, handwritten tables, and JSDoc metadata.
+
+The repository applies this model to two gates.
+
+### One project model expands the root solution
+
+[`TypeScriptProject`](../../../../scripts/ts-project.ts) parses the root `tsconfig.json`, recursively expands every project reference, and combines the referenced source roots into one no-emit semantic program. A normal program created from the solution config can redirect referenced projects to built declarations; explicit expansion keeps the package `src` files available for AST traversal and symbol identity.
+
+The wrapper owns config diagnostics, semantic compiler options, repository-relative paths, source lookup, and the shared checker. Individual gates do not glob package sources or construct partial programs independently.
+
+### A. Event relations follow receiver and value types
+
+[`gen-doc-graphs`](../../../../scripts/gen-doc-graphs.ts) classifies calls by assignability to the repository's actual `Context`, `AgentEventDispatch`, and Cordis `EventsService` types. Variable names and property spellings do not determine whether a call is an event operation.
+
+Context and agent-dispatch calls contribute only finite string-literal event sets. Direct `EventsService.dispatch()` calls recover the event slot through array literals, constant aliases, conditional branches, and resolved call sites of non-exported local helpers. Generic forwarding parameters are not concrete producers: attribution stays with the call sites that supply a closed event value.
+
+Every declared harness event must have a discovered producer. A missing producer fails generation as dead vocabulary or an unsupported semantic dispatch shape; listener-free extension points remain valid. `internal/dispatch` instrumentation is not treated as a subscription to every event it observes, so the matrix contains direct product listeners rather than manually asserted indirect relationships.
+
+### B. Scoped-event routing generates one typed resolver map
+
+[`gen-scoped-events`](../../../../scripts/gen-scoped-events.ts) scans real `scopeTarget(base, key)` calls to establish the routing-key type for each scoped base. It then finds Cordis `Events` members with `this: Scoped<Base>` and searches every payload parameter plus one public property level for a type identical to that key after removing `null` and `undefined`.
+
+Exactly one match generates a resolver. Multiple matches are ambiguous and fail. Zero matches require `@dshScopeScan unsupported`, which is reserved for events whose routing key intentionally stays outside the payload, such as owner-keyed session events and parent-keyed subagent lifecycle events. The annotation records an unsupported scan; it does not encode an event name, parameter index, property path, or replacement type.
+
+The committed [`scoped-events.generated.ts`](../../../../packages/support/invariants/src/scoped-events.generated.ts) imports every scoped-event owner for its type-side `Events` contributions. Each generated lambda accepts `Parameters<Events[K]>`, and the complete object satisfies a `Record` over the derived `ScopedEventName` union. Ordinary TypeScript compilation therefore checks event existence, parameter position, property access, and scoped-event completeness. The only cast adapts Cordis's runtime `unknown[]` dispatch boundary to the already type-checked resolver.
+
+The invariants plugin consumes this generated runtime map instead of maintaining its own table. Additional event-owner packages are dev dependencies and project references of `dsh-invariants`, not peer dependencies, so the compile-time aggregation does not expand the plugin's runtime closure.
+
+### Semantic gaps fail explicitly
+
+The generators reject missing declarations, config diagnostics, widened or generic event names, inconsistent routing-key types, ambiguous payload matches, unnecessary unsupported annotations, and stale generated output. Recovery through local helper call sites is deliberately narrow: exported or unresolved dataflow requires a new semantic rule rather than a package-specific override.
+
+## Verification
+
+`verify-doc-graphs` freshness-checks semantic producer/listener discovery, and `verify-scoped-events` freshness-checks the generated resolver map. The root TypeScript build compiles the resolver against merged `Events`; workspace constraints and runtime-closure checks ensure its type-only aggregation does not become a deployment dependency.
+
+## Alternatives considered
+
+- **Keep syntax-only scans with receiver allowlists and manual overrides.** This is simple per exception but makes renames and new helper shapes update a second representation. Completeness can detect a missing producer, but it cannot prove that the override still describes the source.
+
+## Consequences
+
+- Event relation generation follows semantic receiver identity and closed event values instead of local naming conventions.
+- Scoped-event membership, subject extraction, and runtime invariant coverage come from event declarations and real dispatch contracts rather than handwritten tables.
+- Refactors that change event names, parameter positions, subject properties, or routing-key types fail generation or compilation at the owning contract.
+- Building a flattened Program costs more startup time and memory than parsing isolated files, and semantic gates depend on a valid root project graph.
+- Generated TypeScript remains committed source: changes to event owners or dispatch shapes must regenerate it and the affected documentation.

+ 63 - 0
docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md

@@ -0,0 +1,63 @@
+# RFC: 基于 TypeScript Program 的语义门禁
+
+Status: implemented
+
+[English](2026-07-14-typescript-program-backed-semantic-gates.md) | 中文
+
+## 问题
+
+仓库门禁有时需要判断 TypeScript 语法本身不携带的事实:接收者是否为 Cordis `Context`、哪些具体事件名会进入转发辅助函数、声明合并是否改变了事件签名。
+
+当前的门禁基于 TypeScript 单文件语法解析能力,使用命名约定、手写的表格、JSDoc 等方式来维护这类信息。
+
+仓库需要一个语义真源,同时不能引入运行时包(package)之间的循环依赖、宽泛的兜底启发式逻辑,或重复描述 TypeScript 已有信息的机器可读标注。
+
+## 决策
+
+仓库可以通过项目级类型信息 `ts.Program` 进行跨文件项目类型联合计算,并通过 `TypeChecker` 来提取 **强类型** 信息,用以缓解原有命名约定、手写表格、JSDoc 标注等形式。
+
+当前已完成 A / B 两个门禁的语义化改造。
+
+### 一个项目模型展开根项目配置
+
+[`TypeScriptProject`](../../../../scripts/ts-project.ts) 解析根 `tsconfig.json`,递归展开每个项目引用,并将各引用项目的源码根合并为一个不输出文件的语义 Program。直接从根项目配置创建普通 Program 时,TypeScript 可能将引用项目重定向到构建后的声明文件;显式展开可以让门禁继续遍历各包的 `src` 文件,并使用真实符号标识。
+
+该封装统一负责配置诊断、语义编译选项、仓库相对路径、源码查找和共享 TypeChecker。各门禁不再自行按文件通配模式扫描包源码,也不再分别构建不完整的 Program。
+
+### A. 事件关系由接收者类型和值类型决定
+
+[`gen-doc-graphs`](../../../../scripts/gen-doc-graphs.ts) 根据调用接收者与仓库中真实 `Context`、`AgentEventDispatch` 和 Cordis `EventsService` 类型之间的可赋值关系进行分类。变量名和属性拼写不再决定某次调用是否属于事件操作。
+
+Context 与 AgentEventDispatch 调用只贡献有限的字符串字面量事件集合。对于直接调用 `EventsService.dispatch()` 的路径,生成器会沿数组字面量、常量别名、条件分支和未导出本地辅助函数的已解析调用点恢复事件槽位。泛型转发参数不算作具体生产方:事件仍归属于传入封闭事件值的调用点。
+
+每个已声明的 harness 事件都必须存在扫描得到的生产方。找不到生产方时,生成过程会将其视为无调用方的事件词汇或尚不支持的语义 dispatch 形态并明确失败;没有监听方的扩展点仍然合法。`internal/dispatch` 插桩不会被当作它所观察的每个事件的订阅,因此关系矩阵只记录直接的产品监听方,不再手工补充间接关系。
+
+### B. 带作用域的事件路由生成一份强类型解析函数表
+
+[`gen-scoped-events`](../../../../scripts/gen-scoped-events.ts) 扫描真实的 `scopeTarget(base, key)` 调用,为每种 scoped 基础对象确定路由键类型。随后,它查找带有 `this: Scoped<Base>` 的 Cordis `Events` 成员,并在每个事件参数及其一层公开属性中搜索类型;移除 `null` 和 `undefined` 后,候选类型必须与路由键类型完全相同。
+
+恰好一个匹配项会生成解析函数。存在多个匹配项时,含义不明确,生成器会失败。没有匹配项时,事件必须标记 `@dshScopeScan unsupported`;该标记只用于路由键有意留在事件参数之外的情况,例如按所属 agent(智能体)路由的会话事件和按父 agent 路由的 subagent 生命周期事件。此标记只表示扫描不受支持,不编码事件名、参数下标、属性路径或替代类型。
+
+仓库提交的 [`scoped-events.generated.ts`](../../../../packages/support/invariants/src/scoped-events.generated.ts) 会导入每个带作用域的事件声明方,使它们从类型侧合并进 `Events`。每个生成函数都接收 `Parameters<Events[K]>`,完整对象则满足基于 `ScopedEventName` 联合类型派生出的 `Record`。因此,常规 TypeScript 编译会检查事件是否存在、参数位置、属性访问和带作用域的事件集合完整性。唯一的类型断言只负责将 Cordis 运行时的 `unknown[]` dispatch 边界适配到已经通过类型检查的解析函数。
+
+不变式插件消费这份生成的运行时表,不再维护自己的事件表。新增的事件声明方包只作为 `dsh-invariants` 的开发依赖和项目引用存在,不进入对等依赖,因此编译期聚合不会扩大插件的运行时依赖闭包。
+
+### 语义缺口必须显式失败
+
+遇到声明缺失、配置诊断、事件名被拓宽或保持泛型、路由键类型不一致、事件参数匹配不唯一、不必要的 unsupported 标记,或生成产物陈旧时,生成器都会拒绝继续。通过本地辅助函数调用点恢复信息的能力被刻意限制在窄范围内:如果数据流经过导出或无法解析的边界,应新增通用语义规则,而不是添加特定包的覆盖项。
+
+## 验证
+
+`verify-doc-graphs` 对语义生产方/监听方扫描执行新鲜度检查,`verify-scoped-events` 对生成的解析函数表执行新鲜度检查。根 TypeScript 构建会将解析函数与合并后的 `Events` 一起编译;workspace 约束和运行时依赖闭包检查则确保仅参与类型聚合的依赖不会变成部署依赖。
+
+## 考虑过的替代方案
+
+- **保留语法扫描、接收者白名单和手写覆盖项。** 每个例外都容易单独处理,但重命名和新增辅助函数形态时还必须更新第二份表示。完整性检查能够发现生产方缺失,却无法证明覆盖项仍与源码一致。
+
+## 后果
+
+- 事件关系生成依据语义接收者身份和封闭事件值,不再依赖局部命名约定;
+- 带作用域的事件成员关系、主体提取和运行时不变式覆盖来自事件声明与真实 dispatch 契约,不再来自手写表;
+- 修改事件名、参数位置、主体属性或路由键类型时,会在其所属契约处触发生成或编译失败;
+- 构建扁平化 Program 比解析孤立文件消耗更多启动时间和内存,语义门禁也依赖有效的根项目图;
+- 生成的 TypeScript 仍属于提交到仓库的源码:事件声明方或 dispatch 形态发生变化后,必须重新生成该文件和受影响的文档。

+ 3 - 2
package.json

@@ -65,10 +65,11 @@
     "gen-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts",
     "verify-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts --check",
     "gen-module-graph": "tsx scripts/gen-module-graph.ts",
-    "verify-scoped-dispatch": "tsx scripts/verify-scoped-dispatch.ts",
+    "gen-scoped-events": "tsx scripts/gen-scoped-events.ts",
+    "verify-scoped-events": "tsx scripts/gen-scoped-events.ts --check",
     "verify-module-graph": "tsx scripts/gen-module-graph.ts --check",
     "constraints": "tsx scripts/check-workspace-constraints.ts",
-    "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-dispatch && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations",
+    "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-events && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations",
     "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure",
     "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml",
     "demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml",

+ 4 - 0
packages/core/session/src/index.ts

@@ -41,6 +41,7 @@ declare module 'cordis' {
      * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners
      * receive only sessions entered through that agent's context.
      * @param session - the session just entered and announced.
+     * @dshScopeScan unsupported
      * @mode emit
      */
     'session/created'(this: Scoped<Session>, session: Session): void
@@ -50,6 +51,7 @@ declare module 'cordis' {
      * did not begin. Listener failures are logged and contained.
      * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope.
      * @param session - the session that is no longer live in the store.
+     * @dshScopeScan unsupported
      * @mode emit
      */
     'session/disposed'(this: Scoped<Session>, session: Session): void
@@ -61,6 +63,7 @@ declare module 'cordis' {
      * receive only events from sessions entered through that agent's context.
      * @param session - the session whose log grew.
      * @param event - the appended event, exactly as recorded.
+     * @dshScopeScan unsupported
      * @mode emit
      */
     'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
@@ -70,6 +73,7 @@ declare module 'cordis' {
      * {@link SessionStore.flush}. Scope-filtered dispatch
      * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope.
      * @param session - the session whose buffered events must reach durable storage.
+     * @dshScopeScan unsupported
      * @mode parallel
      */
     'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void

+ 2 - 0
packages/subagent/subagent/src/index.ts

@@ -77,6 +77,7 @@ declare module 'cordis' {
      * parent-scoped listener observes only its own delegations. Paired with
      * `subagent/end`.
      * @param info - the provider and ready child identity.
+     * @dshScopeScan unsupported
      * @mode emit
      */
     'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
@@ -85,6 +86,7 @@ declare module 'cordis' {
      * parent carrier as `subagent/start`, so the lifecycle pair reaches the
      * same scoped audience.
      * @param info - the run identity and terminal outcome.
+     * @dshScopeScan unsupported
      * @mode emit
      */
     'subagent/end'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void

+ 4 - 0
packages/support/invariants/package.json

@@ -33,6 +33,10 @@
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-scope": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-subagent": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-user-approval": "workspace:^",
     "cordis": "^4.0.0-rc.6"
   }
 }

+ 6 - 44
packages/support/invariants/src/index.ts

@@ -14,6 +14,7 @@ import type { CallId, GenerateOptions } from '@deepseek-ai/dsh-llm'
 import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
 import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
+import { scopedSubjectResolverFor } from './scoped-events.generated.ts'
 
 export const name = 'invariants'
 export const inject = ['sessions']
@@ -75,17 +76,6 @@ interface SessionTraceTransition {
   seq: number
 }
 
-/** Event payload prefix for scoped seams whose first argument names its agent. */
-interface AgentSubject {
-  agent: Agent
-}
-
-/** Structural subject fields used without coupling this dev plugin to owning services. */
-interface ScopedSubjectFields {
-  agent?: Agent
-  scope?: object
-}
-
 /** Assert that a step-scoped event names the currently open turn and step. */
 function requireOpenStep(trace: SessionTrace, kind: string, turn: number, step: number): void {
   if (trace.openTurn !== turn || trace.openStep !== step) {
@@ -410,40 +400,12 @@ export function apply(ctx: Context): void {
   // (agent-scoped listeners over-hear foreign agents), and a mis-keyed one
   // delivers to the wrong agent's listeners. `internal/dispatch` fires
   // synchronously before listener delivery, so a violation throws at the
-  // dispatching call site. The table maps each family to how its subject is
-  // read from the event arguments; `null` = the subject is not recoverable
-  // from the arguments (session events key by the OWNING agent; subagent
-  // lifecycle events key by the delegating parent), so only carrier
-  // PRESENCE is asserted there.
-  const scopedSubject: Record<string, ((args: unknown[]) => unknown) | null> = {
-    'agent/created': args => args[0],
-    'agent/disposed': args => args[0],
-    'agent/status': args => args[0],
-    'agent/queued': args => args[0],
-    'agent/session-start': args => args[0],
-    'agent/pre-step': args => args[0],
-    'agent/prompt-submit': args => args[0],
-    'agent/request': args => args[0],
-    'agent/session-prefix': args => args[0],
-    'agent/step-result': args => args[0],
-    'agent/turn-continuation': args => args[0],
-    'agent/turn-stop': args => args[0],
-    'agent/error': args => args[0],
-    'approval/request': args => (args[0] as AgentSubject).agent,
-    'tools/pre-execute': args => (args[0] as ScopedSubjectFields).agent,
-    'tools/execute': args => (args[0] as ScopedSubjectFields).agent,
-    'tools/post-execute': args => (args[0] as ScopedSubjectFields).agent,
-    'tools/result': args => (args[0] as ScopedSubjectFields).agent,
-    'system-prompt/assemble': args => (args[1] as ScopedSubjectFields).scope,
-    'session/created': null,
-    'session/disposed': null,
-    'session/event': null,
-    'session/flush': null,
-    'subagent/start': null,
-    'subagent/end': null,
-  }
+  // dispatching call site. The generated table maps each family to the unique
+  // payload path whose Program type matches the real scopeTarget routing key;
+  // `null` means the key is external to the payload, so only carrier presence
+  // can be asserted.
   ctx.on('internal/dispatch', (_mode, name, args, thisArg) => {
-    const subjectOf = scopedSubject[name]
+    const subjectOf = scopedSubjectResolverFor(name)
     if (subjectOf === undefined) return
     if (!isScopeCarrier(thisArg)) {
       throw new InvariantError(

+ 69 - 0
packages/support/invariants/src/scoped-events.generated.ts

@@ -0,0 +1,69 @@
+/**
+ * Generated scoped-event routing-subject resolvers for dsh-invariants.
+ * Do not edit by hand; run `pnpm run gen-scoped-events`.
+ *
+ * @module @deepseek-ai/dsh-invariants/scoped-events.generated
+ */
+
+import type { Events } from 'cordis'
+import type { Scoped } from '@deepseek-ai/dsh-scope'
+import type {} from '@deepseek-ai/dsh-agent'
+import type {} from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-subagent'
+import type {} from '@deepseek-ai/dsh-system-prompt'
+import type {} from '@deepseek-ai/dsh-tools'
+import type {} from '@deepseek-ai/dsh-user-approval'
+
+type ScopedEventName = {
+  [K in keyof Events]: ThisParameterType<Events[K]> extends Scoped<object> ? K : never
+}[keyof Events]
+
+type ScopedSubjectResolver = (args: readonly unknown[]) => unknown
+
+function adapt<K extends ScopedEventName>(
+  resolver: (args: Parameters<Events[K]>) => unknown,
+): ScopedSubjectResolver {
+  return args => resolver(args as Parameters<Events[K]>)
+}
+
+const scopedSubjectResolvers = Object.freeze({
+  'agent/created': adapt<'agent/created'>(args => args[0]),
+  'agent/disposed': adapt<'agent/disposed'>(args => args[0]),
+  'agent/error': adapt<'agent/error'>(args => args[0]),
+  'agent/pre-step': adapt<'agent/pre-step'>(args => args[0]),
+  'agent/prompt-submit': adapt<'agent/prompt-submit'>(args => args[0]),
+  'agent/queued': adapt<'agent/queued'>(args => args[0]),
+  'agent/request': adapt<'agent/request'>(args => args[0]),
+  'agent/session-prefix': adapt<'agent/session-prefix'>(args => args[0]),
+  'agent/session-start': adapt<'agent/session-start'>(args => args[0]),
+  'agent/status': adapt<'agent/status'>(args => args[0]),
+  'agent/step-result': adapt<'agent/step-result'>(args => args[0]),
+  'agent/turn-continuation': adapt<'agent/turn-continuation'>(args => args[0]),
+  'agent/turn-stop': adapt<'agent/turn-stop'>(args => args[0]),
+  'approval/request': adapt<'approval/request'>(args => args[0].agent),
+  'session/created': null,
+  'session/disposed': null,
+  'session/event': null,
+  'session/flush': null,
+  'subagent/end': null,
+  'subagent/start': null,
+  'system-prompt/assemble': adapt<'system-prompt/assemble'>(args => args[1].scope),
+  'tools/execute': adapt<'tools/execute'>(args => args[0].agent),
+  'tools/post-execute': adapt<'tools/post-execute'>(args => args[0].agent),
+  'tools/pre-execute': adapt<'tools/pre-execute'>(args => args[0].agent),
+  'tools/result': adapt<'tools/result'>(args => args[0].agent),
+} as const satisfies Readonly<Record<ScopedEventName, ScopedSubjectResolver | null>>)
+
+const scopedSubjectResolverIndex: Readonly<Record<string, ScopedSubjectResolver | null>> = scopedSubjectResolvers
+
+/**
+ * Resolve the routing key named by one scoped event payload. A null
+ * resolver means the payload cannot expose its external routing key, so the
+ * invariant checks carrier presence only.
+ * @param event - runtime Cordis event name.
+ * @returns the generated subject resolver, null for presence-only,
+ *   or undefined when the event is not scope-filtered.
+ */
+export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined {
+  return scopedSubjectResolverIndex[event]
+}

+ 1 - 1
packages/support/invariants/tests/invariants.spec.ts

@@ -834,7 +834,7 @@ describe('scoped-dispatch invariants', () => {
 
   it('accepts a matching carrier and rejects a mismatched one for EVERY agent-subject event', async () => {
     const ctx = await scopedCtx()
-    // Real Session objects: the session-start tracker WeakSet-keys them.
+    // Real Session objects keep the synthetic Agent handles structurally valid.
     const agent = { id: 'a1', session: new Session(SessionId('a1-s')) } as unknown as Agent
     const other = { id: 'a2', session: new Session(SessionId('a2-s')) } as unknown as Agent
     // One dispatch per table row keeps every subject extractor covered: the

+ 12 - 0
packages/support/invariants/tsconfig.json

@@ -25,6 +25,18 @@
     },
     {
       "path": "../../core/scope"
+    },
+    {
+      "path": "../../core/system-prompt"
+    },
+    {
+      "path": "../../ui/user-approval"
+    },
+    {
+      "path": "../../core/tools"
+    },
+    {
+      "path": "../../subagent/subagent"
     }
   ]
 }

+ 12 - 0
pnpm-lock.yaml

@@ -1107,6 +1107,18 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-subagent':
+        specifier: workspace:^
+        version: link:../../subagent/subagent
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../../core/system-prompt
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
+      '@deepseek-ai/dsh-user-approval':
+        specifier: workspace:^
+        version: link:../../ui/user-approval
       cordis:
         specifier: ^4.0.0-rc.6
         version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)

+ 247 - 108
scripts/gen-doc-graphs.ts

@@ -5,7 +5,7 @@
  * `--check` verifies the generated set.
  */
 
-import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
 import { dirname, relative, resolve } from 'node:path'
 import ts from 'typescript'
 import { collectEvents, collectServices } from './gen-cordis-catalog.ts'
@@ -15,6 +15,7 @@ import {
   graphNodeId as nodeId,
   type PackageGraphNode,
 } from './package-graph.ts'
+import { TypeScriptProject } from './ts-project.ts'
 
 const root = resolve(import.meta.dirname, '..')
 type Pkg = PackageGraphNode
@@ -45,6 +46,14 @@ interface EventRelation {
   listeners: Set<string>
 }
 
+interface PackageSource {
+  rel: string
+  pkg: string
+  sourceFile: ts.SourceFile
+}
+
+type EventReceiverKind = 'context' | 'agent-dispatch' | 'events-service'
+
 const GROUP_ORDER = [
   'util',
   'llm',
@@ -242,51 +251,6 @@ const SERVICE_ROLES: ServiceRole[] = [
   },
 ]
 
-const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: string }> = [
-  // Creation notifications preserve synchronous veto/rollback but observe
-  // returned promises explicitly so async listener rejection is not unhandled.
-  { event: 'agent/created', pkg: 'agent', method: 'events.dispatch' },
-  // Registry disposal reuses the stable carrier captured before entry commit
-  // and contains each listener directly rather than rebuilding via agentEvents.
-  { event: 'agent/disposed', pkg: 'agent', method: 'events.dispatch' },
-  { event: 'session/created', pkg: 'session', method: 'events.dispatch' },
-  // Session event callbacks are likewise resolved before the log push, then
-  // invoked individually after commit so observer failures are contained.
-  { event: 'session/event', pkg: 'session', method: 'events.dispatch' },
-  // Flush resolves the scoped callback set directly so internal instrumentation
-  // cannot substitute the accepted session before parallel invocation.
-  { event: 'session/flush', pkg: 'session', method: 'events.dispatch' },
-  // Session disposal uses direct callback resolution so teardown contains each
-  // synchronous throw and returned-promise rejection independently.
-  { event: 'session/disposed', pkg: 'session', method: 'events.dispatch' },
-  // tools/result uses ctx.events.dispatch directly so the registry can invoke
-  // every synchronous observer while containing each callback independently.
-  { event: 'tools/result', pkg: 'tools', method: 'events.dispatch' },
-  // Subagent lifecycle events intentionally bypass ctx.emit and call
-  // ctx.events.dispatch directly so one throwing listener cannot starve later
-  // listeners or strand an already-started child run.
-  { event: 'subagent/start', pkg: 'subagent', method: 'events.dispatch' },
-  { event: 'subagent/end', pkg: 'subagent', method: 'events.dispatch' },
-  // provider-removed fires inside the provider registration's DISPOSER and
-  // routes through the same contained dispatch (see emitLifecycle in
-  // dsh-subagent), so the AST scan cannot attribute it either.
-  { event: 'subagent/provider-removed', pkg: 'subagent', method: 'events.dispatch' },
-  // The workflow/* lifecycle events dispatch the same way, for the same
-  // per-listener-containment reason (WorkflowService.emitWorkflowEvent).
-  { event: 'workflow/start', pkg: 'workflow', method: 'events.dispatch' },
-  { event: 'workflow/phase', pkg: 'workflow', method: 'events.dispatch' },
-  { event: 'workflow/log', pkg: 'workflow', method: 'events.dispatch' },
-  { event: 'workflow/agent-start', pkg: 'workflow', method: 'events.dispatch' },
-  { event: 'workflow/agent-end', pkg: 'workflow', method: 'events.dispatch' },
-  { event: 'workflow/end', pkg: 'workflow', method: 'events.dispatch' },
-]
-
-const DYNAMIC_EVENT_LISTENERS: Array<{ event: string; pkg: string }> = [
-  // The invariants oracle marks the session started from its global
-  // internal/dispatch listener before product session-start callbacks run.
-  { event: 'agent/session-start', pkg: 'invariants' },
-]
-
 function generatedHeader(title: string): string[] {
   return [
     '<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand.',
@@ -505,81 +469,256 @@ function renderAppComposition(example: AppExample): string {
   return lines.join('\n')
 }
 
-function collectEventRelations(): Map<string, EventRelation> {
-  const out = new Map<string, EventRelation>()
-  const ensure = (event: string): EventRelation => {
-    const existing = out.get(event)
-    if (existing) return existing
-    const next = { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() }
-    out.set(event, next)
-    return next
+/** Collect event dispatch/listener relations from real cross-file receiver types. */
+class EventRelationCollector {
+  private readonly relations = new Map<string, EventRelation>()
+  private readonly callSites = new Map<ts.SignatureDeclaration | ts.JSDocSignature, ts.CallExpression[]>()
+  private readonly contextType: ts.Type
+  private readonly agentDispatchType: ts.Type
+  private readonly eventsServiceType: ts.Type
+
+  constructor(
+    private readonly project: TypeScriptProject,
+    private readonly sources: readonly PackageSource[],
+  ) {
+    this.contextType = this.declaredType('vendor/cordis/src/context.ts', 'Context')
+    this.agentDispatchType = this.declaredType('packages/core/agent/src/dispatch.ts', 'AgentEventDispatch')
+    this.eventsServiceType = this.declaredType('vendor/cordis/src/events.ts', 'EventsService')
+    this.indexCallSites()
+  }
+
+  /** Return all event relations discovered from the Program. */
+  collect(): Map<string, EventRelation> {
+    for (const source of this.sources) this.visitSource(source)
+    return this.relations
+  }
+
+  /** Resolve one named class/interface declaration to its merged instance type. */
+  private declaredType(relativePath: string, name: string): ts.Type {
+    const sourceFile = this.project.sourceFile(relativePath)
+    const declaration = sourceFile.statements.find((statement): statement is ts.ClassDeclaration | ts.InterfaceDeclaration => {
+      return (ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement)) && statement.name?.text === name
+    })
+    const symbol = declaration?.name && this.project.checker.getSymbolAtLocation(declaration.name)
+    if (!symbol) throw new Error(`cannot resolve TypeScript type ${name} from ${relativePath}`)
+    return this.project.checker.getDeclaredTypeOfSymbol(symbol)
   }
-  for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: root }).sort()) {
-    const [, , leaf] = rel.split('/')
-    if (leaf === undefined) continue
-    const text = readFileSync(resolve(root, rel), 'utf8')
-    const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true)
+
+  /** Index resolved local function calls for narrow argument-flow recovery. */
+  private indexCallSites(): void {
+    const visit = (node: ts.Node): void => {
+      if (ts.isCallExpression(node)) {
+        const declaration = this.project.checker.getResolvedSignature(node)?.declaration
+        if (declaration) {
+          const calls = this.callSites.get(declaration) ?? []
+          calls.push(node)
+          this.callSites.set(declaration, calls)
+        }
+      }
+      ts.forEachChild(node, visit)
+    }
+    for (const source of this.sources) visit(source.sourceFile)
+  }
+
+  /** Walk one package source file and classify event API calls by receiver type. */
+  private visitSource(source: PackageSource): void {
     const visit = (node: ts.Node): void => {
       if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
+        const receiverKind = this.receiverKind(node.expression.expression)
         const method = node.expression.name.text
-        if (!isCordisContextReceiver(node.expression, sf)) {
-          ts.forEachChild(node, visit)
-          return
-        }
-        if (method === 'on') {
-          const event = eventArg(node.arguments, method)
-          if (event) ensure(event).listeners.add(leaf)
-        } else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') {
-          const event = eventArg(node.arguments, method)
-          if (event) {
-            const relation = ensure(event)
-            const methods = relation.dispatchers.get(leaf) ?? new Set<string>()
-            methods.add(method)
-            relation.dispatchers.set(leaf, methods)
+        if (receiverKind === 'events-service' && method === 'dispatch') {
+          const argumentList = node.arguments[1]
+          if (argumentList) {
+            for (const event of this.eventNamesFromArgumentList(argumentList, new Set())) {
+              this.addDispatcher(event, source.pkg, 'events.dispatch')
+            }
+          }
+        } else if (receiverKind === 'context' || receiverKind === 'agent-dispatch') {
+          const eventNames = this.eventNamesFromCall(node, receiverKind)
+          if (method === 'on' || method === 'once') {
+            for (const event of eventNames) this.ensure(event).listeners.add(source.pkg)
+          } else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') {
+            for (const event of eventNames) this.addDispatcher(event, source.pkg, method)
           }
         }
       }
       ts.forEachChild(node, visit)
     }
-    visit(sf)
+    visit(source.sourceFile)
   }
-  for (const entry of DYNAMIC_EVENT_DISPATCHERS) {
-    const relation = ensure(entry.event)
-    const methods = relation.dispatchers.get(entry.pkg) ?? new Set<string>()
-    methods.add(entry.method)
-    relation.dispatchers.set(entry.pkg, methods)
+
+  /** Classify a receiver using assignability to the repository's actual event API types. */
+  private receiverKind(receiver: ts.Expression): EventReceiverKind | undefined {
+    const type = this.project.checker.getTypeAtLocation(receiver)
+    if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) return undefined
+    if (this.project.checker.isTypeAssignableTo(type, this.eventsServiceType)) return 'events-service'
+    if (this.project.checker.isTypeAssignableTo(type, this.contextType)) return 'context'
+    if (this.project.checker.isTypeAssignableTo(type, this.agentDispatchType)) return 'agent-dispatch'
+    return undefined
   }
-  for (const entry of DYNAMIC_EVENT_LISTENERS) {
-    ensure(entry.event).listeners.add(entry.pkg)
+
+  /** Resolve the event-name argument for Context and fused agent dispatch calls. */
+  private eventNamesFromCall(call: ts.CallExpression, receiverKind: Exclude<EventReceiverKind, 'events-service'>): Set<string> {
+    const candidates = receiverKind === 'context' ? call.arguments.slice(0, 2) : call.arguments.slice(0, 1)
+    for (const candidate of candidates) {
+      const values = this.finiteStringValues(candidate)
+      if (values) return values
+    }
+    return new Set()
+  }
+
+  /** Recover the event slot from the argument array handed to EventsService.dispatch(). */
+  private eventNamesFromArgumentList(expression: ts.Expression, seen: Set<ts.Node>): Set<string> {
+    const current = unwrapExpression(expression)
+    if (seen.has(current)) return new Set()
+    seen.add(current)
+
+    if (ts.isArrayLiteralExpression(current)) {
+      for (const element of current.elements.slice(0, 2)) {
+        if (ts.isOmittedExpression(element) || ts.isSpreadElement(element)) continue
+        const values = this.finiteStringValues(element)
+        if (values) return values
+      }
+      return new Set()
+    }
+    if (ts.isConditionalExpression(current)) {
+      return unionSets(
+        this.eventNamesFromArgumentList(current.whenTrue, new Set(seen)),
+        this.eventNamesFromArgumentList(current.whenFalse, new Set(seen)),
+      )
+    }
+    if (!ts.isIdentifier(current)) return new Set()
+
+    const symbol = this.project.checker.getSymbolAtLocation(current)
+    if (!symbol) return new Set()
+    const events = new Set<string>()
+    for (const declaration of symbol.declarations ?? []) {
+      if (ts.isVariableDeclaration(declaration) && declaration.initializer && isConstDeclaration(declaration)) {
+        addAll(events, this.eventNamesFromArgumentList(declaration.initializer, new Set(seen)))
+      } else if (ts.isParameter(declaration)) {
+        addAll(events, this.eventNamesFromParameter(declaration, seen))
+      }
+    }
+    return events
+  }
+
+  /** Follow a non-exported local helper parameter back to every resolved call site. */
+  private eventNamesFromParameter(parameter: ts.ParameterDeclaration, seen: Set<ts.Node>): Set<string> {
+    const owner = parameter.parent
+    if (!ts.isFunctionDeclaration(owner) || hasExportModifier(owner)) return new Set()
+    const index = owner.parameters.indexOf(parameter)
+    if (index < 0) return new Set()
+    const events = new Set<string>()
+    for (const call of this.callSites.get(owner) ?? []) {
+      const argument = call.arguments[index]
+      if (argument) addAll(events, this.eventNamesFromArgumentList(argument, new Set(seen)))
+    }
+    return events
+  }
+
+  /** Return a finite string-literal value set, rejecting widened and generic strings. */
+  private finiteStringValues(expression: ts.Expression): Set<string> | undefined {
+    const current = unwrapExpression(expression)
+    if (ts.isStringLiteralLike(current)) return new Set([current.text])
+    if (this.isForwardedAgentEventParameter(current)) return undefined
+    return finiteStringTypeValues(this.project.checker.getTypeAtLocation(current))
+  }
+
+  /** Reject the contextual parameter inside the AgentEventDispatch forwarding object. */
+  private isForwardedAgentEventParameter(expression: ts.Expression): boolean {
+    if (!ts.isIdentifier(expression)) return false
+    const declarations = this.project.checker.getSymbolAtLocation(expression)?.declarations ?? []
+    return declarations.some((declaration) => {
+      if (!ts.isParameter(declaration)) return false
+      const method = declaration.parent
+      if (!ts.isMethodDeclaration(method) || !ts.isObjectLiteralExpression(method.parent)) return false
+      const contextualType = this.project.checker.getContextualType(method.parent)
+      return contextualType !== undefined
+        && this.project.checker.isTypeAssignableTo(contextualType, this.agentDispatchType)
+    })
+  }
+
+  /** Get or create one relation row. */
+  private ensure(event: string): EventRelation {
+    const existing = this.relations.get(event)
+    if (existing) return existing
+    const relation = { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() }
+    this.relations.set(event, relation)
+    return relation
+  }
+
+  /** Add one dispatcher method without duplicating package/method labels. */
+  private addDispatcher(event: string, pkg: string, method: string): void {
+    const relation = this.ensure(event)
+    const methods = relation.dispatchers.get(pkg) ?? new Set<string>()
+    methods.add(method)
+    relation.dispatchers.set(pkg, methods)
   }
-  return out
 }
 
-function isCordisContextReceiver(expr: ts.PropertyAccessExpression, sf: ts.SourceFile): boolean {
-  // The chained fused-dispatch spelling: `agentEvents(ctx, agent).emit(…)` —
-  // the receiver is a call expression, not an identifier.
-  if (ts.isCallExpression(expr.expression) && expr.expression.expression.getText(sf) === 'agentEvents') {
-    return true
+/** Peel syntax-only wrappers that do not change an expression's runtime value. */
+function unwrapExpression(expression: ts.Expression): ts.Expression {
+  let current = expression
+  while (
+    ts.isParenthesizedExpression(current)
+    || ts.isAsExpression(current)
+    || ts.isTypeAssertionExpression(current)
+    || ts.isNonNullExpression(current)
+    || ts.isSatisfiesExpression(current)
+  ) {
+    current = current.expression
   }
-  const target = expr.expression.getText(sf)
-  if (target === 'ctx' || target === 'this.ctx') return true
-  // Scoped-dispatch spellings are conventional names. Keep this list in sync
-  // with renames or the relationship matrix can silently lose an edge.
-  return target === 'events' || target === 'childCtx' || target === 'this.loopCtx' || target === 'emitCtx'
+  return current
 }
 
-function eventArg(args: ts.NodeArray<ts.Expression>, method: string): string | undefined {
-  if (method === 'waterfall') {
-    const arg = args.find(ts.isStringLiteralLike)
-    return arg?.text
+/** Return every value only when a type is a closed string-literal union. */
+function finiteStringTypeValues(type: ts.Type): Set<string> | undefined {
+  if (type.flags & ts.TypeFlags.StringLiteral) {
+    return new Set([(type as ts.StringLiteralType).value])
   }
-  const first = args[0]
-  if (first && ts.isStringLiteralLike(first)) return first.text
-  // Scope-carrier dispatch: `emit(carrier, 'event/name', …)` puts the event
-  // name second. Accept a string literal in position 1 when position 0 is a
-  // non-literal expression (the carrier).
-  const second = args[1]
-  return second && ts.isStringLiteralLike(second) ? second.text : undefined
+  if (type.flags & ts.TypeFlags.Never) return new Set()
+  if (!type.isUnion()) return undefined
+  const values = new Set<string>()
+  for (const member of type.types) {
+    const memberValues = finiteStringTypeValues(member)
+    if (!memberValues) return undefined
+    addAll(values, memberValues)
+  }
+  return values
+}
+
+/** Return whether a variable declaration belongs to a const declaration list. */
+function isConstDeclaration(declaration: ts.VariableDeclaration): boolean {
+  return (declaration.parent.flags & ts.NodeFlags.Const) !== 0
+}
+
+/** Return whether a declaration is visible to callers outside its source module. */
+function hasExportModifier(node: ts.Node): boolean {
+  return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => {
+    return modifier.kind === ts.SyntaxKind.ExportKeyword || modifier.kind === ts.SyntaxKind.DefaultKeyword
+  }) ?? false)
+}
+
+/** Add every member of source to target. */
+function addAll<T>(target: Set<T>, source: ReadonlySet<T>): void {
+  for (const value of source) target.add(value)
+}
+
+/** Return the union of two sets without mutating either input. */
+function unionSets<T>(left: ReadonlySet<T>, right: ReadonlySet<T>): Set<T> {
+  const out = new Set(left)
+  addAll(out, right)
+  return out
+}
+
+function collectEventRelations(): Map<string, EventRelation> {
+  const project = new TypeScriptProject(root)
+  const sources = project.sourceFiles().flatMap((sourceFile): PackageSource[] => {
+    const rel = project.relativePath(sourceFile)
+    const match = /^packages\/[^/]+\/([^/]+)\/src\/.+\.ts$/.exec(rel)
+    return match?.[1] ? [{ rel, pkg: match[1], sourceFile }] : []
+  }).sort((left, right) => left.rel.localeCompare(right.rel))
+  return new EventRelationCollector(project, sources).collect()
 }
 
 function relationPackages(map: Map<string, Set<string>>, pkgsByShort: Map<string, Pkg>): string {
@@ -599,10 +738,10 @@ function renderEventRelations(pkgs: Pkg[]): string {
   const events = collectEvents()
   const relations = collectEventRelations()
   const pkgsByShort = new Map(pkgs.map(pkg => [pkg.short, pkg]))
-  const maintenance = 'hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`'
+  const maintenance = 'generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program'
   const lines = generatedHeader('Event Producer And Consumer Matrix')
   lines.push(
-    'This matrix shows which packages dispatch each harness-owned event and which packages listen to it. It is intentionally a table rather than one large graph: events are many-to-many, and dense relation data is easier to review in rows. Dynamic dispatch overrides cover sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment.',
+    'This matrix shows which packages dispatch each harness-owned event and which packages listen to it. It is intentionally a table rather than one large graph: events are many-to-many, and dense relation data is easier to review in rows. Receiver and event-name types also cover contained dispatch sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment.',
     '',
     '| Event | Mode | Declared in | Dispatchers | Listeners |',
     '| --- | --- | --- | --- | --- |',
@@ -612,7 +751,7 @@ function renderEventRelations(pkgs: Pkg[]): string {
     lines.push(`| \`${event.name}\` | \`${event.mode}\` | ${sourceLink(event.source)} | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`)
   }
   // Every declared event needs a dispatcher: zero means dead vocabulary or an
-  // unrecognized dispatch spelling. Listener-free extension points remain valid.
+  // unrecognized semantic dispatch shape. Listener-free extension points remain valid.
   const undispatched = [...events]
     .filter(event => (relations.get(event.name)?.dispatchers.size ?? 0) === 0)
     .map(event => event.name)
@@ -620,8 +759,8 @@ function renderEventRelations(pkgs: Pkg[]): string {
   if (undispatched.length > 0) {
     throw new Error(
       `event-producer-consumer matrix: no dispatcher found for declared event${undispatched.length > 1 ? 's' : ''} `
-      + `${undispatched.map(name => `"${name}"`).join(', ')} — dead vocabulary, or a dispatch spelling the scan misses `
-      + '(teach scripts/gen-doc-graphs.ts the spelling or add a DYNAMIC_EVENT_DISPATCHERS override)',
+      + `${undispatched.map(name => `"${name}"`).join(', ')} — dead vocabulary, or a dispatch shape the semantic scan misses `
+      + '(teach scripts/gen-doc-graphs.ts the shape)',
     )
   }
   const declared = new Set(events.map(event => event.name))

+ 441 - 0
scripts/gen-scoped-events.ts

@@ -0,0 +1,441 @@
+/**
+ * Generate the dev-invariants scoped-event resolver map from the
+ * repository TypeScript Program.
+ *
+ * A scoped event declares `this: Scoped<Base>`. Real `scopeTarget(base, key)`
+ * calls establish the routing-key type for that base. The generator searches
+ * every event payload parameter and one property level for exactly one type
+ * equivalent to that key. Each generated resolver compiles against the merged
+ * `Events` parameter tuple. Zero matches require `@dshScopeScan unsupported`;
+ * multiple matches are ambiguous and always fail loud.
+ *
+ *   `tsx scripts/gen-scoped-events.ts`          -> write the generated source
+ *   `tsx scripts/gen-scoped-events.ts --check`  -> exit 1 when it is stale
+ */
+
+import { existsSync, readFileSync, writeFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import ts from 'typescript'
+import { pointer, rawJsDoc } from './jsdoc.ts'
+import { TypeScriptProject } from './ts-project.ts'
+
+const root = resolve(import.meta.dirname, '..')
+const OUT = 'packages/support/invariants/src/scoped-events.generated.ts'
+const SCOPE_DOC_MARKER = 'Scope-filtered dispatch'
+
+interface ScopeTargetContract {
+  baseType: ts.Type
+  keyType: ts.Type
+  source: string
+}
+
+interface SubjectCandidate {
+  path: string
+  parameter: number
+  property?: string
+  type: ts.Type
+}
+
+interface ScopedEventResolver {
+  event: string
+  candidate: SubjectCandidate | null
+  ownerPackage: string
+}
+
+interface ScopeTag {
+  present: boolean
+  unsupported: boolean
+}
+
+/** Program-backed analyzer and renderer for the generated scoped-event resolvers. */
+class ScopedEventGenerator {
+  private readonly checker: ts.TypeChecker
+  private readonly packageSources: ts.SourceFile[]
+  private readonly scopeTargetDeclaration: ts.FunctionDeclaration
+  private readonly scopedSymbol: ts.Symbol
+  private readonly violations: string[] = []
+  private readonly packageNames = new Map<string, string>()
+
+  constructor(private readonly project: TypeScriptProject) {
+    this.checker = project.checker
+    this.packageSources = project.sourceFiles().filter((sourceFile) => {
+      return /^packages\/[^/]+\/[^/]+\/src\/.+\.ts$/.test(project.relativePath(sourceFile))
+    })
+    this.scopeTargetDeclaration = this.functionDeclaration(
+      'packages/core/scope/src/index.ts',
+      'scopeTarget',
+    )
+    this.scopedSymbol = this.typeAliasSymbol(
+      'packages/core/scope/src/index.ts',
+      'Scoped',
+    )
+  }
+
+  /** Render the complete generated TypeScript module or throw every contract violation. */
+  render(): string {
+    const contracts = this.collectScopeTargetContracts()
+    const resolvers = this.collectScopedEventResolvers(contracts)
+    if (this.violations.length > 0) {
+      throw new Error(
+        `gen-scoped-events: ${this.violations.length} scoped-event contract violation(s):\n`
+        + this.violations.map(violation => `  - ${violation}`).join('\n'),
+      )
+    }
+    const ownerImports = [...new Set(resolvers.map(resolver => resolver.ownerPackage))]
+      .sort()
+      .map(packageName => `import type {} from ${quote(packageName)}`)
+    return [
+      '/**',
+      ' * Generated scoped-event routing-subject resolvers for dsh-invariants.',
+      ' * Do not edit by hand; run `pnpm run gen-scoped-events`.',
+      ' *',
+      ' * @module @deepseek-ai/dsh-invariants/scoped-events.generated',
+      ' */',
+      '',
+      "import type { Events } from 'cordis'",
+      "import type { Scoped } from '@deepseek-ai/dsh-scope'",
+      ...ownerImports,
+      '',
+      'type ScopedEventName = {',
+      '  [K in keyof Events]: ThisParameterType<Events[K]> extends Scoped<object> ? K : never',
+      '}[keyof Events]',
+      '',
+      'type ScopedSubjectResolver = (args: readonly unknown[]) => unknown',
+      '',
+      'function adapt<K extends ScopedEventName>(',
+      '  resolver: (args: Parameters<Events[K]>) => unknown,',
+      '): ScopedSubjectResolver {',
+      '  return args => resolver(args as Parameters<Events[K]>)',
+      '}',
+      '',
+      'const scopedSubjectResolvers = Object.freeze({',
+      ...resolvers.map(({ event, candidate }) => {
+        if (candidate === null) return `  '${event}': null,`
+        const subject = candidate.property === undefined
+          ? `args[${candidate.parameter}]`
+          : `args[${candidate.parameter}].${candidate.property}`
+        return `  '${event}': adapt<'${event}'>(args => ${subject}),`
+      }),
+      '} as const satisfies Readonly<Record<ScopedEventName, ScopedSubjectResolver | null>>)',
+      '',
+      'const scopedSubjectResolverIndex: Readonly<Record<string, ScopedSubjectResolver | null>> = scopedSubjectResolvers',
+      '',
+      '/**',
+      ' * Resolve the routing key named by one scoped event payload. A null',
+      ' * resolver means the payload cannot expose its external routing key, so the',
+      ' * invariant checks carrier presence only.',
+      ' * @param event - runtime Cordis event name.',
+      ' * @returns the generated subject resolver, null for presence-only,',
+      ' *   or undefined when the event is not scope-filtered.',
+      ' */',
+      'export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined {',
+      '  return scopedSubjectResolverIndex[event]',
+      '}',
+      '',
+    ].join('\n')
+  }
+
+  /** Resolve one named function declaration from a known source file. */
+  private functionDeclaration(relativePath: string, name: string): ts.FunctionDeclaration {
+    const sourceFile = this.project.sourceFile(relativePath)
+    const declaration = sourceFile.statements.find((statement): statement is ts.FunctionDeclaration => {
+      return ts.isFunctionDeclaration(statement) && statement.name?.text === name
+    })
+    if (!declaration) throw new Error(`gen-scoped-events: cannot resolve function ${name} from ${relativePath}`)
+    return declaration
+  }
+
+  /** Resolve one named type-alias symbol from a known source file. */
+  private typeAliasSymbol(relativePath: string, name: string): ts.Symbol {
+    const sourceFile = this.project.sourceFile(relativePath)
+    const declaration = sourceFile.statements.find((statement): statement is ts.TypeAliasDeclaration => {
+      return ts.isTypeAliasDeclaration(statement) && statement.name.text === name
+    })
+    const symbol = declaration && this.checker.getSymbolAtLocation(declaration.name)
+    if (!symbol) throw new Error(`gen-scoped-events: cannot resolve type ${name} from ${relativePath}`)
+    return symbol
+  }
+
+  /** Collect every real scopeTarget(base, key) base/key type contract. */
+  private collectScopeTargetContracts(): ScopeTargetContract[] {
+    const contracts: ScopeTargetContract[] = []
+    const visit = (sourceFile: ts.SourceFile, node: ts.Node): void => {
+      if (ts.isCallExpression(node)
+        && this.checker.getResolvedSignature(node)?.declaration === this.scopeTargetDeclaration) {
+        const base = node.arguments[0]
+        const key = node.arguments[1]
+        if (!base || !key) {
+          const source = pointer(this.project.relativePath(sourceFile), sourceFile, node)
+          this.violations.push(`${source} calls scopeTarget without base and key arguments`)
+        } else {
+          contracts.push({
+            baseType: this.checker.getTypeAtLocation(base),
+            keyType: this.checker.getTypeAtLocation(key),
+            source: pointer(this.project.relativePath(sourceFile), sourceFile, node),
+          })
+        }
+      }
+      ts.forEachChild(node, (child) => { visit(sourceFile, child) })
+    }
+    for (const sourceFile of this.packageSources) visit(sourceFile, sourceFile)
+    return contracts
+  }
+
+  /** Collect every Events member and derive its generated resolver. */
+  private collectScopedEventResolvers(contracts: readonly ScopeTargetContract[]): ScopedEventResolver[] {
+    const resolvers: ScopedEventResolver[] = []
+    for (const sourceFile of this.packageSources) {
+      const rel = this.project.relativePath(sourceFile)
+      const ownerPackage = this.packageName(packageRootFor(rel))
+      const visit = (node: ts.Node): void => {
+        if (ts.isInterfaceDeclaration(node) && node.name.text === 'Events' && isCordisModuleInterface(node)) {
+          for (const member of node.members) {
+            if (!ts.isMethodSignature(member) || !ts.isStringLiteral(member.name)) continue
+            const event = member.name.text
+            const raw = rawJsDoc(sourceFile.text, member)
+            const where = `event '${event}' (${pointer(rel, sourceFile, member)})`
+            const tag = parseScopeTag(raw, where, this.violations)
+            const thisParameter = member.parameters.find(isThisParameter)
+            const scopedBase = thisParameter && this.scopedBaseType(thisParameter)
+            if (!scopedBase) {
+              if (raw.includes(SCOPE_DOC_MARKER)) {
+                this.violations.push(
+                  `${where} documents scope-filtered dispatch but its signature has no this: Scoped<...> receiver`,
+                )
+              }
+              if (tag.present) {
+                this.violations.push(`${where} has @dshScopeScan metadata but is not a Scoped event`)
+              }
+              continue
+            }
+            if (!raw.includes(SCOPE_DOC_MARKER)) {
+              this.violations.push(
+                `${where} has this: Scoped<...> but its JSDoc does not explain "${SCOPE_DOC_MARKER}"`,
+              )
+            }
+            const keyType = this.routingKeyType(where, scopedBase, contracts)
+            if (!keyType) continue
+            const candidates = this.subjectCandidates(member)
+              .filter(candidate => this.typesEquivalent(candidate.type, keyType))
+            if (candidates.length > 1) {
+              this.violations.push(
+                `${where} has multiple routing-key candidates for ${this.typeText(keyType)}: `
+                + candidates.map(candidate => `${candidate.path}: ${this.typeText(candidate.type)}`).join(', '),
+              )
+              continue
+            }
+            if (candidates.length === 0) {
+              if (!tag.unsupported) {
+                const keyLabel = this.typeText(keyType)
+                this.violations.push(
+                  `${where} exposes no parameter or one-level property equivalent to routing key type ${keyLabel}; `
+                  + 'add @dshScopeScan unsupported only when the key is intentionally absent from the payload',
+                )
+              }
+              resolvers.push({ event, candidate: null, ownerPackage })
+              continue
+            }
+            if (tag.unsupported) {
+              this.violations.push(
+                `${where} has unnecessary @dshScopeScan unsupported; ${candidates[0]?.path} exposes the routing key`,
+              )
+              continue
+            }
+            resolvers.push({ event, candidate: candidates[0] ?? null, ownerPackage })
+          }
+        }
+        ts.forEachChild(node, visit)
+      }
+      visit(sourceFile)
+    }
+    return resolvers.sort((left, right) => left.event.localeCompare(right.event))
+  }
+
+  /** Extract the Base type from one exact this: Scoped<Base> parameter. */
+  private scopedBaseType(parameter: ts.ParameterDeclaration): ts.Type | undefined {
+    const type = this.checker.getTypeAtLocation(parameter)
+    if (type.aliasSymbol !== this.scopedSymbol) return undefined
+    return type.aliasTypeArguments?.[0]
+  }
+
+  /** Resolve one unambiguous key type for a scoped carrier base. */
+  private routingKeyType(
+    where: string,
+    scopedBase: ts.Type,
+    contracts: readonly ScopeTargetContract[],
+  ): ts.Type | undefined {
+    const matches = contracts.filter((contract) => {
+      return this.checker.isTypeAssignableTo(this.normalizedType(contract.baseType), this.normalizedType(scopedBase))
+    })
+    if (matches.length === 0) {
+      this.violations.push(
+        `${where} has no matching scopeTarget(base, key) call for carrier base ${this.typeText(scopedBase)}`,
+      )
+      return undefined
+    }
+    const keyTypes: ts.Type[] = []
+    for (const match of matches) {
+      if (!keyTypes.some(type => this.typesEquivalent(type, match.keyType))) keyTypes.push(match.keyType)
+    }
+    if (keyTypes.length > 1) {
+      this.violations.push(
+        `${where} carrier base ${this.typeText(scopedBase)} has inconsistent routing-key types: `
+        + matches.map(match => `${this.typeText(match.keyType)} at ${match.source}`).join(', '),
+      )
+      return undefined
+    }
+    return keyTypes[0]
+  }
+
+  /** Enumerate every payload parameter and every accessible one-level property. */
+  private subjectCandidates(member: ts.MethodSignature): SubjectCandidate[] {
+    const candidates: SubjectCandidate[] = []
+    let runtimeIndex = 0
+    for (const parameter of member.parameters) {
+      if (isThisParameter(parameter)) continue
+      const directPath = `args[${runtimeIndex}]`
+      const parameterType = this.checker.getTypeAtLocation(parameter)
+      candidates.push({ path: directPath, parameter: runtimeIndex, type: parameterType })
+      for (const property of this.checker.getPropertiesOfType(this.normalizedType(parameterType))) {
+        const name = property.getName()
+        if (name.startsWith('__@') || hasNonPublicDeclaration(property)) continue
+        candidates.push({
+          path: `${directPath}.${name}`,
+          parameter: runtimeIndex,
+          property: name,
+          type: this.checker.getTypeOfSymbolAtLocation(property, parameter),
+        })
+      }
+      runtimeIndex += 1
+    }
+    return dedupeCandidates(candidates)
+  }
+
+  /** Read and cache one workspace package name. */
+  private packageName(packageRoot: string): string {
+    const cached = this.packageNames.get(packageRoot)
+    if (cached) return cached
+    const manifest: unknown = JSON.parse(readFileSync(resolve(root, packageRoot, 'package.json'), 'utf8'))
+    const name: unknown = typeof manifest === 'object' && manifest !== null
+      ? Reflect.get(manifest, 'name')
+      : undefined
+    if (typeof name !== 'string') throw new Error(`gen-scoped-events: ${packageRoot}/package.json has no name`)
+    this.packageNames.set(packageRoot, name)
+    return name
+  }
+
+  /** Compare exact Program type identities after removing null and undefined. */
+  private typesEquivalent(left: ts.Type, right: ts.Type): boolean {
+    const normalizedLeft = this.normalizedType(left)
+    const normalizedRight = this.normalizedType(right)
+    if (normalizedLeft.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return false
+    if (normalizedRight.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return false
+    return normalizedLeft === normalizedRight
+  }
+
+  /** Remove null and undefined from a routing or candidate type. */
+  private normalizedType(type: ts.Type): ts.Type {
+    return this.checker.getNonNullableType(type)
+  }
+
+  /** Render a stable diagnostic type label. */
+  private typeText(type: ts.Type): string {
+    return this.checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation)
+  }
+}
+
+/** Return whether an Events interface is inside declare module 'cordis'. */
+function isCordisModuleInterface(node: ts.InterfaceDeclaration): boolean {
+  const block = node.parent
+  const declaration = block.parent
+  return ts.isModuleBlock(block)
+    && ts.isModuleDeclaration(declaration)
+    && ts.isStringLiteral(declaration.name)
+    && declaration.name.text === 'cordis'
+}
+
+/** Return whether a parameter is the explicit TypeScript this receiver. */
+function isThisParameter(parameter: ts.ParameterDeclaration): boolean {
+  return ts.isIdentifier(parameter.name) && parameter.name.text === 'this'
+}
+
+/** Parse and validate the optional @dshScopeScan unsupported tag. */
+function parseScopeTag(raw: string, where: string, violations: string[]): ScopeTag {
+  const tags = raw
+    .replace(/^\/\*\*/, '')
+    .replace(/\*\/$/, '')
+    .split('\n')
+    .map(line => line.replace(/^\s*\*?\s?/, '').trim())
+    .filter(line => line.startsWith('@dshScopeScan'))
+  if (tags.length > 1) violations.push(`${where} has multiple @dshScopeScan tags`)
+  if (tags.length === 0) return { present: false, unsupported: false }
+  const unsupported = tags[0] === '@dshScopeScan unsupported'
+  if (!unsupported) {
+    violations.push(
+      `${where} has invalid scoped-event scan metadata '${tags[0]}'; expected '@dshScopeScan unsupported'`,
+    )
+  }
+  return { present: true, unsupported }
+}
+
+/** Return whether a property has a private or protected declaration. */
+function hasNonPublicDeclaration(symbol: ts.Symbol): boolean {
+  return (symbol.declarations ?? []).some((declaration) => {
+    if (!ts.canHaveModifiers(declaration)) return false
+    return ts.getModifiers(declaration)?.some((modifier) => {
+      return modifier.kind === ts.SyntaxKind.PrivateKeyword || modifier.kind === ts.SyntaxKind.ProtectedKeyword
+    }) ?? false
+  })
+}
+
+/** Deduplicate candidate paths contributed by merged/intersection types. */
+function dedupeCandidates(candidates: readonly SubjectCandidate[]): SubjectCandidate[] {
+  const seen = new Set<string>()
+  return candidates.filter((candidate) => {
+    if (seen.has(candidate.path)) return false
+    seen.add(candidate.path)
+    return true
+  })
+}
+
+/** Return the workspace package root owning one package source file. */
+function packageRootFor(relativePath: string): string {
+  const match = /^(packages\/[^/]+\/[^/]+)\/src\//.exec(relativePath)
+  if (!match?.[1]) throw new Error(`gen-scoped-events: cannot derive package root from ${relativePath}`)
+  return match[1]
+}
+
+/** Quote a generated property key as a single-quoted TypeScript string. */
+function quote(value: string): string {
+  return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
+}
+
+/**
+ * Render the generated scoped-event resolver module for one repository root.
+ * @param projectRoot - repository root carrying tsconfig.json.
+ * @returns complete generated TypeScript source.
+ */
+export function renderScopedEvents(projectRoot: string = root): string {
+  return new ScopedEventGenerator(new TypeScriptProject(projectRoot)).render()
+}
+
+/** Generate or freshness-check the fixed invariants source file. */
+function main(): void {
+  const content = renderScopedEvents()
+  const output = resolve(root, OUT)
+  if (process.argv.includes('--check')) {
+    const committed = existsSync(output) ? readFileSync(output, 'utf8') : null
+    if (committed === content) {
+      console.log(`gen-scoped-events: ${OUT} is up to date.`)
+      return
+    }
+    console.error(`gen-scoped-events: ${OUT} is stale. Run \`pnpm run gen-scoped-events\` and commit it.`)
+    process.exit(1)
+  }
+  writeFileSync(output, content)
+  console.log(`gen-scoped-events: wrote ${OUT}.`)
+}
+
+if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
+  main()
+}

+ 1 - 1
scripts/run-gates.ts

@@ -276,7 +276,7 @@ function docSyncLeafGates(): Gate[] {
     pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }),
     pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
     pnpmScript('doc-graphs', 'verify-doc-graphs', { label: 'doc graphs' }),
-    pnpmScript('scoped-dispatch', 'verify-scoped-dispatch', { label: 'scoped dispatch' }),
+    pnpmScript('scoped-events', 'verify-scoped-events', { label: 'scoped events' }),
     pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }),
     pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }),
     pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }),

+ 113 - 0
scripts/ts-project.ts

@@ -0,0 +1,113 @@
+/**
+ * Shared TypeScript Program construction for repository gates that need real
+ * cross-file symbols and types instead of isolated syntax trees.
+ */
+
+import { relative, resolve } from 'node:path'
+import ts from 'typescript'
+
+interface ProjectGraph {
+  rootNames: string[]
+  options: ts.CompilerOptions
+}
+
+const configHost: ts.ParseConfigFileHost = {
+  useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames,
+  readDirectory: (...args) => ts.sys.readDirectory(...args),
+  fileExists: fileName => ts.sys.fileExists(fileName),
+  readFile: fileName => ts.sys.readFile(fileName),
+  getCurrentDirectory: () => ts.sys.getCurrentDirectory(),
+  onUnRecoverableConfigFileDiagnostic(diagnostic) {
+    throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
+  },
+}
+
+/** Parse a root tsconfig and flatten all referenced projects into one semantic graph. */
+function loadProjectGraph(projectRoot: string): ProjectGraph {
+  const rootConfigPath = resolve(projectRoot, 'tsconfig.json')
+  const rootConfig = parseConfig(rootConfigPath)
+  const rootNames = new Set<string>()
+  const visited = new Set<string>()
+
+  const collect = (configPath: string, parsed: ts.ParsedCommandLine): void => {
+    if (visited.has(configPath)) return
+    visited.add(configPath)
+    for (const fileName of parsed.fileNames) rootNames.add(fileName)
+    for (const reference of parsed.projectReferences ?? []) {
+      const referencePath = ts.resolveProjectReferencePath(reference)
+      collect(referencePath, parseConfig(referencePath))
+    }
+  }
+  collect(rootConfigPath, rootConfig)
+
+  return {
+    rootNames: [...rootNames],
+    options: rootConfig.options,
+  }
+}
+
+/** Parse one config file and fail loud on any config diagnostic. */
+function parseConfig(configPath: string): ts.ParsedCommandLine {
+  const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost)
+  if (!parsed) throw new Error(`cannot parse TypeScript config ${configPath}`)
+  if (parsed.errors.length > 0) {
+    throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n'))
+  }
+  return parsed
+}
+
+/** Disable emit-only options after loading the root solution config. */
+function semanticCompilerOptions(options: ts.CompilerOptions): ts.CompilerOptions {
+  return {
+    ...options,
+    noEmit: true,
+    composite: false,
+    declaration: false,
+    declarationMap: false,
+    sourceMap: false,
+    incremental: false,
+  }
+}
+
+/** A repository-scoped TypeScript Program and its shared TypeChecker. */
+export class TypeScriptProject {
+  /** The bound cross-file TypeScript program. */
+  readonly program: ts.Program
+  /** The checker shared by every semantic query in this project. */
+  readonly checker: ts.TypeChecker
+
+  constructor(private readonly projectRoot: string) {
+    const graph = loadProjectGraph(projectRoot)
+    this.program = ts.createProgram(graph.rootNames, semanticCompilerOptions(graph.options))
+    this.checker = this.program.getTypeChecker()
+  }
+
+  /**
+   * Return every source file loaded into the flattened root project graph.
+   * @returns program source files, including libraries and external dependencies.
+   */
+  sourceFiles(): readonly ts.SourceFile[] {
+    return this.program.getSourceFiles()
+  }
+
+  /**
+   * Render a loaded source file relative to the project root.
+   * @param sourceFile - a source file from this project.
+   * @returns a slash-separated repository-relative path.
+   */
+  relativePath(sourceFile: ts.SourceFile): string {
+    return relative(this.projectRoot, sourceFile.fileName).replaceAll('\\', '/')
+  }
+
+  /**
+   * Return one program source file by repository-relative path.
+   * @param relativePath - path relative to the project root.
+   * @returns the source file bound into this project.
+   * @throws if a requested root or imported source was not loaded.
+   */
+  sourceFile(relativePath: string): ts.SourceFile {
+    const sourceFile = this.program.getSourceFile(resolve(this.projectRoot, relativePath))
+    if (!sourceFile) throw new Error(`TypeScript project did not load ${relativePath}`)
+    return sourceFile
+  }
+}

+ 0 - 69
scripts/verify-scoped-dispatch.ts

@@ -1,69 +0,0 @@
-/**
- * Scoped-dispatch drift gate: the set of scope-filtered events is declared in TWO places that
- * must never diverge — the dev-invariants runtime table (the `scopedSubject` map in
- * `packages/support/invariants/src/index.ts`, which enforces carriers at dispatch time) and
- * the event declarations' JSDoc (the "Scope-filtered dispatch" sentence rendered into the
- * events catalog, which tells plugin authors what a scoped listener will and won't hear).
- * Registry-subject notifications are intentionally unfiltered and belong in neither set.
- */
-
-import { globSync, readFileSync } from 'node:fs'
-import { resolve } from 'node:path'
-
-const root = resolve(import.meta.dirname, '..')
-
-/** The marker sentence every scope-filtered event's JSDoc carries. */
-const MARKER = 'Scope-filtered dispatch'
-
-/** Events that are deliberately UNFILTERED registry-subject notifications. */
-const REGISTRY_SUBJECT = new Set(['tools/change', 'system-prompt/change', 'subagent/provider-added', 'subagent/provider-removed'])
-
-function invariantTable(): Set<string> {
-  const source = readFileSync(resolve(root, 'packages/support/invariants/src/index.ts'), 'utf8')
-  const start = source.indexOf('const scopedSubject')
-  if (start < 0) throw new Error('verify-scoped-dispatch: cannot find the scopedSubject table in dsh-invariants')
-  const block = source.slice(start, source.indexOf('}', start))
-  return new Set([...block.matchAll(/'([a-z-]+\/[a-z-]+)':/g)].flatMap(match => match[1] === undefined ? [] : [match[1]]))
-}
-
-function documentedSet(): Set<string> {
-  const documented = new Set<string>()
-  for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: root })) {
-    const source = readFileSync(resolve(root, rel), 'utf8')
-    if (!source.includes(MARKER)) continue
-    // Each event declaration: a JSDoc block followed by the quoted event name.
-    // Tolerate `//` comment lines between the JSDoc and the declaration
-    // (e.g. an inline TODO under the doc block).
-    for (const match of source.matchAll(/\/\*\*([\s\S]*?)\*\/\s*\n(?:\s*\/\/[^\n]*\n)*\s*'([a-z-]+\/[a-z-]+)'\(/g)) {
-      const [, doc, event] = match
-      if (doc === undefined || event === undefined) continue
-      if (doc.includes(MARKER)) documented.add(event)
-    }
-  }
-  return documented
-}
-
-const table = invariantTable()
-const documented = documentedSet()
-
-const problems: string[] = []
-for (const event of table) {
-  if (!documented.has(event)) {
-    problems.push(`"${event}" is enforced by the dev-invariants carrier table but its declaration JSDoc carries no "${MARKER}" sentence — document the filtering plugin authors will observe.`)
-  }
-  if (REGISTRY_SUBJECT.has(event)) {
-    problems.push(`"${event}" is a registry-subject notification (deliberately unfiltered) but appears in the dev-invariants carrier table.`)
-  }
-}
-for (const event of documented) {
-  if (!table.has(event)) {
-    problems.push(`"${event}" documents scope-filtered dispatch but is missing from the dev-invariants carrier table (packages/support/invariants) — a bare dispatch of it would silently revert to global delivery.`)
-  }
-}
-
-if (problems.length > 0) {
-  console.error(`verify-scoped-dispatch: ${problems.length} drift(s) between the invariant table and the documented scoped-event set:`)
-  for (const problem of problems) console.error(`  - ${problem}`)
-  process.exit(1)
-}
-console.log(`verify-scoped-dispatch: ${table.size} scope-filtered event(s) consistent between the invariant table and the declaration docs.`)