Prechádzať zdrojové kódy

Gate JSDoc completeness on every package export

New doc-sync gate verify-export-jsdoc walks every module-level exported
name under packages/*/*/src and requires description prose everywhere,
plus @param per parameter and @returns on non-void annotated returns for
function-like exports, public class methods, properties, and accessors.
The parsing + check helpers move out of gen-cordis-catalog.ts into a
shared scripts/jsdoc.ts so 'documented' means one thing on both gated
surfaces.

Deliberate exemptions (documented in the RFC): heritage-declared class
members (the seam declaration is the doc's one home — the one checker
query in an otherwise pure-AST walk), cordis plugin-protocol slots
(name/inject/reusable/Config/apply, top-level and static), constructors,
overload implementations, declare-module augmentation bodies, and
re-export statements (checked at the defining module).

The 203 under-documented exports the gate found at adoption are filled
in this change, so the gate lands green; generated catalogs/graphs are
regenerated for the shifted line pointers.

RFC: docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md
Tianyi Cui 2 mesiacov pred
rodič
commit
cd9737d569
92 zmenil súbory, kde vykonal 1802 pridanie a 289 odobranie
  1. 1 1
      AGENTS.md
  2. 11 11
      docs/cordis-catalog/events.md
  3. 5 5
      docs/cordis-catalog/services.md
  4. 2 2
      docs/development.i18n.yaml
  5. 1 0
      docs/development.md
  6. 1 0
      docs/development.zh.md
  7. 11 11
      docs/event-producer-consumer.md
  8. 15 15
      docs/persistence-catalog/log-events.md
  9. 1 0
      docs/rfc/INDEX.md
  10. 42 0
      docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md
  11. 2 1
      package.json
  12. 27 2
      packages/bash/bash-local/src/run.ts
  13. 11 2
      packages/bash/bash/src/types.ts
  14. 2 0
      packages/bash/tool-bash/src/index.ts
  15. 25 1
      packages/compact/compact-basic/src/index.ts
  16. 3 0
      packages/compact/compact-basic/src/types.ts
  17. 6 0
      packages/core/agent-loop/src/agent.ts
  18. 26 4
      packages/core/agent-loop/src/inbox.ts
  19. 4 0
      packages/core/agent-loop/src/index.ts
  20. 10 1
      packages/core/agent-loop/src/loop.ts
  21. 4 1
      packages/core/agent-loop/src/request-log.ts
  22. 17 1
      packages/core/agent/src/types.ts
  23. 296 0
      packages/core/agent/tests/verify-export-jsdoc.spec.ts
  24. 8 0
      packages/core/session/src/index.ts
  25. 4 0
      packages/core/session/src/json.ts
  26. 2 0
      packages/core/session/src/repair.ts
  27. 4 0
      packages/core/session/src/surface.ts
  28. 6 0
      packages/core/session/src/tool-pairing.ts
  29. 8 1
      packages/core/session/src/types.ts
  30. 5 0
      packages/core/system-prompt/src/index.ts
  31. 13 0
      packages/core/tools/src/schema.ts
  32. 49 1
      packages/fs/fs-local/src/fsio.ts
  33. 1 0
      packages/fs/fs-local/src/index.ts
  34. 12 2
      packages/fs/fs/src/types.ts
  35. 6 0
      packages/fs/tool-fs/src/diff.ts
  36. 17 3
      packages/fs/tool-fs/src/edit.ts
  37. 10 1
      packages/fs/tool-fs/src/read-render.ts
  38. 11 2
      packages/fs/tool-fs/src/read.ts
  39. 5 1
      packages/fs/tool-fs/src/session-cwd.ts
  40. 16 3
      packages/fs/tool-fs/src/write.ts
  41. 6 0
      packages/hooks/hook-protocol/src/codec.ts
  42. 10 1
      packages/hooks/hook-protocol/src/events.ts
  43. 4 0
      packages/hooks/hook-protocol/src/matcher.ts
  44. 2 0
      packages/hooks/hook-protocol/src/merge.ts
  45. 5 0
      packages/hooks/hook-protocol/src/runner.ts
  46. 9 1
      packages/hooks/hooks-claude/src/config.ts
  47. 2 0
      packages/hooks/hooks-codex/src/config.ts
  48. 7 1
      packages/llm/llm-deepseek/src/adapter.ts
  49. 6 0
      packages/llm/llm-deepseek/src/index.ts
  50. 10 1
      packages/llm/llm-deepseek/src/serialize.ts
  51. 2 0
      packages/llm/llm-deepseek/src/sse.ts
  52. 9 1
      packages/llm/llm-deepseek/src/translate.ts
  53. 18 0
      packages/llm/llm-deepseek/src/types.ts
  54. 9 1
      packages/llm/llm-pi-ai/src/adapter.ts
  55. 15 2
      packages/llm/llm-pi-ai/src/convert.ts
  56. 5 0
      packages/llm/llm-pi-ai/src/index.ts
  57. 13 2
      packages/llm/llm/src/assembler.ts
  58. 4 0
      packages/llm/llm/src/attribution.ts
  59. 5 1
      packages/llm/llm/src/brand.ts
  60. 6 1
      packages/llm/llm/src/error.ts
  61. 5 1
      packages/llm/llm/src/index.ts
  62. 3 0
      packages/llm/llm/src/never.ts
  63. 4 0
      packages/llm/llm/src/types.ts
  64. 35 7
      packages/session-persistence/session-persistence-jsonl/src/format.ts
  65. 1 0
      packages/session-persistence/session-persistence-jsonl/src/index.ts
  66. 17 2
      packages/session-persistence/session-persistence-sqlite/src/schema.ts
  67. 5 0
      packages/session-persistence/session-persistence/src/coordinator.ts
  68. 4 0
      packages/session-persistence/session-persistence/src/index.ts
  69. 27 4
      packages/subagent/subagent-acp/src/run.ts
  70. 2 0
      packages/subagent/subagent-fork/src/index.ts
  71. 12 1
      packages/subagent/subagent-inprocess/src/index.ts
  72. 1 0
      packages/subagent/subagent/src/types.ts
  73. 14 0
      packages/support/llm-replay/src/index.ts
  74. 8 0
      packages/ui/acp/src/codec.ts
  75. 36 2
      packages/ui/acp/src/index.ts
  76. 16 0
      packages/ui/app-boot/src/index.ts
  77. 4 0
      packages/ui/stdio-agent/src/stdio-chat.ts
  78. 33 5
      packages/web/tool-web/src/fetch.ts
  79. 4 0
      packages/web/tool-web/src/html.ts
  80. 1 0
      packages/web/tool-web/src/index.ts
  81. 29 4
      packages/web/tool-web/src/search.ts
  82. 1 0
      packages/web/web-fetch-local/src/index.ts
  83. 20 0
      packages/web/web-fetch-local/src/policy.ts
  84. 1 0
      packages/web/web-search-deepseek/src/index.ts
  85. 8 0
      packages/web/web-search-deepseek/src/provider.ts
  86. 1 0
      packages/web/web-search-exa/src/index.ts
  87. 13 1
      packages/web/web-search-exa/src/provider.ts
  88. 1 0
      packages/web/web-search-perplexity/src/index.ts
  89. 11 1
      packages/web/web-search-perplexity/src/provider.ts
  90. 14 177
      scripts/gen-cordis-catalog.ts
  91. 216 0
      scripts/jsdoc.ts
  92. 408 0
      scripts/verify-export-jsdoc.ts

+ 1 - 1
AGENTS.md

@@ -109,7 +109,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY` (and optional `DEEPSEEK_BASE_UR
 
 ## Type safety and documentation
 
-Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` carries a comment saying why a narrower type is infeasible. Every module has a module-level doc comment; every export (and non-obvious method) has a JSDoc explaining semantics — contracts, disposal, errors — not the name restated; internal helpers only where non-obvious; one-liners when one line suffices. Lean toward the stricter lint rule and the extra mechanical gate: encode invariants in checks (`verify-*` scripts), preferring a narrow justified escape hatch over a rule left off globally. Type gymnastics are acceptable inside core packages when they buy plugin-author DX (the `defineTool` schema DSL is the canonical example).
+Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` carries a comment saying why a narrower type is infeasible. Every module has a module-level doc comment; every export (and non-obvious method) has a JSDoc explaining semantics — contracts, disposal, errors — not the name restated; internal helpers only where non-obvious; one-liners when one line suffices. The export half is mechanical: `verify-export-jsdoc` (in `doc-sync`) requires description prose on every package export plus `@param`/`@returns` (and an annotated return) on function-like ones — heritage-declared members, plugin-protocol slots, and constructors exempt ([export-gate RFC](docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md)). Lean toward the stricter lint rule and the extra mechanical gate: encode invariants in checks (`verify-*` scripts), preferring a narrow justified escape hatch over a rule left off globally. Type gymnastics are acceptable inside core packages when they buy plugin-author DX (the `defineTool` schema DSL is the canonical example).
 
 Docs are part of every change: code changes update their README and JSDoc in the SAME change; a bilingual-pair edit updates the counterpart and re-records ([i18n contract](docs/i18n/README.md)). The writing rules — document the current state never the history, one physical line per paragraph, one home per fact — and the word-budget gate live in [docs/AGENTS.md](docs/AGENTS.md).
 

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

@@ -23,7 +23,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages.
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts)
 
 ### `agent/disposed` — emit
 
@@ -35,7 +35,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:271`](../../packages/core/agent/src/types.ts)
 
 ### `agent/error` — emit
 
@@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:404`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:420`](../../packages/core/agent/src/types.ts)
 
 ### `agent/pre-step` — serial
 
@@ -61,7 +61,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:349`](../../packages/core/agent/src/types.ts)
 
 ### `agent/prompt-submit` — waterfall
 
@@ -73,7 +73,7 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a
 
 Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:346`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:362`](../../packages/core/agent/src/types.ts)
 
 ### `agent/queued` — emit
 
@@ -85,7 +85,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv
 
 Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts)
 
 ### `agent/request` — waterfall
 
@@ -97,7 +97,7 @@ Waterfall: shape the step's call configuration — model switching, sampling ove
 
 Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:369`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:385`](../../packages/core/agent/src/types.ts)
 
 ### `agent/session-start` — emit
 
@@ -109,7 +109,7 @@ The agent's session lifecycle began, fired once before its first turn. `source`
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:288`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts)
 
 ### `agent/status` — emit
 
@@ -121,7 +121,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:280`](../../packages/core/agent/src/types.ts)
 
 ### `agent/step-result` — waterfall
 
@@ -133,7 +133,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va
 
 Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:379`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:395`](../../packages/core/agent/src/types.ts)
 
 ### `agent/turn-continuation` — waterfall
 
@@ -145,7 +145,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:392`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:408`](../../packages/core/agent/src/types.ts)
 
 ## `fs/*`
 

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

@@ -21,7 +21,7 @@ createAgent(options: CreateAgentOptions): AgentHandle
 async resume(options: ResumeAgentOptions): Promise<AgentHandle>
 ```
 
-Source: [`packages/core/agent-loop/src/index.ts:63`](../../packages/core/agent-loop/src/index.ts)
+Source: [`packages/core/agent-loop/src/index.ts:67`](../../packages/core/agent-loop/src/index.ts)
 
 ## `ctx.agents` — `AgentRegistry`
 
@@ -124,7 +124,7 @@ stream(options: GenerateOptions): AsyncIterable<StreamChunk>
 
 Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
 
-Source: [`packages/llm/llm/src/index.ts:84`](../../packages/llm/llm/src/index.ts)
+Source: [`packages/llm/llm/src/index.ts:88`](../../packages/llm/llm/src/index.ts)
 
 ## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam)
 
@@ -146,7 +146,7 @@ abstract list(): Promise<SessionHeader[]>
 
 Types: [SessionEvent](../core-data-structures/core.md)
 
-Source: [`packages/session-persistence/session-persistence/src/index.ts:98`](../../packages/session-persistence/session-persistence/src/index.ts)
+Source: [`packages/session-persistence/session-persistence/src/index.ts:102`](../../packages/session-persistence/session-persistence/src/index.ts)
 
 ## `ctx.sessions` — `SessionStore`
 
@@ -163,7 +163,7 @@ get(id: SessionId): Session | undefined
 list(): Session[]
 ```
 
-Source: [`packages/core/session/src/index.ts:371`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:379`](../../packages/core/session/src/index.ts)
 
 ## `ctx.subagents` — `SubagentService`
 
@@ -189,7 +189,7 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine
 assemble(context: AssembleContext = {}): Promise<PromptAssembly>
 ```
 
-Source: [`packages/core/system-prompt/src/index.ts:198`](../../packages/core/system-prompt/src/index.ts)
+Source: [`packages/core/system-prompt/src/index.ts:203`](../../packages/core/system-prompt/src/index.ts)
 
 ## `ctx.tools` — `ToolRegistry`
 

+ 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: f032764fff29baaca007211db8b69d9a5129078f
-development.zh.md: 3a650d03ce7cafd0e34290ae918e5a303c2ad8a9
+development.md: 578fa619b8b3c7f5a306c8ad9cd880ab648b9b0c
+development.zh.md: e0189ae9b4c42f3f61ccc30b9205ff11f64f1fec

+ 1 - 0
docs/development.md

@@ -98,6 +98,7 @@ pnpm run lint:fix       # eslint . --fix
 pnpm run doc-typecheck  # compile checked TypeScript snippets in Markdown docs
 pnpm run gen-cordis-catalog     # regenerate docs/cordis-catalog/events.md + services.md from source
 pnpm run verify-cordis-catalog  # fail if either cordis catalog is stale
+pnpm run verify-export-jsdoc    # fail if a module-level package export lacks complete JSDoc
 pnpm run gen-doc-graphs     # regenerate generated relationship docs from source and curated graph definitions
 pnpm run verify-doc-graphs  # fail if generated relationship docs are stale
 pnpm run gen-rfc-index          # regenerate the docs/rfc/README.md index tables from the RFC tree

+ 1 - 0
docs/development.zh.md

@@ -98,6 +98,7 @@ pnpm run lint:fix       # eslint . --fix
 pnpm run doc-typecheck  # compile checked TypeScript snippets in Markdown docs
 pnpm run gen-cordis-catalog     # regenerate docs/cordis-catalog/events.md + services.md from source
 pnpm run verify-cordis-catalog  # fail if either cordis catalog is stale
+pnpm run verify-export-jsdoc    # fail if a module-level package export lacks complete JSDoc
 pnpm run gen-doc-graphs     # regenerate generated relationship docs from source and curated graph definitions
 pnpm run verify-doc-graphs  # fail if generated relationship docs are stale
 pnpm run gen-rfc-index          # regenerate the docs/rfc/README.md index tables from the RFC tree

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

@@ -7,17 +7,17 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 
 | Event | Mode | Declared in | Dispatchers | Listeners |
 | --- | --- | --- | --- | --- |
-| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:248`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
-| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:404`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
-| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) |
-| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:346`](../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) |
-| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:369`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
-| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:288`](../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:264`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) |
-| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:379`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
-| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:392`](../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) |
+| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:271`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
+| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:420`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
+| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:349`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) |
+| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:362`](../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) |
+| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:385`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
+| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:304`](../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:280`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) |
+| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
+| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:408`](../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) |
 | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
 | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../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:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |

+ 15 - 15
docs/persistence-catalog/log-events.md

@@ -23,7 +23,7 @@ Raw stream chunk — token-level replay fidelity.
 
 Types: [StreamChunk](../core-data-structures/llm-streaming.md)
 
-Source: [`packages/core/session/src/types.ts:298`](../../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:304`](../../packages/core/session/src/types.ts)
 
 #### `assistant/message` — surface
 
@@ -35,7 +35,7 @@ Assembled assistant message for one step (derived history uses this). Carries th
 
 Types: [ContentBlock](../core-data-structures/core.md) · [TokenUsage](../core-data-structures/llm-streaming.md)
 
-Source: [`packages/core/session/src/types.ts:305`](../../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:311`](../../packages/core/session/src/types.ts)
 
 ### `compact/*`
 
@@ -83,7 +83,7 @@ In-session context injection (file-change notices, subdir AGENTS.md, skill conte
 
 Types: [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
 
-Source: [`packages/core/session/src/types.ts:296`](../../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:302`](../../packages/core/session/src/types.ts)
 
 ### `hook/*`
 
@@ -119,7 +119,7 @@ A queued prompt an `agent/prompt-submit` listener VETOED — the durable record
 
 Types: [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
 
-Source: [`packages/core/session/src/types.ts:290`](../../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:296`](../../packages/core/session/src/types.ts)
 
 ### `request/*`
 
@@ -131,7 +131,7 @@ Full snapshot of the EpochHeader the NEXT request is built under, with the Reque
 'request/header': { header: EpochHeader; reason: RequestHeaderReason }
 ```
 
-Source: [`packages/core/session/src/types.ts:350`](../../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:356`](../../packages/core/session/src/types.ts)
 
 #### `request/header-delta` — log-only
 
@@ -141,7 +141,7 @@ Amendment to the folded EpochHeader: at least one of a SystemDelta, a ToolsDelta
 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig }
 ```
 
-Source: [`packages/core/session/src/types.ts:361`](../../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:367`](../../packages/core/session/src/types.ts)
 
 ### `steering/*`
 
@@ -155,7 +155,7 @@ Steering content injected between steps of a running turn.
 
 Types: [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
 
-Source: [`packages/core/session/src/types.ts:323`](../../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:329`](../../packages/core/session/src/types.ts)
 
 ### `step/*`
 
@@ -167,7 +167,7 @@ Closes step `step` of turn `turn`.
 'step/end': { turn: number; step: number }
 ```
 
-Source: [`packages/core/session/src/types.ts:277`](../../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:283`](../../packages/core/session/src/types.ts)
 
 #### `step/start` — log-only
 
@@ -177,7 +177,7 @@ Opens step `step` of turn `turn` — one model call plus the tool executions it
 'step/start': { turn: number; step: number }
 ```
 
-Source: [`packages/core/session/src/types.ts:275`](../../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:281`](../../packages/core/session/src/types.ts)
 
 ### `todo/*`
 
@@ -193,7 +193,7 @@ NOT a SurfaceEventType: it produces no LLM message and never reaches `deriveMess
 
 Types: [TodoItem](../core-data-structures/session.md)
 
-Source: [`packages/core/session/src/types.ts:337`](../../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:343`](../../packages/core/session/src/types.ts)
 
 ### `tool/*`
 
@@ -207,7 +207,7 @@ The model requested one tool invocation: `name` with the raw `arguments` JSON st
 
 Types: [CallId](../core-data-structures/core.md)
 
-Source: [`packages/core/session/src/types.ts:311`](../../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:317`](../../packages/core/session/src/types.ts)
 
 #### `tool/result` — surface
 
@@ -219,7 +219,7 @@ A completed tool call's model-facing result, plus an optional tool-private `meta
 
 Types: [CallId](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md)
 
-Source: [`packages/core/session/src/types.ts:321`](../../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:327`](../../packages/core/session/src/types.ts)
 
 ### `turn/*`
 
@@ -233,7 +233,7 @@ Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awai
 
 Types: [TurnEndReason](../core-data-structures/session.md)
 
-Source: [`packages/core/session/src/types.ts:273`](../../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:279`](../../packages/core/session/src/types.ts)
 
 #### `turn/start` — log-only
 
@@ -245,7 +245,7 @@ Opens turn `turn`. `trigger` records what started it — a drained message batch
 
 Types: [TurnTrigger](../core-data-structures/session.md)
 
-Source: [`packages/core/session/src/types.ts:267`](../../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:273`](../../packages/core/session/src/types.ts)
 
 ### `user/*`
 
@@ -259,4 +259,4 @@ A user-visible prompt (queued message drained at turn start).
 
 Types: [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
 
-Source: [`packages/core/session/src/types.ts:279`](../../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:285`](../../packages/core/session/src/types.ts)

+ 1 - 0
docs/rfc/INDEX.md

@@ -142,6 +142,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
 | [Generate the RFC index tables](implemented/process/2026-07-04-generate-rfc-index-tables.md) | 2026-07-04 |
 | [Generated persistence log event catalog](implemented/process/2026-07-04-persistence-log-catalog.md) | 2026-07-04 |
 | [One gated in-file format for RFCs](implemented/process/2026-07-05-uniform-rfc-format.md) | 2026-07-05 |
+| [Export-surface JSDoc gate](implemented/process/2026-07-06-export-surface-jsdoc-gate.md) | 2026-07-06 |
 
 ### Testing
 

+ 42 - 0
docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md

@@ -0,0 +1,42 @@
+# RFC: Export-surface JSDoc gate
+
+Status: implemented
+
+## Problem
+
+The [cordis JSDoc completeness gate](2026-07-04-cordis-jsdoc-completeness-gate.md) made undocumented parameters and results impossible on the cordis surface — `interface Events` members and `ctx.<key>` service classes — but that surface is a fraction of what a plugin author imports. The AGENTS.md rule "every export (and non-obvious method) has a JSDoc explaining semantics" stayed prose-checkable only by review everywhere else, and nothing at all asked for `@param`/`@returns` on ordinary exported functions. A survey at adoption found 203 under-documented module-level exports across 34 packages: seam-adjacent helpers (`runBash`, `readForEdit`, `htmlToMarkdown`), format codecs, whole undocumented interfaces and type aliases — exactly the names an IDE consumer hovers.
+
+## Decision
+
+A new gate, `scripts/verify-export-jsdoc.ts` (`pnpm run verify-export-jsdoc`, wired into `doc-sync` beside `verify-cordis-catalog`), walks every module-level exported name under each `packages/<group>/<pkg>/src/` tree. The parsing and check helpers moved from `gen-cordis-catalog.ts` into a shared `scripts/jsdoc.ts`, so "documented" means the same thing on both surfaces: description prose ends at the first block tag, every checkable parameter needs a non-empty `@param`, a non-void ANNOTATED return needs a non-empty `@returns`, a stale `@param` errors, and violations aggregate into one report.
+
+The contract by declaration kind:
+
+- Every exported name needs JSDoc with non-empty description prose.
+- Function-like exports (function declarations; consts with function initializers) follow the full function contract. A const whose declarator is type-annotated (`export const f: Handler = …`) defers the return contract to the named type; `@returns` stays optional there.
+- Exported classes need class-level prose; public methods (statics included — reachable on the exported name) follow the function contract; public properties and accessors need prose (a get/set pair is covered by the getter). Overload implementations are exempt — the signatures carry the docs.
+- Exported interfaces, type aliases, and enums need prose on the declaration; member-level enforcement is deliberately deferred (the highest-value member surface — seam service classes — is already under the cordis gate).
+- Exported namespaces recurse; the namespace itself needs prose only when it does not merge with a documented same-name declaration (the Config-namespace idiom documents the plugin once).
+- `declare module` / `declare global` bodies and `export … from` re-export statements are skipped: an augmentation is not an export of the package, and a re-exported definition is checked where it is defined.
+
+Three exemption families keep the gate from demanding boilerplate, in the spirit of the cordis gate's `this`/`next` exemptions (documenting an exempt name anyway is allowed; only absence goes unchecked):
+
+- **Heritage members.** A class member whose name exists on an `extends`/`implements` heritage type is exempt: the seam declaration is the doc's one home, and the IDE inherits it on hover — re-documenting every `LocalBashExecutor.run` invites drift. This is the one question the walk asks the TYPE CHECKER (heritage types live across package boundaries, resolved through the repo `paths` map); everything else stays pure AST, and the annotated-return requirement is kept for symmetry with the cordis gate (it bound nothing at adoption — every exported function was already annotated).
+- **Plugin-protocol slots.** Top-level `name` / `inject` / `reusable` / `Config` consts and the `apply` entry, plus the same slots as statics on a plugin class, are framework protocol: their shape is fixed by cordis, and the module doc comment plus the `interface Config` carry the plugin's real semantics.
+- **Constructors**, mirroring the cordis gate: plugin classes are framework-constructed, and the class doc owns the story.
+
+`collectExportJsdocViolations()` returns the violation list (the CLI exits 1 on non-empty) so the negative-path tests in `packages/core/agent/tests/verify-export-jsdoc.spec.ts` assert on findings directly, driving fixture packages through every rejection and every exemption.
+
+## Alternatives considered
+
+- **eslint-plugin-jsdoc** (`require-jsdoc`/`require-param`/`require-returns`) — covers the mechanical core but cannot express the repo's contract: the heritage-member exemption needs cross-package type resolution, the protocol-slot and namespace-merge idioms are cordis-specific, and the completeness semantics (prose-above-tags, stale-tag errors, aggregate reporting) already have one home in `scripts/jsdoc.ts` shared with the catalog generator. Two subtly different definitions of "documented" is the failure mode this repo's one-home rule exists to prevent.
+- **Extending `gen-cordis-catalog.ts`** — the catalog generator renders a curated surface and gates its freshness; a repo-wide walk has no catalog to render. Sharing the helpers while keeping the walks separate keeps each gate's scope legible.
+- **Enforcing interface/type-alias member docs** — deferred: it would multiply the checked surface for members that are largely self-describing fields, while the seam classes carrying the load-bearing member contracts are already gated. Revisit if member-doc drift shows up in review.
+
+## Consequences
+
+- A new export cannot land undocumented: `verify-export-jsdoc` fails `doc-sync`, which pre-push and CI already run. The 203 gaps found at adoption were filled in the same change, so the gate landed green.
+- Exported functions must annotate return types (universal at adoption, now load-bearing) and use identifier parameters where `@param` must name them.
+- Seam docs are canonical: an implementation inherits its heritage docs, and behavior notes worth keeping on the implementation are additions, not requirements.
+- The gate builds a `ts.Program` (~6s) — the one doc gate that pays for type resolution; acceptable inside `doc-sync`, which already compiles doc snippets.
+- The protocol-slot names are reserved by convention at module top level; a non-protocol export coincidentally named `apply` or `Config` would go unchecked — accepted, documented here.

+ 2 - 1
package.json

@@ -39,6 +39,7 @@
     "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts",
     "gen-rfc-index": "tsx scripts/gen-rfc-index.ts",
     "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check",
+    "verify-export-jsdoc": "tsx scripts/verify-export-jsdoc.ts",
     "gen-tool-catalog": "tsx scripts/gen-tool-catalog.ts",
     "verify-tool-catalog": "tsx scripts/gen-tool-catalog.ts --check",
     "gen-doc-graphs": "tsx scripts/gen-doc-graphs.ts",
@@ -48,7 +49,7 @@
     "gen-module-graph": "tsx scripts/gen-module-graph.ts",
     "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-tool-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && 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",
+    "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && 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",
     "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types",
     "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",

+ 27 - 2
packages/bash/bash-local/src/run.ts

@@ -56,6 +56,8 @@ export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
  * by in-process plugins (the hooks bridges), not the model — `dsh-tool-bash`
  * builds its request from named fields only and does not forward model input
  * here (see its README, § "The tool builds its request from named args only").
+ * @param extra - caller-supplied entries merged last; an explicit entry wins even against the scrub and the overrides.
+ * @returns the environment to hand to `spawn` for the child process.
  */
 export function childEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
   const env: NodeJS.ProcessEnv = {}
@@ -147,6 +149,14 @@ export class OutputCollector {
     private readonly spillDir: string,
   ) {}
 
+  /**
+   * Ingest one stream chunk, counting it toward the whole-stream total. On
+   * first overflow of the in-memory cap a spill file is opened and every chunk
+   * (already-collected ones included) is appended there from then on; the
+   * in-memory tail then drops whole chunks from its head (or the head of a
+   * single over-cap chunk) until it fits the cap again.
+   * @param chunk - the raw bytes from one stream 'data' event.
+   */
   push(chunk: Buffer): void {
     this.total += chunk.length
     const overflows = this.bytes + chunk.length > this.maxBytes
@@ -190,7 +200,10 @@ export class OutputCollector {
   // the bottom of this file) and `totalBytes` is read only by a test. The live
   // background-poll path goes through `readFrom()`, so inline snapshot() into
   // finalize() and drop or privatize the totalBytes getter.
-  /** Read the collected tail without finalizing (the final-result snapshot). */
+  /**
+   * Read the collected tail without finalizing (the final-result snapshot).
+   * @returns the retained tail text, the truncation flag, and the spill path when one was created.
+   */
   snapshot(): CollectedOutput {
     return {
       text: Buffer.concat(this.chunks).toString('utf8'),
@@ -209,6 +222,8 @@ export class OutputCollector {
    * pushed since `fromByte`. When `fromByte` has already slid out of the
    * in-memory tail window, the read is `lossy` — it returns the whole
    * retained tail and the gap is only recoverable from the spill file.
+   * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
+   * @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created.
    */
   readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean; spillPath?: string } {
     const windowStart = this.total - this.bytes
@@ -223,7 +238,12 @@ export class OutputCollector {
     }
   }
 
-  /** Close the spill file (if any) and return the final output. */
+  /**
+   * Close the spill file (if any) and return the final output. A failed close
+   * (delayed writeback fault) stops advertising the spill path — the file may
+   * be missing its tail — but still returns the in-memory result.
+   * @returns the final collected output: tail text, truncation flag, and the spill path when intact.
+   */
   finalize(): CollectedOutput {
     if (this.spillFd !== undefined) {
       try {
@@ -249,6 +269,8 @@ export class OutputCollector {
  * host process — a kill that cannot be delivered is reported by the process
  * NOT dying, which callers already handle via escalation/timeouts. No-op for
  * non-positive pids (spawn never started a process).
+ * @param pid - the group leader's pid; non-positive means the spawn failed and the call is a no-op.
+ * @param sig - the signal to deliver to the whole group.
  */
 export function killGroup(pid: number, sig: NodeJS.Signals): void {
   if (pid <= 0) return
@@ -290,6 +312,9 @@ export interface RunningBash {
  * exec sessions addressable via session ids + stdin writes. We deliberately
  * spawn a fresh non-login `bash -c` per call for determinism (no rc files,
  * no inherited shell state); revisit when real workflows demand it.
+ * @param spec - the fully-resolved run (command, cwd, limits); no defaulting happens here.
+ * @param internals - test-only knobs; omitted fields fall back to the private per-process spill dir.
+ * @returns the live handle: pid, the two live collectors, the outcome promise, and `kill()`.
  */
 export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash {
   const spillDir = internals.spillDir ?? privateSpillDir()

+ 11 - 2
packages/bash/bash/src/types.ts

@@ -11,7 +11,11 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
 /** Identifies one background task within an executor (generated `bash-N`). */
 export type BashTaskId = Branded<'BashTaskId'>
 
-/** Brand a string as a {@link BashTaskId}. */
+/**
+ * Brand a string as a {@link BashTaskId}.
+ * @param id - the raw task-id string (the executor generates `bash-N`).
+ * @returns the same string, branded; no validation is performed.
+ */
 export function BashTaskId(id: string): BashTaskId {
   return id as BashTaskId
 }
@@ -26,7 +30,12 @@ export function BashTaskId(id: string): BashTaskId {
  */
 export type OwnerToken = Branded<'OwnerToken'>
 
-/** Brand a string as an {@link OwnerToken}. */
+/**
+ * Brand a string as an {@link OwnerToken}. Only the consuming boundary
+ * (`dsh-tool-bash`) should cast its own id vocabulary in — see the type's doc.
+ * @param id - the consumer's raw owner identity (the tool layer passes the owning agent's session id).
+ * @returns the same string, branded; no validation is performed.
+ */
 export function OwnerToken(id: string): OwnerToken {
   return id as OwnerToken
 }

+ 2 - 0
packages/bash/tool-bash/src/index.ts

@@ -99,6 +99,8 @@ function streamText(output: CollectedOutput): string {
  * stderr section, then exit-status markers. Non-zero exits are REPORTED, not
  * errored — the model decides how to react; only infrastructure failures
  * (spawn errors, aborts) surface as isError results.
+ * @param result - the completed foreground run from the executor.
+ * @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
  */
 export function renderResult(result: BashRunResult): string {
   const out = streamText(result.stdout)

+ 25 - 1
packages/compact/compact-basic/src/index.ts

@@ -216,6 +216,11 @@ export class BasicCompactService extends CompactService {
    * Estimate the token count of content blocks — chars divided by the
    * `charsPerToken` config, with per-block overhead. Override in a subclass to
    * plug in a real tokenizer.
+   *
+   * @param blocks - the blocks to estimate; `tool-result` blocks recurse into
+   *   their nested content, and unknown (merge-extended) types fall back to
+   *   their JSON-stringified length.
+   * @returns the estimated token count.
    */
   estimateContentTokens(blocks: readonly ContentBlock[]): number {
     const { charsPerToken } = this.config
@@ -246,6 +251,11 @@ export class BasicCompactService extends CompactService {
   /**
    * Estimate token count for a single session event. Returns 0 for non-message
    * event types (boundaries, chunks, usage, errors, compact markers).
+   *
+   * @param event - any session event; only the message-bearing types carry
+   *   content to count.
+   * @returns the estimated token count of the event's content, or 0 for a
+   *   non-message event.
    */
   estimateEventTokens(event: SessionEvent): number {
     switch (event.type) {
@@ -260,7 +270,14 @@ export class BasicCompactService extends CompactService {
     }
   }
 
-  /** Estimate total tokens across a list of messages plus optional system prompt. */
+  /**
+   * Estimate total tokens across a list of messages plus optional system prompt.
+   *
+   * @param messages - the derived conversation messages; each adds a fixed
+   *   role-framing overhead on top of its content estimate.
+   * @param systemPrompt - counted at chars / `charsPerToken` when provided.
+   * @returns the estimated token footprint of the whole request.
+   */
   estimateTokens(messages: readonly Message[], systemPrompt?: string): number {
     let total = 0
     for (const msg of messages) {
@@ -293,6 +310,13 @@ export class BasicCompactService extends CompactService {
    * used (`model`, `maxTokens`) — the caller logs the envelope on the
    * `compact/summary` provenance event, so an overriding subclass (template
    * or remote summarizer) reports its own envelope honestly.
+   *
+   * @param text - plain-text rendering of the conversation region to condense.
+   * @param agent - supplies the fallback model and the session id stamped on
+   *   the call; throws when neither it nor the config names a model.
+   * @param signal - optional abort signal, forwarded into the model call.
+   * @returns the text-only summary blocks plus the call envelope used
+   *   (`model`, and `maxTokens` when the summarizer has a cap).
    */
   async summarize(
     text: string, agent: Agent, signal?: AbortSignal,

+ 3 - 0
packages/compact/compact-basic/src/types.ts

@@ -54,6 +54,9 @@ export type ResolvedConfig = Required<BasicCompactConfig>
  * each committed summary must be smaller than the content it shadows, and
  * `compactIfNeeded` may re-compact up to `compactionRetries` extra times before
  * throwing if the surface still exceeds the threshold.
+ *
+ * @param config - the raw, unresolved backend config.
+ * @returns the validated config with `auto` and `charsPerToken` defaulted.
  */
 export function resolveConfig(config: BasicCompactConfig): ResolvedConfig {
   const resolved: ResolvedConfig = { auto: true, charsPerToken: 4, ...config }

+ 6 - 0
packages/core/agent-loop/src/agent.ts

@@ -22,6 +22,10 @@ import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
  * the agent/* event taxonomy — plugins never need this class.
  */
 export class ReactLoopAgent implements Agent {
+  /**
+   * The queued + steering FIFOs behind {@link send}/{@link steer}. Public so
+   * the driver loop can drain it; {@link cancel} clears it wholesale.
+   */
   readonly inbox = new Inbox()
 
   private _status: AgentStatus = 'idle'
@@ -256,6 +260,8 @@ export class ReactLoopAgent implements Agent {
    * promise (unblocking the idle wait), releases any `whenIdle` waiters, and
    * aborts the current request if any. The returned `agent.done` promise
    * resolves once the loop exits.
+   * @returns the disposer — idempotent and infallible (it runs inside the
+   *   fiber's LIFO disposal chain, where a throw would skip later disposers).
    */
   start(): () => void {
     this.done = runLoop(this.ctx, this, {

+ 26 - 4
packages/core/agent-loop/src/inbox.ts

@@ -24,30 +24,47 @@ export class Inbox {
   private steeringMessages: InboxMessage[] = []
   private wakeup: (() => void) | undefined
 
-  /** Resolves when a queued message arrives (used by the idle loop). */
+  /** True while queued messages are pending — read by the idle wait's fast path and the loop's turn-start checks. */
   get hasQueued(): boolean {
     return this.queuedMessages.length > 0
   }
 
+  /** True while steering messages are pending — read by `cancel()`'s arm gate and the loop's stop-override check. */
   get hasSteering(): boolean {
     return this.steeringMessages.length > 0
   }
 
+  /**
+   * Add a message to the queued FIFO and wake a parked {@link waitForQueued}.
+   * @param message - the message to queue for the next turn start.
+   */
   enqueue(message: InboxMessage): void {
     this.queuedMessages.push(message)
     this.wakeup?.()
   }
 
+  /**
+   * Add a message to the steering FIFO. Deliberately no wakeup: steering is
+   * drained between steps of a running turn, never by the idle wait —
+   * `Agent.steer()` on an idle agent falls back to `send()` instead.
+   * @param message - the message to inject between steps of the running turn.
+   */
   steer(message: InboxMessage): void {
     this.steeringMessages.push(message)
   }
 
-  /** Drain all queued messages (turn start). */
+  /**
+   * Drain all queued messages (turn start).
+   * @returns the drained messages in arrival order; the queued FIFO is left empty.
+   */
   drainQueued(): InboxMessage[] {
     return this.queuedMessages.splice(0)
   }
 
-  /** Drain all steering messages (between steps). */
+  /**
+   * Drain all steering messages (between steps).
+   * @returns the drained messages in arrival order; the steering FIFO is left empty.
+   */
   drainSteering(): InboxMessage[] {
     return this.steeringMessages.splice(0)
   }
@@ -62,7 +79,12 @@ export class Inbox {
     this.steeringMessages.length = 0
   }
 
-  /** Wait until a queued message arrives or `cancel` resolves. */
+  /**
+   * Wait until a queued message arrives or `cancel` resolves.
+   * @param cancel - a promise whose resolution abandons the wait without a
+   *   message (the driver loop passes the agent's disposed promise so a parked
+   *   loop can exit).
+   */
   waitForQueued(cancel: Promise<void>): Promise<void> {
     if (this.hasQueued) return Promise.resolve()
     const { promise, resolve } = Promise.withResolvers<void>()

+ 4 - 0
packages/core/agent-loop/src/index.ts

@@ -29,6 +29,10 @@ declare module 'cordis' {
   }
 }
 
+/**
+ * Plugin config: the agents to create — or resume, via `resumeSessionId` —
+ * declaratively at startup, so a cordis.yml deployment needs no code.
+ */
 export interface Config {
   /** Agents created from configuration at startup. */
   agents: (AgentOptions & {

+ 10 - 1
packages/core/agent-loop/src/loop.ts

@@ -185,6 +185,9 @@ export interface LoopHandle {
  *     re-enqueue leftover steering as queued          ⟵ steering is never stranded
  *   idle (emit agent/status) unless more queued
  * ```
+ * @param ctx - the plugin context the loop reaches events (agent/…, session/flush) and services (systemPrompt, llm, tools) through.
+ * @param agent - the agent this invocation drives for its whole lifetime (its inbox, session, and options).
+ * @param handle - the bridge to the agent's mutable state: status/abort setters plus the disposal and cancel-marker reads.
  */
 export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise<void> {
   // Per-instance transmission bookkeeping: whether THIS loop instance has
@@ -875,7 +878,11 @@ function withoutToolCalls(message: Message): Message {
   return { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
 }
 
-/** The last turn number in a (possibly seeded) session log, or 0. */
+/**
+ * The last turn number in a (possibly seeded) session log, or 0.
+ * @param session - the session whose log is scanned for the latest `turn/start`.
+ * @returns the latest `turn/start`'s turn number, or 0 when the log has none (the next turn is this plus one).
+ */
 export function lastTurnNumber(session: Session): number {
   const lastStart = session.events.findLast(event => event.type === 'turn/start')
   return lastStart?.data.turn ?? 0
@@ -889,6 +896,8 @@ export function lastTurnNumber(session: Session): number {
  * returns to idle), so status is not a reliable open-turn signal. Used by
  * `inject()` to choose between appending into an open turn vs. wrapping the
  * injection in its own one-shot turn (the turn-enclosure RFC).
+ * @param session - the session whose log is inspected.
+ * @returns true when the log's last turn boundary is a `turn/start` with no matching `turn/end` yet.
  */
 export function isTurnOpen(session: Session): boolean {
   const last = session.events.findLast(e => e.type === 'turn/start' || e.type === 'turn/end')

+ 4 - 1
packages/core/agent-loop/src/request-log.ts

@@ -19,7 +19,10 @@ export interface TransmissionLog {
   loggedHeader: boolean
 }
 
-/** Fresh bookkeeping for a newly-started loop instance. */
+/**
+ * Fresh bookkeeping for a newly-started loop instance.
+ * @returns state with `loggedHeader` false, so the instance's first request appends an anchoring snapshot.
+ */
 export function createTransmissionLog(): TransmissionLog {
   return { loggedHeader: false }
 }

+ 17 - 1
packages/core/agent/src/types.ts

@@ -50,7 +50,11 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
 /** Identifies one live agent in the registry. */
 export type AgentId = Branded<'AgentId'>
 
-/** Brand a string as an {@link AgentId}. */
+/**
+ * Brand a string as an {@link AgentId}.
+ * @param id - the raw agent id string.
+ * @returns the same string, branded (a compile-time cast — no runtime cost).
+ */
 export function AgentId(id: string): AgentId {
   return id as AgentId
 }
@@ -80,10 +84,22 @@ export interface AgentOptions {
   model?: string
 }
 
+/**
+ * Options for {@link Agent.send}/{@link Agent.steer}/{@link Agent.inject}. An
+ * absent `source` resolves to `{ kind: 'user' }`, so a plugin supplying content
+ * must label itself here or its message is recorded as a user prompt (see
+ * {@link HookContext} on why that label is load-bearing).
+ */
 export interface SendOptions {
   source?: MessageSource
 }
 
+/**
+ * An agent's lifecycle state, emitted on every transition as `agent/status`:
+ * `idle` (parked, waiting for queued work), `running` (a turn is in progress),
+ * `disposed` (terminal — no transition leaves it, and `send`/`steer`/`inject`
+ * throw).
+ */
 export type AgentStatus = 'idle' | 'running' | 'disposed'
 
 /**

+ 296 - 0
packages/core/agent/tests/verify-export-jsdoc.spec.ts

@@ -0,0 +1,296 @@
+/**
+ * Negative-path tests for the export-surface JSDoc gate
+ * (`scripts/verify-export-jsdoc.ts`).
+ *
+ * The gate's positive half runs against the real tree in CI (`pnpm run
+ * verify-export-jsdoc`, part of doc-sync). What that run cannot prove is that
+ * the walk REJECTS an undocumented surface the way it promises to — and that
+ * every deliberate exemption (heritage members, plugin-protocol slots,
+ * constructors, overload implementations, augmentation bodies, re-exports)
+ * actually holds. These tests drive `collectExportJsdocViolations()` against
+ * synthetic fixture packages, mirroring the gen-cordis-catalog negative
+ * tests.
+ */
+
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { collectExportJsdocViolations } from '../../../../scripts/verify-export-jsdoc.ts'
+
+const roots: string[] = []
+
+afterEach(() => {
+  while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
+})
+
+/** Write fixture files under `packages/group/fix/src/` and return the scan root. */
+function fixture(files: Record<string, string>): string {
+  const root = mkdtempSync(join(tmpdir(), 'export-jsdoc-'))
+  roots.push(root)
+  for (const [rel, content] of Object.entries(files)) {
+    const abs = join(root, 'packages', 'group', 'fix', 'src', rel)
+    mkdirSync(dirname(abs), { recursive: true })
+    writeFileSync(abs, content)
+  }
+  return root
+}
+
+/** Single-file fixture shorthand: the content becomes `src/index.ts`. */
+const make = (content: string): string => fixture({ 'index.ts': content })
+
+describe('verify-export-jsdoc functions and consts', () => {
+  it('accepts a fully documented surface', () => {
+    expect(collectExportJsdocViolations(make(`
+/**
+ * Add one to a count.
+ * @param n - the count to bump.
+ * @returns the count plus one.
+ */
+export function bump(n: number): number { return n + 1 }
+
+/**
+ * Fire-and-forget (void needs no @returns).
+ * @param flag - whether to arm.
+ */
+export function poke(flag: boolean): void { void flag }
+
+/** The default retry budget. */
+export const RETRIES = 3
+
+/**
+ * Halve a count.
+ * @param n - the count to halve.
+ * @returns the count halved.
+ */
+export const halve = (n: number): number => n / 2
+`))).toEqual([])
+  })
+
+  it('flags an exported function with no JSDoc at all', () => {
+    expect(collectExportJsdocViolations(make(
+      'export function bare(): void {}\n',
+    ))).toEqual([expect.stringMatching(/exported function 'bare' .* has no JSDoc\./)])
+  })
+
+  it('flags a missing @param and a missing @returns', () => {
+    const violations = collectExportJsdocViolations(make(
+      '/** Docs without tags. */\nexport function f(x: number): number { return x }\n',
+    ))
+    expect(violations).toEqual([
+      expect.stringMatching(/exported function 'f' .* is missing @param x\./),
+      expect.stringMatching(/exported function 'f' .* is missing @returns \(return type: number\)\./),
+    ])
+  })
+
+  it('flags an unannotated (inferred) return type', () => {
+    expect(collectExportJsdocViolations(make(
+      '/**\n * Docs.\n * @param x - value.\n */\nexport function f(x: number) { return x }\n',
+    ))).toEqual([expect.stringMatching(/no return type annotation/)])
+  })
+
+  it('flags tags-only JSDoc with no description prose', () => {
+    expect(collectExportJsdocViolations(make(
+      '/**\n * @param x - value.\n */\nexport function f(x: number): void {}\n',
+    ))).toEqual([expect.stringMatching(/no description prose above its block tags/)])
+  })
+
+  it('flags a stale @param and a binding-pattern parameter', () => {
+    const violations = collectExportJsdocViolations(make(
+      '/**\n * Docs.\n * @param ghost - not real.\n */\nexport function f({ a }: { a: number }): void {}\n',
+    ))
+    expect(violations).toEqual([
+      expect.stringMatching(/parameter '\{ a \}' is a binding pattern; the export surface needs simple identifier parameters/),
+      expect.stringMatching(/@param ghost does not match any parameter \(stale tag\?\)/),
+    ])
+  })
+
+  it('exempts a `this` receiver annotation from @param', () => {
+    expect(collectExportJsdocViolations(make(
+      '/**\n * Docs.\n * @param x - value.\n */\nexport function f(this: object, x: number): void {}\n',
+    ))).toEqual([])
+  })
+
+  it('waives @returns for a declarator-annotated const but not an unannotated one', () => {
+    expect(collectExportJsdocViolations(make(`
+type Fn = (x: number) => number
+/**
+ * Uses the named signature.
+ * @param x - value.
+ */
+export const good: Fn = x => x
+/**
+ * No signature anywhere.
+ * @param x - value.
+ */
+export const bad = (x: number) => x
+`))).toEqual([expect.stringMatching(/exported const 'bad' .* has no return type annotation/)])
+  })
+
+  it('requires description prose on a non-function const', () => {
+    expect(collectExportJsdocViolations(make(
+      'export const LIMIT = 10\n',
+    ))).toEqual([expect.stringMatching(/exported const 'LIMIT' .* has no JSDoc\./)])
+  })
+})
+
+describe('verify-export-jsdoc type-level exports', () => {
+  it('requires description prose on interfaces, type aliases, and enums', () => {
+    const violations = collectExportJsdocViolations(make(
+      'export interface I { a: number }\nexport type T = number\nexport enum E { A }\n',
+    ))
+    expect(violations).toEqual([
+      expect.stringMatching(/exported interface 'I' .* has no JSDoc\./),
+      expect.stringMatching(/exported type 'T' .* has no JSDoc\./),
+      expect.stringMatching(/exported enum 'E' .* has no JSDoc\./),
+    ])
+  })
+
+  it('skips `declare module` augmentation bodies (the cordis gate owns them)', () => {
+    expect(collectExportJsdocViolations(make(
+      "declare module 'cordis' {\n  interface Events {\n    'fix/x'(): void\n  }\n}\nexport {}\n",
+    ))).toEqual([])
+  })
+})
+
+describe('verify-export-jsdoc export forms', () => {
+  it('resolves an `export { … }` list to the local declaration', () => {
+    expect(collectExportJsdocViolations(make(
+      'function f(): void {}\nexport { f }\n',
+    ))).toEqual([expect.stringMatching(/exported function 'f' .* has no JSDoc\./)])
+  })
+
+  it('reports a re-exported module once, at its defining file', () => {
+    const violations = collectExportJsdocViolations(fixture({
+      'index.ts': "export * from './other.ts'\n",
+      'other.ts': 'export function f(): void {}\n',
+    }))
+    expect(violations).toEqual([expect.stringMatching(/other\.ts:1\) has no JSDoc\./)])
+  })
+
+  it('exempts overload implementations when the signatures are documented', () => {
+    expect(collectExportJsdocViolations(make(`
+/**
+ * From a number.
+ * @param x - the number.
+ * @returns its text.
+ */
+export function f(x: number): string
+/**
+ * From a flag.
+ * @param x - the flag.
+ * @returns its text.
+ */
+export function f(x: boolean): string
+export function f(x: number | boolean): string { return String(x) }
+`))).toEqual([])
+  })
+})
+
+describe('verify-export-jsdoc classes', () => {
+  it('flags an undocumented class, method, property, and accessor', () => {
+    const violations = collectExportJsdocViolations(make(`
+export class C {
+  state = 1
+  get view(): number { return this.state }
+  run(x: number): number { return x }
+}
+`))
+    expect(violations).toEqual([
+      expect.stringMatching(/exported class 'C' .* has no JSDoc\./),
+      expect.stringMatching(/exported class property 'C.state' .* has no JSDoc\./),
+      expect.stringMatching(/exported class accessor 'C.view' .* has no JSDoc\./),
+      expect.stringMatching(/exported class method 'C.run' .* has no JSDoc\./),
+    ])
+  })
+
+  it('exempts members declared by an extends/implements heritage type', () => {
+    expect(collectExportJsdocViolations(make(`
+/** Seam. */
+export abstract class Base {
+  /**
+   * Do it.
+   * @param x - input.
+   * @returns output.
+   */
+  abstract run(x: number): number
+}
+/** Iface. */
+export interface Sized {
+  /** Byte size. */
+  size: number
+}
+/** Impl. */
+export class Impl extends Base implements Sized {
+  size = 0
+  run(x: number): number { return x }
+}
+`))).toEqual([])
+  })
+
+  it('skips private/protected/#private members and constructors', () => {
+    expect(collectExportJsdocViolations(make(`
+/** Documented. */
+export class C {
+  #secret = 1
+  private hidden(): void {}
+  protected hook(): void {}
+  constructor(x: number) { void x }
+}
+`))).toEqual([])
+  })
+
+  it('exempts plugin-protocol statics but checks other statics', () => {
+    const violations = collectExportJsdocViolations(make(`
+/** Plugin. */
+export class C {
+  static Config = { a: 1 }
+  static inject = ['bash']
+  static reusable = true
+  static other = 1
+}
+`))
+    expect(violations).toEqual([expect.stringMatching(/exported class property 'C.other' .* has no JSDoc\./)])
+  })
+
+  it("covers a set accessor by the getter's doc", () => {
+    expect(collectExportJsdocViolations(make(`
+/** Documented. */
+export class C {
+  /** The current width. */
+  get width(): number { return 1 }
+  set width(_v: number) {}
+}
+`))).toEqual([])
+  })
+})
+
+describe('verify-export-jsdoc plugin protocol and namespaces', () => {
+  it('exempts top-level plugin-protocol exports', () => {
+    expect(collectExportJsdocViolations(make(`
+export const name = 'fix'
+export const inject = ['bash']
+export const reusable = true
+export const Config = { parse: true }
+export function apply(): void {}
+`))).toEqual([])
+  })
+
+  it('recurses into namespaces with qualified names and honors the merge idiom', () => {
+    const violations = collectExportJsdocViolations(make(`
+/** The plugin class. */
+export class Fix {}
+export namespace Fix {
+  export interface Config { a: number }
+}
+export namespace Loose {
+  export const x = 1
+}
+`))
+    expect(violations).toEqual([
+      expect.stringMatching(/exported interface 'Fix.Config' .* has no JSDoc\./),
+      expect.stringMatching(/exported namespace 'Loose' .* has no JSDoc\./),
+      expect.stringMatching(/exported const 'Loose.x' .* has no JSDoc\./),
+    ])
+  })
+})

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

@@ -153,10 +153,15 @@ export class Session {
     this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
   }
 
+  /**
+   * The append-only event log, exposed live by reference (readonly-typed, not
+   * a snapshot): later appends are visible through the same array.
+   */
   get events(): readonly SessionEvent[] {
     return this.log
   }
 
+  /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
   get seq(): number {
     return this.log.length
   }
@@ -175,6 +180,9 @@ export class Session {
    *   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` is not losslessly JSON-serializable (BigInt, function,
    *   symbol, undefined, non-finite number, circular ref, or an exotic object
    *   like Map/Set/Date). The event log is the durable source of truth, so this

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

@@ -40,6 +40,10 @@ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key:
  * hiding under a symbol/non-enumerable key cannot make the round-trip lossy.
  * Getters are invoked during the check (again as `JSON.stringify` would), so the
  * contract is for plain data records, not objects with side-effecting accessors.
+ * @param value - the candidate event data to test.
+ * @param seen - objects on the current descent path, for circular-reference
+ *   detection; the recursion threads it — callers omit it.
+ * @returns true when `value` survives a JSON round-trip losslessly.
  */
 export function isJsonValue(value: unknown, seen: Set<object> = new Set()): boolean {
   if (value === null) return true

+ 2 - 0
packages/core/session/src/repair.ts

@@ -54,6 +54,8 @@ import type { SessionEvent } from './types.ts'
  * Only the LAST turn can be open: the invariants plugin guarantees a `turn/end`
  * before any later `turn/start`, so an interior open turn is impossible in a
  * valid committed log. Likewise at most one step is open within that turn.
+ * @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
+ * @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
  */
 export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
   let openTurn: number | null = null

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

@@ -29,6 +29,8 @@ const SURFACE_EVENT_TYPES = new Set<string>([
  * surface-eligible event that is MISSING its mandatory marker (e.g. validating
  * a seed/load log); use {@link isSurfaceEvent} to narrow to a fully-formed
  * {@link SurfaceEvent} with `surfaceOp` present.
+ * @param type - the event type string to test.
+ * @returns true when the type is one of the five message-producing types.
  */
 export function isSurfaceEligibleType(type: string): boolean {
   return SURFACE_EVENT_TYPES.has(type)
@@ -38,6 +40,8 @@ export function isSurfaceEligibleType(type: string): boolean {
  * Narrow a {@link SessionEvent} to {@link SurfaceEvent}: checks that the
  * event's `type` is surface-eligible AND that `surfaceOp` is present.
  * The narrowed type has mandatory {@link SurfaceOp}.
+ * @param event - the event to narrow.
+ * @returns true when the event is surface-eligible and carries its `surfaceOp` marker.
  */
 export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent {
   if (!SURFACE_EVENT_TYPES.has(event.type)) return false

+ 6 - 0
packages/core/session/src/tool-pairing.ts

@@ -74,6 +74,12 @@ function nodeDelta(event: SessionEvent): number {
  * surface successor (`SurfaceNode.next`), or `null` when `end` is the tail —
  * for the cut after `end`.
  *
+ * @param nodes - the surface linked list in head→tail order.
+ * @param events - the session log each node's `seq` indexes into.
+ * @param beforeSeq - names the cut (the node it sits immediately before);
+ *   `null` — or any seq not on the surface — means the after-tail cut.
+ * @returns true when every `tool-call` before the cut is answered before it
+ *   (the unanswered-call depth at the cut is zero).
  * @throws if the surface prefix drives the unanswered-call depth negative — a
  *   `tool/result` with no preceding open `tool-call` on the surface. That is a
  *   corrupt surface (a structural invariant violation), surfaced loudly here

+ 8 - 1
packages/core/session/src/types.ts

@@ -4,7 +4,11 @@ import type { CallId, ContentBlock, LlmCallConfig, MessageSource, StreamChunk, T
 /** Identifies one session in the store (and its persistence artifacts). */
 export type SessionId = Branded<'SessionId'>
 
-/** Brand a string as a {@link SessionId}. */
+/**
+ * Brand a string as a {@link SessionId}.
+ * @param id - the raw session id string.
+ * @returns the same string, branded (a compile-time cast — no runtime cost).
+ */
 export function SessionId(id: string): SessionId {
   return id as SessionId
 }
@@ -102,6 +106,7 @@ export interface TurnTriggerMap {
   injection: { kind: 'injection'; source: MessageSource }
 }
 
+/** The union over {@link TurnTriggerMap} — what started a turn; plugins extend it by merging variants into the map. */
 export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
 
 /**
@@ -156,6 +161,7 @@ export interface TurnEndReasonMap {
   interrupted: { kind: 'interrupted' }
 }
 
+/** The union over {@link TurnEndReasonMap} — why a turn ended; plugins extend it by merging variants into the map. */
 export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap]
 
 /**
@@ -361,6 +367,7 @@ export interface SessionEventMap {
   'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig }
 }
 
+/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
 export type SessionEventType = keyof SessionEventMap
 
 /**

+ 5 - 0
packages/core/system-prompt/src/index.ts

@@ -110,6 +110,7 @@ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
 /** A complete `{{...}}` reference group at the scan position (validated after). */
 const GROUP_AT = /^\{\{([^{}]*)\}\}/
 
+/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
 export interface Config {
   /**
    * The deployment's persona — the ONE deployment-authored fragment of the
@@ -138,6 +139,10 @@ export interface Config {
  * while a `}}` still follows (e.g. `{{{model}}}`, `{{a{b}}`) all throw. A
  * lone `{{` with no `}}` anywhere after it is ordinary prose and passes
  * through verbatim. Substituted values are never re-scanned.
+ * @param assembly - the assembly to render (typically the awaited result of
+ *   {@link SystemPrompt.assemble}); only `sections` and `variables` are read.
+ * @returns the full system prompt text; `''` when every section renders empty
+ *   (the caller then sends no system prompt at all).
  */
 export function renderPrompt(assembly: PromptAssembly): string {
   return assembly.sections

+ 13 - 0
packages/core/tools/src/schema.ts

@@ -155,6 +155,9 @@ export interface JsonSchemaObject {
  * `properties`, `required` array).
  *
  * This is a plain function — no schemastery or other framework dependency.
+ * @param spec - the author-facing per-property schema to convert.
+ * @returns the wire-format JSON Schema; the top-level `required` array is
+ *   omitted entirely when no property is marked required.
  */
 export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject {
   const properties: Record<string, unknown> = {}
@@ -269,6 +272,9 @@ function checkSpec(spec: SchemaSpec, value: unknown, path: string): string[] {
  * keys are allowed (no `additionalProperties: false`); `default` is not
  * applied; an `object`/`array` prop without `properties`/`items` only
  * type-checks; `enum` is membership (strings only).
+ * @param spec - the declared parameter schema to validate against.
+ * @param args - the model-generated arguments, however malformed.
+ * @returns the violation messages in declaration order; empty means valid.
  */
 export function validateArgs(spec: SchemaSpec, args: unknown): string[] {
   return checkSpec(spec, args, '')
@@ -340,6 +346,13 @@ export interface DefineToolOptions<S extends SchemaSpec> {
  * Raw JSON-Schema tool definitions (from MCP servers) are still accepted
  * by `ToolRegistry.register()` directly — `defineTool` is sugar for
  * first-party plugin authors.
+ * @param options - the tool's name, description, typed parameter schema,
+ *   execute body, and optional presenters.
+ * @returns a registry-ready {@link ToolDefinition}: its `execute` validates the
+ *   raw args first (throwing {@link ToolArgsError} on mismatch, which the
+ *   registry turns into an isError result), and its presenters validate softly
+ *   (returning undefined on mismatch, since replay may feed them older-schema
+ *   args).
  */
 export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
   // Object-literal execute methods don't use `this`; the reference is safe.

+ 49 - 1
packages/fs/fs-local/src/fsio.ts

@@ -129,6 +129,9 @@ export interface LocalDirEntry {
  * and intermediate directories are created by the write. Two input paths
  * reaching the same file via symlinks share one key. Falls back to the absolute
  * path only when no ancestor (not even the filesystem root) can be resolved.
+ * @param cwd - base directory a relative `path` resolves against.
+ * @param path - absolute or relative path; empty/whitespace-only throws `FS_NOT_FOUND`.
+ * @returns the absolute display path plus the realpath-derived stable target key.
  */
 export async function resolveLocalTarget(cwd: string, path: string): Promise<LocalTarget> {
   if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
@@ -165,7 +168,11 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
   }
 }
 
-/** Probe a path for its version, mode, type, and size. Null if absent. */
+/**
+ * Probe a path for its version, mode, type, and size. Null if absent.
+ * @param absolutePath - the path to stat (typically a target key; symlinks are followed).
+ * @returns the metadata, or null when the path — or a parent segment — does not exist.
+ */
 export async function probe(absolutePath: string): Promise<PathInfo | null> {
   try {
     const info = await stat(absolutePath)
@@ -200,6 +207,9 @@ async function resolveListedChildTarget(parent: LocalTarget, name: string): Prom
  * List direct children of a directory in stable name order. Each child includes
  * a resolved target plus stat metadata when still available; file contents are
  * never read.
+ * @param target - the resolved directory to list; a missing or non-directory target throws.
+ * @param signal - aborts the listing, checked between children (`FS_ABORTED`).
+ * @returns one entry per direct child, sorted by name.
  */
 export async function listDirectory(target: LocalTarget, signal?: AbortSignal): Promise<LocalDirEntry[]> {
   throwIfAborted(signal, 'list')
@@ -290,6 +300,9 @@ async function statRegularFile(target: LocalTarget, verb: 'read', signal?: Abort
 /**
  * Read a whole regular UTF-8 text file into a single decoded string. Rejects
  * non-regular files, invalid UTF-8, and NUL-byte binary samples.
+ * @param target - the resolved file to read.
+ * @param signal - aborts the read (`FS_ABORTED`).
+ * @returns the full decoded text, byte-for-byte (no normalization).
  */
 export async function readWholeText(target: LocalTarget, signal?: AbortSignal): Promise<string> {
   await statRegularFile(target, 'read', signal)
@@ -305,6 +318,9 @@ export async function readWholeText(target: LocalTarget, signal?: AbortSignal):
  * Stream a whole regular UTF-8 text file as decoded text chunks. Same text
  * semantics as {@link readWholeText} (regular-file check, binary/NUL rejection,
  * cross-chunk UTF-8 decoding), but never holds the whole file in memory.
+ * @param target - the resolved file to stream.
+ * @param signal - aborts the stream, including between chunks (`FS_ABORTED`).
+ * @returns decoded text chunks in file order; chunk boundaries carry no meaning.
  */
 export async function* streamWholeText(target: LocalTarget, signal?: AbortSignal): AsyncIterable<string> {
   await statRegularFile(target, 'read', signal)
@@ -352,6 +368,11 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow
  * (`0o700`) staging directory, fsync, optionally chmod to the final mode while
  * still private, then rename over the target. `mode` (when given) preserves an
  * existing file's permissions across the replace.
+ * @param absolutePath - the final destination (typically a target key); missing parent dirs are created.
+ * @param content - the full UTF-8 text to write.
+ * @param mode - final file mode applied before the rename (an existing file's, to preserve permissions); undefined leaves `0o600`.
+ * @param signal - aborts the write (`FS_ABORTED`); checked before the rename, so the target is never left torn.
+ * @param internals - test seam for pinning temp names and observing the staged file.
  */
 export async function writeFileAtomic(
   absolutePath: string,
@@ -409,6 +430,12 @@ export async function writeFileAtomic(
 /** Line ending style detected before LF normalization. */
 export type LineEndings = 'LF' | 'CRLF'
 
+/**
+ * Collapse CRLF to LF — the canonical in-memory form every edit/diff basis
+ * uses. Lone `\r` bytes (not followed by `\n`) are left untouched.
+ * @param content - decoded text in whatever line-ending style the file had.
+ * @returns the text with every `\r\n` pair replaced by `\n`.
+ */
 function normalizeLineEndings(content: string): string {
   return content.replaceAll('\r\n', '\n')
 }
@@ -420,6 +447,14 @@ function detectLineEndings(raw: string): LineEndings {
   return crlfCount > lfCount ? 'CRLF' : 'LF'
 }
 
+/**
+ * Convert LF-normalized content back to the line-ending style detected at read
+ * time, for write-back. `LF` returns the content unchanged; `CRLF` re-normalizes
+ * first so an already-CRLF sequence is never doubled to `\r\r\n`.
+ * @param content - the LF-normalized (edited) text.
+ * @param lineEndings - the original file's style, as detected by {@link readForEdit}.
+ * @returns the text in the original file's line-ending style.
+ */
 function restoreLineEndings(content: string, lineEndings: LineEndings): string {
   return lineEndings === 'LF' ? content : normalizeLineEndings(content).split('\n').join('\r\n')
 }
@@ -438,6 +473,10 @@ function countOccurrences(content: string, needle: string): number {
 /**
  * Read and decode a file for editing: rejects binaries, returns LF-normalized
  * content plus the original line-ending style for write-back.
+ * @param absolutePath - the file to read (typically a target key).
+ * @param displayPath - the caller-facing path used in error messages.
+ * @param signal - aborts the read (`FS_ABORTED`).
+ * @returns the LF-normalized content and the detected style to restore on write-back.
  */
 export async function readForEdit(
   absolutePath: string,
@@ -459,6 +498,9 @@ export async function readForEdit(
  * prior bytes, so an undiffable prior file simply yields no contextual-hunk basis
  * (the caller treats `null` the same as an absent file: the result renders a
  * whole-file diff rather than an applied hunk).
+ * @param absolutePath - the file to read (typically a target key); it must exist.
+ * @param signal - aborts the read (`FS_ABORTED`).
+ * @returns the LF-normalized text, or null for a binary or non-UTF-8 file.
  */
 export async function readTextForDiff(absolutePath: string, signal?: AbortSignal): Promise<string | null> {
   const buffer = await readFileAbortable(absolutePath, 'read', signal)
@@ -477,6 +519,12 @@ export async function readTextForDiff(absolutePath: string, signal?: AbortSignal
  * `FS_EDIT_NOT_FOUND` on empty `oldString` or zero matches and
  * `FS_AMBIGUOUS_EDIT` on multiple matches when `replaceAll` is false. Returns
  * the edited content (still LF-normalized) and the replacement count.
+ * @param content - the current file content, already LF-normalized.
+ * @param oldString - literal text to find; CRLF inside it is normalized to LF before matching.
+ * @param newString - literal replacement text, normalized the same way.
+ * @param replaceAll - replace every match instead of requiring exactly one.
+ * @param displayPath - the caller-facing path used in error messages.
+ * @returns the edited LF-normalized content plus how many occurrences were replaced.
  */
 export function applyLiteralEdit(
   content: string,

+ 1 - 0
packages/fs/fs-local/src/index.ts

@@ -73,6 +73,7 @@ export class LocalFileSystem extends FileSystem {
     cwd: z.string().default(process.cwd()),
   })
 
+  /** Validated config (schemastery applied the defaults before construction). */
   readonly config: ResolvedConfig
   /** Test seam forwarded to fsio (force streaming path, pin temp names). */
   internals: FsIoInternals = {}

+ 12 - 2
packages/fs/fs/src/types.ts

@@ -29,7 +29,12 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
  */
 export type FsTargetKey = Branded<'FsTargetKey'>
 
-/** Brand a string as an {@link FsTargetKey}. */
+/**
+ * Brand a string as an {@link FsTargetKey}. For backend use only — a consumer
+ * never manufactures a key, it receives one from `resolve()`.
+ * @param key - the backend's raw key string (the local backend passes a realpath).
+ * @returns the same string, branded; no validation is performed.
+ */
 export function FsTargetKey(key: string): FsTargetKey {
   return key as FsTargetKey
 }
@@ -42,7 +47,12 @@ export function FsTargetKey(key: string): FsTargetKey {
  */
 export type FsVersion = Branded<'FsVersion'>
 
-/** Brand a string as an {@link FsVersion}. */
+/**
+ * Brand a string as an {@link FsVersion}. For backend use only — a consumer
+ * never manufactures a version, it receives one from `stat`/write/edit outcomes.
+ * @param v - the backend's raw version string (the local backend derives it from mtime+size).
+ * @returns the same string, branded; no validation is performed.
+ */
 export function FsVersion(v: string): FsVersion {
   return v as FsVersion
 }

+ 6 - 0
packages/fs/tool-fs/src/diff.ts

@@ -40,6 +40,10 @@ export type FsDiffMeta = { diffs: FileDiff[] }
  * (a pure insertion) reports `oldText: null` (nothing to diff against), mirroring
  * the call-time card's new-file convention. The unified-diff "\ No newline at end
  * of file" markers are dropped — they annotate the patch, not file content.
+ * @param path - the path stamped on every produced diff (the model-facing `file_path`; the bridge relativizes it).
+ * @param before - the file text before the change (the backend's LF-normalized diff basis).
+ * @param after - the file text after the change, on the same basis.
+ * @returns one diff per applied hunk, in file order; empty when the texts are identical.
  */
 export function computeHunkDiffs(path: string, before: string, after: string): FileDiff[] {
   const patch = structuredPatch('', '', before, after, undefined, undefined, { context: DIFF_CONTEXT })
@@ -83,6 +87,8 @@ function isFileDiff(value: unknown): value is FileDiff {
  * it validates defensively rather than trusting the payload — a bad `meta` yields
  * `undefined`, and the caller decides the fallback (edit → the generic result
  * rendering; write → an args-derived whole-file diff), never a thrown presenter.
+ * @param meta - the opaque `tool/result` meta payload (live or replayed from the session log).
+ * @returns the validated non-empty hunk list, or undefined for an absent/empty/malformed payload.
  */
 export function diffsFromMeta(meta: unknown): FileDiff[] | undefined {
   if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined

+ 17 - 3
packages/fs/tool-fs/src/edit.ts

@@ -29,7 +29,13 @@ interface EditInput {
   replaceAll: boolean
 }
 
-/** Validate value constraints the schema DSL can't express. */
+/**
+ * Validate value constraints the schema DSL can't express: a non-blank
+ * `file_path`, a non-empty `old_string`, and `old_string !== new_string`
+ * (an equal pair would be a guaranteed no-op edit).
+ * @param args - the schema-validated raw tool arguments.
+ * @returns the camelCased input with `replace_all` defaulted to false.
+ */
 export function parseEditArgs(args: { file_path: string; old_string: string; new_string: string; replace_all?: boolean }): EditInput {
   if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
   if (args.old_string.length === 0) throw new Error('old_string must be a non-empty string')
@@ -42,14 +48,22 @@ export function parseEditArgs(args: { file_path: string; old_string: string; new
   }
 }
 
-/** Format an edit success (single-match or replace-all) as a Claude-style model-facing message. */
+/**
+ * Format an edit success (single-match or replace-all) as a Claude-style model-facing message.
+ * @param displayPath - the backend-resolved path shown to the model.
+ * @param replaceAll - selects the all-occurrences wording over the single-replacement one.
+ * @returns the confirmation sentence the model sees as the tool result.
+ */
 export function formatEditOutput(displayPath: string, replaceAll: boolean): string {
   return replaceAll
     ? `The file ${displayPath} has been updated. All occurrences were successfully replaced.`
     : `The file ${displayPath} has been updated successfully.`
 }
 
-/** Register the `edit` tool and its system-prompt guidance. */
+/**
+ * Register the `edit` tool and its system-prompt guidance.
+ * @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service.
+ */
 export function applyEditTool(ctx: Context): void {
   ctx.systemPrompt.section({
     name: 'tool:edit',

+ 10 - 1
packages/fs/tool-fs/src/read-render.ts

@@ -119,6 +119,10 @@ function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string
  * path serves both. Scans for newlines with a capped line buffer (a newline-free
  * giant line is truncated, never buffered past `request.maxLineLength`),
  * enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF.
+ * @param chunks - decoded text chunks in file order; chunk boundaries carry no meaning.
+ * @param request - the resolved window; the caller has already applied its defaults and caps.
+ * @param displayPath - the caller-facing path used in the offset-out-of-range error.
+ * @returns the numbered window lines, the total line count seen, and the byte-cap truncation flag.
  */
 export async function buildWindow(
   chunks: AsyncIterable<string> | Iterable<string>,
@@ -156,7 +160,12 @@ export async function buildWindow(
   return finish(acc, request, displayPath)
 }
 
-/** Format a read outcome as one OpenCode-style line-numbered text block body. */
+/**
+ * Format a read outcome as one OpenCode-style line-numbered text block body.
+ * @param displayPath - the backend-resolved path rendered in the envelope's `<path>` element.
+ * @param outcome - the windowed read to render.
+ * @returns the model-facing envelope: numbered lines plus a continuation or end-of-file footer.
+ */
 export function formatReadOutput(displayPath: string, outcome: FileReadOutcome): string {
   const endLine = outcome.lines.at(-1)?.number ?? Math.max(0, outcome.offset - 1)
   let footer: string

+ 11 - 2
packages/fs/tool-fs/src/read.ts

@@ -58,7 +58,12 @@ function parsePositiveInteger(value: number, name: string): number {
   return value
 }
 
-/** Validate value constraints the schema DSL can't express. `maxLimit` is the deployment's line cap. */
+/**
+ * Validate value constraints the schema DSL can't express. `maxLimit` is the deployment's line cap.
+ * @param args - the schema-validated raw tool arguments; `offset`/`limit` must be positive integers when given.
+ * @param maxLimit - the configured line cap: both the default `limit` and the largest one accepted.
+ * @returns the validated input with `offset` defaulted to 1 and `limit` to `maxLimit`.
+ */
 export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }, maxLimit: number): ReadInput {
   if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
   const offset = args.offset === undefined ? 1 : parsePositiveInteger(args.offset, 'offset')
@@ -67,7 +72,11 @@ export function parseReadArgs(args: { file_path: string; offset?: number; limit?
   return { filePath: args.file_path, offset, limit }
 }
 
-/** Register the `read` tool and its system-prompt guidance. */
+/**
+ * Register the `read` tool and its system-prompt guidance.
+ * @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service.
+ * @param caps - the deployment's resolved read caps (plugin config after defaulting).
+ */
 export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
   ctx.systemPrompt.section({
     name: 'tool:read',

+ 5 - 1
packages/fs/tool-fs/src/session-cwd.ts

@@ -18,7 +18,11 @@
 
 import type { ToolExecution } from '@deepseek-ai/dsh-tools'
 
-/** The session workspace cwd for this call, or `undefined` when none applies. */
+/**
+ * The session workspace cwd for this call, or `undefined` when none applies.
+ * @param exec - the tool-execution context; only its optional `agent` is read.
+ * @returns the calling agent's session cwd, or undefined for a non-agent caller (the backend then applies its own default).
+ */
 export function sessionCwd(exec: ToolExecution): string | undefined {
   return exec.agent?.session.header.cwd
 }

+ 16 - 3
packages/fs/tool-fs/src/write.ts

@@ -21,13 +21,23 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
 import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
 import { sessionCwd } from './session-cwd.ts'
 
-/** Validate value constraints the schema DSL can't express. */
+/**
+ * Validate value constraints the schema DSL can't express: only a non-blank
+ * `file_path` — an empty `content` is legitimate (it writes an empty file).
+ * @param args - the schema-validated raw tool arguments.
+ * @returns the camelCased input; `content` passes through untouched.
+ */
 export function parseWriteArgs(args: { file_path: string; content: string }): { filePath: string; content: string } {
   if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
   return { filePath: args.file_path, content: args.content }
 }
 
-/** Format a write outcome as one model-facing text block body. */
+/**
+ * Format a write outcome as one model-facing text block body.
+ * @param displayPath - the backend-resolved path rendered in the envelope's `<path>` element.
+ * @param outcome - the write outcome; its `operation` selects the Created/Updated wording.
+ * @returns the model-facing confirmation envelope (no file content is echoed back).
+ */
 export function formatWriteOutput(displayPath: string, outcome: FsWriteOutcome): string {
   const verb = outcome.operation === 'create' ? 'Created' : 'Updated'
   return `<path>${displayPath}</path>
@@ -37,7 +47,10 @@ ${verb} file
 </content>`
 }
 
-/** Register the `write` tool and its system-prompt guidance. */
+/**
+ * Register the `write` tool and its system-prompt guidance.
+ * @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service.
+ */
 export function applyWriteTool(ctx: Context): void {
   ctx.systemPrompt.section({
     name: 'tool:write',

+ 6 - 0
packages/hooks/hook-protocol/src/codec.ts

@@ -75,6 +75,12 @@ function permissionDecisionOf(value: string | undefined): HookOutput['decision']
  * (`decision`/`reason`/`continue`/`stopReason`/`systemMessage`)
  * are unaffected. Omit `expectedEventName` (or pass a matching one) to apply the
  * block as-is — a caller that doesn't key by event opts out of the check.
+ *
+ * @param exitCode - the process exit code; `undefined` when the hook could not be spawned at all.
+ * @param stdout - the captured stdout stream; consulted for structured JSON only on a 0 exit.
+ * @param stderr - the captured stderr stream; becomes the blocking `reason` on exit 2.
+ * @param expectedEventName - the event the hook is firing for; omit to apply a `hookSpecificOutput` block as-is.
+ * @returns the dialect-neutral decoded outcome.
  */
 export function parseHookOutput(exitCode: number | undefined, stdout: string, stderr: string, expectedEventName?: string): HookOutput {
   const trimmedErr = stderr.trim()

+ 10 - 1
packages/hooks/hook-protocol/src/events.ts

@@ -66,6 +66,9 @@ export const DEFAULT_STDERR_SUMMARY_MAX_CHARS = 500
  * `undefined` when empty, cut at `maxChars` with an ellipsis when over. The
  * bound is a parameter — like `runHook`'s `defaultTimeoutMs`, each bridge owns
  * the config default and passes it in.
+ * @param stderr - the hook's raw captured stderr.
+ * @param maxChars - the character cap for the summary (the bridge's config value).
+ * @returns the trimmed, capped summary, or `undefined` when stderr is blank.
  */
 export function summarizeStderr(stderr: string, maxChars: number): string | undefined {
   const t = stderr.trim()
@@ -73,7 +76,11 @@ export function summarizeStderr(stderr: string, maxChars: number): string | unde
   return t.length > maxChars ? t.slice(0, maxChars) + '…' : t
 }
 
-/** Append a `hook/invoked` provenance event to `session`. */
+/**
+ * Append a `hook/invoked` provenance event to `session`.
+ * @param session - the session whose open turn records the event.
+ * @param invocation - the invocation identity; an absent `matcher` is omitted from the payload.
+ */
 export function appendHookInvoked(session: Session, invocation: HookInvocation): void {
   session.append('hook/invoked', {
     turn: invocation.turn,
@@ -91,6 +98,8 @@ export function appendHookInvoked(session: Session, invocation: HookInvocation):
  * else `'pass'`; `stderrSummary` is the trimmed stderr truncated to
  * `record.stderrSummaryMaxChars` characters (omitted when empty); `exitCode`
  * is omitted when the hook never ran.
+ * @param session - the session whose open turn records the event.
+ * @param record - the outcome to record: the decoded output plus the summary cap and duration.
  */
 export function appendHookResult(session: Session, record: HookResultRecord): void {
   const { output } = record

+ 4 - 0
packages/hooks/hook-protocol/src/matcher.ts

@@ -34,6 +34,10 @@ const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/
  * pattern exact-matches the query (splitting `|` into alternatives); every other
  * `claude` pattern and ALL `codex` patterns are tested as an unanchored regex.
  * An invalid regex matches nothing (never throws).
+ * @param matcher - the configured pattern; absent/empty/`'*'` are the match-all sentinels.
+ * @param query - the candidate value (a tool name, a session source, …).
+ * @param mode - the dialect deciding literal-vs-regex interpretation of the pattern.
+ * @returns `true` when the pattern selects the query; `false` on a non-match or an invalid regex.
  */
 export function matchesMatcher(matcher: string | undefined, query: string, mode: MatcherMode): boolean {
   if (isMatchAll(matcher)) return true

+ 2 - 0
packages/hooks/hook-protocol/src/merge.ts

@@ -71,6 +71,8 @@ function decisionForRank(maxRank: number): MergedDecision {
  * into one {@link MergedHookOutcome} by the precedence rules above. An empty list
  * yields a neutral outcome (`decision: 'none'`, no stop, empty context) — the
  * caller treats that as "no hook had anything to say".
+ * @param outputs - every matched hook's decoded output, in hook order.
+ * @returns the single folded outcome the bridge maps onto its seam.
  */
 export function mergeHookOutputs(outputs: HookOutput[]): MergedHookOutcome {
   let maxRank = 0

+ 5 - 0
packages/hooks/hook-protocol/src/runner.ts

@@ -70,6 +70,11 @@ export interface RunHookResult {
  * `exitCode: undefined`, so the caller's merge logic treats it as a
  * non-blocking error rather than crashing the turn. `now` is injected for
  * testable durations.
+ * @param bash - the executor seam the command runs through.
+ * @param hook - the configured command; its `timeoutSec` (wire unit: seconds) overrides the default timeout.
+ * @param options - the invocation's payload, env, cwd, signal, stdin framing, and default timeout.
+ * @param now - millisecond clock used for the reported duration.
+ * @returns the decoded output plus the run's wall-clock duration.
  */
 export async function runHook(
   bash: BashExecutor,

+ 9 - 1
packages/hooks/hooks-claude/src/config.ts

@@ -43,7 +43,12 @@ function asObject(value: unknown): Record<string, unknown> | undefined {
     : undefined
 }
 
-/** Apply `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PROJECT_DIR}` substitution to a command string. */
+/**
+ * Apply `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PROJECT_DIR}` substitution to a command string.
+ * @param command - the raw command from config.
+ * @param vars - the substitution values; a token whose variable is unset stays verbatim.
+ * @returns the command with every occurrence of each set token replaced.
+ */
 export function substituteCommand(command: string, vars: SubstitutionVars): string {
   let out = command
   if (vars.pluginRoot !== undefined) out = out.split('${CLAUDE_PLUGIN_ROOT}').join(vars.pluginRoot)
@@ -57,6 +62,9 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri
  * Non-command hooks and malformed entries are dropped (recorded in `skipped` /
  * silently ignored) rather than throwing — a bad hook config must not crash boot.
  * `vars` are substituted into every surviving `command`.
+ * @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare event map.
+ * @param vars - substitution values applied to every surviving `command` (defaults to none).
+ * @returns the runnable per-event groups plus the skipped non-command hooks.
  */
 export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): ParsedClaudeConfig {
   const config: ClaudeHookConfig = {}

+ 2 - 0
packages/hooks/hooks-codex/src/config.ts

@@ -41,6 +41,8 @@ function asObject(value: unknown): Record<string, unknown> | undefined {
  * `type !== 'command'` and `async: true` command hooks are skipped (recorded in
  * `skipped`). Malformed entries are ignored rather than thrown — a bad config
  * must not crash boot. No command substitution (Codex does none).
+ * @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map.
+ * @returns the runnable per-event groups plus the skipped hooks with their reasons.
  */
 export function parseCodexConfig(raw: unknown): ParsedCodexConfig {
   const config: CodexHookConfig = {}

+ 7 - 1
packages/llm/llm-deepseek/src/adapter.ts

@@ -13,7 +13,9 @@ import { parseSse } from './sse.ts'
 import { translate } from './translate.ts'
 import type { WireError } from './types.ts'
 
+/** Constructor options for {@link DeepSeekAdapter}; the plugin's `apply` resolves them from Config + environment. */
 export interface DeepSeekAdapterOptions {
+  /** Bearer token sent in the `authorization` header on every request. */
   apiKey: string
   /** Endpoint base; `/chat/completions` is appended. */
   baseURL: string
@@ -21,7 +23,11 @@ export interface DeepSeekAdapterOptions {
   defaults?: RequestDefaults
 }
 
-/** Map an HTTP status to a stable LlmError code. */
+/**
+ * Map an HTTP status to a stable LlmError code.
+ * @param status - status of a non-2xx provider response.
+ * @returns `AUTH` (401/403), `RATE_LIMIT` (429), `INVALID_REQUEST` (400), `SERVER` (5xx), or `HTTP_<status>` for anything else.
+ */
 export function httpErrorCode(status: number): string {
   if (status === 401 || status === 403) return 'AUTH'
   if (status === 429) return 'RATE_LIMIT'

+ 6 - 0
packages/llm/llm-deepseek/src/index.ts

@@ -34,6 +34,12 @@ export type * from './types.ts'
 export const name = 'llm-deepseek'
 export const inject = ['llm']
 
+/**
+ * Plugin config, validated by the same-named schemastery schema. Every field
+ * is optional in yml: credentials/endpoint fall back to the environment (a
+ * missing API key fails plugin load, not the first call), and omitted
+ * thinking fields send nothing on the wire, so the provider default applies.
+ */
 export interface Config {
   /** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */
   apiKey?: string

+ 10 - 1
packages/llm/llm-deepseek/src/serialize.ts

@@ -66,6 +66,8 @@ function serializeAssistant(message: Message): WireMessage {
  * `{role: 'tool'}` messages; the harness puts each tool result in its own
  * user-role message, so a mixed user message contributes its text first and
  * its tool results as separate wire messages after.
+ * @param messages - the harness conversation, in order.
+ * @returns the wire messages; order preserved, each tool result expanded into its own entry.
  */
 export function serializeMessages(messages: Message[]): WireMessage[] {
   const wire: WireMessage[] = []
@@ -97,7 +99,14 @@ export function serializeMessages(messages: Message[]): WireMessage[] {
   return wire
 }
 
-/** Build the full wire request. */
+/**
+ * Build the full wire request. Always streaming (`stream: true`, usage
+ * reporting on); optional fields are omitted rather than sent as null, so
+ * provider defaults apply.
+ * @param options - the harness request (model, history, system, tools, sampling).
+ * @param defaults - adapter-level thinking defaults; undefined fields put nothing on the wire.
+ * @returns the chat-completions request body.
+ */
 export function serializeRequest(options: GenerateOptions, defaults: RequestDefaults = {}): WireRequest {
   const messages: WireMessage[] = []
   if (options.system !== undefined) {

+ 2 - 0
packages/llm/llm-deepseek/src/sse.ts

@@ -37,6 +37,8 @@ function eventData(block: string): string | undefined {
  * Parse a byte stream into SSE data payloads. Yields `[DONE]` as the final
  * value and returns; throws `LlmError('STREAM_CLOSED')` when the stream ends
  * without it (truncated response — the model call cannot be trusted).
+ * @param stream - raw SSE bytes; reads may split anywhere, including mid-UTF-8 sequence.
+ * @returns each event's data payload in arrival order, the `[DONE]` sentinel last.
  */
 export async function* parseSse(stream: AsyncIterable<Uint8Array>): AsyncGenerator<string> {
   const decoder = new TextDecoder()

+ 9 - 1
packages/llm/llm-deepseek/src/translate.ts

@@ -29,7 +29,11 @@ interface OpenBlock {
   name?: string
 }
 
-/** Map the wire finish_reason vocabulary to the harness FinishReason. */
+/**
+ * Map the wire finish_reason vocabulary to the harness FinishReason.
+ * @param reason - the wire `finish_reason` string.
+ * @returns the mapped reason; unrecognized values (content_filter, …) become `{kind: 'error'}` with the uppercased value as `code`.
+ */
 export function mapFinishReason(reason: string): FinishReason {
   switch (reason) {
     case 'stop': return { kind: 'stop' }
@@ -46,6 +50,8 @@ export function mapFinishReason(reason: string): FinishReason {
  * (`prompt_tokens = prompt_cache_hit_tokens + prompt_cache_miss_tokens`,
  * api/create-chat-completion); the harness TokenUsage convention is
  * DISJOINT counts, so cache reads are subtracted out of `inputTokens`.
+ * @param usage - wire usage from the finish chunk or the trailing usage-only chunk.
+ * @returns disjoint harness counts; cache/reasoning fields present only when the wire reported them.
  */
 export function mapUsage(usage: WireUsage): TokenUsage {
   const cacheRead = usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens
@@ -75,6 +81,8 @@ function closeBlock(block: OpenBlock): ContentBlock {
 /**
  * Consume SSE data payloads (ending with `[DONE]`) and yield StreamChunks.
  * Malformed JSON payloads abort the stream with `MALFORMED_RESPONSE`.
+ * @param payloads - SSE data payloads from {@link parseSse}, `[DONE]`-terminated.
+ * @returns deltas as they arrive; `block-end`s, `usage`, and `finish` are all deferred to the `[DONE]` sentinel.
  */
 export async function* translate(payloads: AsyncIterable<string>): AsyncGenerator<StreamChunk> {
   let nextIndex = 0

+ 18 - 0
packages/llm/llm-deepseek/src/types.ts

@@ -48,12 +48,18 @@ export interface WireToolMessage {
   content: string
 }
 
+/** One entry of the request `messages` array, discriminated on `role`. */
 export type WireMessage =
   | WireSystemMessage
   | WireUserMessage
   | WireAssistantMessage
   | WireToolMessage
 
+/**
+ * Assistant-role history message. The harness replays `content: ""` (never
+ * null) on tool-call-only turns — some gateways reject null — and sends null
+ * only when the turn carried neither text nor tool calls.
+ */
 export interface WireAssistantMessage {
   role: 'assistant'
   content: string | null
@@ -66,12 +72,14 @@ export interface WireAssistantMessage {
   tool_calls?: WireToolCall[]
 }
 
+/** A completed tool call replayed on an assistant history message; `arguments` is the raw JSON string. */
 export interface WireToolCall {
   id: string
   type: 'function'
   function: { name: string; arguments: string }
 }
 
+/** One entry of the request `tools` array; `parameters` is a JSON Schema object. */
 export interface WireTool {
   type: 'function'
   function: {
@@ -88,11 +96,13 @@ export interface WireChunk {
   usage?: WireUsage | null
 }
 
+/** One streamed choice (requests always ask for a single one); `finish_reason` is non-null only on its terminal chunk. */
 export interface WireChoice {
   delta?: WireDelta
   finish_reason?: string | null
 }
 
+/** The incremental content of one streamed choice; any subset of fields may be present per chunk. */
 export interface WireDelta {
   role?: string
   /** Visible text. Null/empty on reasoning/tool-call chunks. */
@@ -105,6 +115,7 @@ export interface WireDelta {
   tool_calls?: WireToolCallDelta[]
 }
 
+/** A streamed fragment of one tool call; fragments sharing an `index` concatenate into one call. */
 export interface WireToolCallDelta {
   /** Disambiguates parallel tool calls; stable across a call's deltas. */
   index: number
@@ -119,6 +130,13 @@ export interface WireToolCallDelta {
   }
 }
 
+/**
+ * Wire token accounting. `prompt_tokens` INCLUDES cache hits (it equals
+ * `prompt_cache_hit_tokens + prompt_cache_miss_tokens`); `mapUsage` subtracts
+ * them to keep the harness convention of disjoint counts.
+ * `prompt_tokens_details.cached_tokens` is the OpenAI-compat spelling of the
+ * hit count.
+ */
 export interface WireUsage {
   prompt_tokens: number
   completion_tokens: number

+ 9 - 1
packages/llm/llm-pi-ai/src/adapter.ts

@@ -21,14 +21,22 @@ import { toPiContext, toStreamChunks } from './convert.ts'
 /** Reasoning levels surfaced by this adapter (DeepSeek wire: high|max). */
 export type PiAiReasoning = 'off' | 'high' | 'xhigh'
 
+/** Constructor options for {@link PiAiAdapter}; the plugin's `apply` resolves them from Config + environment. */
 export interface PiAiAdapterOptions {
+  /** Bearer token pi-ai sends on every request. */
   apiKey: string
+  /** Endpoint base; `/chat/completions` is appended. */
   baseURL: string
   /** Thinking level applied to every request ('off' disables thinking). */
   reasoning?: PiAiReasoning | undefined
 }
 
-/** Build the inline pi-ai model descriptor for one DeepSeek model name. */
+/**
+ * Build the inline pi-ai model descriptor for one DeepSeek model name.
+ * @param modelId - harness model name; sent verbatim on the wire.
+ * @param options - adapter options; only `baseURL` is read here (key and reasoning apply per request, not per descriptor).
+ * @returns a descriptor with every DeepSeek compat flag explicit — pi-ai's URL-based auto-detection is never relied on.
+ */
 export function buildModel(modelId: string, options: PiAiAdapterOptions): Model<'openai-completions'> {
   return {
     id: modelId,

+ 15 - 2
packages/llm/llm-pi-ai/src/convert.ts

@@ -55,6 +55,8 @@ function parseArguments(raw: string): Record<string, unknown> {
  * NAME (pi-ai's `toolName`), which the harness doesn't carry on the result
  * block — it is recovered from the preceding assistant tool-call with the
  * same id.
+ * @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot.
+ * @returns the pi-ai context; `tools` is omitted entirely when the request declares none.
  */
 export function toPiContext(options: GenerateOptions): PiContext {
   const toolNames = new Map<CallId, string>()
@@ -159,7 +161,11 @@ function emptyPiUsage(): PiUsage {
   }
 }
 
-/** Map pi-ai usage (reasoning folded into output by pi-ai). */
+/**
+ * Map pi-ai usage (reasoning folded into output by pi-ai).
+ * @param usage - cumulative usage from the terminal pi-ai event.
+ * @returns harness counts; cache fields appear only when non-zero (pi-ai reports zeros, not absence).
+ */
 export function mapUsage(usage: PiUsage): TokenUsage {
   return {
     inputTokens: usage.input,
@@ -177,7 +183,11 @@ function classifyPiAiError(message: string): string {
   return 'PI_AI_ERROR'
 }
 
-/** Map a terminal pi-ai event to the harness finish reason. */
+/**
+ * Map a terminal pi-ai event to the harness finish reason.
+ * @param message - the assistant message carried by the `done` or `error` event.
+ * @returns the harness reason; `error` yields `{kind: 'error'}` with a code classified from the error text.
+ */
 export function mapStopReason(message: AssistantMessage): FinishReason {
   switch (message.stopReason) {
     case 'stop': return { kind: 'stop' }
@@ -195,6 +205,9 @@ export function mapStopReason(message: AssistantMessage): FinishReason {
  * Translate the pi-ai event stream into StreamChunks. pi-ai never throws
  * mid-stream — failures arrive as `error` events, which become error/aborted
  * `finish` chunks (the harness protocol's other error-delivery style).
+ * @param events - one assistant turn's pi-ai event stream.
+ * @returns the harness chunks, ending with `usage` then `finish`; throws
+ *   `LlmError` (`STREAM_CLOSED`) if the source ends without a terminal event.
  */
 export async function* toStreamChunks(events: AsyncIterable<AssistantMessageEvent>): AsyncGenerator<StreamChunk> {
   // pi-ai contentIndex ↔ our block index map 1:1 (both count blocks from 0

+ 5 - 0
packages/llm/llm-pi-ai/src/index.ts

@@ -29,6 +29,11 @@ export { mapStopReason, mapUsage, toPiContext, toStreamChunks } from './convert.
 export const name = 'llm-pi-ai'
 export const inject = ['llm']
 
+/**
+ * Plugin config, validated by the same-named schemastery schema. Every field
+ * is optional in yml: credentials/endpoint fall back to the environment (a
+ * missing API key fails plugin load, not the first call).
+ */
 export interface Config {
   /** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */
   apiKey?: string

+ 13 - 2
packages/llm/llm/src/assembler.ts

@@ -40,6 +40,8 @@ export class BlockAssembler {
   /**
    * Feed one chunk. Returns the completed block when the chunk closes one
    * (an explicit `block-end`), otherwise undefined.
+   * @param chunk - the next raw chunk, in stream order.
+   * @returns the authoritative block from the first `block-end` at its index; undefined for every other chunk.
    */
   push(chunk: StreamChunk): ContentBlock | undefined {
     switch (chunk.type) {
@@ -123,20 +125,29 @@ export class BlockAssembler {
     return partial
   }
 
-  /** Assemble all blocks seen so far, in stream order. */
+  /**
+   * 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[] {
     return this.order.map(index => this.assemble(this.mustGet(index), index))
   }
 
+  /** Usage from the `usage` chunk; undefined until one arrives. */
   get usage(): TokenUsage | undefined {
     return this._usage
   }
 
+  /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */
   get finish(): FinishReason {
     return this._finish ?? { kind: 'stop' }
   }
 
-  /** The assembled assistant message. */
+  /**
+   * The assembled assistant message.
+   * @returns an assistant-role message over `blocks()` (same open-block assembly rules).
+   */
   message(): Message {
     return { role: 'assistant', content: this.blocks() }
   }

+ 4 - 0
packages/llm/llm/src/attribution.ts

@@ -54,6 +54,8 @@ export const APP_IDENTITY: AppIdentity = {
  * The standard `User-Agent` value: `product/version (+url)`. The
  * parenthesized `+url` comment is the conventional self-identification form
  * (RFC 9110 §10.1.5 product + comment syntax).
+ * @param identity - the identity to render; defaults to {@link APP_IDENTITY}.
+ * @returns the ready-to-send header value.
  */
 export function userAgent(identity: AppIdentity = APP_IDENTITY): string {
   return `${identity.product}/${identity.version} (+${identity.url})`
@@ -63,6 +65,8 @@ export function userAgent(identity: AppIdentity = APP_IDENTITY): string {
  * Build the attribution headers an adapter must send on every provider
  * request. Header names are lowercase (HTTP field names are case-insensitive
  * on the wire).
+ * @param identity - the identity to send; defaults to {@link APP_IDENTITY} — omission cannot suppress attribution.
+ * @returns headers to merge into the provider request (currently just `user-agent`).
  */
 export function attributionHeaders(
   identity: AppIdentity = APP_IDENTITY,

+ 5 - 1
packages/llm/llm/src/brand.ts

@@ -17,7 +17,11 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
  */
 export type CallId = Branded<'CallId'>
 
-/** Brand a string as a {@link CallId}. */
+/**
+ * Brand a string as a {@link CallId}.
+ * @param id - the provider-issued (or synthesized) call id.
+ * @returns the same string, branded; no validation is performed.
+ */
 export function CallId(id: string): CallId {
   return id as CallId
 }

+ 6 - 1
packages/llm/llm/src/error.ts

@@ -18,6 +18,7 @@
  * `ErrorOptions`. `name` defaults to the subclass constructor name.
  */
 export class HarnessError extends Error {
+  /** Stable machine-routable failure class (e.g. `RATE_LIMIT`); route on this, never by parsing `message`. */
   readonly code: string
 
   constructor(message: string, code: string, options?: ErrorOptions) {
@@ -27,7 +28,11 @@ export class HarnessError extends Error {
   }
 }
 
-/** Narrow an arbitrary thrown value to a HarnessError (for `instanceof` at seams). */
+/**
+ * Narrow an arbitrary thrown value to a HarnessError (for `instanceof` at seams).
+ * @param value - the caught value (`unknown` in catch clauses).
+ * @returns true only for real instances; duck-typed or cross-realm errors do not narrow.
+ */
 export function isHarnessError(value: unknown): value is HarnessError {
   return value instanceof HarnessError
 }

+ 5 - 1
packages/llm/llm/src/index.ts

@@ -73,7 +73,11 @@ export class LlmError extends HarnessError {
  * same value to the wire.
  */
 export abstract class LlmAdapter {
-  /** Stream one model call as raw chunks. The only required method. */
+  /**
+   * 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>
 }
 

+ 3 - 0
packages/llm/llm/src/never.ts

@@ -26,6 +26,9 @@
  * variant was added without updating the switch (compile error at the call
  * site — the desired outcome) or a value escaped its type (runtime throw
  * with diagnostics — the safety net).
+ * @param value - the impossible value; typed `never` so an unhandled variant fails compilation at the call site.
+ * @param context - optional label (e.g. the switch site) prefixed into the throw message.
+ * @returns never — it always throws, with the offending value JSON-rendered in the message.
  */
 export function assertNever(value: never, context?: string): never {
   // JSON.stringify is typed string but returns undefined for undefined input;

+ 4 - 0
packages/llm/llm/src/types.ts

@@ -70,7 +70,9 @@ export interface ContentBlockMap {
   'tool-result': ToolResultBlock
 }
 
+/** The block `type` tag vocabulary; widens as plugins merge new shapes into {@link ContentBlockMap}. */
 export type ContentBlockType = keyof ContentBlockMap
+/** Any known content block, derived from {@link ContentBlockMap}; switch on `type` and fall through unknowns (merge-extensible). */
 export type ContentBlock = ContentBlockMap[ContentBlockType]
 
 /** A single message in a conversation history. */
@@ -88,6 +90,7 @@ export interface MessageSourceMap {
   plugin: { kind: 'plugin'; plugin: string }
 }
 
+/** Any known message source, derived from {@link MessageSourceMap}; switch on `kind` and fall through unknowns (merge-extensible). */
 export type MessageSource = MessageSourceMap[keyof MessageSourceMap]
 
 /**
@@ -102,6 +105,7 @@ export interface FinishReasonMap {
   'error': { kind: 'error'; message: string; code?: string }
 }
 
+/** Any known finish reason, derived from {@link FinishReasonMap}; switch on `kind` and fall through unknowns (merge-extensible). */
 export type FinishReason = FinishReasonMap[keyof FinishReasonMap]
 
 /**

+ 35 - 7
packages/session-persistence/session-persistence-jsonl/src/format.ts

@@ -27,7 +27,11 @@ export interface HeaderLine {
   seedLength?: number
 }
 
-/** Build the header line object from a {@link SessionHeader}. */
+/**
+ * Build the header line object from a {@link SessionHeader}.
+ * @param header - the immutable session metadata to serialize.
+ * @returns the `type: 'session'`-tagged line object, absent optional fields omitted (never null).
+ */
 export function toHeaderLine(header: SessionHeader): HeaderLine {
   return {
     type: 'session',
@@ -40,7 +44,11 @@ export function toHeaderLine(header: SessionHeader): HeaderLine {
   }
 }
 
-/** Parse a header line back into a {@link SessionHeader}. */
+/**
+ * Parse a header line back into a {@link SessionHeader}.
+ * @param line - the shape-checked first line of a log (see the `isHeaderLine` guard).
+ * @returns the header, absent optional fields omitted.
+ */
 export function fromHeaderLine(line: HeaderLine): SessionHeader {
   return {
     version: line.version,
@@ -77,6 +85,8 @@ function isHeaderLine(value: unknown): value is HeaderLine {
  * `Buffer.from(…, 'utf8')` would do, breaking injectivity). `.` is in the safe
  * set for readability but the whole-segment tokens `.`/`..` are escaped so they
  * can never traverse.
+ * @param raw - the string to encode; must be non-empty (throws on `''`).
+ * @returns the escaped single path segment, decodable back to `raw`.
  */
 export function encodeSegment(raw: string): string {
   if (raw.length === 0) throw new Error('cannot encode an empty path segment')
@@ -97,9 +107,12 @@ export function encodeSegment(raw: string): string {
 
 /**
  * The directory a session's files live in: the configured root, then a per-cwd
- * subdirectory so sessions group by project. The cwd subdir is a stable hash
- * (short, collision-resistant, filesystem-safe) plus an encoded suffix for
- * readability; sessions without a cwd go in a shared `_no-cwd` bucket.
+ * subdirectory so sessions group by project. The cwd subdir is a stable hash of
+ * the cwd (short, collision-resistant, filesystem-safe); sessions without a
+ * cwd go in a shared `_no-cwd` bucket.
+ * @param root - the backend's session root directory.
+ * @param cwd - the session's project directory; `undefined` selects the shared `_no-cwd` bucket.
+ * @returns the per-cwd bucket directory path under `root`.
  */
 export function sessionDir(root: string, cwd: string | undefined): string {
   if (cwd === undefined) return join(root, '_no-cwd')
@@ -107,12 +120,22 @@ export function sessionDir(root: string, cwd: string | undefined): string {
   return join(root, `cwd-${hash}`)
 }
 
-/** The append-only event-log file path for a session. */
+/**
+ * The append-only event-log file path for a session.
+ * @param root - the backend's session root directory.
+ * @param cwd - the session's project directory (picks the per-cwd bucket; `undefined` → `_no-cwd`).
+ * @param id - the session id, path-encoded via {@link encodeSegment} before filesystem use.
+ * @returns the session's `.jsonl` log file path.
+ */
 export function logPath(root: string, cwd: string | undefined, id: SessionId): string {
   return join(sessionDir(root, cwd), `${encodeSegment(id)}.jsonl`)
 }
 
-/** Serialize one event as a JSONL line (no trailing newline). */
+/**
+ * Serialize one event as a JSONL line (no trailing newline).
+ * @param event - the event to serialize verbatim.
+ * @returns the event's single-line JSON text; the writer adds the newline.
+ */
 export function eventLine(event: SessionEvent): string {
   return JSON.stringify(event)
 }
@@ -135,6 +158,9 @@ export function eventLine(event: SessionEvent): string {
  * This relies on the session-log invariant that every event lives inside a turn
  * (`Session.append` enforces it): only the final turn can be open, so the
  * preserved tail is at most one unclosed turn.
+ * @param buffer - the raw bytes of the log file (header line first).
+ * @returns the header, the preserved event prefix, and `committedBytes` — the
+ *   byte offset the next append truncates any torn tail to.
  */
 export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionEvent[]; committedBytes: number } {
   const text = buffer.toString('utf8')
@@ -239,6 +265,8 @@ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionE
  * `undefined` if it is missing/not a header. Used by `list()` to read session
  * metadata WITHOUT parsing the whole log: a session picker scales with the
  * number of sessions, not the total size of every conversation.
+ * @param firstLine - the first line of a log file (without its trailing newline).
+ * @returns the parsed header, or `undefined` when the line is not a well-formed session header.
  */
 export function parseHeaderMeta(firstLine: string): SessionHeader | undefined {
   let parsed: unknown

+ 1 - 0
packages/session-persistence/session-persistence-jsonl/src/index.ts

@@ -31,6 +31,7 @@ import {
   encodeSegment, eventLine, logPath, parseHeaderMeta, scanLog, sessionDir, toHeaderLine,
 } from './format.ts'
 
+/** Plugin config: where the JSONL backend keeps its session logs (`root` is required — no default). */
 export interface Config {
   /**
    * Root directory for all session files. Required (no default): a default of

+ 17 - 2
packages/session-persistence/session-persistence-sqlite/src/schema.ts

@@ -76,6 +76,9 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
  * is the merged layout carrying every column; bumping past the collided v3
  * makes the version check reject both sibling v3 databases instead of opening
  * one against columns it does not have.
+ * @param path - the SQLite database file to open (created when absent).
+ * @param journalMode - the journal pragma to apply — a closed in-code union, validated by the plugin Config.
+ * @returns the open handle with pragmas applied and both tables ensured.
  */
 export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync {
   const db = new DatabaseSync(path)
@@ -120,7 +123,11 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy
   return db
 }
 
-/** Reconstruct the {@link SessionHeader} from a `sessions` row. */
+/**
+ * Reconstruct the {@link SessionHeader} from a `sessions` row.
+ * @param row - the `sessions` table row.
+ * @returns the header, `NULL` columns mapped to omitted optional fields.
+ */
 export function rowToMeta(row: SessionRow): SessionHeader {
   return {
     version: row.version,
@@ -132,7 +139,12 @@ export function rowToMeta(row: SessionRow): SessionHeader {
   }
 }
 
-/** Reconstruct a {@link SessionEvent} from an `events` row (parses `data`). */
+/**
+ * Reconstruct a {@link SessionEvent} from an `events` row (parses `data`).
+ * @param row - the `events` table row; `data` and the surface columns hold JSON text.
+ * @returns the reconstructed event; throws when a JSON column fails to parse
+ *   ({@link scanRows} treats that as a hole, not corruption, in the tail).
+ */
 export function rowToEvent(row: EventRow): SessionEvent {
   // Surface-metadata fields are conditional on the event type in the type
   // system; spread them so each variant gets only the fields it declares.
@@ -172,6 +184,9 @@ export function rowToEvent(row: EventRow): SessionEvent {
  * This relies on the session-log invariant that every event lives inside a turn
  * (`Session.append` enforces it): only the final turn can be open, so the
  * preserved tail is at most one unclosed turn.
+ * @param rows - one session's event rows, ordered by seq ascending.
+ * @returns the preserved event prefix, plus `tornFrom` — the seq the physical
+ *   delete starts at — when a torn tail exists.
  */
 export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[]; tornFrom?: number } {
   // Pass 1: parse each row's data; a row whose data is not valid JSON is a hole.

+ 5 - 0
packages/session-persistence/session-persistence/src/coordinator.ts

@@ -186,6 +186,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
   /**
    * Register a new session's metadata (lazy: no physical write until the first
    * {@link append}). Rejects if the id is already tracked or already persisted.
+   * @param meta - the immutable header (id, version, cwd, lineage) to record; snapshotted at call time.
    */
   create(meta: SessionHeader): Promise<void> {
     // Snapshot the metadata at call time: the op runs later (behind the
@@ -216,6 +217,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
   /**
    * Durably persist a batch of events. Honors the append-only and contiguous-seq
    * contracts; rejects non-JSON-serializable `event.data`.
+   * @param id - the session the batch belongs to.
+   * @param events - the contiguous batch to persist, in seq order; deep-cloned at call time.
    */
   async append(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
     // Validate serializability BEFORE cloning so a bad event surfaces the typed
@@ -252,6 +255,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
    * Reload a session: its {@link SessionHeader} plus the event log up to the last
    * durable checkpoint, with any interrupted final turn durably closed (synthetic
    * boundary events) during load.
+   * @param id - the persisted session to reload.
+   * @returns the header plus the event log, ending on a balanced `turn/end`.
    */
   load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
     return this.serialize(id, () => this.loadCore(id))

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

@@ -45,6 +45,9 @@ declare module 'cordis' {
  *
  * The comparison includes the full event payload, not just seq/type/time, so a
  * mutated seed cannot be grafted onto a durable log with the same envelope.
+ * @param seed - the live session's creation-time event snapshot.
+ * @param prefix - the persisted prefix the seed must reproduce.
+ * @returns `true` when the prefix fits within the seed and every event matches by JSON text.
  */
 export function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly SessionEvent[]): boolean {
   return prefix.length <= seed.length
@@ -58,6 +61,7 @@ export function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly
  * Reject non-JSON-serializable event data before a backend serializes a batch.
  * Live session appends already enforce this; persistence append paths also
  * accept replay/fork batches that may bypass a live session instance.
+ * @param events - the batch to validate; throws naming the offending event's type and seq.
  */
 export function assertSerializable(events: readonly SessionEvent[]): void {
   for (const event of events) {

+ 27 - 4
packages/subagent/subagent-acp/src/run.ts

@@ -121,7 +121,12 @@ export const DEFAULT_DISPOSE_GRACE_MS = 3_000
  */
 export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
 
-/** The ambient env minus credential-shaped vars, plus the spec's explicit env. */
+/**
+ * The ambient env minus credential-shaped vars, plus the spec's explicit env.
+ * @param extra - explicit vars layered on top AFTER the scrub, so a
+ * credential-shaped name supplied deliberately still reaches the child.
+ * @returns the environment to spawn the child with.
+ */
 export function buildChildEnv(extra: Record<string, string>): NodeJS.ProcessEnv {
   const env: NodeJS.ProcessEnv = {}
   for (const [key, value] of Object.entries(process.env)) {
@@ -130,7 +135,12 @@ export function buildChildEnv(extra: Record<string, string>): NodeJS.ProcessEnv
   return { ...env, ...extra }
 }
 
-/** Map an ACP {@link StopReason} to a harness {@link SubagentStopReason}. */
+/**
+ * Map an ACP {@link StopReason} to a harness {@link SubagentStopReason}.
+ * @param reason - the terminal reason from the child's `session/prompt` response.
+ * @returns the harness equivalent; `max_turn_requests` and any unknown future
+ * variant map to `error`, so an unclean stop is never reported as `completed`.
+ */
 export function acpStopReason(reason: StopReason): SubagentStopReason {
   switch (reason) {
     case 'end_turn':
@@ -155,12 +165,20 @@ export function acpStopReason(reason: StopReason): SubagentStopReason {
   }
 }
 
-/** Collect the text of an ACP content block (non-text blocks contribute nothing). */
+/**
+ * Collect the text of an ACP content block (non-text blocks contribute nothing).
+ * @param content - the content block off a streamed `agent_message_chunk`.
+ * @returns the block's text, or `''` for a non-text block.
+ */
 export function acpContentText(content: AcpContentBlock): string {
   return content.type === 'text' ? content.text : ''
 }
 
-/** Translate the harness prompt blocks into ACP prompt blocks (text only). */
+/**
+ * Translate the harness prompt blocks into ACP prompt blocks (text only).
+ * @param prompt - the harness prompt; non-text blocks are dropped.
+ * @returns the ACP text blocks, in order.
+ */
 export function toAcpPrompt(prompt: ContentBlock[]): AcpContentBlock[] {
   const blocks: AcpContentBlock[] = []
   for (const block of prompt) {
@@ -206,6 +224,11 @@ function exitsWithin(child: ChildProcess, ms: number): Promise<boolean> {
  * failure (a spawn/transport/RPC error resolves with `stopReason: 'error'`), per
  * the seam contract. `cancel()` sends `session/cancel`; `dispose()` kills the
  * subprocess and awaits its exit (quiescent teardown).
+ * @param request - the start request; the driver consumes `prompt` and `signal`
+ * (an already-aborted signal yields an inert `aborted` run with no spawn).
+ * @param spec - the resolved spawn spec: command/args/cwd, env, permission
+ * policy, dispose graces, and the optional error sink.
+ * @returns the live run handle for the child subprocess.
  */
 export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): SubagentRun {
   const id = AgentId(randomUUID())

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

@@ -47,6 +47,8 @@ export const Config: z<Config> = z.object({
  * empty — i.e. fresh — child). The result is contiguous from seq 0 (the live
  * log keeps `seq === index`), so it is a valid session seed; the in-flight,
  * unbalanced turn is dropped so the invariants replay accepts it.
+ * @param parent - the agent whose session log to slice.
+ * @returns the seed events, contiguous from seq 0; empty when no turn has completed.
  */
 export function completedTurnPrefix(parent: Agent): SessionEvent[] {
   const events = parent.session.events

+ 12 - 1
packages/subagent/subagent-inprocess/src/index.ts

@@ -34,7 +34,11 @@ declare module '@deepseek-ai/dsh-agent' {
   }
 }
 
-/** Read an agent's delegation depth (absent ⇒ a top-level agent, depth 0). */
+/**
+ * Read an agent's delegation depth (absent ⇒ a top-level agent, depth 0).
+ * @param agent - the agent whose options may carry `subagentDepth`.
+ * @returns 0 for a top-level agent, its parent's depth + 1 for a subagent.
+ */
 export function depthOf(agent: Agent): number {
   return agent.options.subagentDepth ?? 0
 }
@@ -88,6 +92,13 @@ export interface InProcessRunOptions {
  * the matching `turn/end.reason` the stop reason. `dispose()` delegates to the
  * factory's {@link AgentHandle.dispose} (stop loop → await quiescence → remove
  * session); `cancel()` cancels the child's in-flight turn.
+ *
+ * Throws {@link SubagentDepthError} before creating anything when the child's
+ * depth (parent depth + 1) would exceed `request.maxDepth`.
+ * @param ctx - the context whose `agents` factory creates and owns the child.
+ * @param request - the start request (prompt, parent, signal, per-child options).
+ * @param options - the backend's inputs: provider name plus the optional seed.
+ * @returns the live run handle for the child agent.
  */
 export function startInProcessRun(
   ctx: Context,

+ 1 - 0
packages/subagent/subagent/src/types.ts

@@ -93,6 +93,7 @@ export interface SubagentStopReasonMap {
   refusal: 'refusal'
 }
 
+/** The union over {@link SubagentStopReasonMap} — widens automatically as backends merge in variants. */
 export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonMap]
 
 /**

+ 14 - 0
packages/support/llm-replay/src/index.ts

@@ -124,6 +124,8 @@ export interface SessionScript {
  * Parse a session `.jsonl` buffer into its event list. Line 0 is the session
  * header (a `{type:'session',…}` record), every subsequent non-empty line is a
  * {@link SessionEvent}. The header is skipped; malformed lines fail loud.
+ * @param text - the raw `.jsonl` file contents.
+ * @returns every event after the header, in log order.
  */
 export function parseSessionLog(text: string): SessionEvent[] {
   const lines = text.split('\n').filter(line => line.trim().length > 0)
@@ -147,6 +149,8 @@ export function parseSessionLog(text: string): SessionEvent[] {
  * own model calls; absent ⇒ 0). A header missing a field falls back to a stable
  * default (`''` / `0` / `0`) rather than throwing: a no-model fixture is
  * header-only and still orders fine as the single (primary) script.
+ * @param text - the raw `.jsonl` file contents (only the header line is read).
+ * @returns the header's `id`, `createdAt`, and `seedLength`, defaulted when absent.
  */
 export function parseSessionHeader(text: string): { id: string; createdAt: number; seedLength: number } {
   const firstLine = text.split('\n').find(line => line.trim().length > 0) ?? '{}'
@@ -176,6 +180,8 @@ export function parseSessionHeader(text: string): { id: string; createdAt: numbe
  * sidecar with an explicit `throw` (or `hang`) entry instead. {@link
  * deriveReplayScript} throws, naming the offending `(turn, step)`, so a missing
  * override fails loud rather than silently replaying a thrown call as success.
+ * @param events - the recorded session's events; only `assistant/chunk` is consulted.
+ * @returns one `chunks` entry per recorded model call, in call order.
  */
 export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] {
   const script: ReplayEntry[] = []
@@ -214,6 +220,8 @@ export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] {
  * Fail-loud if the JSONL fixture is missing (the scenario was never recorded) —
  * never silently returns an empty script, so a coverage hole can't masquerade
  * as a passing replay.
+ * @param config - the fixture paths; only `file` and `overrideFile` are consulted.
+ * @returns the primary session's replay entries.
  */
 export function loadReplayScript(config: ReplayConfig): ReplayEntry[] {
   if (config.overrideFile !== undefined && existsSync(config.overrideFile)) {
@@ -241,6 +249,8 @@ export function loadReplayScript(config: ReplayConfig): ReplayEntry[] {
  * the parent issues the FIRST model call (it must stream before it can delegate
  * in the synchronous nested cut), so binding it to the first live session is
  * correct regardless of a timestamp tie.
+ * @param config - the fixture paths: the primary log plus any recorded child logs.
+ * @returns the primary script first, then the child scripts in bind order.
  */
 export function loadSessionScripts(config: ReplayConfig): SessionScript[] {
   const primaryEntries = loadReplayScript(config)
@@ -355,6 +365,9 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined)
  * Each per-session cursor advances synchronously at listener-invocation time
  * (not lazily inside the generator) so call ORDER within a session, not
  * iteration order, fixes the mapping.
+ * @param ctx - the context whose `llm/stream` waterfall the listener short-circuits.
+ * @param config - the resolved fixture paths (env-var defaulting is `apply`'s job).
+ * @returns the `ctx.on` disposer that removes the listener.
  */
 export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void {
   const scripts = loadSessionScripts(config)
@@ -408,6 +421,7 @@ export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void
 export const name = 'llm-replay'
 export const inject = ['llm']
 
+/** Plugin config: the {@link ReplayConfig} inputs, each defaulting to its `DSH_SNAPSHOT_*` env var in `apply`. */
 export interface Config {
   /** Override the fixture path; defaults to `$DSH_SNAPSHOT_FILE`. */
   file?: string

+ 8 - 0
packages/ui/acp/src/codec.ts

@@ -39,6 +39,8 @@ import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientpr
  *                 hook before any step ran — ACP has no "rejected" reason, and a
  *                 blocked prompt is, from the client's view, the prompt not being
  *                 carried out; `cancelled` is the closest legal wire reason)
+ * @param reason - the harness turn-end reason to translate.
+ * @returns the legal ACP wire value per the mapping above.
  */
 export function turnEndToStopReason(reason: TurnEndReason): StopReason {
   switch (reason.kind) {
@@ -71,6 +73,8 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
  * `reasoning` is surfaced via `agent_thought_chunk`
  * streaming rather than as a message block, and `tool-call`/`tool-result`
  * are handled by the tool-call update path.
+ * @param block - the harness content block to translate.
+ * @returns the ACP block, or `undefined` for a kind with no message-content mapping.
  */
 export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | undefined {
   switch (block.type) {
@@ -89,6 +93,8 @@ export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock |
  * concatenated verbatim; resource links become explicit textual references so
  * baseline ACP clients can point at files without the bridge silently dropping
  * that context.
+ * @param prompt - the ACP prompt blocks to flatten.
+ * @returns the concatenated text, with resource links rendered as bracketed references.
  */
 export function acpPromptToText(prompt: readonly AcpContentBlock[]): string {
   return prompt
@@ -109,6 +115,8 @@ export function acpPromptToText(prompt: readonly AcpContentBlock[]): string {
  * Whether an ACP prompt contains content the bridge cannot accept. Baseline ACP
  * requires `text` and `resource_link`; richer inline payloads (`resource`,
  * image, audio, …) are rejected rather than silently dropped.
+ * @param prompt - the ACP prompt blocks to inspect.
+ * @returns `true` when any block is neither `text` nor `resource_link`.
  */
 export function promptHasUnsupportedContent(prompt: readonly AcpContentBlock[]): boolean {
   return prompt.some(block => block.type !== 'text' && block.type !== 'resource_link')

+ 36 - 2
packages/ui/acp/src/index.ts

@@ -701,6 +701,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
  * Build per-agent options from the plugin config, omitting absent fields
  * (exactOptionalPropertyTypes: never assign `undefined` to an optional key).
  * Exported for unit coverage of both the present and absent branches.
+ * @param config - the plugin config carrying the optional model name.
+ * @returns the per-agent options, with `model` present only when configured.
  */
 export function agentOptions(config: AcpConfig): { model?: string } {
   return {
@@ -764,6 +766,16 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void {
  *
  * Other event types (turn/step boundaries, context/message, …) produce
  * no client update.
+ * @param sessionId - the ACP session id stamped on every emitted notification.
+ * @param event - the harness session event to translate.
+ * @param notify - sink for each produced `session/update` notification; called
+ * zero or more times per event (best-effort UI feed, never load-bearing).
+ * @param presenter - resolves tool-owned render intent for tool events;
+ * defaults to the generic-fallback {@link nullToolPresenter}.
+ * @param terminal - the connection's terminal-rendering context; defaults to
+ * disabled (the plain-text console-block fallback).
+ * @param options - `includeUserMessages` (default `true`): live streaming
+ * passes `false` so a prompt the client just sent is not echoed back.
  */
 export function streamSessionEventUpdate(
   sessionId: SessionId,
@@ -825,6 +837,8 @@ export function streamSessionEventUpdate(
  * harness status triple IS `PlanEntryStatus`). The ACP client REPLACES its whole
  * plan on each `plan` update, matching the harness's whole-list-replace
  * semantics, so no per-entry diffing is needed.
+ * @param todos - the harness todo list (the whole list, not a diff).
+ * @returns the ACP plan body, one entry per todo.
  */
 export function todosToPlan(todos: TodoItem[]): Plan {
   return { entries: todos.map((todo): PlanEntry => ({ content: todo.content, priority: 'medium', status: todo.status })) }
@@ -885,7 +899,16 @@ export class ToolPresenter {
     private readonly onError: (message: string) => void = () => {},
   ) {}
 
-  /** Pending-state render intent for a `tool/call`; remembers `(name, args, card)` for the matching result. */
+  /**
+   * Pending-state render intent for a `tool/call`; remembers `(name, args, card)`
+   * for the matching result.
+   * @param callId - the call id the matching `tool/result` will look up.
+   * @param name - the tool name, resolved against the registry for `presentCall`.
+   * @param argsJson - the raw arguments JSON from the event; parsed for the view
+   * (a non-JSON string is surfaced raw).
+   * @returns the tool-owned view, or the generic fallback (title = tool name,
+   * kind `other`, parsed args as raw input) when the tool defines none or threw.
+   */
   call(callId: CallId, name: string, argsJson: string): ToolCallView {
     const args = parseToolArguments(argsJson)
     let present: ToolCallView | undefined
@@ -905,7 +928,18 @@ export class ToolPresenter {
     return view
   }
 
-  /** Completed-state render intent for a `tool/result`; consumes the remembered `(name, args, card)`. */
+  /**
+   * Completed-state render intent for a `tool/result`; consumes the remembered
+   * `(name, args, card)`.
+   * @param callId - the id of the matching `tool/call`; an unknown or late id
+   * falls back to the raw content.
+   * @param content - the result's content blocks (the fallback and fill-in body).
+   * @param isError - whether the result is an error, forwarded to `presentResult`.
+   * @param meta - the result's machine-readable meta, forwarded when present.
+   * @returns the tool-owned view — an orphaned `terminal` result (no terminal
+   * call side) and a content-less `generic` are normalized — or the raw-content
+   * generic card when the tool defines no `presentResult` or threw.
+   */
   result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView {
     const call = this.pending.get(callId)
     this.pending.delete(callId)

+ 16 - 0
packages/ui/app-boot/src/index.ts

@@ -36,6 +36,10 @@ import Loader from '@cordisjs/plugin-loader'
  * the SAME directory (the keyless replay tree). Other modes — including no
  * snapshot mode at all — use the path as-is. Returns an absolute path resolved
  * from `cwd`.
+ * @param configPath - the requested config path (absolute, or relative to `cwd`).
+ * @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the basename.
+ * @param cwd - the base a relative `configPath` resolves against.
+ * @returns the absolute path of the config to boot.
  */
 export function resolveConfigPath(
   configPath: string, snapshotMode: string | undefined, cwd: string = process.cwd(),
@@ -54,6 +58,9 @@ export function resolveConfigPath(
  * them via the `!!js` tag. A present-but-unreadable `.env` is a real
  * misconfiguration: surface it via `warn` (one line, default stderr) rather
  * than silently running with the wrong environment.
+ * @param binName - the diagnostic prefix on the warn line.
+ * @param dir - the directory whose `.env` to load.
+ * @param warn - sink for the one-line misconfiguration diagnostic.
  */
 export function loadEnv(
   binName: string, dir: string = process.cwd(),
@@ -90,6 +97,9 @@ export interface FailLoudProcess {
  * STDERR (never stdout — for the ACP bin that channel carries JSON-RPC) and
  * guarantees `exit(1)`. Install before `boot()`. Returns the uninstaller
  * (tests use it; the bins run until exit and never do).
+ * @param binName - the diagnostic prefix on the fatal-failure line.
+ * @param proc - the process slice to register on; tests inject a fake.
+ * @returns the uninstaller that removes the rejection handler.
  */
 export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void {
   const handler = (err: unknown): void => {
@@ -108,6 +118,8 @@ export function installFailLoud(binName: string, proc: FailLoudProcess = process
  * entry is the one legitimate fiber-less state: `Entry.refresh()` deliberately
  * skips `init()` for it — a valid "plugin turned off" config, not a failed
  * import — so it is excluded.
+ * @param ctx - the settled context whose loader entries to audit.
+ * @param binName - the diagnostic prefix on the thrown error.
  */
 export function assertEntriesLoaded(ctx: Context, binName: string): void {
   const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
@@ -139,6 +151,10 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
  * active under `node --expose-internals`; a consumer running a built bin must
  * pass that flag (or install the plugins where node hoists them). Relative
  * specifiers resolve against the config directory with no flag.
+ * @param binName - the diagnostic prefix for load-failure errors.
+ * @param absoluteConfigPath - the config to include; must already be absolute
+ * (see {@link resolveConfigPath}).
+ * @returns the root context once every entry has started.
  */
 export async function boot(binName: string, absoluteConfigPath: string): Promise<Context> {
   const ctx = new Context()

+ 4 - 0
packages/ui/stdio-agent/src/stdio-chat.ts

@@ -63,6 +63,10 @@ function isTTYPair(input: Readable, output: Writable): boolean {
  * directly with fakes. Returns nothing — all registration is via `ctx.on`/
  * `ctx.effect`, so fiber disposal tears every listener and the readline
  * interface down.
+ * @param ctx - the context supplying the `agents` service and the event feeds.
+ * @param config - the plugin config; defaults are re-applied here for direct
+ * callers that bypass Loader validation.
+ * @param runtime - the process-I/O seam (line source, render sink, exit hook).
  */
 export function createStdioChat(ctx: Context, config: Config, runtime: StdioRuntime): void {
   // Default here too (not just via schemastery's `.default()`): this helper is

+ 33 - 5
packages/web/tool-web/src/fetch.ts

@@ -14,7 +14,13 @@ import { assertNever } from '@deepseek-ai/dsh-llm'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 import { htmlToMarkdown } from './html.ts'
 
-/** Validate value constraints the schema DSL can't express. */
+/**
+ * Validate value constraints the schema DSL can't express: a non-blank `url`,
+ * and a positive `timeout_ms` when present. Throws a plain `Error` otherwise.
+ *
+ * @param args - the schema-validated `web_fetch` arguments.
+ * @returns the arguments renamed to the seam's camelCase request fields.
+ */
 export function parseFetchArgs(args: { url: string; timeout_ms?: number }): { url: string; timeoutMs?: number } {
   if (args.url.trim().length === 0) throw new Error('url must be a non-empty string')
   if (args.timeout_ms !== undefined && (!Number.isFinite(args.timeout_ms) || args.timeout_ms <= 0)) {
@@ -23,7 +29,13 @@ export function parseFetchArgs(args: { url: string; timeout_ms?: number }): { ur
   return { url: args.url, ...args.timeout_ms !== undefined ? { timeoutMs: args.timeout_ms } : {} }
 }
 
-/** Render a fetched body to model-facing markdown text. */
+/**
+ * Render a fetched body to model-facing markdown text.
+ *
+ * @param body - the decoded body; `html` is converted via
+ *   {@link htmlToMarkdown}, `text` passes through verbatim.
+ * @returns the text for the tool's output block.
+ */
 export function renderBody(body: WebFetchBody): string {
   switch (body.kind) {
     case 'html':
@@ -36,19 +48,35 @@ export function renderBody(body: WebFetchBody): string {
   }
 }
 
-/** Format a fetch result as one model-facing text block. */
+/**
+ * Format a fetch result as one model-facing text block.
+ *
+ * @param result - the seam's fetch outcome.
+ * @returns a `Fetched <url> (HTTP <status>)` header, the rendered body, and a
+ *   fetch-something-narrower notice when the provider truncated the content.
+ */
 export function formatFetchOutput(result: WebFetchResult): string {
   const header = `Fetched ${result.url} (HTTP ${result.statusCode})`
   const footer = result.truncated ? '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)' : ''
   return `${header}\n\n${renderBody(result.body)}${footer}`
 }
 
-/** Pending-call presentation: a fetch card titled by the URL. */
+/**
+ * Pending-call presentation: a fetch card titled by the URL.
+ *
+ * @param args - the raw tool arguments; only `url` feeds the view.
+ * @returns the generic card view (`kind: 'fetch'`) shown while the call runs.
+ */
 export function presentFetchCall(args: { url: string; timeout_ms?: number }): GenericCallView {
   return { card: 'generic', title: args.url, kind: 'fetch', rawInput: args.url }
 }
 
-/** Register the `web_fetch` tool and its system-prompt guidance. */
+/**
+ * Register the `web_fetch` tool and its system-prompt guidance.
+ *
+ * @param ctx - context whose `tools` and `systemPrompt` registries receive the
+ *   registrations; both are effect-scoped and unregister on plugin dispose.
+ */
 export function applyWebFetchTool(ctx: Context): void {
   ctx.systemPrompt.section({
     name: 'tool:web_fetch',

+ 4 - 0
packages/web/tool-web/src/html.ts

@@ -44,6 +44,10 @@ function safeFromCodePoint(code: number, fallback: string): string {
  * Convert an HTML document to a readable markdown-ish text approximation.
  * Best-effort and lossy by design — fidelity is the job of a future heavier
  * converter, not this fallback.
+ *
+ * @param html - the raw HTML source.
+ * @returns plain text with markdown headings, list bullets, and links;
+ *   whitespace collapsed to at most one blank line and trimmed.
  */
 export function htmlToMarkdown(html: string): string {
   let text = html

+ 1 - 0
packages/web/tool-web/src/index.ts

@@ -33,6 +33,7 @@ export const name = 'tool-web'
 /** Services required by the web tool suite. */
 export const inject = ['tools', 'web', 'systemPrompt']
 
+/** Plugin config: which web tools to register, and the `web_search` source cap. */
 export interface Config {
   /** Register `web_search`. Defaults to true. */
   search?: boolean

+ 29 - 4
packages/web/tool-web/src/search.ts

@@ -20,7 +20,13 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
  */
 export const WEB_SEARCH_MAX_RESULTS = 8
 
-/** Validate value constraints the schema DSL can't express. */
+/**
+ * Validate value constraints the schema DSL can't express: a non-blank
+ * `query`. Throws a plain `Error` otherwise.
+ *
+ * @param args - the schema-validated `web_search` arguments.
+ * @returns the accepted arguments, passed through unchanged.
+ */
 export function parseSearchArgs(args: { query: string }): { query: string } {
   if (args.query.trim().length === 0) throw new Error('query must be a non-empty string')
   return { query: args.query }
@@ -38,7 +44,14 @@ function sourceLabel(url: string, title: string | undefined): string {
   }
 }
 
-/** Format a search result as one model-facing text block. */
+/**
+ * Format a search result as one model-facing text block.
+ *
+ * @param result - the seam's search outcome.
+ * @returns the provider answer (when any), a markdown source list with snippet
+ *   and date metadata (or `No results found.`), a refine-the-query note when
+ *   truncated, and a standing cite-your-sources instruction.
+ */
 export function formatSearchOutput(result: WebSearchResult): string {
   const parts: string[] = []
   if (result.content !== undefined && result.content.length > 0) parts.push(result.content)
@@ -62,12 +75,24 @@ export function formatSearchOutput(result: WebSearchResult): string {
   return parts.join('\n\n')
 }
 
-/** Pending-call presentation: a search card titled by the query. */
+/**
+ * Pending-call presentation: a search card titled by the query.
+ *
+ * @param args - the raw tool arguments; only `query` feeds the view.
+ * @returns the generic card view (`kind: 'search'`) shown while the call runs.
+ */
 export function presentSearchCall(args: { query: string }): GenericCallView {
   return { card: 'generic', title: args.query, kind: 'search', rawInput: args.query }
 }
 
-/** Register the `web_search` tool and its system-prompt guidance. `maxResults` is the deployment's source cap. */
+/**
+ * Register the `web_search` tool and its system-prompt guidance.
+ *
+ * @param ctx - context whose `tools` and `systemPrompt` registries receive the
+ *   registrations; both are effect-scoped and unregister on plugin dispose.
+ * @param maxResults - the deployment's source cap, sent as every seam
+ *   request's `maxResults`.
+ */
 export function applyWebSearchTool(ctx: Context, maxResults: number): void {
   ctx.systemPrompt.section({
     name: 'tool:web_search',

+ 1 - 0
packages/web/web-fetch-local/src/index.ts

@@ -30,6 +30,7 @@ export const name = 'web-fetch-local'
 /** The web seam this provider registers into. */
 export const inject = ['web']
 
+/** Plugin config: the provider's transport and size limits plus its `User-Agent` (all defaulted). */
 export interface Config {
   /** Maximum accepted request URL length. */
   maxUrlLength?: number

+ 20 - 0
packages/web/web-fetch-local/src/policy.ts

@@ -16,6 +16,10 @@ export type FetchableKind = 'html' | 'text'
  * enforces before any network access: http(s) only, no embedded credentials,
  * bounded length. Returns the parsed `URL`. Throws {@link WebError} otherwise.
  * (SSRF / private-network blocking is deferred — see the package RFC.)
+ *
+ * @param input - the raw URL string from the fetch request.
+ * @param maxUrlLength - inclusive upper bound on `input`'s length.
+ * @returns the parsed `URL`.
  */
 export function validateFetchUrl(input: string, maxUrlLength: number): URL {
   if (input.length > maxUrlLength) {
@@ -40,6 +44,10 @@ export function validateFetchUrl(input: string, maxUrlLength: number): URL {
  * Two URLs are same-origin when scheme, hostname, and port match. A redirect
  * that crosses origins is refused so each new origin requires a fresh tool call
  * (and thus a fresh provider/permission decision).
+ *
+ * @param a - one of the two URLs to compare.
+ * @param b - the other URL to compare.
+ * @returns true when `a` and `b` share scheme, hostname, and port.
  */
 export function isSameOrigin(a: URL, b: URL): boolean {
   return a.protocol === b.protocol && a.hostname === b.hostname && a.port === b.port
@@ -49,6 +57,10 @@ export function isSameOrigin(a: URL, b: URL): boolean {
  * Classify a response `Content-Type` into a decodable body kind, or `undefined`
  * for an unsupported (e.g. binary) type. `text/html` and `application/xhtml+xml`
  * are `html`; other `text/*` plus a few structured text types are `text`.
+ *
+ * @param contentType - the raw `Content-Type` header, or `null` when the
+ *   response carries none (unsupported).
+ * @returns the decodable kind, or `undefined` for an unsupported type.
  */
 export function classifyContentType(contentType: string | null): FetchableKind | undefined {
   const mime = (contentType ?? '').replace(/;.*$/s, '').trim().toLowerCase()
@@ -63,6 +75,10 @@ export function classifyContentType(contentType: string | null): FetchableKind |
  * or `undefined` when absent. The provider feeds this label to `TextDecoder`
  * so a non-UTF-8 response is decoded with its declared encoding rather than
  * silently mangled into replacement characters.
+ *
+ * @param contentType - the raw `Content-Type` header, or `null` when the
+ *   response carries none.
+ * @returns the lower-cased charset label, or `undefined` when none is declared.
  */
 export function parseCharset(contentType: string | null): string | undefined {
   const match = /;\s*charset\s*=\s*"?([^";]+)"?/i.exec(contentType ?? '')
@@ -74,6 +90,10 @@ export function parseCharset(contentType: string | null): string | undefined {
  * none is declared. Throws {@link WebError} `WEB_UNSUPPORTED_CONTENT_TYPE` when
  * the label is present but not a charset `TextDecoder` recognizes — better to
  * fail loudly than return mojibake.
+ *
+ * @param charset - the declared charset label (from {@link parseCharset}), or
+ *   `undefined` to default to UTF-8.
+ * @returns a decoder for the declared (or defaulted) encoding.
  */
 export function decoderForCharset(charset: string | undefined): TextDecoder {
   if (charset === undefined) return new TextDecoder('utf-8')

+ 1 - 0
packages/web/web-search-deepseek/src/index.ts

@@ -44,6 +44,7 @@ export const name = 'web-search-deepseek'
 /** The web seam this provider registers into. */
 export const inject = ['web']
 
+/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
 export interface Config {
   /** DeepSeek API key. Falls back to `$DEEPSEEK_API_KEY`. Empty → unavailable. */
   apiKey?: string

+ 8 - 0
packages/web/web-search-deepseek/src/provider.ts

@@ -62,6 +62,7 @@ export const DEEPSEEK_DEFAULT_MAX_USES = 5
 /** Attribution header sent on every request. Bump with the package version. */
 const USER_AGENT = 'deepseek-harness/0.0.1'
 
+/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
 export interface DeepSeekSearchProviderOptions {
   /** DeepSeek API key. Empty/absent → `status()` reports `missing-credential`. */
   apiKey: string
@@ -82,6 +83,9 @@ export interface DeepSeekSearchProviderOptions {
  * is the snippet surface: Anthropic `web_search_result` items carry
  * `url`/`title`/`page_age` but typically NO inline snippet — the excerpt lives
  * in a separate `text` block's citation, keyed by `url` (first occurrence wins).
+ *
+ * @param blocks - the response's content blocks; non-`text` blocks are skipped.
+ * @returns the `url → cited_text` map (empty when no citations are present).
  */
 export function citationSnippets(blocks: readonly ContentBlock[]): Map<string, string> {
   const map = new Map<string, string>()
@@ -106,6 +110,10 @@ export function citationSnippets(blocks: readonly ContentBlock[]): Map<string, s
  * Throws `WEB_PROVIDER_ERROR` (strict mode) when no `web_search_tool_result`
  * block is present — native search did not trigger, and prose-scraping is not a
  * fallback.
+ *
+ * @param query - the original request query, echoed on the result.
+ * @param response - the parsed Messages response body.
+ * @returns the normalized result with deduped, snippet-joined sources.
  */
 export function mapAnthropicResponse(query: string, response: AnthropicResponse): WebSearchResult {
   const blocks = response.content ?? []

+ 1 - 0
packages/web/web-search-exa/src/index.ts

@@ -35,6 +35,7 @@ export const name = 'web-search-exa'
 /** The web seam this provider registers into. */
 export const inject = ['web']
 
+/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
 export interface Config {
   /** Exa API key. Falls back to `$EXA_API_KEY`. Empty → provider unavailable. */
   apiKey?: string

+ 13 - 1
packages/web/web-search-exa/src/provider.ts

@@ -37,6 +37,7 @@ export const EXA_DEFAULT_HIGHLIGHTS_PER_RESULT = 1
 /** Attribution header sent on every request. Bump with the package version. */
 const USER_AGENT = 'deepseek-harness/0.0.1'
 
+/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
 export interface ExaSearchProviderOptions {
   /** Exa API key. Empty/absent → `status()` reports `missing-credential`. */
   apiKey: string
@@ -54,6 +55,10 @@ export interface ExaSearchProviderOptions {
  * Map one Exa result to a normalized source, or `undefined` when it carries no
  * portable snippet (an entry with no highlight is dropped — the seam has no
  * other field to derive a snippet from, and inventing one would lie).
+ *
+ * @param result - one entry of Exa's `results[]`.
+ * @returns the normalized source, or `undefined` when the entry has no
+ *   non-blank highlight.
  */
 export function mapExaResult(result: ExaResult): WebSearchSource | undefined {
   const snippet = result.highlights?.find(highlight => highlight.trim().length > 0)
@@ -66,7 +71,14 @@ export function mapExaResult(result: ExaResult): WebSearchSource | undefined {
   }
 }
 
-/** Map an Exa response envelope to a normalized search result. */
+/**
+ * Map an Exa response envelope to a normalized search result.
+ *
+ * @param query - the original request query, echoed on the result.
+ * @param response - the parsed `POST /search` response body.
+ * @returns the normalized result; snippet-less entries are dropped
+ *   ({@link mapExaResult}).
+ */
 export function mapExaResponse(query: string, response: ExaSearchResponse): WebSearchResult {
   const sources = (response.results ?? [])
     .map(mapExaResult)

+ 1 - 0
packages/web/web-search-perplexity/src/index.ts

@@ -29,6 +29,7 @@ export const name = 'web-search-perplexity'
 /** The web seam this provider registers into. */
 export const inject = ['web']
 
+/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
 export interface Config {
   /** Perplexity API key. Falls back to `$PERPLEXITY_API_KEY`. Empty → unavailable. */
   apiKey?: string

+ 11 - 1
packages/web/web-search-perplexity/src/provider.ts

@@ -41,6 +41,7 @@ export type PerplexityRecency = 'day' | 'week' | 'month' | 'year'
 /** Attribution header sent on every request. Bump with the package version. */
 const USER_AGENT = 'deepseek-harness/0.0.1'
 
+/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
 export interface PerplexitySearchProviderOptions {
   /** Perplexity API key. Empty/absent → `status()` reports `missing-credential`. */
   apiKey: string
@@ -54,7 +55,12 @@ export interface PerplexitySearchProviderOptions {
   searchRecency?: PerplexityRecency
 }
 
-/** Map one structured Perplexity search result to a normalized source. */
+/**
+ * Map one structured Perplexity search result to a normalized source.
+ *
+ * @param result - one entry of the response's `search_results[]`.
+ * @returns the normalized source; blank fields are omitted rather than set empty.
+ */
 export function mapPerplexityResult(result: PerplexitySearchResult): WebSearchSource {
   return {
     url: result.url,
@@ -68,6 +74,10 @@ export function mapPerplexityResult(result: PerplexitySearchResult): WebSearchSo
  * Map a Perplexity response envelope to a normalized search result. Prefers
  * structured `search_results[]`; falls back to URL-only `citations[]` (those
  * sources carry just a `url`) only when `search_results` is absent.
+ *
+ * @param query - the original request query, echoed on the result.
+ * @param response - the parsed chat-completions response body.
+ * @returns the normalized result; `content` is omitted when the answer is empty.
  */
 export function mapPerplexityResponse(query: string, response: PerplexityResponse): WebSearchResult {
   const content = response.choices?.[0]?.message?.content

+ 14 - 177
scripts/gen-cordis-catalog.ts

@@ -40,7 +40,9 @@
  * a stale `@param` naming no real parameter errors. Violations aggregate into
  * ONE error listing every offender. The tags are enforcement-only: parseJsDoc
  * stops prose at the first block tag, so they never change the rendered
- * catalog. The INHERITED
+ * catalog. The parsing + check helpers live in `scripts/jsdoc.ts`, shared with
+ * the whole-export-surface gate (`scripts/verify-export-jsdoc.ts`) so
+ * "documented" means the same thing on both surfaces. The INHERITED
  * tier (cordis core + loader/hmr/timer) is pinned vendor source a plugin author
  * also sees; it is rendered tersely (name + one-line + source pointer) from a
  * curated table in this script, NOT elevated to the harness tier's prominence.
@@ -53,6 +55,7 @@
 import { globSync, readFileSync, writeFileSync } from 'node:fs'
 import { resolve } from 'node:path'
 import ts from 'typescript'
+import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
 
 const root = resolve(import.meta.dirname, '..')
 const OUT_EVENTS = 'docs/cordis-catalog/events.md'
@@ -62,9 +65,6 @@ const OUT_SERVICES = 'docs/cordis-catalog/services.md'
  * doc-typecheck, since a bare signature fragment is not standalone-compilable). */
 const FENCE = 'ts cordis-catalog'
 
-/** A dispatch mode, rendered as the badge after an event name. */
-type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial'
-
 /**
  * Cross-link map: a type name that appears in a signature → the
  * core-data-structures page that documents it (path relative to the catalogs'
@@ -146,132 +146,6 @@ interface InheritedEntry {
   source: string
 }
 
-/** Repo-relative source pointer `file:line` for a node's first character. */
-function pointer(rel: string, sf: ts.SourceFile, node: ts.Node): string {
-  const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
-  return `${rel}:${line + 1}`
-}
-
-/** The raw `/** … *​/` JSDoc block immediately preceding a node, or '' if none. */
-function rawJsDoc(text: string, node: ts.Node): string {
-  const ranges = ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []
-  const jsdoc = ranges.filter(r => text.slice(r.pos, r.pos + 3) === '/**').at(-1)
-  return jsdoc ? text.slice(jsdoc.pos, jsdoc.end) : ''
-}
-
-/**
- * Parse a raw JSDoc block into description prose + the `@mode` tag (when
- * present). Output obeys the repo's markdown conventions so the generated file
- * passes verify-md-wrap: each prose paragraph collapses to ONE physical line,
- * and a `-` bullet list is preserved with each item on its own single line
- * (continuation lines folded in). `{@link Foo}` unwraps to `Foo`. Description
- * prose ends at the FIRST block tag (standard JSDoc semantics): tag lines and
- * their continuation lines are never prose, so `@param`/`@returns` blocks are
- * invisible to the rendered catalog.
- */
-function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
-  const inner = raw
-    .replace(/^\/\*\*/, '')
-    .replace(/\*\/$/, '')
-    .split('\n')
-    .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
-  let mode: Mode | null = null
-  let inTags = false
-  const blocks: string[] = []
-  let para: string[] = []
-  let list: string[] = []
-  let item: string[] = []
-  const join = (parts: string[]): string => parts.join(' ').replace(/\s+/g, ' ').trim()
-  const flushItem = (): void => {
-    if (item.length) list.push(join(item))
-    item = []
-  }
-  const flushList = (): void => {
-    flushItem()
-    if (list.length) blocks.push(list.join('\n')) // one block, items on own lines
-    list = []
-  }
-  const flushPara = (): void => {
-    flushList()
-    if (para.length) blocks.push(join(para))
-    para = []
-  }
-  for (const line of inner) {
-    const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(line)
-    if (m) { mode = m[1] as Mode; flushPara(); inTags = true; continue }
-    if (line.startsWith('@')) { flushPara(); inTags = true; continue }
-    if (inTags) continue // block-tag territory: continuations are never prose
-    if (line.trim() === '') { flushPara(); continue }
-    if (/^-\s+/.test(line)) {
-      // A list item starts: a pending paragraph (e.g. an intro line directly
-      // above the list, no blank between) flushes FIRST so it renders above.
-      flushItem()
-      if (para.length) { blocks.push(join(para)); para = [] }
-      item.push(line)
-      continue
-    }
-    if (item.length) { item.push(line); continue } // continuation of current item
-    para.push(line)
-  }
-  flushPara()
-  const doc = blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim()
-  return { doc, mode }
-}
-
-/**
- * Parse the block tags of a raw JSDoc comment for the completeness checks:
- * every `@param name — description` entry plus the `@returns` description.
- * Standard JSDoc block-tag semantics — a tag's description runs across
- * continuation lines until the next tag or a blank line, and the `-`/`—`
- * separator after a param name is optional. `[name]` optional-brackets unwrap
- * to `name`. Rendering never sees these: parseJsDoc stops prose at the first
- * block tag.
- */
-function parseTags(raw: string): { params: Map<string, string>; returns: string | null } {
-  const inner = raw
-    .replace(/^\/\*\*/, '')
-    .replace(/\*\/$/, '')
-    .split('\n')
-    .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
-  const params = new Map<string, string>()
-  let returns: string | null = null
-  let sink: ((text: string) => void) | null = null
-  for (const line of inner) {
-    const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line)
-    if (param) {
-      const name = (param[1] ?? '').replace(/^\[|\]$/g, '')
-      let acc = param[2] ?? ''
-      params.set(name, acc)
-      sink = (t) => { acc = acc ? `${acc} ${t}` : t; params.set(name, acc) }
-      continue
-    }
-    const ret = /^@returns?(?:\s+[-—–]?\s*(.*))?$/.exec(line)
-    if (ret) {
-      let acc = ret[1] ?? ''
-      returns = acc
-      sink = (t) => { acc = acc ? `${acc} ${t}` : t; returns = acc }
-      continue
-    }
-    if (line.startsWith('@') || line.trim() === '') { sink = null; continue }
-    sink?.(line.trim())
-  }
-  return { params, returns }
-}
-
-/**
- * Throw one aggregate error for every completeness violation a walk collected.
- * Aggregation (vs the fail-fast the @mode check used to do) is deliberate: a
- * remediation pass sees the whole list at once instead of replaying the gate
- * once per offender.
- */
-function reportViolations(violations: string[]): void {
-  if (violations.length === 0) return
-  throw new Error(
-    `gen-cordis-catalog: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):\n`
-    + violations.map(v => `  ${v}`).join('\n'),
-  )
-}
-
 /** Find the `declare module 'cordis'` body in a source file, or null. */
 function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
   for (const stmt of sf.statements) {
@@ -334,27 +208,13 @@ export function collectEvents(scanRoot: string = root): EventEntry[] {
         // (mode machinery, documented once by @mode semantics). Documenting an
         // exempt parameter anyway is allowed — only absence is checked.
         const { params } = parseTags(raw)
-        for (const p of member.parameters) {
-          if (!ts.isIdentifier(p.name)) {
-            violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the event surface needs simple identifier parameters so @param can name them.`)
-            continue
-          }
-          const pname = p.name.text
-          if (pname === 'this' || (hasNext && p === last)) continue
-          const desc = params.get(pname)
-          if (desc === undefined) violations.push(`${where} is missing @param ${pname}.`)
-          else if (!desc.trim()) violations.push(`${where}: @param ${pname} has an empty description.`)
-        }
-        for (const tag of params.keys()) {
-          if (!member.parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) {
-            violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`)
-          }
-        }
+        checkParams(where, 'event', member.parameters, params, sf,
+          p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
         if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
       }
     }
   }
-  reportViolations(violations)
+  reportViolations('gen-cordis-catalog', violations)
   return entries
 }
 
@@ -415,35 +275,12 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
         if (!raw) { violations.push(`${where} has no JSDoc.`); continue }
         if (!parseJsDoc(raw).doc) violations.push(`${where} has no description prose above its block tags.`)
         const { params, returns } = parseTags(raw)
-        // Every parameter needs a non-empty @param; a `this` receiver
-        // annotation is not payload and is exempt.
-        for (const p of member.parameters) {
-          if (!ts.isIdentifier(p.name)) {
-            violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the service surface needs simple identifier parameters so @param can name them.`)
-            continue
-          }
-          const pname = p.name.text
-          if (pname === 'this') continue
-          const desc = params.get(pname)
-          if (desc === undefined) violations.push(`${where} is missing @param ${pname}.`)
-          else if (!desc.trim()) violations.push(`${where}: @param ${pname} has an empty description.`)
-        }
-        for (const tag of params.keys()) {
-          if (!member.parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) {
-            violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`)
-          }
-        }
-        // A non-void result needs a non-empty @returns. The return type must be
-        // ANNOTATED: a pure-AST walk cannot classify an inferred return. On a
-        // `void`/`Promise<void>` method @returns stays optional (resolution
-        // timing can be worth documenting), never required.
-        const rt = member.type?.getText(sf).replace(/\s+/g, ' ')
-        if (rt === undefined) {
-          violations.push(`${where} has no return type annotation; annotate it explicitly so the gate can classify the result.`)
-        } else if (!/^(void|Promise<void>)$/.test(rt)) {
-          if (returns === null) violations.push(`${where} is missing @returns (return type: ${rt}).`)
-          else if (!returns.trim()) violations.push(`${where}: @returns has an empty description.`)
-        }
+        // Every parameter needs a non-empty @param (`this` receiver exempt),
+        // and a non-void ANNOTATED result needs a non-empty @returns — the
+        // shared checkers carry the exact contract.
+        checkParams(where, 'service', member.parameters, params, sf,
+          p => ts.isIdentifier(p.name) && p.name.text === 'this', violations)
+        checkReturns(where, member.type, returns, sf, violations)
       }
       entries.push({
         key,
@@ -455,7 +292,7 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
       })
     }
   }
-  reportViolations(violations)
+  reportViolations('gen-cordis-catalog', violations)
   return entries.sort((a, b) => a.key.localeCompare(b.key))
 }
 

+ 216 - 0
scripts/jsdoc.ts

@@ -0,0 +1,216 @@
+/**
+ * Shared JSDoc parsing and completeness-check helpers for the documentation
+ * gates: the cordis catalog generator (`scripts/gen-cordis-catalog.ts` — the
+ * events + `ctx.<key>` service surface) and the export-surface gate
+ * (`scripts/verify-export-jsdoc.ts` — every module-level export). One home for
+ * the mechanics so "documented" means the same thing on every gated surface:
+ * description prose ends at the first block tag; every checkable parameter
+ * needs a non-empty `@param`; a non-void ANNOTATED return needs a non-empty
+ * `@returns`; a stale `@param` naming no real parameter errors.
+ */
+
+import ts from 'typescript'
+
+/** Repo-relative source pointer `file:line` for a node's first character. */
+export function pointer(rel: string, sf: ts.SourceFile, node: ts.Node): string {
+  const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
+  return `${rel}:${line + 1}`
+}
+
+/** The raw `/** … *​/` JSDoc block immediately preceding a node, or '' if none. */
+export function rawJsDoc(text: string, node: ts.Node): string {
+  const ranges = ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []
+  const jsdoc = ranges.filter(r => text.slice(r.pos, r.pos + 3) === '/**').at(-1)
+  return jsdoc ? text.slice(jsdoc.pos, jsdoc.end) : ''
+}
+
+/** A dispatch mode, rendered as the badge after an event name in the catalog. */
+export type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial'
+
+/**
+ * Parse a raw JSDoc block into description prose + the `@mode` tag (when
+ * present). Output obeys the repo's markdown conventions so the generated
+ * catalog passes verify-md-wrap: each prose paragraph collapses to ONE physical
+ * line, and a `-` bullet list is preserved with each item on its own single
+ * line (continuation lines folded in). `{@link Foo}` unwraps to `Foo`.
+ * Description prose ends at the FIRST block tag (standard JSDoc semantics):
+ * tag lines and their continuation lines are never prose, so `@param` /
+ * `@returns` blocks are invisible to the rendered catalog.
+ * @param raw - the raw comment text including the JSDoc delimiters.
+ * @returns the collapsed description prose plus the parsed `@mode` (or null).
+ */
+export function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
+  const inner = raw
+    .replace(/^\/\*\*/, '')
+    .replace(/\*\/$/, '')
+    .split('\n')
+    .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
+  let mode: Mode | null = null
+  let inTags = false
+  const blocks: string[] = []
+  let para: string[] = []
+  let list: string[] = []
+  let item: string[] = []
+  const join = (parts: string[]): string => parts.join(' ').replace(/\s+/g, ' ').trim()
+  const flushItem = (): void => {
+    if (item.length) list.push(join(item))
+    item = []
+  }
+  const flushList = (): void => {
+    flushItem()
+    if (list.length) blocks.push(list.join('\n')) // one block, items on own lines
+    list = []
+  }
+  const flushPara = (): void => {
+    flushList()
+    if (para.length) blocks.push(join(para))
+    para = []
+  }
+  for (const line of inner) {
+    const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(line)
+    if (m) { mode = m[1] as Mode; flushPara(); inTags = true; continue }
+    if (line.startsWith('@')) { flushPara(); inTags = true; continue }
+    if (inTags) continue // block-tag territory: continuations are never prose
+    if (line.trim() === '') { flushPara(); continue }
+    if (/^-\s+/.test(line)) {
+      // A list item starts: a pending paragraph (e.g. an intro line directly
+      // above the list, no blank between) flushes FIRST so it renders above.
+      flushItem()
+      if (para.length) { blocks.push(join(para)); para = [] }
+      item.push(line)
+      continue
+    }
+    if (item.length) { item.push(line); continue } // continuation of current item
+    para.push(line)
+  }
+  flushPara()
+  const doc = blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim()
+  return { doc, mode }
+}
+
+/**
+ * Parse the block tags of a raw JSDoc comment for the completeness checks:
+ * every `@param name — description` entry plus the `@returns` description.
+ * Standard JSDoc block-tag semantics — a tag's description runs across
+ * continuation lines until the next tag or a blank line, and the `-`/`—`
+ * separator after a param name is optional. `[name]` optional-brackets unwrap
+ * to `name`. Rendering never sees these: parseJsDoc stops prose at the first
+ * block tag.
+ * @param raw - the raw comment text including the JSDoc delimiters.
+ * @returns the `@param` name→description map plus the `@returns` description
+ * (null when the tag is absent, '' when present but empty).
+ */
+export function parseTags(raw: string): { params: Map<string, string>; returns: string | null } {
+  const inner = raw
+    .replace(/^\/\*\*/, '')
+    .replace(/\*\/$/, '')
+    .split('\n')
+    .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
+  const params = new Map<string, string>()
+  let returns: string | null = null
+  let sink: ((text: string) => void) | null = null
+  for (const line of inner) {
+    const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line)
+    if (param) {
+      const name = (param[1] ?? '').replace(/^\[|\]$/g, '')
+      let acc = param[2] ?? ''
+      params.set(name, acc)
+      sink = (t) => { acc = acc ? `${acc} ${t}` : t; params.set(name, acc) }
+      continue
+    }
+    const ret = /^@returns?(?:\s+[-—–]?\s*(.*))?$/.exec(line)
+    if (ret) {
+      let acc = ret[1] ?? ''
+      returns = acc
+      sink = (t) => { acc = acc ? `${acc} ${t}` : t; returns = acc }
+      continue
+    }
+    if (line.startsWith('@') || line.trim() === '') { sink = null; continue }
+    sink?.(line.trim())
+  }
+  return { params, returns }
+}
+
+/**
+ * Check the `@param` half of the completeness contract for one function-like
+ * declaration: every checkable parameter carries a non-empty `@param`, and no
+ * `@param` is stale. A binding-pattern parameter is a violation (it has no name
+ * for `@param` to match); an exempt parameter may be documented but its absence
+ * is never checked. Violations append to `violations` in place.
+ * @param where - the offender label violations open with, e.g. `event 'x' (file:1)`.
+ * @param surface - the surface noun for the binding-pattern message ("event", "service", "export").
+ * @param parameters - the declaration's parameter list.
+ * @param tags - the parsed `@param` name→description map from parseTags.
+ * @param sf - the source file (for rendering a binding pattern's text).
+ * @param isExempt - which parameters need no `@param` (e.g. `this`, a waterfall's trailing `next`).
+ * @param violations - the aggregate list violations append to.
+ */
+export function checkParams(
+  where: string,
+  surface: string,
+  parameters: readonly ts.ParameterDeclaration[],
+  tags: Map<string, string>,
+  sf: ts.SourceFile,
+  isExempt: (p: ts.ParameterDeclaration) => boolean,
+  violations: string[],
+): void {
+  for (const p of parameters) {
+    if (!ts.isIdentifier(p.name)) {
+      violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the ${surface} surface needs simple identifier parameters so @param can name them.`)
+      continue
+    }
+    if (isExempt(p)) continue
+    const desc = tags.get(p.name.text)
+    if (desc === undefined) violations.push(`${where} is missing @param ${p.name.text}.`)
+    else if (!desc.trim()) violations.push(`${where}: @param ${p.name.text} has an empty description.`)
+  }
+  for (const tag of tags.keys()) {
+    if (!parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) {
+      violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`)
+    }
+  }
+}
+
+/**
+ * Check the `@returns` half of the completeness contract: a non-`void` /
+ * `Promise<void>` return needs a non-empty `@returns`, and the return type must
+ * be ANNOTATED — a pure-AST walk cannot classify an inferred return. On a void
+ * declaration `@returns` stays optional (resolution timing can be worth
+ * documenting), never required. Violations append to `violations` in place.
+ * @param where - the offender label violations open with.
+ * @param typeNode - the declared return type annotation, or undefined when inferred.
+ * @param returns - the parsed `@returns` description from parseTags (null when absent).
+ * @param sf - the source file (for rendering the annotation's text).
+ * @param violations - the aggregate list violations append to.
+ */
+export function checkReturns(
+  where: string,
+  typeNode: ts.TypeNode | undefined,
+  returns: string | null,
+  sf: ts.SourceFile,
+  violations: string[],
+): void {
+  if (typeNode === undefined) {
+    violations.push(`${where} has no return type annotation; annotate it explicitly so the gate can classify the result.`)
+    return
+  }
+  const rt = typeNode.getText(sf).replace(/\s+/g, ' ')
+  if (/^(void|Promise<void>)$/.test(rt)) return
+  if (returns === null) violations.push(`${where} is missing @returns (return type: ${rt}).`)
+  else if (!returns.trim()) violations.push(`${where}: @returns has an empty description.`)
+}
+
+/**
+ * Throw one aggregate error for every completeness violation a walk collected.
+ * Aggregation (vs failing fast) is deliberate: a remediation pass sees the
+ * whole list at once instead of replaying the gate once per offender.
+ * @param gate - the reporting gate's name, prefixed to the error message.
+ * @param violations - the collected violation lines; no-op when empty.
+ */
+export function reportViolations(gate: string, violations: string[]): void {
+  if (violations.length === 0) return
+  throw new Error(
+    `${gate}: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):\n`
+    + violations.map(v => `  ${v}`).join('\n'),
+  )
+}

+ 408 - 0
scripts/verify-export-jsdoc.ts

@@ -0,0 +1,408 @@
+/**
+ * Verify JSDoc completeness for EVERY module-level exported name of every
+ * non-vendored package (each `packages/<group>/<pkg>/src/` tree). This is the
+ * mechanical form of the AGENTS.md rule "every export has a JSDoc explaining
+ * semantics", generalizing the cordis-surface gate (`gen-cordis-catalog.ts`,
+ * which owns `interface Events` members and `ctx.<key>` service classes) to
+ * the whole export surface; the parsing + check helpers are shared via
+ * `scripts/jsdoc.ts` so "documented" means the same thing on both.
+ *
+ *   `tsx scripts/verify-export-jsdoc.ts`  → exit 1 listing every offender
+ *
+ * The contract, per exported declaration kind:
+ *
+ * - Every exported name needs JSDoc with non-empty description prose (prose
+ *   ends at the first block tag, standard JSDoc semantics).
+ * - A function-like export (function declaration, or a const with a function
+ *   initializer) additionally needs a non-empty `@param` per parameter
+ *   (`this` receiver annotations exempt; a stale `@param` errors) and a
+ *   non-empty `@returns` unless the return type is `void`/`Promise<void>`.
+ *   The walk classifies returns syntactically, so the return type must be
+ *   ANNOTATED — except a const whose DECLARATOR is type-annotated (e.g.
+ *   `export const f: Handler = …`), where the named type owns the return
+ *   contract and `@returns` stays optional.
+ * - An exported class needs class-level JSDoc; its public methods (static
+ *   included — they are reachable on the exported name) follow the function
+ *   contract, and public properties and accessors need description prose (on
+ *   a get/set pair the getter's doc covers both). A member whose name exists
+ *   on an `extends`/`implements` heritage type is EXEMPT — the seam
+ *   declaration is the doc's one home, the IDE inherits it, and re-documenting
+ *   every implementation invites drift. This is the one question the walk
+ *   asks the TYPE CHECKER (heritage members live across package boundaries);
+ *   everything else is pure AST. Constructors are exempt like the cordis
+ *   gate's: plugin classes are framework-constructed, and the class doc owns
+ *   the story.
+ * - Exported interfaces, type aliases, enums: description prose on the
+ *   declaration (member-level docs stay review's job; the highest-value
+ *   member surface — seam service classes — is already under the cordis
+ *   gate).
+ * - An exported namespace recurses (its exported members are package
+ *   surface); the namespace itself needs prose only when it does not merge
+ *   with an already-documented same-name declaration (the Config-namespace
+ *   idiom documents the class/function once, not twice).
+ * - The cordis plugin-protocol slots are exempt: top-level `name` / `inject`
+ *   / `reusable` / `Config` consts and the `apply` entry, plus the same
+ *   slots as statics on a plugin class. Their shape is fixed by the
+ *   framework, so a doc would restate the protocol — the module doc comment
+ *   and the `interface Config` carry the plugin's real semantics. (These
+ *   names are reserved by cordis convention; documenting one anyway is
+ *   allowed, only absence goes unchecked.)
+ * - Overload groups: each overload signature carries its own docs; the
+ *   implementation signature is exempt (callers never see it).
+ * - Skipped: `declare module` / `declare global` augmentation bodies (the
+ *   cordis gate's turf; an augmentation is not an export of the package) and
+ *   re-export statements with a module specifier (`export … from`) — the
+ *   defining module is walked on its own, and external definitions are not
+ *   ours to document.
+ */
+
+import { existsSync, globSync } from 'node:fs'
+import { resolve } from 'node:path'
+import ts from 'typescript'
+import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc } from './jsdoc.ts'
+
+const root = resolve(import.meta.dirname, '..')
+
+/** Plugin-protocol slot names exempt as statics on an exported class. */
+const PROTOCOL_STATICS = new Set(['Config', 'inject', 'name', 'reusable'])
+
+/** Plugin-protocol slot names exempt as top-level exports (const or function). */
+const PROTOCOL_EXPORTS = new Set(['Config', 'inject', 'name', 'reusable', 'apply'])
+
+/** Per-file walk state threaded through the scope recursion. */
+interface Walk {
+  /** Repo-relative path of the file being walked. */
+  rel: string
+  /** The parsed source file. */
+  sf: ts.SourceFile
+  /** Raw file text (rawJsDoc reads comment ranges out of it). */
+  text: string
+  /** The program's checker, consulted only for heritage-member lookups. */
+  checker: ts.TypeChecker
+  /** The aggregate violation list, appended in place. */
+  violations: string[]
+}
+
+/** True when a statement carries the `export` modifier. */
+function isExported(stmt: ts.Statement): boolean {
+  return ts.canHaveModifiers(stmt) && (ts.getModifiers(stmt)?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)
+}
+
+/** True for a class member a consumer cannot reach: `private`/`protected`/`#name`. */
+function isNonPublic(member: ts.ClassElement): boolean {
+  const mods = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined
+  return (mods?.some(m => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false)
+    || ('name' in member && ts.isPrivateIdentifier(member.name))
+}
+
+/** True when a class member carries the `static` modifier. */
+function isStatic(member: ts.ClassElement): boolean {
+  const mods = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined
+  return mods?.some(m => m.kind === ts.SyntaxKind.StaticKeyword) ?? false
+}
+
+/** The `this`-receiver exemption every function-like check shares. */
+function thisReceiver(p: ts.ParameterDeclaration): boolean {
+  return ts.isIdentifier(p.name) && p.name.text === 'this'
+}
+
+/**
+ * True when a member name exists on any `extends`/`implements` heritage type
+ * of the class — the member implements or overrides a documented seam
+ * declaration, which is the doc's one home (the IDE inherits it on hover).
+ * Static members are looked up on the base CONSTRUCTOR type (only an
+ * `extends` expression has one; an unresolvable or interface expression
+ * yields no property and therefore no exemption).
+ * @param cls - the class whose heritage to search.
+ * @param name - the member name to look up.
+ * @param staticSide - whether to search the constructor side instead of the instance side.
+ * @param checker - the program's type checker.
+ * @returns true when a heritage type declares the member.
+ */
+function inheritedMember(cls: ts.ClassDeclaration, name: string, staticSide: boolean, checker: ts.TypeChecker): boolean {
+  for (const clause of cls.heritageClauses ?? []) {
+    for (const t of clause.types) {
+      const type = staticSide ? checker.getTypeAtLocation(t.expression) : checker.getTypeAtLocation(t)
+      if (type.getProperty(name) !== undefined) return true
+    }
+  }
+  return false
+}
+
+/**
+ * Check description-prose presence for one labeled declaration: JSDoc must
+ * exist and carry prose above its block tags.
+ * @param where - the offender label violations open with.
+ * @param raw - the declaration's raw JSDoc block ('' if none).
+ * @param w - the walk state violations append to.
+ */
+function checkDescribed(where: string, raw: string, w: Walk): void {
+  if (!raw) w.violations.push(`${where} has no JSDoc.`)
+  else if (!parseJsDoc(raw).doc) w.violations.push(`${where} has no description prose above its block tags.`)
+}
+
+/**
+ * Check the full function contract for one labeled function-like declaration:
+ * description prose, `@param` per parameter, `@returns` on a non-void result.
+ * @param where - the offender label violations open with.
+ * @param raw - the declaration's raw JSDoc block ('' if none).
+ * @param parameters - the declaration's parameter list.
+ * @param returnType - the return type annotation, or undefined when inferred.
+ * @param returnsWaived - suppress the `@returns`/annotation requirement (a
+ * declarator-annotated const defers its return contract to the named type).
+ * @param w - the walk state violations append to.
+ */
+function checkFunctionLike(
+  where: string,
+  raw: string,
+  parameters: readonly ts.ParameterDeclaration[],
+  returnType: ts.TypeNode | undefined,
+  returnsWaived: boolean,
+  w: Walk,
+): void {
+  if (!raw) { w.violations.push(`${where} has no JSDoc.`); return }
+  if (!parseJsDoc(raw).doc) w.violations.push(`${where} has no description prose above its block tags.`)
+  const { params, returns } = parseTags(raw)
+  checkParams(where, 'export', parameters, params, w.sf, thisReceiver, w.violations)
+  if (!returnsWaived) checkReturns(where, returnType, returns, w.sf, w.violations)
+}
+
+/**
+ * Check one exported class: class-level prose, the function contract on every
+ * public method (overload implementations exempt), and description prose on
+ * public properties and accessors (a get/set pair is covered by the getter's
+ * doc). Members declared by a heritage type and the plugin-protocol statics
+ * are exempt; constructors are not checked (framework-constructed plugins,
+ * and the class doc owns the story).
+ * @param cls - the exported class declaration.
+ * @param name - the class's surface name (namespace-qualified).
+ * @param w - the walk state violations append to.
+ */
+function checkClass(cls: ts.ClassDeclaration, name: string, w: Walk): void {
+  checkDescribed(`exported class '${name}' (${pointer(w.rel, w.sf, cls)})`, rawJsDoc(w.text, cls), w)
+  const overloadSigs = new Set<string>()
+  const documentedGetters = new Set<string>()
+  for (const m of cls.members) {
+    if ('name' in m && ts.isComputedPropertyName(m.name)) continue
+    if (ts.isMethodDeclaration(m) && !m.body) overloadSigs.add(m.name.getText(w.sf))
+    if (ts.isGetAccessorDeclaration(m)) documentedGetters.add(m.name.getText(w.sf))
+  }
+  for (const m of cls.members) {
+    if (isNonPublic(m) || ts.isConstructorDeclaration(m)) continue
+    if (!('name' in m) || ts.isComputedPropertyName(m.name)) continue // computed/symbol members
+    const mname = m.name.getText(w.sf)
+    if (isStatic(m) && PROTOCOL_STATICS.has(mname)) continue // cordis plugin-protocol slot
+    if (inheritedMember(cls, mname, isStatic(m), w.checker)) continue // the heritage declaration owns the doc
+    if (ts.isMethodDeclaration(m)) {
+      if (m.body && overloadSigs.has(mname)) continue // overload implementation: the signatures carry the docs
+      checkFunctionLike(`exported class method '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`, rawJsDoc(w.text, m), m.parameters, m.type, false, w)
+    } else if (ts.isGetAccessorDeclaration(m) || ts.isPropertyDeclaration(m)) {
+      const kind = ts.isPropertyDeclaration(m) ? 'property' : 'accessor'
+      checkDescribed(`exported class ${kind} '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`, rawJsDoc(w.text, m), w)
+    } else if (ts.isSetAccessorDeclaration(m) && !documentedGetters.has(mname)) {
+      checkDescribed(`exported class accessor '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`, rawJsDoc(w.text, m), w)
+    }
+    // index signatures / static blocks: not named surface
+  }
+}
+
+/**
+ * Check one exported declaration statement, dispatching on its kind.
+ * @param stmt - the exported statement (export modifier or export-list target).
+ * @param prefix - the namespace qualification for surface names ('' at top level).
+ * @param overloadSigs - names in this scope declared as bodyless function overload signatures.
+ * @param byName - this scope's named declarations (for namespace/sibling-merge lookups).
+ * @param w - the walk state violations append to.
+ */
+function checkDecl(
+  stmt: ts.Statement,
+  prefix: string,
+  overloadSigs: Set<string>,
+  byName: Map<string, ts.Statement[]>,
+  w: Walk,
+): void {
+  const at = (n: ts.Node): string => ` (${pointer(w.rel, w.sf, n)})`
+  if (ts.isFunctionDeclaration(stmt)) {
+    const name = stmt.name?.text ?? 'default'
+    if (prefix === '' && PROTOCOL_EXPORTS.has(name)) return // cordis plugin-protocol slot
+    if (stmt.body && overloadSigs.has(name)) return // overload implementation: the signatures carry the docs
+    checkFunctionLike(`exported function '${prefix}${name}'${at(stmt)}`, rawJsDoc(w.text, stmt),
+      stmt.parameters, stmt.type, false, w)
+    return
+  }
+  if (ts.isClassDeclaration(stmt)) {
+    checkClass(stmt, `${prefix}${stmt.name?.text ?? 'default'}`, w)
+    return
+  }
+  if (ts.isInterfaceDeclaration(stmt)) {
+    checkDescribed(`exported interface '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
+    return
+  }
+  if (ts.isTypeAliasDeclaration(stmt)) {
+    checkDescribed(`exported type '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
+    return
+  }
+  if (ts.isEnumDeclaration(stmt)) {
+    checkDescribed(`exported enum '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
+    return
+  }
+  if (ts.isVariableStatement(stmt)) {
+    const raw = rawJsDoc(w.text, stmt) // JSDoc sits on the statement, not the declarator
+    for (const d of stmt.declarationList.declarations) {
+      const name = ts.isIdentifier(d.name) ? d.name.text : d.name.getText(w.sf)
+      if (prefix === '' && PROTOCOL_EXPORTS.has(name)) continue // cordis plugin-protocol slot
+      const where = `exported const '${prefix}${name}'${at(d)}`
+      const init = d.initializer
+      if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) {
+        // A declarator type annotation (`const f: Handler = …`) hands the
+        // return contract to the named type; the arrow's own annotation is
+        // still checked when it is the only signature the reader has.
+        checkFunctionLike(where, raw, init.parameters, init.type, init.type === undefined && d.type !== undefined, w)
+      } else {
+        checkDescribed(where, raw, w)
+      }
+    }
+    return
+  }
+  if (ts.isModuleDeclaration(stmt) && ts.isIdentifier(stmt.name)) {
+    // A namespace merging with a documented same-name sibling (the
+    // Config-namespace idiom) needs no second doc block of its own.
+    const siblings = (byName.get(stmt.name.text) ?? []).filter(s => s !== stmt)
+    const merged = siblings.some(s => parseJsDoc(rawJsDoc(w.text, s)).doc !== '')
+    if (!merged) checkDescribed(`exported namespace '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
+    let body = stmt.body
+    let nsPrefix = `${prefix}${stmt.name.text}.`
+    while (body !== undefined && ts.isModuleDeclaration(body)) { // dotted `namespace A.B`
+      nsPrefix += `${body.name.getText(w.sf)}.`
+      body = body.body
+    }
+    if (body !== undefined && ts.isModuleBlock(body)) checkScope(body.statements, nsPrefix, w)
+  }
+}
+
+/**
+ * Walk one lexical scope (file top level or a namespace body): check every
+ * exported declaration, resolving `export { … }` lists (no module specifier)
+ * to their local declarations.
+ * @param statements - the scope's statements.
+ * @param prefix - the namespace qualification for surface names ('' at top level).
+ * @param w - the walk state violations append to.
+ */
+function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk): void {
+  const byName = new Map<string, ts.Statement[]>()
+  const overloadSigs = new Set<string>()
+  const add = (name: string, stmt: ts.Statement): void => {
+    byName.set(name, [...(byName.get(name) ?? []), stmt])
+  }
+  for (const stmt of statements) {
+    if (ts.isFunctionDeclaration(stmt)) {
+      if (stmt.name) add(stmt.name.text, stmt)
+      if (!stmt.body && stmt.name) overloadSigs.add(stmt.name.text)
+    } else if (ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt)
+      || ts.isTypeAliasDeclaration(stmt) || ts.isEnumDeclaration(stmt)) {
+      if (stmt.name) add(stmt.name.text, stmt)
+    } else if (ts.isModuleDeclaration(stmt) && ts.isIdentifier(stmt.name)) {
+      add(stmt.name.text, stmt)
+    } else if (ts.isVariableStatement(stmt)) {
+      for (const d of stmt.declarationList.declarations) {
+        if (ts.isIdentifier(d.name)) add(d.name.text, stmt)
+      }
+    }
+  }
+  const checked = new Set<ts.Statement>()
+  const check = (stmt: ts.Statement): void => {
+    if (checked.has(stmt)) return
+    checked.add(stmt)
+    checkDecl(stmt, prefix, overloadSigs, byName, w)
+  }
+  for (const stmt of statements) {
+    if (ts.isModuleDeclaration(stmt)
+      && (ts.isStringLiteral(stmt.name) || (stmt.flags & ts.NodeFlags.GlobalAugmentation) !== 0)) {
+      continue // `declare module '…'` / `declare global` augmentation: not an export of this package
+    }
+    if (ts.isExportDeclaration(stmt)) {
+      if (stmt.moduleSpecifier) continue // re-export: the defining module is walked on its own
+      if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
+        for (const el of stmt.exportClause.elements) {
+          for (const decl of byName.get((el.propertyName ?? el.name).text) ?? []) check(decl)
+          // a name with no local declaration is an imported binding re-exported
+          // without a specifier — its defining module is walked on its own
+        }
+      }
+      continue
+    }
+    if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
+      if (ts.isIdentifier(stmt.expression)) {
+        for (const decl of byName.get(stmt.expression.text) ?? []) check(decl)
+      } else {
+        checkDescribed(`default export (${pointer(w.rel, w.sf, stmt)})`, rawJsDoc(w.text, stmt), w)
+      }
+      continue
+    }
+    if (isExported(stmt)) check(stmt)
+  }
+}
+
+/**
+ * Compiler options for the walk's program. The real repo hands over its
+ * tsconfig.base.json (whose `paths` map resolves cross-package imports to
+ * source, so heritage-member lookups see seam types); a fixture root without
+ * one gets bare defaults — fixtures are single-file and self-contained.
+ * Emit-side options are stripped: the walk never emits or asks for
+ * diagnostics, it only binds types on demand.
+ * @param scanRoot - the root being scanned.
+ * @returns compiler options for ts.createProgram.
+ */
+function loadCompilerOptions(scanRoot: string): ts.CompilerOptions {
+  const cfgPath = resolve(scanRoot, 'tsconfig.base.json')
+  if (!existsSync(cfgPath)) return { skipLibCheck: true }
+  const cfg = ts.readConfigFile(cfgPath, ts.sys.readFile.bind(ts.sys)) as { config?: unknown }
+  const parsed = ts.parseJsonConfigFileContent(cfg.config ?? {}, ts.sys, scanRoot)
+  return {
+    ...parsed.options,
+    noEmit: true,
+    composite: false,
+    declaration: false,
+    declarationMap: false,
+    sourceMap: false,
+    incremental: false,
+  }
+}
+
+/**
+ * Walk every non-vendored package source file and collect JSDoc-completeness
+ * violations for its module-level exports. Returns findings instead of
+ * throwing so tests assert on the list; the CLI entry turns a non-empty list
+ * into exit 1.
+ * @param scanRoot - the repo root to scan; tests pass a fixture dir.
+ * @returns every violation, in file order, one human-readable line each.
+ */
+export function collectExportJsdocViolations(scanRoot: string = root): string[] {
+  const violations: string[] = []
+  const rels = globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()
+  const program = ts.createProgram(rels.map(rel => resolve(scanRoot, rel)), loadCompilerOptions(scanRoot))
+  const checker = program.getTypeChecker()
+  for (const rel of rels) {
+    const sf = program.getSourceFile(resolve(scanRoot, rel))
+    if (!sf) continue // program root files always resolve; guard for narrowing
+    checkScope(sf.statements, '', { rel, sf, text: sf.text, checker, violations })
+  }
+  return violations
+}
+
+/** CLI entry: list every violation and exit 1, or confirm a clean surface. */
+function main(): void {
+  const violations = collectExportJsdocViolations()
+  if (violations.length === 0) {
+    console.log('verify-export-jsdoc: every exported name on the package surface is documented.')
+    return
+  }
+  console.error(`verify-export-jsdoc: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):`)
+  for (const v of violations) console.error(`  ${v}`)
+  process.exit(1)
+}
+
+// Run only when invoked as a script, not when imported by a test.
+if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
+  main()
+}