diff --git a/AGENTS.md b/AGENTS.md index c33774db9e..22c1f47aa8 100644 --- a/AGENTS.md +++ b/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-`; 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. diff --git a/docs/architecture.md b/docs/architecture.md index d9c3d86a1e..b17c3302d8 100644 --- a/docs/architecture.md +++ b/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 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index aa87ea66d7..4af431f8c0 100644 --- a/docs/cordis-catalog/events.md +++ b/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): 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): 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): Promise | 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, 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, 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/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ca83b3c489..1509afc76a 100644 --- a/docs/cordis-catalog/services.md +++ b/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 ``` -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` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d1a097fe24..bdf1c6e320 100644 --- a/docs/event-producer-consumer.md +++ b/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. diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index e0a9e008e7..e7af14caa6 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -175,6 +175,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 diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index f09ab214f5..2c3126793f 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/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. diff --git a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml new file mode 100644 index 0000000000..eb411f1158 --- /dev/null +++ b/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 diff --git a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md new file mode 100644 index 0000000000..3e7a76e86d --- /dev/null +++ b/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` 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`, 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. diff --git a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md new file mode 100644 index 0000000000..0a13452012 --- /dev/null +++ b/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` 的 Cordis `Events` 成员,并在每个事件参数及其一层公开属性中搜索类型;移除 `null` 和 `undefined` 后,候选类型必须与路由键类型完全相同。 + +恰好一个匹配项会生成解析函数。存在多个匹配项时,含义不明确,生成器会失败。没有匹配项时,事件必须标记 `@dshScopeScan unsupported`;该标记只用于路由键有意留在事件参数之外的情况,例如按所属 agent(智能体)路由的会话事件和按父 agent 路由的 subagent 生命周期事件。此标记只表示扫描不受支持,不编码事件名、参数下标、属性路径或替代类型。 + +仓库提交的 [`scoped-events.generated.ts`](../../../../packages/support/invariants/src/scoped-events.generated.ts) 会导入每个带作用域的事件声明方,使它们从类型侧合并进 `Events`。每个生成函数都接收 `Parameters`,完整对象则满足基于 `ScopedEventName` 联合类型派生出的 `Record`。因此,常规 TypeScript 编译会检查事件是否存在、参数位置、属性访问和带作用域的事件集合完整性。唯一的类型断言只负责将 Cordis 运行时的 `unknown[]` dispatch 边界适配到已经通过类型检查的解析函数。 + +不变式插件消费这份生成的运行时表,不再维护自己的事件表。新增的事件声明方包只作为 `dsh-invariants` 的开发依赖和项目引用存在,不进入对等依赖,因此编译期聚合不会扩大插件的运行时依赖闭包。 + +### 语义缺口必须显式失败 + +遇到声明缺失、配置诊断、事件名被拓宽或保持泛型、路由键类型不一致、事件参数匹配不唯一、不必要的 unsupported 标记,或生成产物陈旧时,生成器都会拒绝继续。通过本地辅助函数调用点恢复信息的能力被刻意限制在窄范围内:如果数据流经过导出或无法解析的边界,应新增通用语义规则,而不是添加特定包的覆盖项。 + +## 验证 + +`verify-doc-graphs` 对语义生产方/监听方扫描执行新鲜度检查,`verify-scoped-events` 对生成的解析函数表执行新鲜度检查。根 TypeScript 构建会将解析函数与合并后的 `Events` 一起编译;workspace 约束和运行时依赖闭包检查则确保仅参与类型聚合的依赖不会变成部署依赖。 + +## 考虑过的替代方案 + +- **保留语法扫描、接收者白名单和手写覆盖项。** 每个例外都容易单独处理,但重命名和新增辅助函数形态时还必须更新第二份表示。完整性检查能够发现生产方缺失,却无法证明覆盖项仍与源码一致。 + +## 后果 + +- 事件关系生成依据语义接收者身份和封闭事件值,不再依赖局部命名约定; +- 带作用域的事件成员关系、主体提取和运行时不变式覆盖来自事件声明与真实 dispatch 契约,不再来自手写表; +- 修改事件名、参数位置、主体属性或路由键类型时,会在其所属契约处触发生成或编译失败; +- 构建扁平化 Program 比解析孤立文件消耗更多启动时间和内存,语义门禁也依赖有效的根项目图; +- 生成的 TypeScript 仍属于提交到仓库的源码:事件声明方或 dispatch 形态发生变化后,必须重新生成该文件和受影响的文档。 diff --git a/package.json b/package.json index a9ce8a6b87..b436a161fe 100644 --- a/package.json +++ b/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", diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 28312abb7a..89f1627445 100644 --- a/packages/core/session/src/index.ts +++ b/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): 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): 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, 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): Promise | void diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 4635de9697..8657e7d06e 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/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, 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, info: SubagentRunEndInfo): void diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index 3568465d18..85a249dbd1 100644 --- a/packages/support/invariants/package.json +++ b/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" } } diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index f5b7828a3d..8da5429b06 100644 --- a/packages/support/invariants/src/index.ts +++ b/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 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( diff --git a/packages/support/invariants/src/scoped-events.generated.ts b/packages/support/invariants/src/scoped-events.generated.ts new file mode 100644 index 0000000000..06cca6bf55 --- /dev/null +++ b/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 extends Scoped ? K : never +}[keyof Events] + +type ScopedSubjectResolver = (args: readonly unknown[]) => unknown + +function adapt( + resolver: (args: Parameters) => unknown, +): ScopedSubjectResolver { + return args => resolver(args as Parameters) +} + +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>) + +const scopedSubjectResolverIndex: Readonly> = 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] +} diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index e53b0b8e40..0212501114 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/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 diff --git a/packages/support/invariants/tsconfig.json b/packages/support/invariants/tsconfig.json index cd17f67d7c..6c5bc479b5 100644 --- a/packages/support/invariants/tsconfig.json +++ b/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" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcc34369d9..312058db4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1135,6 +1135,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) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 468a46e904..a6660f7cd3 100644 --- a/scripts/gen-doc-graphs.ts +++ b/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 } +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 [ '