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

Merge latest PR #340 into codex/simplify-agent-identity-plumbing

Tianyi Cui 2 месяцев назад
Родитель
Сommit
5a4ea6c08d

+ 1 - 1
docs/AGENTS.md

@@ -27,7 +27,7 @@ Placement: bugs → postmortems; rationale → RFCs; procedures → cookbooks; t
 - **Document current state, not change history.** Avoid "previously/now/no longer", PRs, commits, and stack positions in durable prose; name the live mechanism. Put change stories in commits, PRs, RFCs, or postmortems.
 - **Write an RFC in the same PR for decisions a maintainer may reasonably revisit.** Mechanical or self-evident changes need none ([when to write one](rfc/README.md)).
 - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit.
-- **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc are fenced ` ```ts type-equiv ` and registered in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)).
+- **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)).
 - **The [core-data-structures catalog](core-data-structures/core.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types ([what counts as core](core-data-structures/core.md#what-counts-as-core)).
 - **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)).
 - **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, conditions, timing, modality, exceptions, consequences, and non-obvious orientation; delete implementation narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link to its owning rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage, decision rules, and examples.

+ 1 - 3
docs/core-data-structures/core.md

@@ -36,9 +36,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
 | [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` |
 | [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality |
 
-> Type declarations and their JSDoc on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)).
-
-FIXME(catalog-verbs): the drift gate covers only the nouns (the pasted type shapes); every method surface on these pages is hand-written prose. core-data-structures should probably also generate the *verbs* — the public methods of the cataloged classes — so a signature change cannot silently outdate the catalog.
+> Type declarations and their JSDoc on these pages are source-equivalent and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Ordinary blocks preserve complete declarations; `public-api` blocks preserve body-stripped public class declarations. Cordis services use the generated [service catalog](../cordis-catalog/services.md).
 
 ## The `…Map → derived-union` pattern
 

+ 69 - 0
docs/core-data-structures/llm-streaming.md

@@ -91,10 +91,79 @@ interface TokenUsage {
 
 `BlockAssembler` ([`packages/llm/llm/src/assembler.ts`](../../packages/llm/llm/src/assembler.ts)) is the single shared implementation that folds a `StreamChunk` stream back into `ContentBlock`s, usage, finish reason, and replay state. The loop logs the raw chunks while feeding the same chunks through an assembler, then stores the assembled assistant content with its provider/model provenance. A consumer that needs the assembled result without re-implementing the fold uses this.
 
+```ts public-api
+/**
+ * Incrementally assembles raw {@link StreamChunk}s into complete
+ * {@link ContentBlock}s and a final assistant {@link Message}.
+ *
+ * The agent loop feeds it while logging raw chunks for replay fidelity, then
+ * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends.
+ *
+ * Tolerant of delta-only protocols (no block-start/end); deltas arriving for
+ * an index already closed by `block-end` are ignored (malformed stream) so a
+ * misbehaving adapter cannot grow memory or corrupt a completed block.
+ */
+declare class BlockAssembler {
+  /**
+   * Feed one chunk into the assembly state.
+   * @param chunk - the next raw chunk, in stream order.
+   */
+  push(chunk: StreamChunk): void;
+  /**
+   * Assemble all blocks seen so far, in stream order.
+   * @returns one block per seen index; an open block assembles from its
+   *   accumulated deltas (an unknown block type never closed by `block-end` throws).
+   */
+  blocks(): ContentBlock[];
+  /** Usage from the `usage` chunk; undefined until one arrives. */
+  get usage(): TokenUsage | undefined;
+  /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */
+  get finish(): FinishReason;
+  /** Adapter-private replay state from the terminal finish chunk, if any. */
+  get replayState(): unknown;
+  /**
+   * The assembled assistant message.
+   * @returns an assistant-role message over `blocks()` (same open-block assembly rules).
+   */
+  message(): Message;
+}
+```
+
 ## The seam
 
 `LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm).
 
+```ts public-api
+/**
+ * Provider-wire adapter for the harness message and stream vocabulary. Register implementations
+ * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include
+ * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The hand-rolled
+ * DeepSeek and pi-ai adapters intentionally exercise this contract through different internals.
+ */
+declare abstract class LlmAdapter {
+  /**
+   * Describe one provider route owned by this adapter.
+   * @param provider - a route passed to `registerAdapter()` for this instance.
+   * @returns detached display metadata whose id must equal `provider`.
+   */
+  providerInfo(provider: string): LlmProviderInfo;
+  /**
+   * List models this adapter can currently advertise for one owned provider.
+   * The result is advisory: an adapter may accept unlisted model ids, and
+   * consumers must not turn absence into request rejection.
+   * @param _provider - one provider route owned by this adapter.
+   * @returns discoverable models in adapter-preferred order.
+   */
+  listModels(_provider: string): Promise<readonly LlmModelInfo[]>;
+  /**
+   * Stream one model call as raw chunks. The only required method.
+   * @param options - the fully-assembled request; implementations must honor `options.signal`.
+   * @returns the chunk stream, obeying the adapter contract documented on `StreamChunk`.
+   */
+  abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
+}
+```
+
 `ContentBlockType` (the key set the `index`-correlated blocks carry) derives from `ContentBlockMap`:
 
 ```ts type-equiv

+ 120 - 0
docs/core-data-structures/session.md

@@ -305,6 +305,126 @@ interface SurfaceFoldResult {
 }
 ```
 
+## `Session` public API
+
+The body-stripped declaration keeps the plain class's public constructor, state accessors, append boundary, and history projections synchronized with source. Store operations remain in the generated [`ctx.sessions` service catalog](../cordis-catalog/services.md#ctxsessions--sessionstore).
+
+```ts public-api
+/**
+ * An event-sourced session: an append-only log of {@link SessionEvent}s.
+ *
+ * Plain class (not a Service) — create instances via `ctx.sessions.create()`.
+ * Seeding with an existing event log replays/forks a session.
+ */
+declare class Session {
+  /** The ordered surface over this session's event log. */
+  get surface(): SessionSurface;
+  /**
+   * Detached, deep-frozen creation metadata (format version, cwd, lineage,
+   * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
+   * `Session` is constructed bare (tests, ad-hoc replay), a minimal header is
+   * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
+   * `session.header` is always present. Kept out of the event log — it is a
+   * storage concern, not replayable conversation state.
+   */
+  readonly header: SessionHeader;
+  /** The session identity, derived from its durable header's single copy. */
+  get id(): SessionId;
+  constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);
+  /**
+   * An immutable snapshot of the append-only event log. The snapshot is reused
+   * until the next append; a previously returned array does not grow later.
+   * Events and their nested data are deep-frozen at acceptance, so neither a
+   * cast nor ordinary JavaScript can rewrite durable history.
+   */
+  get events(): readonly SessionEvent[];
+  /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
+  get seq(): number;
+  /**
+   * Append one typed event to the log and synchronously notify observers via
+   * the store-owned, module-private publication hooks. The hot path never blocks
+   * on I/O — persistence plugins buffer asynchronously. Once the event enters
+   * the log, the append is committed: observer failures are logged and
+   * contained per listener, so they do not change the return value or prevent
+   * later listeners from observing the same accepted event.
+   *
+   * @param type - The event type (key of {@link SessionEventMap}).
+   * @param data - The event payload; must be JSON-serializable.
+   * @param opts - Surface metadata: `surfaceOp` controls how the event enters
+   *   the ordered surface; `sourceEventSeqs` records provenance (the seq
+   *   numbers of events this one derives from). REQUIRED for
+   *   {@link SurfaceEventType} events (every message-producing event must
+   *   declare how it joins the surface, the sole source of derived history) and
+   *   rejected by the compiler for non-surface types like `turn/start` or
+   *   `assistant/chunk`.
+   * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
+   *   `data` that entered the log, so reading `event.data` back sees the logged
+   *   value, never the caller's still-mutable input.
+   * @throws if `data` or surface metadata is not losslessly JSON-serializable
+   *   (BigInt, function, symbol, undefined, negative zero, non-finite number,
+   *   circular reference, sparse array, or an exotic object such as
+   *   Map/Set/Date/class instance), or when the candidate violates the
+   *   canonical surface contract (marker shape and eligibility, unique
+   *   earlier provenance, positional replacement validity, and complete
+   *   shadowed-node coverage). One recursive pass reads, validates, and
+   *   copies each nested value once, so a stateful getter cannot supply one value
+   *   to validation and another to storage. The event log is the durable source
+   *   of truth, so a bad event fails at the append site rather than later during
+   *   a backend flush. A synchronous internal dispatch validation failure or an
+   *   append reentered while this acceptance/publication boundary is open also
+   *   rejects before the log changes.
+   */
+  append<T extends SessionEventType>(
+    type: T,
+    data: SessionEventMap[T],
+    ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
+  ): SessionEvent<T>;
+  /**
+   * The {@link EpochHeader} in force after the log's last header event — the
+   * header the NEXT request will be compared against — or undefined before
+   * the first `request/header` snapshot. The live, incrementally-maintained
+   * form of `foldRequestHeader(session.events)`: each header event is folded
+   * once, when first seen, so a per-step read costs O(new events).
+   * @returns the folded header, or undefined when no header event exists yet.
+   */
+  requestHeader(): EpochHeader | undefined;
+  /**
+   * Derive the LLM message history by walking the ordered sequences of
+   * message-producing events maintained by `surfaceOp` markers. The
+   * surface is the single source of derived history: every message-producing
+   * append records its `surfaceOp`, so a raw event with no marker (a chunk, a
+   * turn boundary) is correctly absent, and a compaction `replace` deletes the
+   * shadowed nodes from the derivation. The projection rules are
+   * {@link deriveEventMessage}, folded per node.
+   *
+   * CACHED: each surface node is projected exactly once, when first seen — a
+   * call costs O(new nodes), and a surface rewrite (a `replace`;
+   * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is
+   * a fresh snapshot per call (later appends never grow an array a caller
+   * already holds); the `Message` objects in it are SHARED and **deep-frozen**.
+   * Their content reuses the already frozen durable event data, so the cache
+   * needs no second deep clone and consumers still cannot mutate the log.
+   * @returns a fresh array of the shared, frozen derived history.
+   */
+  deriveMessages(): Message[];
+  /**
+   * Project a single event into the LLM message it derives to, or null when
+   * it produces none — a non-surface event (chunk, boundary, log-only record)
+   * or an empty-content assistant/message (which exists only to host usage).
+   * The per-node pure function {@link deriveMessages} folds over the surface;
+   * an external reconstructor (or the dev invariant) folds the same function
+   * over a log prefix's surface to rebuild the exact messages any request was
+   * built from (the reconstructability RFC). The returned message wrapper is
+   * fresh; its content reuses the logged event's already deep-frozen durable
+   * data, so changing the wrapper cannot rewrite the log and changing content
+   * throws.
+   * @param event - the event to project.
+   * @returns the derived message, or null when the event produces none.
+   */
+  deriveEventMessage(event: SessionEvent): Message | null;
+}
+```
+
 ## Derived history: `deriveMessages()` and `deriveEventMessage()`
 
 `Session.deriveMessages()` projects the event log into the `Message[]` the model sees — cached (each surface node projected once, when first seen; a surface rewrite rebuilds) and frozen (a fresh array per call over shared, deep-frozen messages, so mutating logged history through a projection is unrepresentable). `deriveEventMessage(event)` is the per-node pure function the fold applies — public so external reconstructors and the dev invariant project a log prefix with exactly the same rules and cannot disagree with the cache. The projection rules:

+ 2 - 2
docs/development.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write
-development.md: 452f4e82beaeacb23aa6bc3e7a60c0f2b1e2c95e
-development.zh.md: 2285482726c43aa02d31c7d2094e2058de1d3863
+development.md: a3268164cd06fb8bf66f52391bc42c2dc3ca9396
+development.zh.md: f8f29c64aa6e49e3ed6d7ef12df2a4911c9182b0

+ 2 - 2
docs/development.md

@@ -145,13 +145,13 @@ Pick the tag that matches the urgency so anyone scanning the code can tell a rel
 
 ## Documenting types verbatim (`ts type-equiv`)
 
-The [core data structures](core-data-structures/core.md) docs paste real type declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:
+The [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:
 
 ```json
 { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }
 ```
 
-`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate also enforces a 1:1 correspondence: every `ts type-equiv` block has exactly one manifest entry and vice-versa, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips `ts type-equiv` blocks (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change.
+`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate also enforces a 1:1 correspondence by document, symbol, and projection, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips both fence kinds (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change.
 
 ## Architecture context
 

+ 2 - 2
docs/development.zh.md

@@ -145,13 +145,13 @@ pnpm run demo:acp
 
 ## 逐字记录类型(`ts type-equiv`)
 
-[核心数据结构](core-data-structures/core.md)文档会把真实类型声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:
+[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:
 
 ```json
 { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }
 ```
 
-`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁还强制 1:1 对应:每个 `ts type-equiv` 块恰好有一条 manifest 条目,反之亦然;因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过 `ts type-equiv` 块(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个块时,请在同一个变更里更新 manifest。
+`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁还按文档、符号和投影强制 1:1 对应,因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过两种围栏(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个块时,请在同一个变更里更新 manifest。
 
 ## 架构上下文
 

+ 3 - 3
docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md

@@ -29,8 +29,8 @@ The rule that settled the remaining cases: ***the type you write, hold, or recei
 
 The durability requirement was specific: the doc shows the **literal** current type declaration and original JSDoc (so a reader sees the real shape and source contract, not a paraphrase) **and** is mechanically guaranteed to match source. The repo already compiles fenced ` ```ts ` blocks (`doc-typecheck`), but a real typechecked block needs import noise and proves only *assignability* — a renamed field or changed JSDoc can pass. So:
 
-- Type declarations and their JSDoc are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. `doc-typecheck` recognizes the fence and skips it (a bare definition is not standalone-compilable), and **excludes it from the opt-out ratio** — it is a separately-checked category, not an unchecked sketch.
-- A new `scripts/verify-type-equiv.ts` extracts each block via the TypeScript parser and asserts that its declaration structure and every JSDoc comment match the declared symbol, ignoring only formatting whitespace and non-JSDoc comments. This is chosen over a compiled `_Check` assertion because source names and documentation identity, not assignability, are the properties the catalog preserves.
+- Complete type declarations and their JSDoc are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. A concise ` ```ts public-api ` fence carries the source-equivalent ambient projection for a class whose implementation bodies do not belong in the catalog. `doc-typecheck` recognizes both and skips them (the bare declarations are not standalone-compilable), and **excludes them from the opt-out ratio** — they are a separately-checked category, not unchecked sketches.
+- A new `scripts/verify-type-equiv.ts` extracts each block via the TypeScript parser and asserts that its declaration structure and every JSDoc comment match the declared symbol, ignoring only formatting whitespace and non-JSDoc comments. Ordinary blocks retain the complete declaration. A `public-api` projection retains a class's public fields, constructor, accessors, and methods with their original JSDoc while removing implementation bodies and private or protected members. This is chosen over a compiled `_Check` assertion because source names and documentation identity, not assignability, are the properties the catalog preserves.
 - Provenance lives in a central `scripts/type-equiv.manifest.json` (`{ doc, symbol, source }` entries), **not** in directive comments in the prose. The script enforces a **1:1 correspondence**: every type-equiv block has exactly one manifest entry and vice versa, so a block can never be silently unchecked and an entry can never rot.
 - Wired into `doc-sync`, so it runs in the same lefthook pre-push and CI paths as the other doc gates.
 
@@ -52,7 +52,7 @@ The spine-vs-seam rule was tested against `BashExecRequest`, tool schemas and de
 
 ## Consequences
 
-- The vocabulary now has a single home that **cannot silently drift**: a field rename in source fails `verify-type-equiv` in the pre-push hook and CI until the paste is refreshed.
+- The vocabulary now has a single home that **cannot silently drift**: a field or public class-member change in source fails `verify-type-equiv` in the pre-push hook and CI until the paste is refreshed. Cordis service methods remain owned by the generated services catalog rather than being duplicated here.
 - The spine-vs-seam line is a reusable scoping tool, not a one-off: the same "the thing you write/hold/receive is core; the machinery that types/renders/persists it is a detail" rule is what later scoped the events/services catalog's harness-vs-inherited tiering.
 - The `ts type-equiv` fence is a third doc-block category alongside ` ```ts ` (compiled) and ` ```ts ignore-check ` (sketch). A later sibling added a fourth, ` ```ts cordis-catalog ` (generated signature), reusing the same skip-and-exclude treatment.
 - Adding or reshaping a core type now carries a documentation obligation the author must honor (the gate cannot detect a missing *new* type), backstopped by the `dsh-code-review` checklist.

+ 2 - 1
scripts/doc-typecheck.ts

@@ -1,6 +1,6 @@
 /**
  * Typecheck Markdown `ts` fences against the workspace API. `ignore-check` fences are reported as
- * opt-outs; generated catalog fragments and `type-equiv` blocks are skipped here because their
+ * opt-outs; generated catalog fragments and source-equivalence blocks are skipped here because their
  * owning gates verify them. A build-coordinated mode consumes existing declarations without emit.
  */
 
@@ -33,6 +33,7 @@ const KIND_BY_INFO: Record<string, BlockKind> = {
   'ts': 'check',
   'ts ignore-check': 'ignore',
   'ts type-equiv': 'type-equiv',
+  'ts public-api': 'type-equiv',
   'ts cordis-catalog': 'cordis-catalog',
   'ts persistence-catalog': 'persistence-catalog',
   'ts config-catalog': 'config-catalog',

+ 4 - 1
scripts/type-equiv.manifest.json

@@ -1,5 +1,5 @@
 {
-  "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source declaration and original JSDoc it must match. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.",
+  "comment": "Maps each ` ```ts type-equiv ` or ` ```ts public-api ` block (by doc + declared symbol + projection) to the source declaration and original JSDoc it must match. Omit projection for the complete declaration; use public-api with a ` ```ts public-api ` block for a body-stripped public class declaration. verify-type-equiv.ts enforces a 1:1 correspondence: every source-equivalence block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a source-equivalence block; remove it when you remove the block.",
   "entries": [
     { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/util/brand/src/index.ts" },
     { "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" },
@@ -33,6 +33,8 @@
     { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" },
     { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" },
     { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "AppIdentity", "source": "packages/llm/llm/src/attribution.ts" },
+    { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "BlockAssembler", "source": "packages/llm/llm/src/assembler.ts", "projection": "public-api" },
+    { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "LlmAdapter", "source": "packages/llm/llm/src/index.ts", "projection": "public-api" },
 
     { "doc": "docs/core-data-structures/token-meter.md", "symbol": "TokenMeasurement", "source": "packages/llm/token-meter/src/types.ts" },
     { "doc": "docs/core-data-structures/token-meter.md", "symbol": "TokenSurfaceNode", "source": "packages/llm/token-meter/src/types.ts" },
@@ -50,6 +52,7 @@
     { "doc": "docs/core-data-structures/session.md", "symbol": "SessionSurface", "source": "packages/core/session/src/surface.ts" },
     { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldReplacement", "source": "packages/core/session/src/surface.ts" },
     { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldResult", "source": "packages/core/session/src/surface.ts" },
+    { "doc": "docs/core-data-structures/session.md", "symbol": "Session", "source": "packages/core/session/src/index.ts", "projection": "public-api" },
 
     { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" },
     { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" },

+ 98 - 15
scripts/verify-type-equiv.ts

@@ -1,6 +1,8 @@
 /**
- * Verify every `ts type-equiv` block against the source symbol named by the
- * manifest. Blocks and entries have a one-to-one relationship; comparison
+ * Verify every `ts type-equiv` and `ts public-api` block against the source
+ * symbol named by the manifest. Ordinary entries preserve the complete
+ * declaration; `public-api` entries preserve a class's body-stripped public
+ * declaration. Blocks and entries have a one-to-one relationship; comparison
  * ignores whitespace and non-JSDoc comments but preserves declaration
  * structure and every original JSDoc comment.
  */
@@ -14,23 +16,27 @@ const root = resolve(import.meta.dirname, '..')
 /** Scan doc-typecheck's full Markdown scope so unmanifested blocks also fail. */
 const MARKDOWN_GLOBS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'website/zh-CN/**/*.md']
 
-/** One manifest entry: a documented type-equiv block and its source symbol. */
+/** One manifest entry: a source-equivalence block and its source symbol. */
 interface ManifestEntry {
-  /** Doc file (repo-relative) containing the ` ```ts type-equiv ` block. */
+  /** Doc file (repo-relative) containing the source-equivalence block. */
   doc: string
   /** The declared symbol the block must match (e.g. `SessionEvent`). */
   symbol: string
   /** Source file (repo-relative) that exports the symbol. */
   source: string
+  /** Complete declaration (default), or a body-stripped public class API. */
+  projection?: 'public-api'
 }
 
-/** One extracted ` ```ts type-equiv ` block. */
+/** One extracted ` ```ts type-equiv ` or ` ```ts public-api ` block. */
 interface EquivBlock {
   doc: string
   /** 1-based line of the opening fence (for diagnostics). */
   line: number
   /** Symbol name parsed from the block's declaration. */
   symbol: string
+  /** Complete declaration (default), or a body-stripped public class API. */
+  projection?: 'public-api'
   /** Block body (the pasted declaration). */
   code: string
 }
@@ -58,7 +64,7 @@ function stripExport(code: string): string {
   return code.replace(/^export\s+(default\s+)?/, '')
 }
 
-/** Parse the declared symbol name from a type-equiv block body. */
+/** Parse the declared symbol name from a source-equivalence block body. */
 function blockSymbol(code: string): string | null {
   const sf = ts.createSourceFile('type-equiv.ts', code, ts.ScriptTarget.Latest, /* setParentNodes */ false, ts.ScriptKind.TS)
   for (const stmt of sf.statements) {
@@ -70,12 +76,12 @@ function blockSymbol(code: string): string | null {
   return null
 }
 
-/** Extract every ` ```ts type-equiv ` block from one Markdown file. */
+/** Extract every source-equivalence block from one Markdown file. */
 function extractEquivBlocks(docRel: string): EquivBlock[] {
   const text = readFileSync(resolve(root, docRel), 'utf8')
   const lines = text.split('\n')
   const blocks: EquivBlock[] = []
-  let open: { line: number; body: string[] } | null = null
+  let open: { line: number; body: string[]; projection?: 'public-api' } | null = null
 
   for (let i = 0; i < lines.length; i++) {
     const raw = lines[i] ?? ''
@@ -90,11 +96,22 @@ function extractEquivBlocks(docRel: string): EquivBlock[] {
       if (!symbol) {
         throw new Error(`verify-type-equiv: ${docRel}:${open.line} — type-equiv block has no parseable interface/type/class declaration`)
       }
-      blocks.push({ doc: docRel, line: open.line, symbol, code })
+      blocks.push({
+        doc: docRel,
+        line: open.line,
+        symbol,
+        code,
+        ...(open.projection === undefined ? {} : { projection: open.projection }),
+      })
       open = null
       continue
     }
-    if ((fence[2] ?? '').trim() === 'ts type-equiv') open = { line: i + 1, body: [] }
+    const info = (fence[2] ?? '').trim()
+    if (info === 'ts type-equiv public-api') {
+      throw new Error(`verify-type-equiv: ${docRel}:${i + 1} — use the concise \`ts public-api\` fence`)
+    }
+    if (info === 'ts type-equiv') open = { line: i + 1, body: [] }
+    if (info === 'ts public-api') open = { line: i + 1, body: [], projection: 'public-api' }
   }
   if (open) throw new Error(`verify-type-equiv: ${docRel}:${open.line} — unterminated type-equiv block`)
   return blocks
@@ -127,13 +144,77 @@ function sourceDeclaration(sourceRel: string, symbol: string): string | null {
   return null
 }
 
+/** Leading source JSDoc attached to one declaration or member. */
+function sourceJSDoc(text: string, node: ts.Node): string {
+  return ts.getJSDocCommentsAndTags(node)
+    .filter(ts.isJSDoc)
+    .map(doc => text.slice(doc.pos, doc.end))
+    .join('\n')
+}
+
+/** Whether a class member is part of its public declaration. */
+function isPublicMember(member: ts.ClassElement): boolean {
+  if (ts.isClassStaticBlockDeclaration(member)) return false
+  const name = ts.getNameOfDeclaration(member)
+  if (name && ts.isPrivateIdentifier(name)) return false
+  const modifiers = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined
+  return !(modifiers?.some(modifier =>
+    modifier.kind === ts.SyntaxKind.PrivateKeyword
+    || modifier.kind === ts.SyntaxKind.ProtectedKeyword,
+  ) ?? false)
+}
+
+/** Remove an implementation body while retaining the source signature. */
+function bodylessMember(text: string, sf: ts.SourceFile, member: ts.ClassElement): string {
+  const start = member.getStart(sf)
+  let end = member.end
+  if (ts.isConstructorDeclaration(member) || ts.isMethodDeclaration(member)
+    || ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member)) {
+    if (member.body) end = member.body.getStart(sf)
+  }
+  if (ts.isPropertyDeclaration(member) && member.initializer) end = member.initializer.getStart(sf)
+  const signature = text.slice(start, end).trimEnd().replace(/;$/, '').replace(/=\s*$/, '').trimEnd()
+  return `${signature};`
+}
+
+/**
+ * Render a class as an ambient declaration containing only its public fields,
+ * constructor, accessors, and methods. Implementation bodies and private or
+ * protected members are deliberately absent; original class/member JSDoc is
+ * retained so the projection is the source-owned public contract.
+ */
+function sourcePublicApi(sourceRel: string, symbol: string): string | null {
+  const abs = resolve(root, sourceRel)
+  const text = readFileSync(abs, 'utf8')
+  const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true)
+  for (const stmt of sf.statements) {
+    if (!ts.isClassDeclaration(stmt) || stmt.name?.text !== symbol) continue
+    const classDoc = sourceJSDoc(text, stmt)
+    const abstract = stmt.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AbstractKeyword) ? 'abstract ' : ''
+    const typeParameters = stmt.typeParameters?.map(parameter => parameter.getText(sf)).join(', ')
+    const heritage = stmt.heritageClauses?.map(clause => clause.getText(sf)).join(' ')
+    const header = `declare ${abstract}class ${symbol}${typeParameters ? `<${typeParameters}>` : ''}${heritage ? ` ${heritage}` : ''} {`
+    const members = stmt.members
+      .filter(isPublicMember)
+      .map((member) => {
+        const jsDoc = sourceJSDoc(text, member)
+        const declaration = bodylessMember(text, sf, member)
+        return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}`
+      })
+    const declaration = [header, ...members.map(member => member.split('\n').map(line => `  ${line}`).join('\n')), '}'].join('\n')
+    return classDoc === '' ? declaration : `${classDoc}\n${declaration}`
+  }
+  return null
+}
+
 const manifestRaw = readFileSync(resolve(root, 'scripts/type-equiv.manifest.json'), 'utf8')
 const manifest = JSON.parse(manifestRaw) as { entries: ManifestEntry[] }
 const entries = manifest.entries
 
-// Key a block/entry by doc + symbol (a symbol may be documented in more than one
-// doc, but at most once per doc).
-const keyOf = (x: { doc: string; symbol: string }): string => `${x.doc}::${x.symbol}`
+// Key a block/entry by doc + symbol + projection. A symbol may be documented in
+// more than one doc, and a doc may carry both complete and projected forms.
+const keyOf = (x: { doc: string; symbol: string; projection?: 'public-api' }): string =>
+  `${x.doc}::${x.symbol}::${x.projection ?? 'declaration'}`
 
 // Collect every type-equiv block across ALL docs in scope — not only the docs
 // the manifest names — so a block in an unmanifested doc is found and reported
@@ -152,7 +233,7 @@ for (const d of [...new Set(entries.map(e => e.doc))]) {
   else if (!docSet.has(d)) errors.push(`manifest references ${d}, which is outside the scanned markdown scope (${MARKDOWN_GLOBS.join(', ')})`)
 }
 
-// Duplicate-block guard: the same symbol twice in one doc is ambiguous.
+// Duplicate-block guard: the same projected symbol twice in one doc is ambiguous.
 const blockByKey = new Map<string, EquivBlock>()
 for (const b of blocks) {
   const k = keyOf(b)
@@ -192,7 +273,9 @@ let verified = 0
 for (const e of entries) {
   const b = blockByKey.get(keyOf(e))
   if (!b) continue // already reported as an orphan entry
-  const decl = sourceDeclaration(e.source, e.symbol)
+  const decl = e.projection === 'public-api'
+    ? sourcePublicApi(e.source, e.symbol)
+    : sourceDeclaration(e.source, e.symbol)
   if (decl === null) {
     errors.push(`symbol ${e.symbol} not found in ${e.source} (manifest entry for ${e.doc})`)
     continue