소스 검색

Add JSDoc completeness gate for the cordis surface

gen-cordis-catalog now hard-errors (aggregated, not fail-fast) when an
event lacks description prose or a payload @param, or a public service
method lacks JSDoc, a @param per parameter, a @returns on a non-void
result, or an explicit return type annotation. The this receiver and the
trailing waterfall next are exempt on events (mode machinery owned by
@mode); a stale @param naming no real parameter errors, mirroring the
@mode contradiction check. parseJsDoc now ends prose at the first block
tag (standard JSDoc semantics), so the tags never change the rendered
catalog — only Source: line pointers moved.

Fills the ~139 gaps found across the 15 surface files, extends the spec
with negative-path fixtures for every new guard plus the exemptions,
records the decision as an implemented process RFC, and extends the
AGENTS.md typed-events bullet with the authoring rule. Runs inside
verify-cordis-catalog -> doc-sync, so CI and pre-push enforce it with
zero new wiring.
Tianyi Cui 2 달 전
부모
커밋
a29bbe1453

+ 1 - 1
AGENTS.md

@@ -83,7 +83,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY` (and optional `DEEPSEEK_BASE_UR
 - Every npm package is `@deepseek-ai/dsh-<name>`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package.
 - ESM everywhere (`"type": "module"`). Cross-package imports use package names, never relative paths; in-package relative imports use explicit `.ts` extensions. Dev/test/demo run unbuilt via tsx + the root tsconfig `paths` map; building is only for consumers outside the repo.
 - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
-- **Typed events via declaration merging**; extensible unions use the merge-extensible-map pattern (`ContentBlockMap`, `SessionEventMap`, …). Every new event's JSDoc carries an `@mode` tag — the catalog generator hard-errors without it; mode semantics are in the [generated catalog](docs/cordis-catalog/events-and-services.md) header and [the catalog RFC](docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md).
+- **Typed events via declaration merging**; extensible unions use the merge-extensible-map pattern (`ContentBlockMap`, `SessionEventMap`, …). Every new event's JSDoc carries an `@mode` tag and a `@param` per payload parameter (`this`/trailing `next` exempt); every public service-class method documents each parameter and non-void return (`@param`/`@returns`) — the catalog generator hard-errors otherwise ([completeness RFC](docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md)); mode semantics are in the [generated catalog](docs/cordis-catalog/events-and-services.md) header and [the catalog RFC](docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md).
 - **Discriminated unions: `switch` on the tag**, not if-chains. Closed unions end with `default: assertNever(...)`; merge-extensible unions must NOT — handle known cases and fall through `default` with a comment.
 - **Waterfall listeners MUST call `next()`** to delegate; returning without it is the veto ([semantics](docs/architecture.md#cordis-waterfall-semantics-important)).
 - **Plugins, not loop changes**: new behavior goes on the documented extension seams; changing `agent-loop` requires updating docs/architecture.md.

+ 32 - 32
docs/cordis-catalog/events-and-services.md

@@ -25,7 +25,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:233`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:234`](../../packages/core/agent/src/types.ts)
 
 #### `agent/disposed` — emit
 
@@ -37,7 +37,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:239`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:241`](../../packages/core/agent/src/types.ts)
 
 #### `agent/error` — emit
 
@@ -49,7 +49,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:353`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:389`](../../packages/core/agent/src/types.ts)
 
 #### `agent/pre-step` — serial
 
@@ -63,7 +63,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:305`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:319`](../../packages/core/agent/src/types.ts)
 
 #### `agent/prompt-submit` — waterfall
 
@@ -75,7 +75,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:315`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:332`](../../packages/core/agent/src/types.ts)
 
 #### `agent/queued` — emit
 
@@ -87,7 +87,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:252`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts)
 
 #### `agent/request` — waterfall
 
@@ -99,7 +99,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo
 
 Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:324`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts)
 
 #### `agent/session-start` — emit
 
@@ -111,7 +111,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:265`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:274`](../../packages/core/agent/src/types.ts)
 
 #### `agent/status` — emit
 
@@ -123,7 +123,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:246`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:250`](../../packages/core/agent/src/types.ts)
 
 #### `agent/steering` — emit
 
@@ -135,7 +135,7 @@ Steering content was injected into a running turn.
 
 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:347`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:379`](../../packages/core/agent/src/types.ts)
 
 #### `agent/step-result` — waterfall
 
@@ -147,7 +147,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:330`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:355`](../../packages/core/agent/src/types.ts)
 
 #### `agent/turn-continuation` — waterfall
 
@@ -159,7 +159,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:340`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts)
 
 ### `fs/*`
 
@@ -173,7 +173,7 @@ Single-slot decision: produce the optional version guard for the next FileSystem
 
 Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md)
 
-Source: [`packages/fs/fs/src/index.ts:119`](../../packages/fs/fs/src/index.ts)
+Source: [`packages/fs/fs/src/index.ts:123`](../../packages/fs/fs/src/index.ts)
 
 #### `fs/observed` — emit
 
@@ -185,7 +185,7 @@ Record that an actor observed a target at a version, after a successful read/wri
 
 Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md)
 
-Source: [`packages/fs/fs/src/index.ts:131`](../../packages/fs/fs/src/index.ts)
+Source: [`packages/fs/fs/src/index.ts:138`](../../packages/fs/fs/src/index.ts)
 
 #### `fs/write-intent` — waterfall
 
@@ -197,7 +197,7 @@ Single-slot decision: produce the write intent for the next FileSystem.writeText
 
 Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md)
 
-Source: [`packages/fs/fs/src/index.ts:107`](../../packages/fs/fs/src/index.ts)
+Source: [`packages/fs/fs/src/index.ts:109`](../../packages/fs/fs/src/index.ts)
 
 ### `llm/*`
 
@@ -211,7 +211,7 @@ Waterfall around every streaming model call (retry, caching, routing). Bound to
 
 Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
 
-Source: [`packages/llm/llm/src/index.ts:31`](../../packages/llm/llm/src/index.ts)
+Source: [`packages/llm/llm/src/index.ts:32`](../../packages/llm/llm/src/index.ts)
 
 ### `session/*`
 
@@ -223,7 +223,7 @@ A session was created in the store.
 'session/created'(session: Session): void
 ```
 
-Source: [`packages/core/session/src/index.ts:35`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:36`](../../packages/core/session/src/index.ts)
 
 #### `session/event` — emit
 
@@ -235,7 +235,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per-
 
 Types: [SessionEvent](../core-data-structures/core.md)
 
-Source: [`packages/core/session/src/index.ts:41`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:44`](../../packages/core/session/src/index.ts)
 
 #### `session/flush` — parallel
 
@@ -245,7 +245,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flus
 'session/flush'(session: Session): Promise<void> | void
 ```
 
-Source: [`packages/core/session/src/index.ts:50`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:54`](../../packages/core/session/src/index.ts)
 
 ### `subagent/*`
 
@@ -257,7 +257,7 @@ A subagent run settled — emitted when SubagentRun.result resolves (any stop re
 'subagent/end'(info: SubagentRunEndInfo): void
 ```
 
-Source: [`packages/subagent/subagent/src/index.ts:75`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:77`](../../packages/subagent/subagent/src/index.ts)
 
 #### `subagent/start` — emit
 
@@ -267,7 +267,7 @@ A subagent run started — emitted after the provider is resolved and its capabi
 'subagent/start'(info: SubagentRunInfo): void
 ```
 
-Source: [`packages/subagent/subagent/src/index.ts:69`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:70`](../../packages/subagent/subagent/src/index.ts)
 
 ### `system-prompt/*`
 
@@ -279,7 +279,7 @@ Waterfall around prompt assembly — mutate or extend the PromptAssembly (sectio
 'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
 ```
 
-Source: [`packages/core/system-prompt/src/index.ts:24`](../../packages/core/system-prompt/src/index.ts)
+Source: [`packages/core/system-prompt/src/index.ts:26`](../../packages/core/system-prompt/src/index.ts)
 
 #### `system-prompt/change` — emit
 
@@ -289,7 +289,7 @@ A section or tool provider was registered or unregistered (the assembly inputs c
 'system-prompt/change'(): void
 ```
 
-Source: [`packages/core/system-prompt/src/index.ts:30`](../../packages/core/system-prompt/src/index.ts)
+Source: [`packages/core/system-prompt/src/index.ts:32`](../../packages/core/system-prompt/src/index.ts)
 
 ### `tools/*`
 
@@ -301,7 +301,7 @@ A tool was registered or unregistered (the available tool set changed).
 'tools/change'(): void
 ```
 
-Source: [`packages/core/tools/src/index.ts:84`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:87`](../../packages/core/tools/src/index.ts)
 
 #### `tools/post-execute` — waterfall
 
@@ -313,7 +313,7 @@ Waterfall AFTER a tool runs — where hook plugins inspect the result and accept
 
 Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:79`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:82`](../../packages/core/tools/src/index.ts)
 
 #### `tools/pre-execute` — waterfall
 
@@ -325,7 +325,7 @@ Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook pl
 
 Types: [ToolExecution](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:65`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:66`](../../packages/core/tools/src/index.ts)
 
 ### `web/*`
 
@@ -444,7 +444,7 @@ abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: F
 
 Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md)
 
-Source: [`packages/fs/fs/src/index.ts:165`](../../packages/fs/fs/src/index.ts)
+Source: [`packages/fs/fs/src/index.ts:172`](../../packages/fs/fs/src/index.ts)
 
 ### `ctx.llm` — `LlmService`
 
@@ -458,7 +458,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:69`](../../packages/llm/llm/src/index.ts)
+Source: [`packages/llm/llm/src/index.ts:70`](../../packages/llm/llm/src/index.ts)
 
 ### `ctx.sessionPersistence` — `SessionPersistence` (abstract seam)
 
@@ -497,7 +497,7 @@ get(id: SessionId): Session | undefined
 list(): Session[]
 ```
 
-Source: [`packages/core/session/src/index.ts:323`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:327`](../../packages/core/session/src/index.ts)
 
 ### `ctx.subagents` — `SubagentService`
 
@@ -510,7 +510,7 @@ list(): string[]
 start(name: string, request: SubagentStartRequest): SubagentRun
 ```
 
-Source: [`packages/subagent/subagent/src/index.ts:121`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:123`](../../packages/subagent/subagent/src/index.ts)
 
 ### `ctx.systemPrompt` — `SystemPrompt`
 
@@ -522,7 +522,7 @@ tools(provider: () => ToolSchema[]): () => void
 assemble(): Promise<PromptAssembly>
 ```
 
-Source: [`packages/core/system-prompt/src/index.ts:71`](../../packages/core/system-prompt/src/index.ts)
+Source: [`packages/core/system-prompt/src/index.ts:73`](../../packages/core/system-prompt/src/index.ts)
 
 ### `ctx.tools` — `ToolRegistry`
 
@@ -537,7 +537,7 @@ async execute(exec: ToolExecution): Promise<ToolExecutionResult>
 
 Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:265`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:268`](../../packages/core/tools/src/index.ts)
 
 ### `ctx.web` — `WebService`
 

+ 1 - 0
docs/rfc/README.md

@@ -168,6 +168,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
 | [Generated tool-schema catalog (boot-and-harvest)](implemented/process/2026-07-02-tool-schema-catalog.md) | 2026-07-02 |
 | [Bilingual documentation via paired sibling files and a pairing gate](implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md) | 2026-07-02 |
 | [Documentation tiers, budgets, and the ceiling gate](implemented/process/2026-07-04-doc-tiers-and-budgets.md) | 2026-07-04 |
+| [JSDoc completeness gate for the cordis surface](implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md) | 2026-07-04 |
 
 ### Testing
 

+ 33 - 0
docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md

@@ -0,0 +1,33 @@
+# RFC: JSDoc completeness gate for the cordis surface
+
+Status: implemented (accepted 2026-07-04)
+
+## Context
+
+The [generated cordis catalog](2026-06-20-generated-cordis-catalog.md) already walks every harness `interface Events` member and every `ctx.<key>` service class with the TypeScript compiler API, and already hard-errors on a missing `@mode` tag — a forcing function that made dispatch modes impossible to leave undocumented. Nothing equivalent guarded the rest of the JSDoc: a service method could ship with no doc at all, and no event or method documented its parameters or return value individually. A survey at adoption found 5 public service methods with no JSDoc and roughly 139 missing `@param`/`@returns` entries across 15 files — on the product API spine (`ctx.bash`, `ctx.fs`, `ctx.sessions`, …) and the cross-plugin event payload contracts, exactly the surface where "what does this argument mean" is the question a plugin author asks the IDE.
+
+The AGENTS.md rule ("every export has a JSDoc explaining semantics") is prose-checkable only by review; the repo's stated preference is to encode invariants in mechanical gates. The scope "cordis service functions and events" has a precise machine definition that only the catalog generator knows: events are the `interface Events` members inside `declare module 'cordis'`, and the service surface is the public methods of the class each `interface Context` key names. An ESLint rule cannot see that mapping; the generator computes it on every run.
+
+## Decision
+
+Extend `scripts/gen-cordis-catalog.ts` — the same walk, the same `@mode` precedent — to enforce JSDoc COMPLETENESS on everything it catalogs. `verify-cordis-catalog` runs inside `doc-sync`, which both CI and the lefthook pre-push hook already execute, so the gate needs zero new wiring (quality-gates principle: one source of truth).
+
+The contract:
+
+- **Events** need description prose plus a non-empty `@param` for every **payload parameter**. A payload parameter is a signature parameter that carries event data; the `this` receiver annotation and the trailing waterfall `next` are exempt — `next` is dispatch machinery whose semantics the `@mode waterfall` tag (and its structural cross-check) already owns, so restating it per event would be boilerplate. Documenting an exempt parameter anyway is allowed; only absence is checked.
+- **Service classes** need class-level JSDoc, and every public method needs description prose, a non-empty `@param` per parameter, and a non-empty `@returns` unless the annotated return type is `void`/`Promise<void>` (where `@returns` stays optional — resolution timing can be worth documenting — but is never required).
+- **Stale tags error**: an `@param` naming no real parameter is a violation, mirroring the `@mode`-contradicts-signature check. Tag descriptions must be non-empty; their semantic quality beyond that is review's job.
+- **Explicitness the walk can check**: the gate is a pure-AST pass (no type checker), so a service method must annotate its return type (an inferred return cannot be classified) and surface parameters must be simple identifiers (a binding pattern has no name for `@param` to match).
+- **Violations aggregate** into one error listing every offender — a remediation pass sees the whole list at once. The previously fail-fast `@mode` checks moved into the same aggregated report, with their message texts unchanged.
+
+The tags are **enforcement-only**: `parseJsDoc` now ends description prose at the first block tag (standard JSDoc semantics, which also stops multi-line tag descriptions from leaking into the catalog as prose), so `@param`/`@returns` never change the rendered catalog. Rendering them — restructuring the services section into per-method entries — was considered and deliberately deferred: source JSDoc plus IDE hover is where method docs are consumed, and the catalog stays an index. No escape-hatch tag exists; the surface is small and curated (12 services, 57 methods, 27 events at adoption), and the point is that the check cannot be waved off.
+
+Negative-path tests in `packages/core/agent/tests/gen-cordis-catalog.spec.ts` drive `collectEvents`/`collectServices` against synthetic fixtures to prove each guard fires and that the exemptions hold. The authoring rule lives in the root [AGENTS.md](../../../../AGENTS.md) conventions bullet alongside the `@mode` rule.
+
+## Consequences
+
+- A new event or service method cannot land with an undocumented parameter or result: the generator refuses to regenerate and `verify-cordis-catalog` fails pre-push and in CI. The ~139 gaps found at adoption were filled in the same change, so the gate landed green.
+- The service surface must annotate return types explicitly and use identifier parameters. Neither constraint bound at adoption (every method already annotated; no destructured seam parameters existed); both are now load-bearing requirements a violating change will discover mechanically.
+- The general AGENTS.md JSDoc rule ("one-liners when one line suffices") acquires a stricter carve-out on this surface: a one-line summary still suffices only when the method has no parameters and a void result.
+- `@param` on `next` or `this` stays legal but unchecked — a deliberate asymmetry: the gate enforces the payload contract and refuses to demand boilerplate.
+- The rendered catalog is unchanged by the tags (prose stops at the first block tag). If method-level rendering is wanted later, that is a catalog-design decision to take separately, not a gap in this gate.

+ 35 - 5
packages/bash/bash/src/index.ts

@@ -77,16 +77,32 @@ export abstract class BashExecutor extends Service {
    * call this, then pass the result to {@link run}/{@link start} — keeping
    * defaulting in the implementation that owns the config while the seam type
    * stays explicit (no hidden `?? default` inside run/start).
+   * @param request - the caller's request; omitted fields get this
+   *   implementation's defaults, capped fields are clamped.
+   * @returns the fully-specified spec to hand to {@link run}/{@link start}.
    */
   abstract resolve(request: BashExecRequest): BashExecSpec
 
-  /** Run a command in the foreground; resolves when it finishes. */
+  /**
+   * Run a command in the foreground; resolves when it finishes.
+   * @param spec - a resolved spec from {@link resolve}, never a raw request.
+   * @returns the outcome; nonzero exits, timeout kills, and abort kills
+   *   resolve with a descriptive result rather than reject.
+   */
   abstract run(spec: BashExecSpec): Promise<BashRunResult>
 
-  /** Start a background task and return its handle immediately. */
+  /**
+   * Start a background task and return its handle immediately.
+   * @param spec - a resolved spec from {@link resolve}, never a raw request.
+   * @returns the live task handle; completion fires {@link onTaskDone}.
+   */
   abstract start(spec: BashExecSpec): BashTask
 
-  /** Look up a background task by id. */
+  /**
+   * Look up a background task by id.
+   * @param id - the task id to look up.
+   * @returns the tracked task, or undefined for an id this executor never issued.
+   */
   abstract get(id: BashTaskId): BashTask | undefined
 
   /**
@@ -101,24 +117,38 @@ export abstract class BashExecutor extends Service {
    * loudly at the subsequent {@link readOutput}/{@link kill} ("unknown task").
    * Storing ownership in the executor (disposed with ITS fiber) — not in the
    * tool plugin — is what makes ownership survive a `tool-bash` HMR reload.
+   * @param id - the background task id to look up ownership for.
+   * @returns the token recorded at start, verbatim; undefined for an unknown
+   *   id or a known-but-ownerless task.
    */
   abstract ownerOf(id: BashTaskId): OwnerToken | undefined
 
-  /** All tracked background tasks (insertion order). */
+  /**
+   * All tracked background tasks (insertion order).
+   * @returns every task this executor started, running or finished.
+   */
   abstract list(): BashTask[]
 
-  /** Read output produced since the previous read. Throws for unknown ids. */
+  /**
+   * Read output produced since the previous read. Throws for unknown ids.
+   * @param id - the task to read from.
+   * @returns the incremental read; consecutive reads never re-deliver output.
+   */
   abstract readOutput(id: BashTaskId): BashTaskRead
 
   /**
    * Kill a running background task. Returns false when it had already
    * finished (no-op). Throws for unknown ids.
+   * @param id - the task to kill.
+   * @returns true when this call killed it, false when it had already finished.
    */
   abstract kill(id: BashTaskId): boolean
 
   /**
    * Register a background-task completion listener (disposed with the
    * calling fiber). Listeners never fire after this service is disposed.
+   * @param listener - called exactly once per task completion.
+   * @returns the disposer that unregisters the listener.
    */
   onTaskDone(listener: BashTaskListener): () => void {
     const dispose = this.ctx.effect(() => {

+ 1 - 0
packages/compact/compact/src/index.ts

@@ -141,6 +141,7 @@ export abstract class CompactService extends Service {
    *   prior replace can leave the surface non-monotonic in seq order), or if
    *   either boundary is not a balanced tool-pairing cut (would split a step's
    *   tool-call/result pair).
+   * @returns what the compaction did (the replaced range and its summary node).
    */
   abstract compactRegion(
     session: Session,

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

@@ -121,6 +121,9 @@ export class AgentLoop extends Service implements AgentFactory {
    * deliberate resume-or-create policy (resume the prior session if one exists,
    * else start fresh) or an explicit caller-chosen session id — revisit when the
    * UI/ACP path owns session selection.
+   * @param id - the agent id; also seeds the generated session id.
+   * @param options - loop options (model, limits, …); defaults applied per option.
+   * @returns the running agent, owned by the calling fiber (no handle).
    */
   create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent {
     this.assertAgentIdFree(id)
@@ -142,6 +145,9 @@ export class AgentLoop extends Service implements AgentFactory {
    * `seed` (a balanced completed-turn prefix of the parent's log) so the child
    * starts with the parent's context. Returns an {@link AgentHandle} the owner
    * disposes to tear down exactly this agent.
+   * @param options - agent id, caller-supplied session id, optional seed/meta,
+   *   and agent options.
+   * @returns the handle whose dispose tears down exactly this agent.
    */
   createAgent(options: CreateAgentOptions): AgentHandle {
     // Check the agent id BEFORE preparing the session: register() would reject a
@@ -168,6 +174,8 @@ export class AgentLoop extends Service implements AgentFactory {
    * configured. NOT hard-injected (that would make non-persistent demos pend
    * forever) — callers that need resume (ACP) inject `sessionPersistence`, so
    * by the time this runs the service exists.
+   * @param options - the persisted session id to reload, plus agent id/options.
+   * @returns the handle for the agent resumed on the reconstructed session.
    */
   async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
     // Read the service through `ctx.get('sessionPersistence')` — a direct

+ 17 - 0
packages/core/agent/src/index.ts

@@ -126,6 +126,8 @@ export class AgentRegistry extends Service {
    * Register the agent-creation factory (the loop calls this on construction,
    * effect-scoped). Throws if a factory is already registered. Returns the
    * disposer; on dispose the factory slot is cleared.
+   * @param factory - the loop-owned factory {@link create}/{@link resume} delegate to.
+   * @returns the disposer that clears the factory slot.
    */
   setFactory(factory: AgentFactory): () => void {
     const dispose = this.ctx.effect(() => {
@@ -142,6 +144,8 @@ export class AgentRegistry extends Service {
    * agent): this constructs the agent and its session. Throws if no factory is
    * registered. Returns an {@link AgentHandle} — the owner disposes it to tear
    * down exactly this agent.
+   * @param options - agent id, session id/seed/metadata, and agent options.
+   * @returns the handle whose dispose tears down exactly this agent.
    */
   create(options: CreateAgentOptions): AgentHandle {
     if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
@@ -152,6 +156,8 @@ export class AgentRegistry extends Service {
    * Load a persisted session and resume an agent on it through the registered
    * factory. Rejects if no factory is registered; the factory rejects if
    * session persistence is not configured. Returns an {@link AgentHandle}.
+   * @param options - the persisted session id plus agent id and options.
+   * @returns the handle for the resumed agent.
    */
   async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
     if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
@@ -162,6 +168,8 @@ export class AgentRegistry extends Service {
    * Register a live agent. Throws if an agent with the same id is already
    * registered. Emits `agent/created` on registration and `agent/disposed`
    * when the calling fiber is disposed. Returns the disposer.
+   * @param agent - the already-constructed agent to record in the store.
+   * @returns the disposer that removes the agent and emits `agent/disposed`.
    */
   register(agent: Agent): () => void {
     const dispose = this.ctx.effect(function* (this: AgentRegistry) {
@@ -200,10 +208,19 @@ export class AgentRegistry extends Service {
     return () => void dispose()
   }
 
+  /**
+   * Look up a live agent.
+   * @param id - the agent id to look up.
+   * @returns the agent, or undefined when no live agent has that id.
+   */
   get(id: AgentId): Agent | undefined {
     return this.store.get(id)
   }
 
+  /**
+   * All live agents, in registration order.
+   * @returns a fresh array; mutating it does not affect the registry.
+   */
   list(): Agent[] {
     return [...this.store.values()]
   }

+ 36 - 0
packages/core/agent/src/types.ts

@@ -228,12 +228,14 @@ declare module 'cordis' {
     /**
      * An agent was registered in the {@link AgentRegistry} and is ready to
      * receive messages.
+     * @param agent - the newly registered agent, already resolvable in the registry.
      * @mode emit
      */
     'agent/created'(agent: Agent): void
     /**
      * An agent was disposed and removed from the registry; its fiber and any
      * in-flight turn have been torn down.
+     * @param agent - the agent that was torn down; its handle is now inert.
      * @mode emit
      */
     'agent/disposed'(agent: Agent): void
@@ -241,12 +243,17 @@ declare module 'cordis' {
      * Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive
      * lifecycle off this transition, never off a status you just requested —
      * `send()` does not flip status to `running` before it returns.
+     * @param agent - the agent whose status flipped.
+     * @param status - the status just entered (the transition's destination).
      * @mode emit
      */
     'agent/status'(agent: Agent, status: AgentStatus): void
     /**
      * A message entered the agent's inbox (queued or steering). `source` is
      * the resolved source (defaults applied), not the caller's raw options.
+     * @param agent - the agent whose inbox received the message.
+     * @param content - the enqueued content blocks, verbatim.
+     * @param info - the resolved source plus whether it entered as steering.
      * @mode emit
      */
     'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
@@ -260,6 +267,8 @@ declare module 'cordis' {
      * so via `agent.inject()` (a `context/message` the first request sees), not
      * by returning a decision. Cannot block the session from starting; that gap
      * is deliberate (a bridge logs/injects, it does not gate startup).
+     * @param agent - the agent whose session lifecycle began.
+     * @param source - why the session started (fresh startup, resume, …).
      * @mode emit
      */
     'agent/session-start'(agent: Agent, source: SessionStartSource): void
@@ -295,6 +304,11 @@ declare module 'cordis' {
      * listener needs to measure pressure (the system prompt counts toward the
      * budget). `signal` cancels any in-flight work a listener starts (e.g. a
      * summarization model call).
+     * @param agent - the agent about to open the step.
+     * @param turn - the already-open turn this step belongs to.
+     * @param step - the number of the step about to start.
+     * @param fullSystemPrompt - the assembled prompt, for measuring token pressure.
+     * @param signal - aborts in-flight listener work when the turn is torn down.
      * @mode serial
      */
     // TODO: `fullSystemPrompt` is a smell on a generic per-step seam — compaction
@@ -310,6 +324,9 @@ declare module 'cordis' {
      * turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook.
      * Call `next()` to delegate to the default (allow unchanged), or return a
      * {@link PromptDecision} without calling `next()` to short-circuit.
+     * @param agent - the agent draining its inbox.
+     * @param content - the drained message's blocks, as queued.
+     * @param source - the message's resolved source.
      * @mode waterfall
      */
     'agent/prompt-submit'(agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
@@ -319,12 +336,20 @@ declare module 'cordis' {
      * delegate, or return without it to short-circuit. For surface mutation that
      * must precede history derivation (compaction), use {@link agent/pre-step}
      * instead — by the time this fires, `options.messages` is already derived.
+     * @param agent - the agent making the model call.
+     * @param turn - the open turn number.
+     * @param step - the step whose request this is.
+     * @param options - the assembled request; listeners return a transformed copy.
      * @mode waterfall
      */
     'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise<GenerateOptions>): Promise<GenerateOptions>
     /**
      * Waterfall: post-process the assembled assistant {@link Message} before
      * tool dispatch (validation, content rewriting, …).
+     * @param agent - the agent that received the step's response.
+     * @param turn - the open turn number.
+     * @param step - the step that produced the message.
+     * @param message - the assistant message as assembled from the stream.
      * @mode waterfall
      */
     'agent/step-result'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
@@ -335,6 +360,9 @@ declare module 'cordis' {
      * Listeners force-continue (`/goal`, `/loop` — optionally attaching a
      * `reason` recorded as next-step steering) or force-stop (budget guards).
      * Call `next()` to delegate to the default, or return a decision to override.
+     * @param agent - the agent deciding whether to run another step.
+     * @param turn - the turn being continued or stopped.
+     * @param defaultDecision - what the loop would do absent an override.
      * @mode waterfall
      */
     'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
@@ -342,12 +370,20 @@ declare module 'cordis' {
     // ---- streaming + tool notifications (emit) ----
     /**
      * Steering content was injected into a running turn.
+     * @param agent - the agent that absorbed the steering.
+     * @param turn - the running turn that received it.
+     * @param content - the injected blocks.
+     * @param source - the steering message's resolved source.
      * @mode emit
      */
     'agent/steering'(agent: Agent, turn: number, content: ContentBlock[], source: MessageSource): void
     /**
      * A step or turn errored. The loop reports a failure here (plus the logger)
      * even when the error has no in-turn position for a session `error` event.
+     * @param agent - the agent whose turn errored.
+     * @param turn - the turn in which the failure surfaced.
+     * @param step - the step at which the failure surfaced.
+     * @param error - the failure, verbatim.
      * @mode emit
      */
     'agent/error'(agent: Agent, turn: number, step: number, error: Error): void

+ 137 - 10
packages/core/agent/tests/gen-cordis-catalog.spec.ts

@@ -4,17 +4,20 @@
  * The generated catalog is frozen by a regenerate-and-diff freshness gate, so
  * the freshness half is exercised by `pnpm run verify-cordis-catalog` in CI.
  * What a freshness diff CANNOT prove is that the generator REJECTS malformed
- * source the way it promises to — a missing `@mode` tag, or a tag that
- * contradicts the signature shape. These tests drive `collectEvents()` against
- * synthetic fixture packages to prove each guard fires (and that a well-formed
- * event passes), mirroring the drift-guard negative tests for verify-type-equiv.
+ * source the way it promises to — a missing `@mode` tag, a tag that
+ * contradicts the signature shape, or a JSDoc-completeness violation (missing
+ * prose, an undocumented parameter, a stale `@param`, a missing `@returns`, an
+ * unannotated return type). These tests drive `collectEvents()` /
+ * `collectServices()` against synthetic fixture packages to prove each guard
+ * fires (and that well-formed declarations pass), mirroring the drift-guard
+ * negative tests for verify-type-equiv.
  */
 
 import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, describe, expect, it } from 'vitest'
-import { collectEvents } from '../../../../scripts/gen-cordis-catalog.ts'
+import { collectEvents, collectServices } from '../../../../scripts/gen-cordis-catalog.ts'
 
 /** Write a fixture package exposing one `interface Events` block and return the
  * scan root to hand `collectEvents`. */
@@ -29,12 +32,31 @@ function fixtureRoot(eventsBlock: string): string {
   return root
 }
 
+/** Write a fixture package exposing one `interface Context` entry (`ctx.fix` →
+ * `FixService`) plus the class source, and return the scan root to hand
+ * `collectServices`. */
+function serviceFixtureRoot(classSource: string): string {
+  const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-'))
+  const dir = join(root, 'packages', 'group', 'fix', 'src')
+  mkdirSync(dir, { recursive: true })
+  writeFileSync(
+    join(dir, 'index.ts'),
+    `declare module 'cordis' {\n  interface Context {\n    fix: FixService\n  }\n}\n\n${classSource}\n`,
+  )
+  return root
+}
+
 const roots: string[] = []
 const make = (block: string): string => {
   const r = fixtureRoot(block)
   roots.push(r)
   return r
 }
+const makeService = (classSource: string): string => {
+  const r = serviceFixtureRoot(classSource)
+  roots.push(r)
+  return r
+}
 
 afterEach(() => {
   while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
@@ -43,7 +65,7 @@ afterEach(() => {
 describe('gen-cordis-catalog collectEvents', () => {
   it('extracts a well-formed event with its @mode and JSDoc', () => {
     const events = collectEvents(make(
-      '    /**\n     * A thing happened.\n     * @mode emit\n     */\n    \'fix/happened\'(id: string): void',
+      '    /**\n     * A thing happened.\n     * @param id - which thing.\n     * @mode emit\n     */\n    \'fix/happened\'(id: string): void',
     ))
     expect(events).toHaveLength(1)
     expect(events[0]).toMatchObject({ name: 'fix/happened', scope: 'fix', mode: 'emit', doc: 'A thing happened.' })
@@ -51,7 +73,7 @@ describe('gen-cordis-catalog collectEvents', () => {
 
   it('classifies a trailing-next signature as a waterfall', () => {
     const events = collectEvents(make(
-      '    /**\n     * Intercept it.\n     * @mode waterfall\n     */\n    \'fix/intercept\'(x: number, next: () => Promise<number>): Promise<number>',
+      '    /**\n     * Intercept it.\n     * @param x - the value under interception.\n     * @mode waterfall\n     */\n    \'fix/intercept\'(x: number, next: () => Promise<number>): Promise<number>',
     ))
     expect(events[0]?.mode).toBe('waterfall')
   })
@@ -65,19 +87,124 @@ describe('gen-cordis-catalog collectEvents', () => {
 
   it('hard-errors when an event is missing its @mode tag', () => {
     expect(() => collectEvents(make(
-      '    /** No mode here. */\n    \'fix/untagged\'(id: string): void',
+      '    /** No mode here. */\n    \'fix/untagged\'(): void',
     ))).toThrow(/missing an @mode tag/)
   })
 
   it('hard-errors when @mode contradicts a trailing-next (waterfall) shape', () => {
     expect(() => collectEvents(make(
-      '    /**\n     * Mislabeled.\n     * @mode emit\n     */\n    \'fix/wrong\'(x: number, next: () => Promise<number>): Promise<number>',
+      '    /**\n     * Mislabeled.\n     * @param x - the value.\n     * @mode emit\n     */\n    \'fix/wrong\'(x: number, next: () => Promise<number>): Promise<number>',
     ))).toThrow(/trailing 'next' parameter .* tagged '@mode emit'/)
   })
 
   it('hard-errors when @mode waterfall has no trailing next to delegate to', () => {
     expect(() => collectEvents(make(
-      '    /**\n     * Not actually a waterfall.\n     * @mode waterfall\n     */\n    \'fix/nonext\'(id: string): void',
+      '    /**\n     * Not actually a waterfall.\n     * @param id - which thing.\n     * @mode waterfall\n     */\n    \'fix/nonext\'(id: string): void',
     ))).toThrow(/tagged '@mode waterfall' but has no trailing 'next'/)
   })
+
+  it('hard-errors on an undocumented payload parameter', () => {
+    expect(() => collectEvents(make(
+      '    /**\n     * A thing happened.\n     * @mode emit\n     */\n    \'fix/happened\'(id: string): void',
+    ))).toThrow(/is missing @param id/)
+  })
+
+  it('hard-errors on a stale @param naming no real parameter', () => {
+    expect(() => collectEvents(make(
+      '    /**\n     * A thing happened.\n     * @param id - which thing.\n     * @param ghost - not a parameter.\n     * @mode emit\n     */\n    \'fix/happened\'(id: string): void',
+    ))).toThrow(/@param ghost does not match any parameter/)
+  })
+
+  it('hard-errors on an @param with an empty description', () => {
+    expect(() => collectEvents(make(
+      '    /**\n     * A thing happened.\n     * @param id\n     * @mode emit\n     */\n    \'fix/happened\'(id: string): void',
+    ))).toThrow(/@param id has an empty description/)
+  })
+
+  it('hard-errors on an event whose JSDoc has no description prose', () => {
+    expect(() => collectEvents(make(
+      '    /**\n     * @param id - which thing.\n     * @mode emit\n     */\n    \'fix/happened\'(id: string): void',
+    ))).toThrow(/no description prose/)
+  })
+
+  it('exempts the `this` receiver and the trailing waterfall `next` from @param', () => {
+    const events = collectEvents(make(
+      '    /**\n     * Scoped interception.\n     * @param x - the value under interception.\n     * @mode waterfall\n     */\n    \'fix/scoped\'(this: object, x: number, next: () => Promise<number>): Promise<number>',
+    ))
+    expect(events).toHaveLength(1)
+  })
+
+  it('aggregates every violation into one error instead of failing fast', () => {
+    expect(() => collectEvents(make(
+      '    /** First. */\n    \'fix/one\'(): void\n    /** Second. */\n    \'fix/two\'(): void',
+    ))).toThrow(/2 JSDoc completeness violation\(s\)[\s\S]*fix\/one[\s\S]*fix\/two/)
+  })
+})
+
+describe('gen-cordis-catalog collectServices', () => {
+  const WELL_FORMED = `/** Fixture service. */
+export class FixService {
+  /**
+   * Do the thing.
+   * @param id - which thing to do.
+   * @returns the outcome of doing it.
+   */
+  run(id: string): string { return id }
+
+  /** Fire and forget (void needs no @returns). */
+  poke(): void {}
+
+  /** Flush (Promise<void> needs no @returns either). */
+  flush(): Promise<void> { return Promise.resolve() }
+}`
+
+  it('extracts a well-formed service with its methods and class JSDoc', () => {
+    const services = collectServices(makeService(WELL_FORMED))
+    expect(services).toHaveLength(1)
+    expect(services[0]).toMatchObject({ key: 'fix', type: 'FixService', abstract: false, doc: 'Fixture service.' })
+    expect(services[0]?.methods).toHaveLength(3)
+  })
+
+  it('hard-errors on a public method with no JSDoc at all', () => {
+    expect(() => collectServices(makeService(
+      '/** Fixture service. */\nexport class FixService {\n  run(id: string): string { return id }\n}',
+    ))).toThrow(/ctx\.fix\.run .* has no JSDoc/)
+  })
+
+  it('hard-errors on an undocumented method parameter', () => {
+    expect(() => collectServices(makeService(
+      '/** Fixture service. */\nexport class FixService {\n  /**\n   * Do the thing.\n   * @returns the outcome.\n   */\n  run(id: string): string { return id }\n}',
+    ))).toThrow(/ctx\.fix\.run .* is missing @param id/)
+  })
+
+  it('hard-errors on a missing @returns for a non-void return type', () => {
+    expect(() => collectServices(makeService(
+      '/** Fixture service. */\nexport class FixService {\n  /**\n   * Do the thing.\n   * @param id - which thing.\n   */\n  run(id: string): string { return id }\n}',
+    ))).toThrow(/is missing @returns \(return type: string\)/)
+  })
+
+  it('hard-errors on an unannotated (inferred) return type', () => {
+    expect(() => collectServices(makeService(
+      '/** Fixture service. */\nexport class FixService {\n  /**\n   * Do the thing.\n   * @param id - which thing.\n   */\n  run(id: string) { return id }\n}',
+    ))).toThrow(/no return type annotation/)
+  })
+
+  it('hard-errors on a service class with no JSDoc', () => {
+    expect(() => collectServices(makeService(
+      'export class FixService {\n  /** Fire and forget. */\n  poke(): void {}\n}',
+    ))).toThrow(/class FixService has no JSDoc/)
+  })
+
+  it('hard-errors on a stale method @param', () => {
+    expect(() => collectServices(makeService(
+      '/** Fixture service. */\nexport class FixService {\n  /**\n   * Fire and forget.\n   * @param ghost - not a parameter.\n   */\n  poke(): void {}\n}',
+    ))).toThrow(/@param ghost does not match any parameter/)
+  })
+
+  it('ignores private/protected/static members (not the ctx.<key> surface)', () => {
+    const services = collectServices(makeService(
+      '/** Fixture service. */\nexport class FixService {\n  private hidden(id: string): string { return id }\n  protected hook(): void {}\n  static helper(): void {}\n}',
+    ))
+    expect(services[0]?.methods).toHaveLength(0)
+  })
 })

+ 23 - 1
packages/core/session/src/index.ts

@@ -30,12 +30,15 @@ declare module 'cordis' {
   interface Events {
     /**
      * A session was created in the store.
+     * @param session - the session just entered and announced.
      * @mode emit
      */
     'session/created'(session: Session): void
     /**
      * An event was appended to a session log (sync, fire-and-forget). This is
      * the per-append feed a UI or invariant plugin tails.
+     * @param session - the session whose log grew.
+     * @param event - the appended event, exactly as recorded.
      * @mode emit
      */
     'session/event'(session: Session, event: SessionEvent): void
@@ -45,6 +48,7 @@ declare module 'cordis' {
      * plugins (JSONL, SQLite) drain their write-behind buffers here and on
      * fiber dispose. Awaited (parallel), not a waterfall: every listener runs
      * and the loop waits for all of them, but none can veto.
+     * @param session - the session whose buffered events must reach durable storage.
      * @mode parallel
      */
     'session/flush'(session: Session): Promise<void> | void
@@ -342,6 +346,9 @@ export class SessionStore extends Service {
    * {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s
    * `startOwned`).
    *
+   * @param id - the session id; omitted, the store mints `session-<n>`.
+   * @param options - seed events and/or creation metadata for the header.
+   * @returns the live session, already entered and announced.
    * @throws if a session with `id` already exists, or if `meta.cwd` is a
    *   non-absolute path (storage backends key directories off it).
    */
@@ -367,6 +374,9 @@ export class SessionStore extends Service {
    * chain rather than as racing sibling effects — which would detach `onAppend`
    * before the loop's closing `session/flush`, dropping the closing events.
    *
+   * @param id - the session id; omitted, the store mints `session-<n>`.
+   * @param options - seed events and/or creation metadata for the header.
+   * @returns the constructed session, NOT yet in the store.
    * @throws if a session with `id` already exists, or if `meta.cwd` is a
    *   non-absolute path.
    */
@@ -404,6 +414,8 @@ export class SessionStore extends Service {
    * the two back-to-back so they never trip this, but the public seam cannot
    * assume that.
    *
+   * @param session - a {@link prepare}d session not yet in the store.
+   * @returns the detach disposer (`onAppend = undefined` + store removal).
    * @throws if a session with this id is already in the store.
    */
   enter(session: Session): () => void {
@@ -418,15 +430,25 @@ export class SessionStore extends Service {
 
   /** Emit `session/created` for an {@link enter}ed session. Separate from
    * {@link enter} so the caller can yield the detach disposer first (rollback
-   * safety — see {@link enter}). */
+   * safety — see {@link enter}).
+   * @param session - the entered session to announce to listeners. */
   announce(session: Session): void {
     this.ctx.emit('session/created', session)
   }
 
+  /**
+   * Look up a live session.
+   * @param id - the session id to look up.
+   * @returns the session, or undefined when no live session has that id.
+   */
   get(id: SessionId): Session | undefined {
     return this.store.get(id)
   }
 
+  /**
+   * All live sessions, in creation order.
+   * @returns a fresh array; mutating it does not affect the store.
+   */
   list(): Session[] {
     return [...this.store.values()]
   }

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

@@ -19,6 +19,8 @@ declare module 'cordis' {
      * Waterfall around prompt assembly — mutate or extend the
      * {@link PromptAssembly} (sections + tool schemas) before it is rendered.
      * Bound to the {@link SystemPrompt} service; call `next()` to delegate.
+     * @param assembly - the assembly built from the registered sections and
+     *   tool providers; listeners may mutate it or return a replacement.
      * @mode waterfall
      */
     'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
@@ -80,6 +82,8 @@ export class SystemPrompt extends Service {
    * Contribute a text section to the system prompt. Order is determined by
    * `section.order` (ascending). The section is removed when the calling
    * fiber is disposed. Emits `system-prompt/change` on register/unregister.
+   * @param section - the section to contribute (name, order, text or provider).
+   * @returns the disposer that removes the section.
    */
   section(section: PromptSection): () => void {
     const dispose = this.ctx.effect(function* (this: SystemPrompt) {
@@ -105,6 +109,8 @@ export class SystemPrompt extends Service {
    * Contribute a tool-schema provider that is evaluated at each assembly
    * call (so it can reflect the live registry state). The provider is
    * removed when the calling fiber is disposed. Emits `system-prompt/change`.
+   * @param provider - evaluated at every {@link assemble} for fresh schemas.
+   * @returns the disposer that removes the provider.
    */
   tools(provider: () => ToolSchema[]): () => void {
     const dispose = this.ctx.effect(function* (this: SystemPrompt) {
@@ -132,6 +138,7 @@ export class SystemPrompt extends Service {
    * listeners the opportunity to mutate or replace the assembly before it
    * reaches the model. Await the result before reading the assembly values —
    * waterfall listeners may be async.
+   * @returns the assembly after the waterfall has run.
    */
   assemble(): Promise<PromptAssembly> {
     const assembly: PromptAssembly = {

+ 15 - 0
packages/core/tools/src/index.ts

@@ -60,6 +60,7 @@ declare module 'cordis' {
      * tool body never runs. Input rewrite is deliberately NOT offered here (see
      * {@link PreToolDecision}); `ask` degrades to deny until the permission
      * system lands (`FIXME(permissions)`).
+     * @param exec - the pending call (name, parsed arguments, caller agent).
      * @mode waterfall
      */
     'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
@@ -74,6 +75,8 @@ declare module 'cordis' {
      * `execute`'s outer try/catch (and the tool body keeps its own inner
      * try/catch, so a thrown tool still reaches `post-execute` as an `isError`
      * result).
+     * @param exec - the call that just ran (name, parsed arguments, caller agent).
+     * @param result - the dispatch outcome a listener may accept, replace, or block.
      * @mode waterfall
      */
     'tools/post-execute'(this: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
@@ -277,6 +280,9 @@ export class ToolRegistry extends Service {
    * registered. The tool's schema (minus the `execute` function) is
    * automatically contributed to the system-prompt assembly. Disposed
    * with the calling fiber. Emits `tools/change` on register/unregister.
+   * @param definition - the tool's schema plus its execute (and optional
+   *   presentation) functions.
+   * @returns the disposer that unregisters the tool.
    */
   register(definition: ToolDefinition): () => void {
     const dispose = this.ctx.effect(function* (this: ToolRegistry) {
@@ -300,6 +306,11 @@ export class ToolRegistry extends Service {
     return () => void dispose()
   }
 
+  /**
+   * Look up a registered tool.
+   * @param name - the tool name as registered.
+   * @returns the definition, or undefined when no tool has that name.
+   */
   get(name: string): ToolDefinition | undefined {
     return this.store.get(name)
   }
@@ -313,6 +324,7 @@ export class ToolRegistry extends Service {
    * those (especially the functions) must never leak into a model request. An
    * allowlist can't drift when a new non-schema member is added to the
    * definition; a denylist (rest-destructure) would silently leak it.
+   * @returns one deep-cloned schema per registered tool, in registration order.
    */
   schemas(): ToolSchema[] {
     return [...this.store.values()].map(({ name, description, parameters, strict }): ToolSchema => ({
@@ -334,6 +346,9 @@ export class ToolRegistry extends Service {
    * still inspect. If the tool is not registered, the result is an `isError`
    * carrying a `UNKNOWN_TOOL` structured error. A thrown {@link HarnessError}
    * surfaces its `{ name, code }` on the result.
+   * @param exec - the call to run (name, parsed arguments, caller agent, signal).
+   * @returns the final result after both waterfalls; failures resolve as
+   *   `isError` results, never rejections.
    */
   async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
     try {

+ 38 - 2
packages/fs/fs/src/index.ts

@@ -102,6 +102,8 @@ declare module 'cordis' {
      * chain. The slot is first-wins: the first non-`next()` decider (registration
      * order, or `prepend`) occupies it; a second decider is a misconfiguration,
      * not layering. `actor` is the opaque tool-execution context, never read here.
+     * @param target - the resolved target about to be written.
+     * @param actor - the opaque tool-execution context the decider keys off.
      * @mode waterfall
      */
     'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
@@ -114,6 +116,8 @@ declare module 'cordis' {
      * `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset
      * or has not observed the target. Does NOT call `next()`: one decision,
      * first-wins (see {@link Events.'fs/write-intent'}).
+     * @param target - the resolved target about to be edited.
+     * @param actor - the opaque tool-execution context the decider keys off.
      * @mode waterfall
      */
     'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
@@ -126,6 +130,9 @@ declare module 'cordis' {
      * await listener promises — async or fallible audit/telemetry does not
      * belong here. No listener ⇒ nothing recorded. `actor` is the opaque
      * tool-execution context.
+     * @param target - the target that was read/written/edited.
+     * @param version - the version the actor now holds as its observation.
+     * @param actor - the observing tool-execution context; undefined records nothing useful.
      * @mode emit
      */
     'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
@@ -180,13 +187,26 @@ export abstract class FileSystem extends Service {
    * caller's per-session workspace (`exec.agent.session.header.cwd`) without the
    * provider depending on `dsh-agent`/`dsh-session`. Mirrors how `dsh-tool-bash`
    * defaults a bash `workdir` to the session cwd.
+   * @param path - the path to resolve; relative paths resolve against `opts.cwd`.
+   * @param opts - `cwd` overrides the backend's default base for relative paths.
+   * @returns the stable target; the same file yields the same `targetKey`.
    */
   abstract resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>
 
-  /** Return target metadata, or `undefined` when the target does not exist. */
+  /**
+   * Return target metadata, or `undefined` when the target does not exist.
+   * @param target - the resolved target to stat.
+   * @param signal - aborts the metadata round-trip.
+   * @returns metadata only, never content; undefined for an absent target.
+   */
   abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
 
-  /** Read the whole regular text file as a single decoded string. */
+  /**
+   * Read the whole regular text file as a single decoded string.
+   * @param target - the resolved target to read.
+   * @param signal - aborts the read.
+   * @returns the full decoded UTF-8 content.
+   */
   abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
 
   /**
@@ -194,12 +214,18 @@ export abstract class FileSystem extends Service {
    * semantics as {@link readText}, for large files). The backend owns
    * cross-chunk UTF-8 decoding and binary rejection so the policy layer never
    * touches raw bytes.
+   * @param target - the resolved target to read.
+   * @param signal - aborts the stream, including between chunks.
+   * @returns the chunk iterable, decoded and validated like {@link readText}.
    */
   abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
 
   /**
    * List direct children of a directory in stable name order. Returns resolved
    * child targets plus cheap metadata only; never reads file contents.
+   * @param target - the resolved directory target.
+   * @param signal - aborts the listing.
+   * @returns one entry per direct child, in stable name order.
    */
   abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>
 
@@ -208,6 +234,11 @@ export abstract class FileSystem extends Service {
    * create-vs-replace decision and stale guard when supplied; OMITTING it is an
    * unconditional create-or-overwrite (the bare provider — no version guard, no
    * read-first requirement). Atomic either way.
+   * @param target - the resolved target to write.
+   * @param content - the full new file content.
+   * @param expected - the write intent guarding the write; omit for unconditional.
+   * @param signal - aborts before the atomic rename takes effect.
+   * @returns the outcome, including the version the write produced.
    */
   abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
 
@@ -217,6 +248,11 @@ export abstract class FileSystem extends Service {
    * matching; OMITTING it edits the current content unconditionally (no version
    * guard). Either way applies the replacement and writes atomically — one
    * mutation critical section — and a missing target reports `FS_STALE_VERSION`.
+   * @param target - the resolved target to edit.
+   * @param edit - the literal search/replace request.
+   * @param expected - the version guard; omit for an unconditional edit.
+   * @param signal - aborts before the atomic rename takes effect.
+   * @returns the outcome, including the version the edit produced.
    */
   abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
 }

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

@@ -26,6 +26,7 @@ declare module 'cordis' {
      * Waterfall around every streaming model call (retry, caching, routing).
      * Bound to the {@link LlmService}; call `next()` to reach the resolved
      * adapter's stream, or yield your own chunks to short-circuit.
+     * @param options - the full request; listeners may rewrite it before delegating.
      * @mode waterfall
      */
     'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
@@ -77,6 +78,9 @@ export class LlmService extends Service {
    * Register an adapter for the given model names. Throws `LlmError` with code
    * `DUPLICATE_ADAPTER` if any model already has an adapter (all-or-nothing).
    * Disposed with the fiber.
+   * @param models - every model name this adapter should serve.
+   * @param adapter - the adapter that streams calls for those models.
+   * @returns the disposer that unregisters all of them.
    */
   registerAdapter(models: string[], adapter: LlmAdapter): () => void {
     const dispose = this.ctx.effect(function* (this: LlmService) {
@@ -95,7 +99,10 @@ export class LlmService extends Service {
     return () => void dispose()
   }
 
-  /** Model names with a registered adapter. */
+  /**
+   * Model names with a registered adapter.
+   * @returns the registered names, in registration order.
+   */
   models(): string[] {
     return [...this.adapters.keys()]
   }
@@ -110,6 +117,8 @@ export class LlmService extends Service {
    * Stream one model call as raw chunks (token-level deltas). Throws
    * `LlmError` with code `NO_ADAPTER` if no adapter is registered for
    * `options.model`. Dispatches through the `llm/stream` waterfall.
+   * @param options - the full request; `options.model` selects the adapter.
+   * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.
    */
   stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
     return this.ctx.waterfall(this, 'llm/stream', options, () => {

+ 10 - 1
packages/session-persistence/session-persistence/src/index.ts

@@ -105,6 +105,7 @@ export abstract class SessionPersistence extends Service {
    * until the first {@link append} (lazy materialization), in which case a
    * created-but-never-appended session is absent from {@link list}
    * — abandoned sessions leave nothing behind.
+   * @param meta - the immutable header (id, version, cwd, lineage) to record.
    */
   abstract create(meta: SessionHeader): Promise<void>
 
@@ -114,6 +115,8 @@ export abstract class SessionPersistence extends Service {
    * contracts: the first event's `seq` MUST equal the stored next-seq (after
    * `load` has durably closed any interrupted turn). Rejects non-JSON-
    * serializable `event.data` with an error naming the offending event type.
+   * @param id - the session the batch belongs to.
+   * @param events - the contiguous batch to persist, in seq order.
    */
   abstract append(id: SessionId, events: readonly SessionEvent[]): Promise<void>
 
@@ -138,10 +141,16 @@ export abstract class SessionPersistence extends Service {
    * COMMITTED region (at or before the last real `turn/end`) makes the session
    * unloadable (reject). Rejects an unknown format `version`. See the session-persistence RFC for
    * the crash-recovery contract.
+   * @param id - the persisted session to reload.
+   * @returns the header plus the event log, ending on a balanced `turn/end` —
+   *   immediately usable as a session seed.
    */
   abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>
 
-  /** Lightweight listing from metadata, without a full-log parse. */
+  /**
+   * Lightweight listing from metadata, without a full-log parse.
+   * @returns one header per materialized session.
+   */
   abstract list(): Promise<SessionHeader[]>
 }
 

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

@@ -64,12 +64,14 @@ declare module 'cordis' {
      * A subagent run started — emitted after the provider is resolved and its
      * capabilities validated, as the child run begins. Paired with
      * {@link Events['subagent/end']}.
+     * @param info - which provider started which child agent.
      * @mode emit
      */
     'subagent/start'(info: SubagentRunInfo): void
     /**
      * A subagent run settled — emitted when {@link SubagentRun.result}
      * resolves (any stop reason). Paired with {@link Events['subagent/start']}.
+     * @param info - the run identity plus stop reason and final output.
      * @mode emit
      */
     'subagent/end'(info: SubagentRunEndInfo): void
@@ -129,6 +131,8 @@ export class SubagentService extends Service {
    * Register a provider under its `provider.name`. Throws {@link SubagentError}
    * (`DUPLICATE_PROVIDER`) if the name is already taken. Effect-scoped: disposed
    * with the calling fiber (HMR-safe).
+   * @param provider - the provider; its `name` is the registry key.
+   * @returns the disposer that unregisters the provider.
    */
   registerProvider(provider: SubagentProvider): () => void {
     const dispose = this.ctx.effect(function* (this: SubagentService) {
@@ -145,12 +149,19 @@ export class SubagentService extends Service {
     return () => void dispose()
   }
 
-  /** Look up a registered provider by name (`undefined` if absent). */
+  /**
+   * Look up a registered provider by name (`undefined` if absent).
+   * @param name - the provider name as registered.
+   * @returns the provider, or undefined when the name is unknown.
+   */
   getProvider(name: string): SubagentProvider | undefined {
     return this.providers.get(name)
   }
 
-  /** The names of all registered providers (insertion order). */
+  /**
+   * The names of all registered providers (insertion order).
+   * @returns the registered provider names.
+   */
   list(): string[] {
     return [...this.providers.keys()]
   }
@@ -162,6 +173,9 @@ export class SubagentService extends Service {
    * for the first unmet one — fail loud, before any child is created), then
    * delegates to {@link SubagentProvider.start} and emits `subagent/start` /
    * `subagent/end` around the run.
+   * @param name - the provider to run on.
+   * @param request - the child's prompt, capabilities, and options.
+   * @returns the live run (its `result` resolves when the child settles).
    */
   start(name: string, request: SubagentStartRequest): SubagentRun {
     const provider = this.providers.get(name)

+ 18 - 2
packages/web/web/src/index.ts

@@ -129,6 +129,8 @@ export class WebService extends Service {
    * if its id is already registered for search. Returns a disposer; emits
    * `web/providers-change` after a successful register and again on dispose.
    * Disposed with the calling fiber.
+   * @param provider - the provider; its `id` is the registry key.
+   * @returns the disposer that unregisters the provider.
    */
   registerSearchProvider(provider: WebSearchProvider): () => void {
     return this.registerProvider(this.searchProviders, provider)
@@ -139,6 +141,8 @@ export class WebService extends Service {
    * if its id is already registered for fetch. Returns a disposer; emits
    * `web/providers-change` after a successful register and again on dispose.
    * Disposed with the calling fiber.
+   * @param provider - the provider; its `id` is the registry key.
+   * @returns the disposer that unregisters the provider.
    */
   registerFetchProvider(provider: WebFetchProvider): () => void {
     return this.registerProvider(this.fetchProviders, provider)
@@ -165,7 +169,10 @@ export class WebService extends Service {
     return () => void dispose()
   }
 
-  /** Search-capability selection status, derived live (never stored). */
+  /**
+   * Search-capability selection status, derived live (never stored).
+   * @returns which provider would serve a search right now, or why none would.
+   */
   searchStatus(): WebCapabilityStatus {
     return resolveStatus({
       providers: this.searchProviders,
@@ -173,7 +180,10 @@ export class WebService extends Service {
     })
   }
 
-  /** Fetch-capability selection status, derived live (never stored). */
+  /**
+   * Fetch-capability selection status, derived live (never stored).
+   * @returns which provider would serve a fetch right now, or why none would.
+   */
   fetchStatus(): WebCapabilityStatus {
     return resolveStatus({
       providers: this.fetchProviders,
@@ -186,6 +196,9 @@ export class WebService extends Service {
    * time with the selection rules above; throws {@link WebError} when the
    * capability cannot run. The seam enforces `request.maxResults` on the result:
    * if the provider over-returns, `sources[]` is truncated and `truncated` set.
+   * @param request - the query plus result-shaping options.
+   * @param exec - the tool-execution context, forwarded to the provider.
+   * @returns the provider's results, capped to `request.maxResults`.
    */
   async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> {
     const provider = resolveProvider({
@@ -200,6 +213,9 @@ export class WebService extends Service {
    * Retrieve one URL through the selected provider. Resolves the provider at
    * call time with the selection rules above; throws {@link WebError} when the
    * capability cannot run. A non-2xx response is a result, not a throw.
+   * @param request - the URL plus retrieval options.
+   * @param exec - the tool-execution context, forwarded to the provider.
+   * @returns the retrieval outcome; non-2xx responses resolve descriptively.
    */
   async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> {
     const provider = resolveProvider({

+ 155 - 16
scripts/gen-cordis-catalog.ts

@@ -26,7 +26,18 @@
  * Every harness event MUST carry an `@mode emit|waterfall|parallel|serial` tag
  * — the generator hard-errors on a missing tag, and where the signature shape is
  * conclusive (a trailing `next: () => …` parameter is structurally a waterfall)
- * it asserts the tag agrees and hard-errors on a contradiction. The INHERITED
+ * it asserts the tag agrees and hard-errors on a contradiction. Beyond the tag,
+ * the walk enforces JSDoc COMPLETENESS on the whole harness surface (the
+ * jsdoc-completeness-gate RFC): every event and public service method carries
+ * description prose; every payload parameter has a non-empty `@param` (`this`
+ * receivers and the trailing waterfall `next` are exempt — next's semantics are
+ * documented once by the mode); a service method with a non-`void`/
+ * `Promise<void>` return carries a non-empty `@returns` and needs an EXPLICIT
+ * return type annotation (a pure-AST walk cannot classify an inferred return);
+ * 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
  * 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.
@@ -147,8 +158,10 @@ function rawJsDoc(text: string, node: ts.Node): string {
  * 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`; `@`-tag lines
- * other than `@mode` end the current prose run.
+ * (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
@@ -157,6 +170,7 @@ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
     .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[] = []
@@ -178,8 +192,9 @@ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
   }
   for (const line of inner) {
     const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(line)
-    if (m) { mode = m[1] as Mode; continue }
-    if (line.startsWith('@')) { flushPara(); continue } // other tags end the prose
+    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
@@ -197,6 +212,60 @@ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
   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) {
@@ -215,10 +284,13 @@ function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.Source
   return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim()
 }
 
-/** Walk every harness `interface Events` block and extract its events.
- * `scanRoot` defaults to the repo root; tests pass a fixture dir. */
+/** Walk every harness `interface Events` block and extract its events, hard-
+ * erroring (aggregated) on any JSDoc-completeness violation: a missing/
+ * contradicted `@mode`, missing description prose, or an undocumented payload
+ * parameter. `scanRoot` defaults to the repo root; tests pass a fixture dir. */
 export function collectEvents(scanRoot: string = root): EventEntry[] {
   const entries: EventEntry[] = []
+  const violations: string[] = []
   for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).sort()) {
     const abs = resolve(scanRoot, rel)
     const text = readFileSync(abs, 'utf8')
@@ -232,33 +304,63 @@ export function collectEvents(scanRoot: string = root): EventEntry[] {
         if (!ts.isMethodSignature(member)) continue
         const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf)
         const signature = memberSignature(member, sf)
-        const { doc, mode } = parseJsDoc(rawJsDoc(text, member))
+        const raw = rawJsDoc(text, member)
+        const { doc, mode } = parseJsDoc(raw)
         const src = pointer(rel, sf, member)
+        const where = `event '${name}' (${src})`
         if (!mode) {
-          throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
+          violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
         }
         // Conclusive structural check: a trailing `next: () => …` parameter is a
         // waterfall. (emit vs parallel vs serial is not structurally
         // distinguishable, so it is trusted from the tag.)
         const last = member.parameters.at(-1)
         const hasNext = !!last && last.name.getText(sf) === 'next'
-        if (hasNext && mode !== 'waterfall') {
-          throw new Error(`gen-cordis-catalog: event '${name}' (${src}) has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
+        if (mode && hasNext && mode !== 'waterfall') {
+          violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
+        }
+        if (mode && !hasNext && mode === 'waterfall') {
+          violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
+        }
+        if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`)
+        // Payload parameters need a non-empty @param each. Exempt the `this`
+        // receiver annotation (not payload) and the trailing waterfall `next`
+        // (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.`)
         }
-        if (!hasNext && mode === 'waterfall') {
-          throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
+        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?).`)
+          }
         }
-        entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
+        if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
       }
     }
   }
+  reportViolations(violations)
   return entries
 }
 
-/** Walk every harness `interface Context` block + its service class.
+/** Walk every harness `interface Context` block + its service class, hard-
+ * erroring (aggregated) on any JSDoc-completeness violation: a class or public
+ * method without JSDoc prose, an undocumented parameter, a stale `@param`, a
+ * missing `@returns` on a non-void method, or an inferred (unannotated) return
+ * type the pure-AST walk cannot classify.
  * `scanRoot` defaults to the repo root; tests pass a fixture dir. */
 export function collectServices(scanRoot: string = root): ServiceEntry[] {
   const entries: ServiceEntry[] = []
+  const violations: string[] = []
   for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).sort()) {
     const abs = resolve(scanRoot, rel)
     const text = readFileSync(abs, 'utf8')
@@ -284,6 +386,8 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
       )
       if (!cls) continue // a Pick-mixin member (e.g. timer helpers), not a class here
       const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false
+      const clsDoc = parseJsDoc(rawJsDoc(text, cls)).doc
+      if (!clsDoc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`)
       const methods: string[] = []
       for (const member of cls.members) {
         if (!ts.isMethodDeclaration(member)) continue
@@ -300,17 +404,52 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
         const memberName = member.name.getText(sf)
         if (memberName.startsWith('[')) continue // computed/symbol members
         methods.push(memberSignature(member, sf))
+        const where = `service method ctx.${key}.${memberName} (${pointer(rel, sf, member)})`
+        const raw = rawJsDoc(text, member)
+        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.`)
+        }
       }
       entries.push({
         key,
         type,
         abstract,
-        doc: parseJsDoc(rawJsDoc(text, cls)).doc,
+        doc: clsDoc,
         methods,
         source: pointer(rel, sf, cls),
       })
     }
   }
+  reportViolations(violations)
   return entries.sort((a, b) => a.key.localeCompare(b.key))
 }