Ver Fonte

Merge pull request #188 from deepseek-harness/config-catalog

feat: generated plugin config catalog (docs/config-catalog.md)
Tianyi Cui há 2 meses atrás
pai
commit
215ff9a4e6

+ 1 - 1
docs/AGENTS.md

@@ -17,7 +17,7 @@ Every fact has exactly one home — the tier whose job it is — and every other
 | [cookbook/](cookbook/adding-a-package.md) | Step-by-step how-tos with numbered verify steps | Design rationale (→ the RFC each guide links) |
 | Package README | The per-package contract: config, semantics, limitations, extension points | JSDoc restatement, generated-catalog restatement (event/tool tables), other packages' concerns |
 | [development.md](development.md) | Human-facing setup and daily workflow; a bilingual pair under the [i18n contract](i18n/README.md) | Gate-by-gate enumerations that drift from `package.json` scripts |
-| Generated catalogs: [cordis events](cordis-catalog/events.md), [cordis services](cordis-catalog/services.md), [tool-catalog](tool-catalog/tools.md), [persistence-catalog](persistence-catalog/log-events.md), [module-graph.md](module-graph.md) | Exhaustive enumerations regenerated from source, freshness-gated | Hand edits of any kind |
+| Generated catalogs: [cordis events](cordis-catalog/events.md), [cordis services](cordis-catalog/services.md), [tool-catalog](tool-catalog/tools.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog/log-events.md), [module-graph.md](module-graph.md) | Exhaustive enumerations regenerated from source, freshness-gated | Hand edits of any kind |
 | Skills (`.agents/skills/`) | Workflows: how to carry out a recurring task against the contracts | The contracts themselves (→ docs) |
 
 Placement test: a story about a bug → postmortem. Why we chose X → RFC. How to do task Y → cookbook. What type Z looks like → core-data-structures. What package P promises → its README. A rule every agent must always obey → root AGENTS.md, one line, linking the home that holds the why.

+ 761 - 0
docs/config-catalog.md

@@ -0,0 +1,761 @@
+<!-- Generated by scripts/gen-config-catalog.ts — do not edit by hand.
+     Run `pnpm run gen-config-catalog` to regenerate. -->
+
+# Plugin Config Catalog
+
+Every `config:` block a `cordis.yml` entry can set: for each loadable harness package, the verbatim config declaration (JSDoc included) its `apply` function or service constructor receives, with every referenced type pasted alongside (package-local types) or linked (everything else). The paste is the plugin's full declared config type — a field the runtime schema deliberately excludes is a runtime-only seam (its own JSDoc says so) and is not settable from `cordis.yml`. This is the **deployment**-axis reference — the wiring a plugin author works against is the cordis [events](cordis-catalog/events.md) + [services](cordis-catalog/services.md) catalogs, the model-facing tool schemas are the [tool catalog](tool-catalog/tools.md), and [core-data-structures/](core-data-structures/core.md) documents the types these declarations reference.
+
+This file is GENERATED from source (`scripts/gen-config-catalog.ts`) and verified fresh by `pnpm run verify-config-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks use a `ts config-catalog` fence (skipped by doc-typecheck, since a lone declaration referencing imports is not standalone-compilable). The generator also cross-checks the runtime schemastery schema against the pasted declaration — every schema-validated key, nested keys included, must be locatable on the declared config type — so the paste cannot hide a loader-accepted field.
+
+A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` tree must also load providers for those services. Scope is the harness tier (`packages/`); the vendored cordis plugins a config tree may also load (`hmr`, the console logger, …) are pinned upstream source ([vendoring policy](../vendor/README.md)) and not catalogued here.
+
+## `@deepseek-ai/dsh-acp`
+
+Requires: `agents` · `sessions` · `sessionPersistence` · `tools`
+
+```ts config-catalog
+/** Plugin config: the agent template ACP sessions are created from. */
+export interface AcpConfig {
+  /** Model name for created agents (must have a registered adapter). */
+  model?: string
+  /**
+   * Transport stream override. Production omits this (the plugin wires
+   * `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an
+   * in-memory `Stream` (e.g. an `ndJsonStream` over a `Duplex` pair) to drive
+   * the bridge without a subprocess. Not part of the schemastery `Config` —
+   * it is a runtime-only seam, never set from a `cordis.yml`.
+   */
+  stream?: Stream
+}
+```
+
+Depends on: `Stream` (`@agentclientprotocol/sdk`)
+
+Source: [`packages/ui/acp/src/index.ts:115`](../packages/ui/acp/src/index.ts)
+
+## `@deepseek-ai/dsh-acp-agent`
+
+```ts config-catalog
+/**
+ * App config: the swappable per-deployment values. `model` configures the
+ * agent template the ACP bridge creates each session's agent from (NOT a
+ * pre-created agent — ACP creates agents at `session/new`); `persona` is the
+ * deployment persona (forwarded to the system-prompt plugin);
+ * `persistenceRoot` is the JSONL backend's directory.
+ */
+export interface Config {
+  /** Model name for ACP-created agents (must have a registered adapter). */
+  model: string
+  /** Deployment persona (the system-prompt plugin's `persona` config). */
+  persona?: string
+  /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
+  persistenceRoot?: string
+}
+```
+
+Source: [`packages/ui/acp-agent/src/index.ts:48`](../packages/ui/acp-agent/src/index.ts)
+
+## `@deepseek-ai/dsh-agent-core`
+
+```ts config-catalog
+/**
+ * Bundle config: each field forwarded verbatim to the child that owns it —
+ * `agents` to the agent loop (an app that pre-creates no agents, like the ACP
+ * bridge, simply omits it), `persona` to the system-prompt plugin (the
+ * deployment's persona section). Both are optional INPUT here because each
+ * owner's schema supplies the default (`[]` / `''`); the schema is the
+ * INTERSECTION of the owners' own schemas, so validation and defaulting can
+ * never drift from them.
+ */
+export interface Config {
+  /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
+  agents?: AgentLoopConfig['agents']
+  /** The deployment persona (see dsh-system-prompt's `Config`). */
+  persona?: SystemPromptConfig['persona']
+}
+```
+
+Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt)
+
+Source: [`packages/core/agent-core/src/index.ts:68`](../packages/core/agent-core/src/index.ts)
+
+## `@deepseek-ai/dsh-agent-loop`
+
+Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt`
+
+```ts config-catalog
+export interface Config {
+  /** Agents created from configuration at startup. */
+  agents: (AgentOptions & {
+    /** Agent id to register under; also seeds the fresh per-run session id (`${id}-session-<uuid>`). */
+    id: AgentId
+    /**
+     * If set, the config agent RESUMES this persisted session id instead of
+     * starting a fresh `${id}-session-<uuid>`. Sourced from an env var in
+     * cordis.yml (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`), so a
+     * demo can continue a prior conversation without code changes. Requires a
+     * `dsh-session-persistence` backend; the resume is deferred until that
+     * service is available (via `ctx.inject`) and the loaded session's events
+     * seed the live session so history continues.
+     *
+     * The schema accepts a plain string at runtime (cordis.yml values are
+     * untyped); the brand is compile-time only — the config format is the
+     * boundary where an id enters, so the TYPE declares the brand here.
+     */
+    resumeSessionId?: SessionId
+  })[]
+}
+```
+
+Depends on: [`AgentId`](../packages/core/agent/src/index.ts) · [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts)
+
+Source: [`packages/core/agent-loop/src/index.ts:32`](../packages/core/agent-loop/src/index.ts)
+
+## `@deepseek-ai/dsh-bash-local`
+
+```ts config-catalog
+/** Plugin config (all optional — `static Config` supplies the defaults). */
+export interface Config {
+  /** Default working directory for commands (default: process.cwd()). */
+  cwd?: string
+  /** Default foreground timeout in milliseconds. */
+  timeoutMs?: number
+  /** Upper bound for per-call timeout overrides. */
+  maxTimeoutMs?: number
+  /** Per-stream in-memory output cap; overflow spills to a temp file. */
+  maxOutputBytes?: number
+  /** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
+  graceMs?: number
+}
+```
+
+Source: [`packages/bash/bash-local/src/index.ts:28`](../packages/bash/bash-local/src/index.ts)
+
+## `@deepseek-ai/dsh-compact-basic`
+
+Requires: `llm`
+
+```ts config-catalog
+/**
+ * Backend configuration. Every knob is REQUIRED except `auto` and
+ * `charsPerToken`: there is no concrete data yet to justify default
+ * thresholds/budgets, so a consumer must state each value explicitly rather
+ * than inherit a guessed default. `auto` alone defaults to `true`
+ * (auto-compaction is the intended posture), and `charsPerToken` defaults to
+ * the English-text heuristic its estimator was calibrated on.
+ */
+export interface BasicCompactConfig {
+  /** Context window size in tokens. */
+  contextWindow: number
+  /** Compact when estimated token usage exceeds this fraction of context window. */
+  thresholdRatio: number
+  /** Number of tokens of recent context to retain during compaction. */
+  retainTokens: number
+  /** Model to use for summarization (`''` — uses the agent's model). */
+  summarizationModel: string
+  /** Provider generation cap for the summarization call. */
+  maxTokens: number
+  /** Extra compaction attempts when the first compacted surface is still over threshold. */
+  compactionRetries: number
+  /** Enable automatic compaction on the `agent/pre-step` seam (default true). */
+  auto?: boolean
+  /**
+   * Text density for the token estimator: estimated tokens = chars /
+   * `charsPerToken`. Defaults to 4 (typical English text). A CJK-heavy
+   * deployment should set ~1-2 — CJK runs at roughly 1-2 chars per token, so
+   * the default UNDERestimates several-fold and compaction fires far too late.
+   * May be fractional.
+   */
+  charsPerToken?: number
+}
+```
+
+Source: [`packages/compact/compact-basic/src/types.ts:20`](../packages/compact/compact-basic/src/types.ts)
+
+## `@deepseek-ai/dsh-fs-local`
+
+```ts config-catalog
+/** Configuration for the local filesystem backend. */
+export interface Config {
+  /** Base directory for relative paths. Defaults to `process.cwd()`. */
+  cwd?: string
+}
+```
+
+Source: [`packages/fs/fs-local/src/index.ts:58`](../packages/fs/fs-local/src/index.ts)
+
+## `@deepseek-ai/dsh-hooks-claude`
+
+Requires: `bash`
+
+```ts config-catalog
+/** Plugin config: where the CC hook config lives + substitution roots. */
+export interface Config {
+  /**
+   * Path to a `hooks.json` or a settings file whose `hooks` key holds the config.
+   * PROCESS-LEVEL: read once at load, a relative path resolves against the process
+   * launch cwd, so one config applies to the whole process.
+   * TODO(per-session-hook-config): per-session discovery of a project-local
+   * `hooks.json` from each `session/new.cwd` is not yet implemented.
+   */
+  configPath: string
+  /**
+   * Replaces `${CLAUDE_PLUGIN_ROOT}` in command strings (the plugin's root dir).
+   */
+  pluginRoot?: string
+  /**
+   * Replaces `${CLAUDE_PROJECT_DIR}` in command strings AND is exported as the
+   * `CLAUDE_PROJECT_DIR` env var for hook processes. When omitted, the env var
+   * defaults per-run to the agent's session workspace (`session.header.cwd`, the
+   * same dir the hook runs in) — Claude Code always exports this var, and common
+   * unmodified hooks reference `$CLAUDE_PROJECT_DIR` for project-relative paths.
+   */
+  projectDir?: string
+  /** Default per-hook timeout in ms when a hook sets none (CC default: 600000). */
+  defaultTimeoutMs?: number
+  /** Character cap for the `hook/result` event's persisted stderr summary. */
+  stderrSummaryMaxChars?: number
+}
+```
+
+Source: [`packages/hooks/hooks-claude/src/index.ts:55`](../packages/hooks/hooks-claude/src/index.ts)
+
+## `@deepseek-ai/dsh-hooks-codex`
+
+Requires: `bash`
+
+```ts config-catalog
+/** Plugin config: where the Codex hooks.json lives + the model name for payloads. */
+export interface Config {
+  /**
+   * Path to a Codex `hooks.json`. PROCESS-LEVEL: read once at load, a relative
+   * path resolves against the process launch cwd.
+   * TODO(per-session-hook-config): per-session project-local discovery from each
+   * `session/new.cwd` is not yet implemented.
+   */
+  configPath: string
+  /** The model name stamped on every payload (Codex includes `model` on each event). */
+  model?: string
+  /** Default per-hook timeout in ms when a hook sets none (Codex default: 600000). */
+  defaultTimeoutMs?: number
+  /** Character cap for the `hook/result` event's persisted stderr summary. */
+  stderrSummaryMaxChars?: number
+}
+```
+
+Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-codex/src/index.ts)
+
+## `@deepseek-ai/dsh-invariants`
+
+Requires: `sessions`
+
+```ts config-catalog
+/** Plugin config. */
+export interface Config {
+  /**
+   * Deep-freeze logged session-event data so mutating a logged event throws.
+   * Default true — this plugin only runs in dev/test, where freezing is the
+   * point. Set false to assert the event contract without freezing.
+   */
+  freeze?: boolean
+}
+```
+
+Source: [`packages/support/invariants/src/index.ts:45`](../packages/support/invariants/src/index.ts)
+
+## `@deepseek-ai/dsh-llm-deepseek`
+
+Requires: `llm`
+
+```ts config-catalog
+export interface Config {
+  /** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */
+  apiKey?: string
+  /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
+  baseURL?: string
+  /** Model names to register (sent verbatim on the wire). */
+  models?: string[]
+  /** Thinking-mode default for every request (provider default: enabled). */
+  thinking?: 'enabled' | 'disabled'
+  /** Thinking effort (only meaningful with thinking enabled). */
+  reasoningEffort?: 'high' | 'max'
+}
+```
+
+Source: [`packages/llm/llm-deepseek/src/index.ts:37`](../packages/llm/llm-deepseek/src/index.ts)
+
+## `@deepseek-ai/dsh-llm-pi-ai`
+
+Requires: `llm`
+
+```ts config-catalog
+export interface Config {
+  /** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */
+  apiKey?: string
+  /** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
+  baseURL?: string
+  /** Model names to register (sent verbatim on the wire). */
+  models?: string[]
+  /**
+   * Thinking level for every request: 'off' disables thinking mode; 'high'
+   * and 'xhigh' (wire 'max') set the effort. Omitted = provider default
+   * (thinking enabled), matching llm-deepseek's omission semantics.
+   */
+  reasoning?: PiAiReasoning
+}
+
+/** Reasoning levels surfaced by this adapter (DeepSeek wire: high|max). */
+export type PiAiReasoning = 'off' | 'high' | 'xhigh'
+```
+
+Source: [`packages/llm/llm-pi-ai/src/index.ts:32`](../packages/llm/llm-pi-ai/src/index.ts)
+
+## `@deepseek-ai/dsh-llm-replay`
+
+Requires: `llm`
+
+```ts config-catalog
+export interface Config {
+  /** Override the fixture path; defaults to `$DSH_SNAPSHOT_FILE`. */
+  file?: string
+  /** Override the sidecar path; defaults to `$DSH_SNAPSHOT_OVERRIDE`. */
+  overrideFile?: string
+  /**
+   * Override the child-log paths; defaults to `$DSH_SNAPSHOT_CHILD_FILES` (a
+   * path-separator-delimited list). Each is a recorded subagent session log for
+   * a nested-agent scenario; absent/empty for a single-session scenario.
+   */
+  childFiles?: string[]
+}
+```
+
+Source: [`packages/support/llm-replay/src/index.ts:411`](../packages/support/llm-replay/src/index.ts)
+
+## `@deepseek-ai/dsh-session-persistence-jsonl`
+
+Requires: `sessions`
+
+```ts config-catalog
+export interface Config {
+  /**
+   * Root directory for all session files. Required (no default): a default of
+   * `process.cwd()` would scatter session files as the process's cwd changes
+   * (bash calls, subprocesses). Sessions group under per-cwd subdirectories.
+   */
+  root: string
+}
+```
+
+Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:34`](../packages/session-persistence/session-persistence-jsonl/src/index.ts)
+
+## `@deepseek-ai/dsh-session-persistence-sqlite`
+
+Requires: `sessions`
+
+```ts config-catalog
+/** Plugin configuration. */
+export interface Config {
+  /**
+   * Filesystem path to the SQLite database file. The special value `:memory:`
+   * opens an in-process database (tests); a file path is created (with parent
+   * dirs) on construction.
+   */
+  path: string
+  /**
+   * SQLite `journal_mode` pragma. `wal` (the default) is the recorded
+   * durability model; pick a rollback-journal mode (`delete`/`truncate`/
+   * `persist`) on filesystems where WAL's shared-memory files do not work
+   * (network mounts). See {@link JournalMode}.
+   */
+  journalMode?: JournalMode
+}
+
+/**
+ * Journal modes the backend will run under. `wal` is the default and the
+ * durability model the persistence ADR records; the rollback-journal modes
+ * (`delete`/`truncate`/`persist`) exist for filesystems where WAL's
+ * shared-memory files do not work (network mounts). `memory`/`off` are
+ * excluded: dropping journal durability silently contradicts what this
+ * backend promises.
+ */
+export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
+```
+
+Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:50`](../packages/session-persistence/session-persistence-sqlite/src/index.ts)
+
+## `@deepseek-ai/dsh-stdio-agent`
+
+```ts config-catalog
+/**
+ * App config: the swappable per-demo values, each routed to where the app wires
+ * it. `model`/`resumeSessionId` configure the pre-created `main` agent (through
+ * {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is
+ * the deployment persona (forwarded to the system-prompt plugin);
+ * `persistenceRoot` is the JSONL backend's directory; `welcome` is the UI banner.
+ */
+export interface Config {
+  /** Model name for the `main` agent (must have a registered adapter). */
+  model: string
+  /** Deployment persona (the system-prompt plugin's `persona` config). */
+  persona?: string
+  /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
+  persistenceRoot?: string
+  /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
+  welcome?: string
+  /**
+   * If set, the `main` agent RESUMES this persisted session id instead of
+   * starting fresh. Sourced from an env var in the leaf `cordis.yml`
+   * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`).
+   */
+  resumeSessionId?: string
+}
+```
+
+Source: [`packages/ui/stdio-agent/src/index.ts:59`](../packages/ui/stdio-agent/src/index.ts)
+
+## `@deepseek-ai/dsh-subagent-acp`
+
+Requires: `subagents`
+
+```ts config-catalog
+/** Config: how to spawn and drive the child ACP agent process. */
+export interface Config {
+  /** Provider name on `ctx.subagents` (default `acp`). */
+  providerName: string
+  /** The executable to spawn for each run (the child ACP agent). */
+  command: string
+  /** Arguments passed to {@link command}. */
+  args: string[]
+  /**
+   * Working directory for the child process and its ACP session. Defaults to
+   * the parent process's cwd when omitted.
+   */
+  cwd?: string
+  /**
+   * How to auto-answer the child's `session/request_permission` prompts:
+   * `reject` (default — decline every prompt) or `allow` (approve via the first
+   * allow-shaped option). The first cut surfaces no prompt to a human.
+   */
+  permission: PermissionPolicy
+  /**
+   * Extra environment variables for the child process — e.g. the child
+   * harness's own `DEEPSEEK_API_KEY`. Forwarded on top of a credential-scrubbed
+   * copy of the parent env, so an explicit key here reaches the child while
+   * ambient secrets do not leak implicitly.
+   */
+  env: Record<string, string>
+  /**
+   * Grace period (ms) for the child's EOF-driven quiesce on dispose — its
+   * window to flush persistence and tear down its own nested subprocesses
+   * before the parent escalates to a signal.
+   */
+  disposeEofGraceMs?: number
+  /** Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation on dispose. */
+  disposeGraceMs?: number
+}
+
+/**
+ * How the client answers a child's `session/request_permission`. The first cut
+ * does not surface permission prompts to a human, so every request is
+ * auto-answered by this fixed policy:
+ *
+ * - `reject` — decline every prompt (answer `cancelled`). Safe default: a child
+ *   that asks before a side effect does not get to take it.
+ * - `allow` — approve every prompt by selecting its first `allow_*` option (or,
+ *   if none is offered, `cancelled`). Use when the child is trusted to act.
+ */
+export type PermissionPolicy = 'allow' | 'reject'
+```
+
+Source: [`packages/subagent/subagent-acp/src/index.ts:30`](../packages/subagent/subagent-acp/src/index.ts)
+
+## `@deepseek-ai/dsh-subagent-fork`
+
+Requires: `subagents` · `agents`
+
+```ts config-catalog
+/** Config: the registry name to register the provider under. */
+export interface Config {
+  /** Provider name on `ctx.subagents` (default `fork`). */
+  providerName: string
+}
+```
+
+Source: [`packages/subagent/subagent-fork/src/index.ts:34`](../packages/subagent/subagent-fork/src/index.ts)
+
+## `@deepseek-ai/dsh-subagent-mock`
+
+Requires: `subagents`
+
+```ts config-catalog
+/** Config for the mock provider; all optional with test-friendly defaults. */
+export interface Config {
+  /** Registry name to register under. */
+  name: string
+  /** The text the scripted child "returns" as its final answer. */
+  reply?: string
+  /** The stop reason the run settles with. */
+  stopReason?: SubagentStopReason
+  /** Which start-time capabilities to advertise (default: all `true`). */
+  capabilities?: Partial<SubagentCapabilities>
+  /**
+   * The context contract to declare ({@link SubagentProvider.inheritsParentContext});
+   * default `false` (spawn-like). Set `true` to exercise the fork-shaped tool
+   * wording in consumer tests.
+   */
+  inheritsParentContext?: boolean
+  /**
+   * Structured value surfaced when a request carries an `outputSchema` and the
+   * `outputSchema` capability is on (default: `{ reply }`).
+   */
+  structured?: unknown
+}
+```
+
+Depends on: [`SubagentCapabilities`](../packages/subagent/subagent/src/index.ts) · [`SubagentStopReason`](../packages/subagent/subagent/src/index.ts)
+
+Source: [`packages/support/subagent-mock/src/index.ts:84`](../packages/support/subagent-mock/src/index.ts)
+
+## `@deepseek-ai/dsh-subagent-spawn`
+
+Requires: `subagents` · `agents`
+
+```ts config-catalog
+/** Config: the registry name to register the provider under. */
+export interface Config {
+  /** Provider name on `ctx.subagents` (default `spawn`). */
+  providerName: string
+}
+```
+
+Source: [`packages/subagent/subagent-spawn/src/index.ts:26`](../packages/subagent/subagent-spawn/src/index.ts)
+
+## `@deepseek-ai/dsh-system-prompt`
+
+```ts config-catalog
+export interface Config {
+  /**
+   * The deployment's persona — the ONE deployment-authored fragment of the
+   * system prompt, rendered as the order-0 `deployment:persona` section
+   * (after the harness identity, before all tool guidance). Every agent in
+   * the context shares it, subagents included. Template, not free-form text:
+   * every complete `{{…}}` group is interpreted strictly against the
+   * registered prompt variables (the shipped agent loop registers `{{model}}`
+   * and `{{cwd}}`), and there is no escape syntax for literal `{{…}}` prose
+   * yet (a deliberate deferral; see the prompt-variables RFC). Defaults to
+   * `''` — the empty section is dropped at render, so a persona-less
+   * deployment opens with the harness identity alone.
+   */
+  persona?: string
+}
+```
+
+Source: [`packages/core/system-prompt/src/index.ts:113`](../packages/core/system-prompt/src/index.ts)
+
+## `@deepseek-ai/dsh-tool-fs`
+
+Requires: `tools` · `fs` · `systemPrompt`
+
+```ts config-catalog
+/** Plugin config (all optional — `Config` supplies the defaults). */
+export interface Config {
+  /** Default and maximum number of lines returned by one `read` call. */
+  readLimit?: number
+  /** Maximum characters returned for a single line before truncation. */
+  readMaxLineLength?: number
+  /** Maximum bytes returned for the selected lines of one `read` call. */
+  readMaxBytes?: number
+  /** Files at or above this size stream instead of loading whole into memory. */
+  readStreamMinSize?: number
+}
+```
+
+Source: [`packages/fs/tool-fs/src/index.ts:48`](../packages/fs/tool-fs/src/index.ts)
+
+## `@deepseek-ai/dsh-tool-subagent`
+
+Requires: `tools` · `subagents`
+
+```ts config-catalog
+/** Config: which registered provider this tool delegates to, plus child defaults. */
+export interface Config {
+  /** The `ctx.subagents` provider name to start runs on (e.g. `spawn`, `acp`). */
+  provider: string
+  /**
+   * The model-facing tool name to register (default `subagent`). To expose more
+   * than one transport, load this plugin once per provider — each load MUST set
+   * a distinct `toolName` (the tool registry rejects a duplicate name), e.g.
+   * `{ provider: 'spawn', toolName: 'subagent' }` and
+   * `{ provider: 'acp', toolName: 'subagent_acp' }`.
+   */
+  toolName?: string
+  /**
+   * Default per-child agent options (model) applied to every spawned child.
+   * Omitted fields fall back to the child loop's own defaults. There is no
+   * per-child persona: the deployment persona (the system-prompt plugin's
+   * `persona` config) is a context-wide section every agent shares.
+   */
+  agentOptions?: AgentOptions
+}
+```
+
+Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts)
+
+Source: [`packages/subagent/tool-subagent/src/index.ts:44`](../packages/subagent/tool-subagent/src/index.ts)
+
+## `@deepseek-ai/dsh-tool-web`
+
+Requires: `tools` · `web` · `systemPrompt`
+
+```ts config-catalog
+export interface Config {
+  /** Register `web_search`. Defaults to true. */
+  search?: boolean
+  /** Register `web_fetch`. Defaults to true. */
+  fetch?: boolean
+  /** Upper bound on sources returned by one `web_search` call. */
+  searchMaxResults?: number
+}
+```
+
+Source: [`packages/web/tool-web/src/index.ts:36`](../packages/web/tool-web/src/index.ts)
+
+## `@deepseek-ai/dsh-web`
+
+```ts config-catalog
+/**
+ * Config for the web seam. `searchProvider` / `fetchProvider` pin which provider
+ * wins for each capability; both are optional (a single registered usable
+ * provider auto-selects). Operational overrides such as environment variables
+ * must feed these same fields rather than introduce a hidden priority chain.
+ */
+export interface WebServiceConfig {
+  /** Explicit search provider id. Omitted = auto-select when exactly one usable. */
+  readonly searchProvider?: string
+  /** Explicit fetch provider id. Omitted = auto-select when exactly one usable. */
+  readonly fetchProvider?: string
+}
+```
+
+Source: [`packages/web/web/src/index.ts:68`](../packages/web/web/src/index.ts)
+
+## `@deepseek-ai/dsh-web-fetch-local`
+
+Requires: `web`
+
+```ts config-catalog
+export interface Config {
+  /** Maximum accepted request URL length. */
+  maxUrlLength?: number
+  /** Maximum response body size in bytes. */
+  maxResponseBytes?: number
+  /** Maximum decoded body length in characters. */
+  maxBodyChars?: number
+  /** Default fetch timeout in milliseconds. */
+  timeoutMs?: number
+  /** Upper bound for a per-request timeout override. */
+  maxTimeoutMs?: number
+  /** Maximum number of same-origin redirect hops to follow. */
+  maxRedirects?: number
+  /** `User-Agent` header sent on every request. */
+  userAgent?: string
+}
+```
+
+Source: [`packages/web/web-fetch-local/src/index.ts:33`](../packages/web/web-fetch-local/src/index.ts)
+
+## `@deepseek-ai/dsh-web-search-deepseek`
+
+Requires: `web`
+
+```ts config-catalog
+export interface Config {
+  /** DeepSeek API key. Falls back to `$DEEPSEEK_API_KEY`. Empty → unavailable. */
+  apiKey?: string
+  /** Anthropic-compatible endpoint base; `/messages` is appended. */
+  baseURL?: string
+  /** Anthropic-format model name. Defaults to `deepseek-v4-flash`. */
+  model?: string
+  /** `anthropic-version` header value. Defaults to `2023-06-01`. */
+  apiVersion?: string
+  /** Upper bound on generated tokens for the Messages request. Defaults to 4096. */
+  maxTokens?: number
+  /** Maximum `web_search` server-tool uses per request. Defaults to 5. */
+  maxUses?: number
+}
+```
+
+Source: [`packages/web/web-search-deepseek/src/index.ts:47`](../packages/web/web-search-deepseek/src/index.ts)
+
+## `@deepseek-ai/dsh-web-search-exa`
+
+Requires: `web`
+
+```ts config-catalog
+export interface Config {
+  /** Exa API key. Falls back to `$EXA_API_KEY`. Empty → provider unavailable. */
+  apiKey?: string
+  /** Endpoint base; `/search` is appended. Defaults to the public API. */
+  baseURL?: string
+  /** Retrieval mode sent as Exa's `type`. Defaults to `auto`. */
+  searchType?: 'auto' | 'keyword' | 'neural'
+  /** Default result count when a request carries no `maxResults`. Omitted = none. */
+  numResults?: number
+  /** Highlight sentences requested per result. Defaults to 1. */
+  highlightsPerResult?: number
+}
+```
+
+Source: [`packages/web/web-search-exa/src/index.ts:38`](../packages/web/web-search-exa/src/index.ts)
+
+## `@deepseek-ai/dsh-web-search-perplexity`
+
+Requires: `web`
+
+```ts config-catalog
+export interface Config {
+  /** Perplexity API key. Falls back to `$PERPLEXITY_API_KEY`. Empty → unavailable. */
+  apiKey?: string
+  /** Endpoint base; `/chat/completions` is appended. Defaults to the public API. */
+  baseURL?: string
+  /** Search model name. Defaults to `sonar`. */
+  model?: string
+  /** Upper bound on generated answer tokens. Defaults to 1024. */
+  maxTokens?: number
+  /** Recency window sent as `search_recency_filter`. Omitted = no filter. */
+  searchRecency?: 'day' | 'week' | 'month' | 'year'
+}
+```
+
+Source: [`packages/web/web-search-perplexity/src/index.ts:32`](../packages/web/web-search-perplexity/src/index.ts)
+
+## Loadable plugins with no config
+
+These load from a `cordis.yml` entry with no `config:` block; they declare no config surface.
+
+- `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
+- `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts))
+- `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
+- `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
+- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
+- `@deepseek-ai/dsh-tool-bash` — requires `tools` · `bash` · `systemPrompt` ([`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/index.ts))
+- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts))
+- `@deepseek-ai/dsh-tools` — requires `systemPrompt` ([`packages/core/tools/src/index.ts`](../packages/core/tools/src/index.ts))
+
+## Seam packages (not directly loadable)
+
+Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](rfc/implemented/architecture/2026-06-13-capability-seams.md)).
+
+- `@deepseek-ai/dsh-bash` — abstract `BashExecutor` ([`packages/bash/bash/src/index.ts`](../packages/bash/bash/src/index.ts))
+- `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts))
+- `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts))
+- `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts))
+
+## Library packages (no plugin entry)
+
+Imported as libraries by other packages; a `cordis.yml` cannot load them.
+
+- `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts))
+- `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts))
+- `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
+- `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts))

+ 1 - 1
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:64`](../../packages/core/agent-loop/src/index.ts)
 
 ## `ctx.agents` — `AgentRegistry`
 

+ 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 |
+| [Generated plugin config catalog](implemented/process/2026-07-06-generated-config-catalog.md) | 2026-07-06 |
 | [Parallel GitHub CI gates](implemented/process/2026-07-06-parallel-github-ci-gates.md) | 2026-07-06 |
 | [Parallel pre-push gates](implemented/process/2026-07-06-parallel-pre-push-gates.md) | 2026-07-06 |
 

+ 38 - 0
docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md

@@ -0,0 +1,38 @@
+# RFC: Generated plugin config catalog
+
+Status: implemented
+
+## Problem
+
+The config surface — the exact set of fields a `cordis.yml` entry's `config:` block can set for each plugin, with types, defaults, and semantics — had no reference page. A deployment author assembling a config tree had to open every plugin's source (or trust its README) to learn what is settable. The per-package README `## Config` sections cover parts of it by hand, in formats that diverged package-by-package (a key/default table here, an annotated YAML snippet there) and with no gate tying them to source. Nothing enumerated which packages are loadable at all — plugin vs abstract seam vs plain library — and nothing verified that the runtime schemastery schema and the documented `Config` interface agree, so a schema-validated field could exist with no documentation anywhere.
+
+## Decision
+
+Generate the catalog from source: `scripts/gen-config-catalog.ts` emits [docs/config-catalog.md](../../../config-catalog.md), one section per configurable package containing the VERBATIM config declaration — the `export interface Config` (or equivalently named type) with its JSDoc, pasted as-is in a ` ```ts config-catalog ` fence — plus a `Requires:` line (the plugin's `inject`), a `Depends on:` line resolving every type name the paste references, and a source pointer. The paste is the plugin's full declared config type: a field the runtime schema deliberately excludes is a runtime-only seam, marked as such by its own JSDoc, not a `cordis.yml`-settable knob. Package-local referenced types are pasted transitively into the same fence; another plugin's config type links to that plugin's section; names in the cordis catalog's shared `LINK_MAP` link to core-data-structures; any other workspace type links to its source; an external type is named with its module. It mirrors the `gen-cordis-catalog` pattern exactly: `--write` regenerates, `--check` (`verify-config-catalog`, inside `doc-sync`) fails if the committed file is stale, output is deterministic, the file is a build artifact never hand-edited.
+
+Pure AST generation is correct here for the same reason it is for the events/services catalog and NOT for the tool catalog: a config type is a static declaration and every schemastery schema in the repo is a static `z.object`/`z.intersect` literal, so the source is the whole truth — nothing about the config surface is runtime-composed.
+
+Specific choices:
+
+- **The config type is the second-parameter type.** What the catalog documents is the declared type of `apply(ctx, config)` / the service constructor's `(ctx, config)` — the value cordis actually passes — not a `Config` export located by naming convention. This is what makes the walk total: it works for interfaces named `AcpConfig` or `BasicCompactConfig`, for types declared in a sibling file, and for plugins with no validating schema at all.
+- **Classification is total.** Every `packages/<group>/<pkg>` entry resolves, mirroring the Loader's `unwrapExports` (`exports.default ?? exports`), to a configurable plugin, a config-free plugin, an abstract seam class, or a library — each rendered in its own section — and an unclassifiable entry hard-errors. A new package cannot be silently undocumented.
+- **Per-field JSDoc is enforced.** Every property of a pasted declaration (nested type literals included) needs non-empty JSDoc prose, or generation fails. The paste IS the documentation, so this is the same forcing function the events catalog applies via `@mode`: thin source docs fail the gate rather than yielding a thin catalog.
+- **The schema is cross-checked, one-directionally, nested keys included.** When a plugin declares a schemastery schema (`export const Config` / `static Config`), the generator walks it statically — object-literal keys and their nested object/array compositions as key paths (`agents[].id`), chained refinements, and `z.intersect` composition across workspace packages — and every schema-validated key path must be locatable on the declared config type, resolving package-local and workspace-imported types (re-export chains included), intersections, unions, utility wrappers, and indexed access. So the paste cannot hide a loader-accepted field, top-level or nested. The check is presence-only and fails loud only on a definite miss: a path crossing a type the walk cannot enumerate (an external package's type) is skipped rather than mis-reported, and dynamic-key shapes (`z.dict`) or union alternatives contribute no nested paths. The reverse direction is deliberately unchecked: a declared field may be a runtime-only seam the schema excludes (the ACP bridge's test-injected `stream`).
+- **A dedicated fence.** Pasted declarations use a ` ```ts config-catalog ` info string that `doc-typecheck` skips (a lone declaration referencing imported types is not standalone-compilable), excluded from the opt-out ratio — the same treatment the `cordis-catalog` and `persistence-catalog` fences get.
+- **A single file at `docs/config-catalog.md`**, not a one-file directory: the page serves one audience (the `cordis.yml` author) with one axis, unlike `cordis-catalog/`, which holds two sibling pages.
+
+The package README `## Config` sections stay. The overlap is accepted deliberately: the README is the curated per-package contract (config semantics in deployment context, alongside limitations and extension points), the catalog is the exhaustive generated enumeration. Because the catalog is generated, a disagreement between the two indicts the README, and the fix is a README edit — the catalog cannot drift.
+
+## Alternatives considered
+
+- **Synthesized per-field rendering** — a bullet list, table, or annotated-YAML snippet per field, assembled from parsed JSDoc plus schema metadata. Rejected for the verbatim paste: the interface with its JSDoc is already the authored contract in its authored form, and a synthesizing renderer re-formats prose it does not own, adding a rendering layer that can misrepresent it.
+- **Runtime boot + schema introspection, as the tool catalog does** — rejected: nothing here is runtime-composed, and the schema alone under-documents the surface (prose-documented defaults, runtime-only fields, plugins with no schema at all). Booting would add fragility without adding truth.
+- **Two-directional schema/interface equality** — rejected for the subset check: the declared type legitimately carries members the schema refuses to accept from config (runtime-only seams).
+- **Retiring the README `## Config` sections in the same change** — rejected: the accepted duplication keeps the per-package contract readable in place, and a sweep would have to fold each README's extra facts into field JSDoc first — separable work the catalog does not depend on.
+
+## Consequences
+
+- The catalog cannot drift: a source change the committed file does not reflect fails `verify-config-catalog` in pre-push and CI. An undocumented config field, an unresolvable referenced type name, or a schema key missing from the config type fails the generator outright.
+- Config prose now has a forcing function at the declaration: writing a new config field means writing its JSDoc, which becomes the catalog entry verbatim.
+- The generator hard-errors on shapes it cannot walk statically — an aliased package-local config import, a schema built by anything other than `object`/`intersect` composition, an unlisted global type name. Introducing such a shape includes teaching the generator (or the shape stays out of the repo), which is the point: the catalog stays the whole truth.
+- `gen-cordis-catalog.ts` exports its JSDoc/pointer helpers and `LINK_MAP` for reuse, so the two catalogs cross-link types identically and a link-map addition serves both.

+ 3 - 1
package.json

@@ -49,6 +49,8 @@
     "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check",
     "gen-tool-catalog": "tsx scripts/gen-tool-catalog.ts",
     "verify-tool-catalog": "tsx scripts/gen-tool-catalog.ts --check",
+    "gen-config-catalog": "tsx scripts/gen-config-catalog.ts",
+    "verify-config-catalog": "tsx scripts/gen-config-catalog.ts --check",
     "gen-doc-graphs": "tsx scripts/gen-doc-graphs.ts",
     "verify-doc-graphs": "tsx scripts/gen-doc-graphs.ts --check",
     "gen-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts",
@@ -56,7 +58,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-tool-catalog && pnpm run verify-config-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",

+ 458 - 0
packages/core/agent-core/tests/gen-config-catalog.spec.ts

@@ -0,0 +1,458 @@
+/**
+ * Negative-path tests for the config catalog generator (`scripts/gen-config-catalog.ts`).
+ *
+ * The generated catalog is frozen by a regenerate-and-diff freshness gate, so
+ * the freshness half is exercised by `pnpm run verify-config-catalog` in CI.
+ * What a freshness diff CANNOT prove is that the generator REJECTS malformed
+ * source the way it promises to — an unclassifiable package, an undocumented
+ * config field, a schema key the config type does not declare, or a referenced
+ * type name that resolves nowhere. These tests drive `collectConfigCatalog()`
+ * against synthetic fixture packages to prove each guard fires (and that
+ * well-formed packages classify and extract correctly), mirroring the
+ * negative tests for gen-cordis-catalog. The spec lives in this package
+ * because agent-core is the config-composition plugin (its schema is the
+ * intersection of its children's), the shape the generator's cross-package
+ * folding exists for.
+ */
+
+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 { collectConfigCatalog, render } from '../../../../scripts/gen-config-catalog.ts'
+
+/** Write one fixture package (package.json + src files) under a scan root. */
+function writePkg(root: string, dir: string, name: string, files: Record<string, string>): void {
+  const pkgDir = join(root, 'packages', dir)
+  mkdirSync(join(pkgDir, 'src'), { recursive: true })
+  writeFileSync(join(pkgDir, 'package.json'), JSON.stringify({ name }))
+  for (const [rel, text] of Object.entries(files)) writeFileSync(join(pkgDir, rel), text)
+}
+
+const roots: string[] = []
+const makeRoot = (): string => {
+  const root = mkdtempSync(join(tmpdir(), 'config-catalog-'))
+  roots.push(root)
+  return root
+}
+/** One-package fixture: the common case. */
+const make = (files: Record<string, string>, name = '@fix/one'): string => {
+  const root = makeRoot()
+  writePkg(root, 'group/one', name, files)
+  return root
+}
+
+afterEach(() => {
+  while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
+})
+
+const DOCUMENTED_CONFIG = `/** Fixture config. */
+export interface Config {
+  /** A knob. */
+  knob?: string
+}
+`
+
+describe('gen-config-catalog classification', () => {
+  it('classifies an apply plugin with a config parameter and extracts the paste', () => {
+    const entries = collectConfigCatalog(make({
+      'src/index.ts': `import type { Context } from 'cordis'
+export const inject = ['tools']
+${DOCUMENTED_CONFIG}
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+    }))
+    expect(entries).toHaveLength(1)
+    expect(entries[0]).toMatchObject({ pkg: '@fix/one', kind: 'config', configTypeName: 'Config', inject: ['tools'] })
+    expect(entries[0]?.pastes?.[0]?.text).toContain('/** A knob. */')
+  })
+
+  it('classifies a default service class, reading its constructor and static inject', () => {
+    const entries = collectConfigCatalog(make({
+      'src/index.ts': `import type { Context } from 'cordis'
+import z from 'schemastery'
+${DOCUMENTED_CONFIG}
+/** Fixture service. */
+export default class Fix {
+  static inject = ['llm']
+  static Config = z.object({ knob: z.string() }) as unknown as z<Config>
+  constructor(ctx: Context, config: Config) {}
+}
+`,
+    }))
+    expect(entries[0]).toMatchObject({ kind: 'config', className: 'Fix', inject: ['llm'], schemaKeys: ['knob'] })
+  })
+
+  it('classifies an abstract default class as a seam', () => {
+    const entries = collectConfigCatalog(make({
+      'src/index.ts': 'export default abstract class FixSeam { abstract run(): void }\n',
+    }))
+    expect(entries[0]).toMatchObject({ kind: 'seam', className: 'FixSeam' })
+  })
+
+  it('classifies a plugin whose apply takes no config as no-config', () => {
+    const entries = collectConfigCatalog(make({
+      'src/index.ts': 'import type { Context } from \'cordis\'\n/** Load. */\nexport function apply(ctx: Context): void {}\n',
+    }))
+    expect(entries[0]?.kind).toBe('no-config')
+  })
+
+  it('classifies a module with neither default export nor apply as a library', () => {
+    const entries = collectConfigCatalog(make({
+      'src/index.ts': 'export const helper = 1\n',
+    }))
+    expect(entries[0]?.kind).toBe('library')
+  })
+
+  it('hard-errors on a package with no entry file', () => {
+    const root = makeRoot()
+    mkdirSync(join(root, 'packages', 'group', 'one'), { recursive: true })
+    writeFileSync(join(root, 'packages', 'group', 'one', 'package.json'), JSON.stringify({ name: '@fix/one' }))
+    expect(() => collectConfigCatalog(root)).toThrow(/entry .* is missing or unreadable/)
+  })
+
+  it('hard-errors on a package.json without a name', () => {
+    const root = makeRoot()
+    mkdirSync(join(root, 'packages', 'group', 'one', 'src'), { recursive: true })
+    writeFileSync(join(root, 'packages', 'group', 'one', 'package.json'), '{}')
+    expect(() => collectConfigCatalog(root)).toThrow(/has no "name"/)
+  })
+})
+
+describe('gen-config-catalog config extraction guards', () => {
+  it('hard-errors on a config field with no JSDoc prose', () => {
+    expect(() => collectConfigCatalog(make({
+      'src/index.ts': `import type { Context } from 'cordis'
+export interface Config {
+  knob?: string
+}
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+    }))).toThrow(/config field 'Config\.knob' .* has no JSDoc prose/)
+  })
+
+  it('hard-errors on an undocumented field nested in a type literal', () => {
+    expect(() => collectConfigCatalog(make({
+      'src/index.ts': `import type { Context } from 'cordis'
+/** Fixture config. */
+export interface Config {
+  /** Entries. */
+  entries: {
+    id: string
+  }[]
+}
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+    }))).toThrow(/config field 'Config\.entries\.id' .* has no JSDoc prose/)
+  })
+
+  it('pastes a package-local type transitively and records external refs', () => {
+    const entries = collectConfigCatalog(make({
+      'src/index.ts': `import type { Context } from 'cordis'
+import type { Mode } from './types.ts'
+import type { Remote } from '@fix/dep'
+/** Fixture config. */
+export interface Config {
+  /** The mode. */
+  mode?: Mode
+  /** The remote. */
+  remote?: Remote
+}
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+      'src/types.ts': '/** Fixture mode. */\nexport type Mode = \'a\' | \'b\'\n',
+    }))
+    expect(entries[0]?.pastes?.map(p => p.source)).toEqual([
+      'packages/group/one/src/index.ts:5',
+      'packages/group/one/src/types.ts:2',
+    ])
+    expect(entries[0]?.refs).toEqual([{ alias: 'Remote', imported: 'Remote', specifier: '@fix/dep' }])
+  })
+
+  it('hard-errors on a referenced type name that resolves nowhere', () => {
+    expect(() => collectConfigCatalog(make({
+      'src/index.ts': `import type { Context } from 'cordis'
+/** Fixture config. */
+export interface Config {
+  /** The ghost. */
+  ghost?: Ghost
+}
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+    }))).toThrow(/references 'Ghost' .* neither declared in the package, imported, nor a known global/)
+  })
+
+  it('hard-errors on a config type imported from another package', () => {
+    expect(() => collectConfigCatalog(make({
+      'src/index.ts': `import type { Context } from 'cordis'
+import type { Config } from '@fix/dep'
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+    }))).toThrow(/config type 'Config' is imported from '@fix\/dep'/)
+  })
+
+  it('hard-errors when one name resolves to two different declarations across the closure', () => {
+    expect(() => collectConfigCatalog(make({
+      'src/index.ts': `import type { Context } from 'cordis'
+import type { A } from './a.ts'
+import type { B } from './b.ts'
+/** Fixture config. */
+export interface Config {
+  /** A. */
+  a?: A
+  /** B. */
+  b?: B
+}
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+      'src/a.ts': '/** First Option. */\nexport interface Option {\n  /** X. */\n  x?: string\n}\n/** A. */\nexport interface A {\n  /** O. */\n  o?: Option\n}\n',
+      'src/b.ts': '/** Second Option. */\nexport interface Option {\n  /** Y. */\n  y?: string\n}\n/** B. */\nexport interface B {\n  /** O. */\n  o?: Option\n}\n',
+    }))).toThrow(/type name 'Option' resolves to two different declarations/)
+  })
+})
+
+describe('gen-config-catalog schema cross-check', () => {
+  it('accepts a chained schema whose keys all appear on the config type', () => {
+    const entries = collectConfigCatalog(make({
+      'src/index.ts': `import type { Context } from 'cordis'
+import z from 'schemastery'
+${DOCUMENTED_CONFIG}
+export const Config: z<Config> = z.object({ knob: z.string() }).default({})
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+    }))
+    expect(entries[0]?.schemaKeys).toEqual(['knob'])
+  })
+
+  it('hard-errors on a schema key the config type does not declare', () => {
+    expect(() => collectConfigCatalog(make({
+      'src/index.ts': `import type { Context } from 'cordis'
+import z from 'schemastery'
+${DOCUMENTED_CONFIG}
+export const Config: z<Config> = z.object({ knob: z.string(), hidden: z.number() })
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+    }))).toThrow(/schema validates key 'hidden' but config type 'Config' declares no such member/)
+  })
+
+  it('hard-errors on a NESTED schema key the config type does not declare', () => {
+    expect(() => collectConfigCatalog(make({
+      'src/index.ts': `import type { Context } from 'cordis'
+import z from 'schemastery'
+/** Fixture config. */
+export interface Config {
+  /** Entries. */
+  entries: {
+    /** Id. */
+    id: string
+  }[]
+}
+export const Config: z<Config> = z.object({ entries: z.array(z.object({ id: z.string(), ghost: z.string() })) })
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+    }))).toThrow(/schema validates key 'entries\[\]\.ghost'/)
+  })
+
+  it('resolves nested keys through a workspace-imported intersection part (re-export chains included)', () => {
+    const root = makeRoot()
+    writePkg(root, 'group/dep', '@fix/dep', {
+      'src/index.ts': 'export * from \'./types.ts\'\n',
+      'src/types.ts': '/** Shared options. */\nexport interface Opts {\n  /** Model. */\n  model?: string\n}\n',
+    })
+    writePkg(root, 'group/one', '@fix/one', {
+      'src/index.ts': `import type { Context } from 'cordis'
+import z from 'schemastery'
+import type { Opts } from '@fix/dep'
+/** Fixture config. */
+export interface Config {
+  /** Entries. */
+  entries: (Opts & {
+    /** Id. */
+    id: string
+  })[]
+}
+export const Config: z<Config> = z.object({ entries: z.array(z.object({ id: z.string(), model: z.string() })) })
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+    })
+    expect(() => collectConfigCatalog(root)).not.toThrow()
+  })
+
+  it('resolves nested keys through a Partial<> wrapper', () => {
+    expect(() => collectConfigCatalog(make({
+      'src/index.ts': `import type { Context } from 'cordis'
+import z from 'schemastery'
+/** Caps. */
+export interface Caps {
+  /** X. */
+  x?: boolean
+}
+/** Fixture config. */
+export interface Config {
+  /** Capabilities. */
+  capabilities?: Partial<Caps>
+}
+export const Config: z<Config> = z.object({ capabilities: z.object({ x: z.boolean() }) })
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+    }))).not.toThrow()
+  })
+
+  it('leaves a nested key under an external (unresolvable) type unreported', () => {
+    expect(() => collectConfigCatalog(make({
+      'src/index.ts': `import type { Context } from 'cordis'
+import z from 'schemastery'
+import type { External } from 'some-external-pkg'
+/** Fixture config. */
+export interface Config {
+  /** Options. */
+  options?: External
+}
+export const Config: z<Config> = z.object({ options: z.object({ whatever: z.string() }) })
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+    }))).not.toThrow()
+  })
+
+  it('folds an intersected workspace schema into the subset check', () => {
+    const root = makeRoot()
+    writePkg(root, 'group/leaf', '@fix/leaf', {
+      'src/index.ts': `import type { Context } from 'cordis'
+import z from 'schemastery'
+/** Leaf config. */
+export interface Config {
+  /** Leaf knob. */
+  leaf?: string
+}
+/** Leaf service. */
+export default class Leaf {
+  static Config = z.object({ leaf: z.string() }) as unknown as z<Config>
+  constructor(ctx: Context, config: Config) {}
+}
+`,
+    })
+    writePkg(root, 'group/bundle', '@fix/bundle', {
+      'src/index.ts': `import type { Context } from 'cordis'
+import z from 'schemastery'
+import Leaf from '@fix/leaf'
+/** Bundle config. */
+export interface Config {
+  /** Forwarded leaf knob. */
+  leaf?: string
+}
+export const Config = z.intersect([Leaf.Config]) as unknown as z<Config>
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+    })
+    const entries = collectConfigCatalog(root)
+    expect(entries.find(e => e.pkg === '@fix/bundle')?.schemaComposes).toEqual(['@fix/leaf'])
+  })
+
+  it('resolves composed nested keys through an indexed-access forwarder', () => {
+    const root = makeRoot()
+    writePkg(root, 'group/leaf', '@fix/leaf', {
+      'src/index.ts': `import type { Context } from 'cordis'
+import z from 'schemastery'
+/** Leaf config. */
+export interface Config {
+  /** Agents. */
+  agents: {
+    /** Id. */
+    id: string
+  }[]
+}
+/** Leaf service. */
+export default class Leaf {
+  static Config = z.object({ agents: z.array(z.object({ id: z.string() })) }) as unknown as z<Config>
+  constructor(ctx: Context, config: Config) {}
+}
+`,
+    })
+    writePkg(root, 'group/bundle', '@fix/bundle', {
+      'src/index.ts': `import type { Context } from 'cordis'
+import z from 'schemastery'
+import Leaf, { type Config as LeafConfig } from '@fix/leaf'
+/** Bundle config forwarding the leaf's agents list. */
+export interface Config {
+  /** Forwarded agents list. */
+  agents?: LeafConfig['agents']
+}
+export const Config = z.intersect([Leaf.Config]) as unknown as z<Config>
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+    })
+    expect(() => collectConfigCatalog(root)).not.toThrow()
+  })
+
+  it('hard-errors when an intersected schema key is missing from the bundle config type', () => {
+    const root = makeRoot()
+    writePkg(root, 'group/leaf', '@fix/leaf', {
+      'src/index.ts': `import type { Context } from 'cordis'
+import z from 'schemastery'
+/** Leaf config. */
+export interface Config {
+  /** Leaf knob. */
+  leaf?: string
+}
+/** Leaf service. */
+export default class Leaf {
+  static Config = z.object({ leaf: z.string() }) as unknown as z<Config>
+  constructor(ctx: Context, config: Config) {}
+}
+`,
+    })
+    writePkg(root, 'group/bundle', '@fix/bundle', {
+      'src/index.ts': `import type { Context } from 'cordis'
+import z from 'schemastery'
+import Leaf from '@fix/leaf'
+/** Bundle config that forgot to declare the forwarded field. */
+export interface Config {
+  /** Unrelated. */
+  other?: string
+}
+export const Config = z.intersect([Leaf.Config]) as unknown as z<Config>
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+    })
+    expect(() => collectConfigCatalog(root)).toThrow(/schema validates key 'leaf' but config type 'Config' declares no such member/)
+  })
+})
+
+describe('gen-config-catalog render', () => {
+  it('renders sections, fences, and the terse classification lists', () => {
+    const root = makeRoot()
+    writePkg(root, 'group/one', '@fix/one', {
+      'src/index.ts': `import type { Context } from 'cordis'
+${DOCUMENTED_CONFIG}
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+    })
+    writePkg(root, 'group/lib', '@fix/lib', { 'src/index.ts': 'export const helper = 1\n' })
+    writePkg(root, 'group/seam', '@fix/seam', {
+      'src/index.ts': 'export default abstract class Seam { abstract run(): void }\n',
+    })
+    const page = render(collectConfigCatalog(root))
+    expect(page).toContain('## `@fix/one`')
+    expect(page).toContain('```ts config-catalog')
+    expect(page).toContain('/** A knob. */')
+    expect(page).toContain('- `@fix/lib` ([`packages/group/lib/src/index.ts`](../packages/group/lib/src/index.ts))')
+    expect(page).toContain('- `@fix/seam` — abstract `Seam`')
+  })
+})

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

@@ -32,6 +32,7 @@ declare module 'cordis' {
 export interface Config {
   /** Agents created from configuration at startup. */
   agents: (AgentOptions & {
+    /** Agent id to register under; also seeds the fresh per-run session id (`${id}-session-<uuid>`). */
     id: AgentId
     /**
      * If set, the config agent RESUMES this persisted session id instead of

+ 13 - 6
scripts/doc-typecheck.ts

@@ -9,15 +9,17 @@
  * opts out with an explicit ` ```ts ignore-check ` info string — the opt-out
  * is visible in the source, and this script reports the ratio so the escape
  * hatch can't quietly become the norm. A third info string,
- * doc-typecheck.ts recognizes three more fence variants and skips all three (each
+ * doc-typecheck.ts recognizes four more fence variants and skips all four (each
  * is a separately-checked category, not an unchecked sketch, so none counts in
  * the opt-out ratio): ` ```ts type-equiv ` is a verbatim source-type paste that
  * `scripts/verify-type-equiv.ts` drift-checks, ` ```ts cordis-catalog ` is a
  * generated event/service signature fragment in the cordis catalog (a bare
  * signature is not standalone-compilable; the catalog is generated and frozen by
- * `scripts/gen-cordis-catalog.ts` + its `--check` freshness gate), and
+ * `scripts/gen-cordis-catalog.ts` + its `--check` freshness gate),
  * ` ```ts persistence-catalog ` is a generated log-event payload fragment in the
- * persistence catalog (same reasoning, frozen by `scripts/gen-persistence-catalog.ts`).
+ * persistence catalog (same reasoning, frozen by `scripts/gen-persistence-catalog.ts`),
+ * and ` ```ts config-catalog ` is a generated verbatim config declaration in the
+ * plugin config catalog (same reasoning, frozen by `scripts/gen-config-catalog.ts`).
  *
  * Run: `tsx scripts/doc-typecheck.ts`.
  */
@@ -49,8 +51,12 @@ const root = resolve(import.meta.dirname, '..')
  *   log-event payload fragment in the persistence catalog. Same treatment for
  *   the same reason; frozen by `scripts/gen-persistence-catalog.ts` + its
  *   `--check` freshness gate.
+ * - `config-catalog` (` ```ts config-catalog `) — a generated verbatim config
+ *   declaration in the plugin config catalog (a lone declaration referencing
+ *   imported types does not stand alone). Same treatment for the same reason;
+ *   frozen by `scripts/gen-config-catalog.ts` + its `--check` freshness gate.
  */
-type BlockKind = 'check' | 'ignore' | 'type-equiv' | 'cordis-catalog' | 'persistence-catalog'
+type BlockKind = 'check' | 'ignore' | 'type-equiv' | 'cordis-catalog' | 'persistence-catalog' | 'config-catalog'
 
 /** One extracted code block. */
 interface Block {
@@ -62,7 +68,7 @@ interface Block {
 }
 
 /** Extract every ts / ts ignore-check / ts type-equiv / ts cordis-catalog /
- * ts persistence-catalog block from one Markdown file. */
+ * ts persistence-catalog / ts config-catalog block from one Markdown file. */
 function extractBlocks(absPath: string): Block[] {
   const text = readFileSync(absPath, 'utf8')
   const lines = text.split('\n')
@@ -90,7 +96,8 @@ function extractBlocks(absPath: string): Block[] {
           : info === 'ts type-equiv' ? 'type-equiv'
             : info === 'ts cordis-catalog' ? 'cordis-catalog'
               : info === 'ts persistence-catalog' ? 'persistence-catalog'
-                : null
+                : info === 'ts config-catalog' ? 'config-catalog'
+                  : null
     if (kind) open = { line: i + 1, kind, body: [] }
   })
   return blocks

+ 928 - 0
scripts/gen-config-catalog.ts

@@ -0,0 +1,928 @@
+/**
+ * Generate (and verify) the plugin config catalog in docs/config-catalog.md.
+ *
+ * The page is the DEPLOYMENT-axis reference: for every harness package a
+ * `cordis.yml` entry can load, the exact config surface its `apply` function or
+ * service constructor receives — pasted VERBATIM from source (the `export
+ * interface Config` declaration with its JSDoc), plus resolved links for every
+ * type the declaration references. It complements the wiring-axis cordis
+ * catalogs (events + services, what a plugin AUTHOR listens to and calls) the
+ * same way the tool catalog complements them for the model-facing axis.
+ *
+ * The catalog is FULLY GENERATED from source — never hand-edit it. Like the
+ * cordis catalog (and unlike the tool catalog, which must boot plugins), this
+ * is a pure-AST pass: every config type is a static declaration and every
+ * schemastery schema is a static `z.object`/`z.intersect` literal, so
+ * generation cannot drift and a regenerate-and-diff freshness check (`--check`)
+ * gates staleness. Because generation enumerates every package under
+ * `packages/<group>/<pkg>`, a brand-new plugin cannot be silently
+ * undocumented: it must classify as configurable, config-free, seam, or
+ * library, and an unclassifiable entry hard-errors the generator.
+ *
+ *   `tsx scripts/gen-config-catalog.ts`          → write the catalog
+ *   `tsx scripts/gen-config-catalog.ts --check`  → exit 1 if the committed
+ *                                                  catalog is stale (CI /
+ *                                                  pre-push gate)
+ *
+ * What the walk enforces (aggregated into one error, like the sibling
+ * generators):
+ *
+ * - CLASSIFICATION is total. Every package entry resolves, mirroring the
+ *   cordis Loader's `unwrapExports` (`exports.default ?? exports`), to a
+ *   loadable plugin (default class / `apply` function), an abstract seam
+ *   class, or a plain library. Anything else is an error, not a skip.
+ * - The CONFIG TYPE is the declared type of the plugin's second parameter
+ *   (`apply(ctx, config)` / `constructor(ctx, config)`) — the type cordis
+ *   actually passes — and it must resolve to a declaration inside the owning
+ *   package (entry file or a package-local relative import).
+ * - Every property of a pasted declaration carries non-empty JSDoc prose: the
+ *   paste IS the documentation, so an undocumented field is a gate failure,
+ *   the same forcing function the events catalog applies via `@mode`.
+ * - Every type NAME a pasted declaration references resolves: pasted
+ *   transitively when package-local, linked when it is another plugin's
+ *   config type / a core-data-structures entry / a workspace or external
+ *   import. An unresolvable name is an error, and so is a NAME COLLISION —
+ *   two distinct declarations, or a declaration and an import, sharing one
+ *   name across the closure (a verbatim fence has a single flat namespace) —
+ *   never a silent skip.
+ * - The runtime schemastery schema (`Config` export or `static Config`),
+ *   when present, is walked statically — `z.object` keys, nested object/array
+ *   compositions as key PATHS (`agents[].id`), and `z.intersect` composition
+ *   across packages — and every schema-validated key path must be locatable
+ *   on the declared config type, resolving package-local and
+ *   workspace-imported types, re-export chains, intersections, utility
+ *   wrappers, and indexed access. The paste cannot hide a loader-accepted
+ *   field, top-level or nested. A path that crosses a type the walk cannot
+ *   enumerate (an external package's type) is skipped, never mis-reported,
+ *   and nested keys under dynamic-key shapes (`z.dict`) or union alternatives
+ *   contribute no paths. The reverse direction is deliberately NOT checked: a
+ *   declared field may be a runtime-only seam the schema excludes (e.g. the
+ *   ACP bridge's test-injected `stream`).
+ *
+ * Config fences use the ` ```ts config-catalog ` info string: doc-typecheck
+ * recognizes it and skips compilation (a lone interface referencing imported
+ * types is not standalone-compilable, like the ` ```ts cordis-catalog `
+ * signature blocks).
+ */
+
+import { globSync, readFileSync, writeFileSync } from 'node:fs'
+import { dirname, resolve } from 'node:path'
+import ts from 'typescript'
+import { LINK_MAP, parseJsDoc, pointer, rawJsDoc } from './gen-cordis-catalog.ts'
+
+const root = resolve(import.meta.dirname, '..')
+const OUT = 'docs/config-catalog.md'
+
+/** The fenced-block info string for pasted config declarations (skipped by
+ * doc-typecheck, since a lone declaration referencing imports is not
+ * standalone-compilable). */
+const FENCE = 'ts config-catalog'
+
+/** TypeScript/Node global type names a config declaration may reference
+ * without importing; never treated as unresolved. Extend when a new global
+ * legitimately appears — the generator hard-errors on unknown names, so an
+ * omission is loud, not silent. */
+const GLOBAL_TYPES = new Set([
+  'Array', 'ReadonlyArray', 'Record', 'Partial', 'Required', 'Readonly', 'Pick', 'Omit',
+  'Promise', 'Map', 'Set', 'Date', 'Error', 'RegExp', 'Exclude', 'Extract', 'NonNullable',
+  'ReturnType', 'Parameters', 'AbortSignal', 'URL', 'Buffer', 'NodeJS', 'Iterable', 'AsyncIterable',
+])
+
+/** How a package classifies for the catalog. */
+type Kind = 'config' | 'no-config' | 'seam' | 'library'
+
+/** One name a pasted declaration references but the paste does not contain. */
+interface TypeRef {
+  /** The name as it appears in the pasted text (the local import alias). */
+  alias: string
+  /** The name the source module exports it under (pre-alias). */
+  imported: string
+  /** The import module specifier (package name or external module). */
+  specifier: string
+}
+
+/** One verbatim declaration paste. */
+interface Paste {
+  /** Full source text: leading JSDoc (when present) through the closing token. */
+  text: string
+  /** Source pointer `packages/…/file.ts:line` of the declaration. */
+  source: string
+}
+
+/** One package's catalog entry. */
+export interface CatalogEntry {
+  /** npm package name, e.g. `@deepseek-ai/dsh-agent-loop`. */
+  pkg: string
+  /** Repo-relative package dir, e.g. `packages/core/agent-loop`. */
+  dir: string
+  /** Repo-relative entry file, `<dir>/src/index.ts`. */
+  entry: string
+  kind: Kind
+  /** Service keys the plugin `inject`s (empty when none declared). */
+  inject: string[]
+  /** Seam/service class name (kinds `seam` and class-based plugins). */
+  className?: string
+  /** Name of the config type (kind `config`). */
+  configTypeName?: string
+  /** Verbatim declaration pastes, the config type first (kind `config`). */
+  pastes?: Paste[]
+  /** References the pastes leave unresolved locally (kind `config`). */
+  refs?: TypeRef[]
+  /** Top-level keys and nested key paths (`agents[].id`) of the runtime
+   * schema, `null` when no schema exists (kind `config`). */
+  schemaKeys?: string[] | null
+  /** Package names whose schemas an intersect composes (kind `config`). */
+  schemaComposes?: string[]
+}
+
+/** A parsed source file plus its import map (local name → origin). */
+interface FileCtx {
+  abs: string
+  rel: string
+  text: string
+  sf: ts.SourceFile
+  /** Local binding name → `{ imported, specifier }`; default imports record
+   * `imported: 'default'`. */
+  imports: Map<string, { imported: string; specifier: string }>
+}
+
+/** Throw one aggregate error for every violation the walk collected. */
+function report(violations: string[]): void {
+  if (violations.length === 0) return
+  throw new Error(
+    `gen-config-catalog: ${violations.length} violation(s):\n`
+    + violations.map(v => `  ${v}`).join('\n'),
+  )
+}
+
+/** Parse a source file and index its import declarations. */
+function loadFile(abs: string, rel: string, cache: Map<string, FileCtx>): FileCtx {
+  const cached = cache.get(abs)
+  if (cached) return cached
+  const text = readFileSync(abs, 'utf8')
+  const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
+  const imports = new Map<string, { imported: string; specifier: string }>()
+  for (const stmt of sf.statements) {
+    if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue
+    const specifier = stmt.moduleSpecifier.text
+    const clause = stmt.importClause
+    if (!clause) continue
+    if (clause.name) imports.set(clause.name.text, { imported: 'default', specifier })
+    if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
+      for (const el of clause.namedBindings.elements) {
+        imports.set(el.name.text, { imported: (el.propertyName ?? el.name).text, specifier })
+      }
+    }
+    if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
+      imports.set(clause.namedBindings.name.text, { imported: '*', specifier })
+    }
+  }
+  const ctx = { abs, rel, text, sf, imports }
+  cache.set(abs, ctx)
+  return ctx
+}
+
+/** A type declaration a paste can contain. */
+type TypeDecl = ts.InterfaceDeclaration | ts.TypeAliasDeclaration
+
+/** Find an interface/type-alias declaration by name in a file, or null. */
+function findTypeDecl(ctx: FileCtx, name: string): TypeDecl | null {
+  for (const stmt of ctx.sf.statements) {
+    if ((ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)) && stmt.name.text === name) return stmt
+  }
+  return null
+}
+
+/**
+ * Resolve a type name from a file to its declaration (following package-local
+ * relative imports transitively) or to the import that brings it in. Returns
+ * `null` when the name is neither declared, imported, nor a known global.
+ */
+function resolveTypeName(
+  ctx: FileCtx,
+  name: string,
+  cache: Map<string, FileCtx>,
+  violations: string[],
+): { decl: TypeDecl; ctx: FileCtx } | { ref: TypeRef } | null {
+  const local = findTypeDecl(ctx, name)
+  if (local) return { decl: local, ctx }
+  const imp = ctx.imports.get(name)
+  if (!imp) return null
+  if (imp.specifier.startsWith('.')) {
+    if (!imp.specifier.endsWith('.ts')) {
+      violations.push(`${ctx.rel}: relative import '${imp.specifier}' lacks the explicit .ts extension the repo convention requires.`)
+      return null
+    }
+    if (imp.imported !== name) {
+      violations.push(`${ctx.rel}: '${name}' aliases '${imp.imported}' across a package-local import; the catalog pastes declarations verbatim, so keep package-local config types unaliased.`)
+      return null
+    }
+    const abs = resolve(dirname(ctx.abs), imp.specifier)
+    const rel = ctx.rel.slice(0, ctx.rel.lastIndexOf('/') + 1) + imp.specifier.replace(/^\.\//, '')
+    const target = loadFile(abs, rel, cache)
+    return resolveTypeName(target, imp.imported, cache, violations)
+  }
+  return { ref: { alias: name, imported: imp.imported, specifier: imp.specifier } }
+}
+
+/** Collect every type NAME referenced in type positions under a node. */
+function collectTypeNames(node: ts.Node, out: Set<string>): void {
+  const visit = (n: ts.Node): void => {
+    if (ts.isTypeReferenceNode(n)) {
+      let head: ts.EntityName = n.typeName
+      while (ts.isQualifiedName(head)) head = head.left
+      out.add(head.text)
+    } else if (ts.isExpressionWithTypeArguments(n) && ts.isIdentifier(n.expression)) {
+      out.add(n.expression.text) // heritage clause: `extends X`
+    }
+    ts.forEachChild(n, visit)
+  }
+  visit(node)
+}
+
+/** The verbatim paste text of a declaration: leading JSDoc through the end. */
+function pasteText(ctx: FileCtx, decl: TypeDecl): string {
+  const raw = rawJsDoc(ctx.text, decl)
+  const start = raw ? ctx.text.indexOf(raw, decl.getFullStart()) : decl.getStart(ctx.sf)
+  return ctx.text.slice(start, decl.end)
+}
+
+/** Enforce non-empty JSDoc prose on every property of a pasted declaration,
+ * recursing into nested type literals (e.g. an array-of-objects field). */
+function checkMemberDocs(ctx: FileCtx, decl: TypeDecl, violations: string[]): void {
+  const walkMembers = (members: ts.NodeArray<ts.TypeElement>, path: string): void => {
+    for (const member of members) {
+      if (!ts.isPropertySignature(member)) continue
+      const name = member.name.getText(ctx.sf)
+      const where = `config field '${path}.${name}' (${pointer(ctx.rel, ctx.sf, member)})`
+      if (!parseJsDoc(rawJsDoc(ctx.text, member)).doc) violations.push(`${where} has no JSDoc prose.`)
+      if (member.type) walkNested(member.type, `${path}.${name}`)
+    }
+  }
+  const walkNested = (type: ts.Node, path: string): void => {
+    if (ts.isTypeLiteralNode(type)) walkMembers(type.members, path)
+    else ts.forEachChild(type, (n) => { walkNested(n, path) })
+  }
+  if (ts.isInterfaceDeclaration(decl)) walkMembers(decl.members, decl.name.text)
+  else walkNested(decl.type, decl.name.text)
+}
+
+/** Cross-file resolution context for the schema-path check. */
+interface World {
+  scanRoot: string
+  cache: Map<string, FileCtx>
+  /** Workspace package name → repo-relative package dir. */
+  pkgDirByName: Map<string, string>
+}
+
+/** How a schema key path fared against the declared config type: definitely
+ * present, definitely absent, or crossing a shape the walk cannot enumerate
+ * (only `missing` is a violation — `unknown` must never mis-report). */
+type PathLookup = 'found' | 'missing' | 'unknown'
+
+/** One step of a schema key path: a named member, or an array-element hop. */
+type PathStep = { member: string } | { array: true }
+
+/** Parse a schema key path (`agents[].id`) into member/array steps. */
+function parsePath(path: string): PathStep[] {
+  const steps: PathStep[] = []
+  for (const seg of path.split('.')) {
+    let name = seg
+    let arrays = 0
+    while (name.endsWith('[]')) {
+      name = name.slice(0, -2)
+      arrays += 1
+    }
+    steps.push({ member: name })
+    for (let i = 0; i < arrays; i += 1) steps.push({ array: true })
+  }
+  return steps
+}
+
+/** Load a package-relative import target as a FileCtx. */
+function loadRelative(world: World, from: FileCtx, specifier: string): FileCtx {
+  const abs = resolve(dirname(from.abs), specifier)
+  const rel = from.rel.slice(0, from.rel.lastIndexOf('/') + 1) + specifier.replace(/^\.\//, '')
+  return loadFile(abs, rel, world.cache)
+}
+
+/** Find a type declaration EXPORTED (directly or via re-export chains) from a
+ * file, following `export … from './x.ts'` and `export * from './x.ts'`. */
+function findExportedTypeDecl(world: World, ctx: FileCtx, name: string, seen = new Set<string>()): { decl: TypeDecl; ctx: FileCtx } | null {
+  const key = `${ctx.abs}#${name}`
+  if (seen.has(key)) return null
+  seen.add(key)
+  const local = findTypeDecl(ctx, name)
+  if (local) return { decl: local, ctx }
+  for (const stmt of ctx.sf.statements) {
+    if (!ts.isExportDeclaration(stmt) || !stmt.moduleSpecifier || !ts.isStringLiteral(stmt.moduleSpecifier)) continue
+    const spec = stmt.moduleSpecifier.text
+    if (!spec.startsWith('.') || !spec.endsWith('.ts')) continue
+    let lookFor: string | null = null
+    if (!stmt.exportClause) {
+      lookFor = name // export * from './x.ts'
+    } else if (ts.isNamedExports(stmt.exportClause)) {
+      const el = stmt.exportClause.elements.find(e => e.name.text === name)
+      if (el) lookFor = (el.propertyName ?? el.name).text
+    }
+    if (lookFor === null) continue
+    const hit = findExportedTypeDecl(world, loadRelative(world, ctx, spec), lookFor, seen)
+    if (hit) return hit
+  }
+  return null
+}
+
+/** Resolve a referenced type NAME to its declaration: declared locally, via a
+ * package-relative import, or via a workspace-package import (entry file +
+ * re-export chains). `'unknown'` = external or otherwise out of reach. */
+function declForTypeName(world: World, ctx: FileCtx, name: string): { decl: TypeDecl; ctx: FileCtx } | 'unknown' {
+  const local = findTypeDecl(ctx, name)
+  if (local) return { decl: local, ctx }
+  const imp = ctx.imports.get(name)
+  if (!imp) return 'unknown'
+  if (imp.specifier.startsWith('.')) {
+    if (!imp.specifier.endsWith('.ts')) return 'unknown'
+    return findExportedTypeDecl(world, loadRelative(world, ctx, imp.specifier), imp.imported) ?? 'unknown'
+  }
+  const dir = world.pkgDirByName.get(imp.specifier)
+  if (dir === undefined) return 'unknown'
+  const entryRel = `${dir}/src/index.ts`
+  let entry: FileCtx
+  try {
+    entry = loadFile(resolve(world.scanRoot, entryRel), entryRel, world.cache)
+  } catch {
+    // A workspace package without a readable entry is reported by its own
+    // classification pass; for a lookup it is merely out of reach.
+    return 'unknown'
+  }
+  return findExportedTypeDecl(world, entry, imp.imported) ?? 'unknown'
+}
+
+/** Utility wrappers that pass a member lookup through to their type argument. */
+const PASSTHROUGH_WRAPPERS = new Set(['Partial', 'Required', 'Readonly', 'NonNullable'])
+
+/**
+ * Walk a schema key path against a declared type. This is a PRESENCE check,
+ * not a shape check: it answers "does the declared config type have a member
+ * here", resolving interfaces (heritage included), type aliases, literals,
+ * intersections, unions, arrays, indexed access, pass-through utility
+ * wrappers, and type references across package-local and workspace imports.
+ * Anything it cannot see through resolves `'unknown'`, never `'missing'`.
+ */
+function lookupPath(world: World, ctx: FileCtx, node: ts.Node, steps: PathStep[], seen: Set<string>): PathLookup {
+  if (steps.length === 0) return 'found'
+  // Guard recursion at NAMED declarations only — the sole way a walk can loop
+  // (a recursive interface/alias). Structural nodes must not be guarded: a
+  // first child shares `.pos` with its parent, so a span-keyed guard there
+  // would mistake ordinary descent for a cycle.
+  if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) {
+    const key = `${ctx.abs}:${node.pos}:${steps.length}`
+    if (seen.has(key)) return 'unknown' // recursive type — bail rather than loop
+    seen.add(key)
+  }
+  const step = steps[0]
+  if (step === undefined) return 'found'
+  // Combine branch results: any found wins, else any unknown taints, else missing.
+  const combine = (results: PathLookup[]): PathLookup => {
+    if (results.includes('found')) return 'found'
+    if (results.includes('unknown')) return 'unknown'
+    return 'missing'
+  }
+  const intoMembers = (members: ts.NodeArray<ts.TypeElement>): PathLookup | null => {
+    if (!('member' in step)) return null
+    for (const m of members) {
+      if (!ts.isPropertySignature(m) || m.name.getText(ctx.sf) !== step.member) continue
+      if (steps.length === 1) return 'found'
+      return m.type ? lookupPath(world, ctx, m.type, steps.slice(1), seen) : 'unknown'
+    }
+    return null // not among these members; caller consults heritage/parts
+  }
+  if (ts.isInterfaceDeclaration(node)) {
+    if (!('member' in step)) return 'unknown' // an array step cannot land on an interface
+    const direct = intoMembers(node.members)
+    if (direct !== null) return direct
+    const bases: PathLookup[] = []
+    for (const clause of node.heritageClauses ?? []) {
+      for (const base of clause.types) {
+        if (!ts.isIdentifier(base.expression)) {
+          bases.push('unknown')
+          continue
+        }
+        const resolved = declForTypeName(world, ctx, base.expression.text)
+        bases.push(resolved === 'unknown' ? 'unknown' : lookupPath(world, resolved.ctx, resolved.decl, steps, seen))
+      }
+    }
+    return bases.length ? combine(bases) : 'missing'
+  }
+  if (ts.isTypeAliasDeclaration(node)) return lookupPath(world, ctx, node.type, steps, seen)
+  if (ts.isTypeLiteralNode(node)) {
+    if (!('member' in step)) return 'unknown'
+    return intoMembers(node.members) ?? 'missing'
+  }
+  if (ts.isParenthesizedTypeNode(node)) return lookupPath(world, ctx, node.type, steps, seen)
+  if (ts.isIntersectionTypeNode(node)) {
+    return combine(node.types.map(t => lookupPath(world, ctx, t, steps, seen)))
+  }
+  if (ts.isUnionTypeNode(node)) {
+    // Presence on a union is only definite when every branch agrees.
+    const results = node.types.map(t => lookupPath(world, ctx, t, steps, seen))
+    if (results.every(r => r === 'found')) return 'found'
+    if (results.every(r => r === 'missing')) return 'missing'
+    return 'unknown'
+  }
+  if (ts.isArrayTypeNode(node)) {
+    return 'array' in step ? lookupPath(world, ctx, node.elementType, steps.slice(1), seen) : 'unknown'
+  }
+  if (ts.isTypeOperatorNode(node)) return lookupPath(world, ctx, node.type, steps, seen)
+  if (ts.isIndexedAccessTypeNode(node)) {
+    const index = node.indexType
+    if (ts.isLiteralTypeNode(index) && ts.isStringLiteral(index.literal)) {
+      return lookupPath(world, ctx, node.objectType, [{ member: index.literal.text }, ...steps], seen)
+    }
+    return 'unknown'
+  }
+  if (ts.isTypeReferenceNode(node)) {
+    let head: ts.EntityName = node.typeName
+    while (ts.isQualifiedName(head)) head = head.left
+    const name = head.text
+    if (PASSTHROUGH_WRAPPERS.has(name) && node.typeArguments?.[0]) {
+      return lookupPath(world, ctx, node.typeArguments[0], steps, seen)
+    }
+    if ((name === 'Array' || name === 'ReadonlyArray') && node.typeArguments?.[0]) {
+      return 'array' in step ? lookupPath(world, ctx, node.typeArguments[0], steps.slice(1), seen) : 'unknown'
+    }
+    if (!ts.isIdentifier(node.typeName)) return 'unknown' // namespace-qualified: out of reach
+    const resolved = declForTypeName(world, ctx, name)
+    return resolved === 'unknown' ? 'unknown' : lookupPath(world, resolved.ctx, resolved.decl, steps, seen)
+  }
+  return 'unknown'
+}
+
+/** Unwrap `as` / `satisfies` / parenthesized wrappers around an expression. */
+function unwrapExpr(expr: ts.Expression): ts.Expression {
+  let e = expr
+  while (ts.isAsExpression(e) || ts.isSatisfiesExpression(e) || ts.isParenthesizedExpression(e)) e = e.expression
+  return e
+}
+
+/**
+ * Statically walk a schemastery schema expression to its key paths plus the
+ * packages whose schemas an intersect composes. A key path is the top-level
+ * key or a nested path through object/array compositions (`agents[].id`).
+ * Handles the shapes the repo declares — `z.object({…})` (possibly behind
+ * chained calls) and `z.intersect([X.Config, …])` — and hard-errors on
+ * anything else, so a schema the walk cannot see fails the gate instead of
+ * silently thinning it. Nested values that are neither `object` nor `array`
+ * compositions (primitives, unions, dynamic-key dicts) contribute no paths.
+ */
+function walkSchemaExpr(
+  ctx: FileCtx,
+  expr: ts.Expression,
+  where: string,
+  violations: string[],
+): { keys: string[]; composes: string[] } {
+  const keys: string[] = []
+  const composes: string[] = []
+  // Nested paths under one object property's VALUE expression: recurse through
+  // chained refinements toward the base call, descending into object/array.
+  const collectValuePaths = (value: ts.Expression, base: string): void => {
+    const call = unwrapExpr(value)
+    if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) return
+    const method = call.expression.name.text
+    if (method === 'object' && call.arguments[0] && ts.isObjectLiteralExpression(call.arguments[0])) {
+      for (const prop of call.arguments[0].properties) {
+        if (!ts.isPropertyAssignment(prop)) continue
+        const key = ts.isStringLiteral(prop.name) ? prop.name.text : prop.name.getText(ctx.sf)
+        keys.push(`${base}.${key}`)
+        collectValuePaths(prop.initializer, `${base}.${key}`)
+      }
+      return
+    }
+    if (method === 'array' && call.arguments[0]) {
+      collectValuePaths(call.arguments[0], `${base}[]`)
+      return
+    }
+    const inner = unwrapExpr(call.expression.expression)
+    if (ts.isCallExpression(inner)) collectValuePaths(inner, base)
+  }
+  const visit = (e: ts.Expression): void => {
+    const call = unwrapExpr(e)
+    if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) {
+      violations.push(`${where}: schema expression is not a statically walkable schemastery call.`)
+      return
+    }
+    const method = call.expression.name.text
+    if (method === 'object' && call.arguments[0] && ts.isObjectLiteralExpression(call.arguments[0])) {
+      for (const prop of call.arguments[0].properties) {
+        if (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) {
+          const key = ts.isStringLiteral(prop.name) ? prop.name.text : prop.name.getText(ctx.sf)
+          keys.push(key)
+          if (ts.isPropertyAssignment(prop)) collectValuePaths(prop.initializer, key)
+        } else {
+          violations.push(`${where}: schema object property '${prop.getText(ctx.sf)}' is not a plain key.`)
+        }
+      }
+      return
+    }
+    if (method === 'intersect' && call.arguments[0] && ts.isArrayLiteralExpression(call.arguments[0])) {
+      for (const el of call.arguments[0].elements) {
+        const part = unwrapExpr(el)
+        if (ts.isPropertyAccessExpression(part) && part.name.text === 'Config' && ts.isIdentifier(part.expression)) {
+          const imp = ctx.imports.get(part.expression.text)
+          if (imp && !imp.specifier.startsWith('.')) { composes.push(imp.specifier); continue }
+        }
+        if (ts.isCallExpression(part)) { visit(part); continue }
+        violations.push(`${where}: intersect element '${part.getText(ctx.sf)}' is neither a workspace plugin's Config nor an inline schema call.`)
+      }
+      return
+    }
+    // A chained refinement (`z.object({…}).default(…)` etc.): the keys live on
+    // the call the chain hangs off — keep unwrapping toward it.
+    const base = unwrapExpr(call.expression.expression)
+    if (ts.isCallExpression(base)) { visit(base); return }
+    violations.push(`${where}: schema call '${method}' is not object/intersect and hangs off no walkable base call.`)
+  }
+  visit(expr)
+  return { keys, composes }
+}
+
+/** Find a plugin's schemastery schema expression: an exported `const Config`
+ * in the entry file, else a `static Config` on the plugin class. */
+function findSchemaExpr(ctx: FileCtx, pluginClass: ts.ClassDeclaration | null): ts.Expression | null {
+  for (const stmt of ctx.sf.statements) {
+    if (!ts.isVariableStatement(stmt)) continue
+    if (!stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)) continue
+    for (const decl of stmt.declarationList.declarations) {
+      if (ts.isIdentifier(decl.name) && decl.name.text === 'Config' && decl.initializer) return decl.initializer
+    }
+  }
+  for (const member of pluginClass?.members ?? []) {
+    if (!ts.isPropertyDeclaration(member) || member.name.getText() !== 'Config') continue
+    if (!member.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword)) continue
+    if (member.initializer) return member.initializer
+  }
+  return null
+}
+
+/** Read an `inject` service-key list: `export const inject = […]` in the entry
+ * file, else `static inject = […]` on the plugin class. */
+function findInject(ctx: FileCtx, pluginClass: ts.ClassDeclaration | null, violations: string[]): string[] {
+  const fromArray = (expr: ts.Expression, where: string): string[] => {
+    if (!ts.isArrayLiteralExpression(expr)) {
+      violations.push(`${where}: inject is not a plain string-array literal; teach the generator the new shape.`)
+      return []
+    }
+    return expr.elements.map(el => ts.isStringLiteral(el) ? el.text : el.getText(ctx.sf))
+  }
+  for (const stmt of ctx.sf.statements) {
+    if (!ts.isVariableStatement(stmt)) continue
+    for (const decl of stmt.declarationList.declarations) {
+      if (ts.isIdentifier(decl.name) && decl.name.text === 'inject' && decl.initializer) {
+        return fromArray(decl.initializer, ctx.rel)
+      }
+    }
+  }
+  for (const member of pluginClass?.members ?? []) {
+    if (ts.isPropertyDeclaration(member) && member.name.getText() === 'inject' && member.initializer) {
+      return fromArray(member.initializer, ctx.rel)
+    }
+  }
+  return []
+}
+
+/** Resolve the entry file's default export to its class/function declaration
+ * (mirroring the Loader's `unwrapExports`), or null when there is none. */
+function defaultExport(ctx: FileCtx): ts.ClassDeclaration | ts.FunctionDeclaration | null {
+  for (const stmt of ctx.sf.statements) {
+    if (ts.isExportAssignment(stmt) && !stmt.isExportEquals && ts.isIdentifier(stmt.expression)) {
+      const name = stmt.expression.text
+      for (const s of ctx.sf.statements) {
+        if ((ts.isClassDeclaration(s) || ts.isFunctionDeclaration(s)) && s.name?.text === name) return s
+      }
+      return null
+    }
+    if ((ts.isClassDeclaration(stmt) || ts.isFunctionDeclaration(stmt))
+      && stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.DefaultKeyword)) return stmt
+  }
+  return null
+}
+
+/** Find the exported `apply` function declaration in the entry file, or null. */
+function applyExport(ctx: FileCtx): ts.FunctionDeclaration | null {
+  for (const stmt of ctx.sf.statements) {
+    if (ts.isFunctionDeclaration(stmt) && stmt.name?.text === 'apply'
+      && stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)) return stmt
+  }
+  return null
+}
+
+/**
+ * Walk every `packages/<group>/<pkg>` entry and build the catalog entries.
+ * Hard-errors (aggregated) on any violation listed in the module doc.
+ * `scanRoot` defaults to the repo root; tests pass a fixture dir.
+ */
+export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] {
+  const violations: string[] = []
+  const cache = new Map<string, FileCtx>()
+  const entries: CatalogEntry[] = []
+
+  // Pre-pass: package name → dir, so schema-path lookups can follow
+  // workspace-package imports while individual packages are still being walked.
+  const pkgDirByName = new Map<string, string>()
+  const manifests: { dir: string; pkg: string }[] = []
+  for (const manifestRel of globSync('packages/*/*/package.json', { cwd: scanRoot }).sort()) {
+    const dir = manifestRel.slice(0, -'/package.json'.length)
+    const pkg = (JSON.parse(readFileSync(resolve(scanRoot, manifestRel), 'utf8')) as { name?: string }).name
+    if (!pkg) {
+      violations.push(`${manifestRel} has no "name".`)
+      continue
+    }
+    pkgDirByName.set(pkg, dir)
+    manifests.push({ dir, pkg })
+  }
+  const world: World = { scanRoot, cache, pkgDirByName }
+
+  for (const { dir, pkg } of manifests) {
+    const entryRel = `${dir}/src/index.ts`
+    let ctx: FileCtx
+    try {
+      ctx = loadFile(resolve(scanRoot, entryRel), entryRel, cache)
+    } catch {
+      // A package without src/index.ts cannot be classified — that is the
+      // violation itself; nothing else in this loop body can run without it.
+      violations.push(`${pkg}: entry ${entryRel} is missing or unreadable.`)
+      continue
+    }
+
+    // Classify, mirroring the Loader's unwrapExports: the default export IS
+    // the plugin when present; else an exported `apply` makes the module
+    // namespace the plugin; else the package is a plain library.
+    const dflt = defaultExport(ctx)
+    const apply = applyExport(ctx)
+    let pluginClass: ts.ClassDeclaration | null = null
+    let configParam: ts.ParameterDeclaration | undefined
+    let kind: Kind
+    let className: string | undefined
+    if (dflt && ts.isClassDeclaration(dflt)) {
+      className = dflt.name?.text
+      if (dflt.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword)) {
+        kind = 'seam'
+      } else {
+        pluginClass = dflt
+        const ctor = dflt.members.find(ts.isConstructorDeclaration)
+        configParam = ctor?.parameters[1]
+        kind = configParam ? 'config' : 'no-config'
+      }
+    } else if (dflt) {
+      configParam = dflt.parameters[1]
+      kind = configParam ? 'config' : 'no-config'
+    } else if (apply) {
+      configParam = apply.parameters[1]
+      kind = configParam ? 'config' : 'no-config'
+    } else {
+      kind = 'library'
+    }
+
+    const entry: CatalogEntry = {
+      pkg,
+      dir,
+      entry: entryRel,
+      kind,
+      inject: kind === 'library' || kind === 'seam' ? [] : findInject(ctx, pluginClass, violations),
+      ...className !== undefined ? { className } : {},
+    }
+    entries.push(entry)
+    if (kind !== 'config' || !configParam) continue
+
+    // Resolve the config type and paste its package-local transitive closure.
+    if (!configParam.type || !ts.isTypeReferenceNode(configParam.type) || !ts.isIdentifier(configParam.type.typeName)) {
+      violations.push(`${pkg}: config parameter type (${pointer(entryRel, ctx.sf, configParam)}) is not a plain type-name reference; declare a named config type.`)
+      continue
+    }
+    const typeName = configParam.type.typeName.text
+    entry.configTypeName = typeName
+    const pastes: Paste[] = []
+    const refs = new Map<string, TypeRef>()
+    // A bare name is the fence's whole namespace: two DIFFERENT declarations
+    // (or a declaration in one file and an import in another) sharing a name
+    // cannot both render unambiguously, so every resolution is identity-checked
+    // by source pointer and a collision is a violation, never a silent skip.
+    const pastedDeclByName = new Map<string, string>()
+    const queue: { name: string; from: FileCtx }[] = [{ name: typeName, from: ctx }]
+    for (let item = queue.shift(); item !== undefined; item = queue.shift()) {
+      const { name, from } = item
+      const resolved = resolveTypeName(from, name, cache, violations)
+      if (resolved === null) {
+        violations.push(`${pkg}: config declaration references '${name}' (via ${from.rel}), which is neither declared in the package, imported, nor a known global type.`)
+        continue
+      }
+      if ('ref' in resolved) {
+        if (name === typeName) {
+          violations.push(`${pkg}: config type '${name}' is imported from '${resolved.ref.specifier}'; a plugin's config type must live in its own package.`)
+          continue
+        }
+        if (pastedDeclByName.has(name)) {
+          violations.push(`${pkg}: '${name}' resolves to a package-local declaration (${pastedDeclByName.get(name) ?? ''}) in one file and an import from '${resolved.ref.specifier}' in another; rename one so the fence is unambiguous.`)
+          continue
+        }
+        const existing = refs.get(name)
+        if (existing && (existing.specifier !== resolved.ref.specifier || existing.imported !== resolved.ref.imported)) {
+          violations.push(`${pkg}: '${name}' is imported from both '${existing.specifier}' (${existing.imported}) and '${resolved.ref.specifier}' (${resolved.ref.imported}) across the pasted closure; disambiguate the aliases.`)
+          continue
+        }
+        refs.set(name, resolved.ref)
+        continue
+      }
+      const declKey = pointer(resolved.ctx.rel, resolved.ctx.sf, resolved.decl)
+      const prior = pastedDeclByName.get(name)
+      if (prior === declKey) continue // same declaration reached again — benign
+      if (prior !== undefined) {
+        violations.push(`${pkg}: type name '${name}' resolves to two different declarations (${prior} and ${declKey}) across the pasted closure; rename one — a verbatim fence cannot carry two same-named declarations.`)
+        continue
+      }
+      if (refs.has(name)) {
+        violations.push(`${pkg}: '${name}' resolves to an import from '${refs.get(name)?.specifier ?? ''}' in one file and a package-local declaration (${declKey}) in another; rename one so the fence is unambiguous.`)
+        continue
+      }
+      pastedDeclByName.set(name, declKey)
+      pastes.push({ text: pasteText(resolved.ctx, resolved.decl), source: declKey })
+      checkMemberDocs(resolved.ctx, resolved.decl, violations)
+      const names = new Set<string>()
+      collectTypeNames(resolved.decl, names)
+      for (const n of names) {
+        if (GLOBAL_TYPES.has(n)) continue
+        queue.push({ name: n, from: resolved.ctx })
+      }
+    }
+    entry.pastes = pastes
+    entry.refs = [...refs.values()].sort((a, b) => a.alias.localeCompare(b.alias))
+
+    // Statically walk the runtime schema (when one exists) for the subset check.
+    const schemaExpr = findSchemaExpr(ctx, pluginClass)
+    if (schemaExpr) {
+      const { keys, composes } = walkSchemaExpr(ctx, unwrapExpr(schemaExpr), `${pkg} (${entryRel})`, violations)
+      entry.schemaKeys = keys
+      entry.schemaComposes = composes
+    } else {
+      entry.schemaKeys = null
+    }
+  }
+
+  // Second phase: fold composed schemas' key paths in, then walk every
+  // schema-validated path against the declared config type. Only a definite
+  // miss is a violation — a path through a shape the walk cannot enumerate
+  // stays silent rather than mis-reporting.
+  const byName = new Map(entries.map(e => [e.pkg, e]))
+  for (const entry of entries) {
+    if (entry.kind !== 'config' || entry.schemaKeys === null || entry.schemaKeys === undefined) continue
+    const seen = new Set<string>()
+    const foldComposed = (e: CatalogEntry): string[] => {
+      if (seen.has(e.pkg)) return []
+      seen.add(e.pkg)
+      const keys = [...e.schemaKeys ?? []]
+      for (const composed of e.schemaComposes ?? []) {
+        const target = byName.get(composed)
+        if (!target) {
+          violations.push(`${entry.pkg}: schema intersects '${composed}', which is not a workspace package the walk collected.`)
+          continue
+        }
+        keys.push(...foldComposed(target))
+      }
+      return keys
+    }
+    const allKeys = foldComposed(entry)
+    const mainPaste = entry.pastes?.[0]
+    const mainFile = mainPaste?.source.split(':')[0]
+    const mainCtx = mainFile !== undefined ? cache.get(resolve(scanRoot, mainFile)) : undefined
+    const mainDecl = mainCtx && entry.configTypeName !== undefined ? findTypeDecl(mainCtx, entry.configTypeName) : null
+    if (!mainCtx || !mainDecl) {
+      violations.push(`${entry.pkg}: cannot locate config type '${entry.configTypeName ?? ''}' for the schema-path check.`)
+      continue
+    }
+    for (const keyPath of allKeys) {
+      if (lookupPath(world, mainCtx, mainDecl, parsePath(keyPath), new Set()) === 'missing') {
+        violations.push(`${entry.pkg}: schema validates key '${keyPath}' but config type '${entry.configTypeName ?? ''}' declares no such member — the catalog paste would hide a loader-accepted field.`)
+      }
+    }
+  }
+
+  report(violations)
+  return entries.sort((a, b) => a.pkg.localeCompare(b.pkg))
+}
+
+/** GitHub-style anchor slug for a `## \`pkg\`` heading. */
+function slug(heading: string): string {
+  return heading.toLowerCase().replace(/[^a-z0-9 -]/g, '').replace(/ /g, '-')
+}
+
+/** Render the `Requires:` service-key line, or '' when the plugin injects nothing. */
+function requiresLine(inject: string[]): string {
+  return inject.length ? `Requires: ${inject.map(k => `\`${k}\``).join(' · ')}` : ''
+}
+
+/** Render one reference as a link: another plugin's config type → its section,
+ * a curated core-data-structures name → its page, any other workspace type →
+ * its source file, an external type → named with its module, unlinked. */
+function refLink(ref: TypeRef, byName: Map<string, CatalogEntry>): string {
+  const target = byName.get(ref.specifier)
+  if (target?.kind === 'config' && ref.imported === target.configTypeName) {
+    return `[\`${ref.alias}\`](#${slug(target.pkg)})`
+  }
+  const page = LINK_MAP[ref.imported]
+  if (page) return `[\`${ref.alias}\`](core-data-structures/${page})`
+  if (target) return `[\`${ref.alias}\`](../${target.entry})`
+  return `\`${ref.alias}\` (\`${ref.specifier}\`)`
+}
+
+/** Render one configurable plugin's section. */
+function renderConfigEntry(entry: CatalogEntry, byName: Map<string, CatalogEntry>): string[] {
+  const out = [`## \`${entry.pkg}\``, '']
+  const requires = requiresLine(entry.inject)
+  if (requires) out.push(requires, '')
+  out.push('```' + FENCE, ...(entry.pastes ?? []).map(p => p.text).join('\n\n').split('\n'), '```', '')
+  if (entry.refs && entry.refs.length > 0) {
+    out.push(`Depends on: ${entry.refs.map(r => refLink(r, byName)).join(' · ')}`, '')
+  }
+  const source = entry.pastes?.[0]?.source ?? entry.entry
+  out.push(`Source: [\`${source}\`](../${source.split(':')[0]})`, '')
+  return out
+}
+
+/** Render one terse list line (the no-config / seam / library sections). */
+function renderTerse(entry: CatalogEntry, detail: string): string {
+  const requires = entry.inject.length ? ` — requires ${entry.inject.map(k => `\`${k}\``).join(' · ')}` : ''
+  return `- \`${entry.pkg}\`${detail}${requires} ([\`${entry.entry}\`](../${entry.entry}))`
+}
+
+/** Render the full catalog (pure, deterministic given sorted entries). */
+export function render(entries: CatalogEntry[]): string {
+  const byName = new Map(entries.map(e => [e.pkg, e]))
+  const lines: string[] = [
+    '<!-- Generated by scripts/gen-config-catalog.ts — do not edit by hand.',
+    '     Run `pnpm run gen-config-catalog` to regenerate. -->',
+    '',
+    '# Plugin Config Catalog',
+    '',
+    'Every `config:` block a `cordis.yml` entry can set: for each loadable harness package, the verbatim config declaration (JSDoc included) its `apply` function or service constructor receives, with every referenced type pasted alongside (package-local types) or linked (everything else). The paste is the plugin\'s full declared config type — a field the runtime schema deliberately excludes is a runtime-only seam (its own JSDoc says so) and is not settable from `cordis.yml`. This is the **deployment**-axis reference — the wiring a plugin author works against is the cordis [events](cordis-catalog/events.md) + [services](cordis-catalog/services.md) catalogs, the model-facing tool schemas are the [tool catalog](tool-catalog/tools.md), and [core-data-structures/](core-data-structures/core.md) documents the types these declarations reference.',
+    '',
+    'This file is GENERATED from source (`scripts/gen-config-catalog.ts`) and verified fresh by `pnpm run verify-config-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks use a `ts config-catalog` fence (skipped by doc-typecheck, since a lone declaration referencing imports is not standalone-compilable). The generator also cross-checks the runtime schemastery schema against the pasted declaration — every schema-validated key, nested keys included, must be locatable on the declared config type — so the paste cannot hide a loader-accepted field.',
+    '',
+    'A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` tree must also load providers for those services. Scope is the harness tier (`packages/`); the vendored cordis plugins a config tree may also load (`hmr`, the console logger, …) are pinned upstream source ([vendoring policy](../vendor/README.md)) and not catalogued here.',
+    '',
+  ]
+  for (const entry of entries.filter(e => e.kind === 'config')) {
+    lines.push(...renderConfigEntry(entry, byName))
+  }
+  lines.push(
+    '## Loadable plugins with no config',
+    '',
+    'These load from a `cordis.yml` entry with no `config:` block; they declare no config surface.',
+    '',
+    ...entries.filter(e => e.kind === 'no-config').map(e => renderTerse(e, '')),
+    '',
+    '## Seam packages (not directly loadable)',
+    '',
+    'Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](rfc/implemented/architecture/2026-06-13-capability-seams.md)).',
+    '',
+    ...entries.filter(e => e.kind === 'seam').map(e => renderTerse(e, ` — abstract \`${e.className ?? ''}\``)),
+    '',
+    '## Library packages (no plugin entry)',
+    '',
+    'Imported as libraries by other packages; a `cordis.yml` cannot load them.',
+    '',
+    ...entries.filter(e => e.kind === 'library').map(e => renderTerse(e, '')),
+    '',
+  )
+  return lines.join('\n')
+}
+
+/** CLI entry: default writes the catalog, `--check` fails if the committed
+ * copy is stale. Guarded behind an entry-point check so importing this module
+ * for tests neither regenerates the committed file nor calls process.exit. */
+function main(): void {
+  const content = render(collectConfigCatalog())
+  if (process.argv.includes('--check')) {
+    let committed: string | null = null
+    try {
+      committed = readFileSync(resolve(root, OUT), 'utf8')
+    } catch {
+      // Only ENOENT (not yet generated) is expected; a present-but-unreadable
+      // file is not a state this repo produces. Either way the remedy is the
+      // same — regenerate — so treat a read failure as "stale".
+      committed = null
+    }
+    if (committed === content) {
+      console.log(`gen-config-catalog: ${OUT} is up to date.`)
+      process.exit(0)
+    }
+    console.error(`gen-config-catalog: ${OUT} is stale. Run \`pnpm run gen-config-catalog\` and commit ${OUT}.`)
+    process.exit(1)
+  }
+  writeFileSync(resolve(root, OUT), content)
+  console.log(`gen-config-catalog: wrote ${OUT}.`)
+}
+
+// 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()
+}

+ 12 - 7
scripts/gen-cordis-catalog.ts

@@ -73,11 +73,13 @@ type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial'
  * that manifest documents the `…Map` symbols (`ContentBlockMap`) while
  * signatures reference the derived UNION names (`ContentBlock`), and it lists a
  * few symbols on two pages. Here each name resolves to exactly one PRIMARY page.
+ * Shared with `gen-config-catalog.ts` (each caller prefixes its own relative
+ * path to `core-data-structures/`), so both catalogs cross-link identically.
  * TODO(catalog-type-links): add a verifier or generator for link-map coverage
  * so new hook-era decision types like `PromptDecision` / `PreToolDecision` do
  * not silently appear in signatures without a "Types:" link.
  */
-const LINK_MAP: Record<string, string> = {
+export const LINK_MAP: Record<string, string> = {
   Agent: 'core.md',
   ContentBlock: 'core.md',
   Message: 'core.md',
@@ -146,14 +148,16 @@ 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 {
+/** Repo-relative source pointer `file:line` for a node's first character.
+ * Shared with `gen-config-catalog.ts`. */
+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. */
-function rawJsDoc(text: string, node: ts.Node): string {
+/** The raw `/** … *​/` JSDoc block immediately preceding a node, or '' if none.
+ * Shared with `gen-config-catalog.ts`. */
+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) : ''
@@ -167,9 +171,10 @@ function rawJsDoc(text: string, node: ts.Node): string {
  * (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.
+ * invisible to the rendered catalog. Shared with `gen-config-catalog.ts`
+ * (which uses only the prose-presence half).
  */
-function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
+export function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
   const inner = raw
     .replace(/^\/\*\*/, '')
     .replace(/\*\/$/, '')

+ 1 - 0
scripts/translation-pairing.manifest.json

@@ -9,6 +9,7 @@
   "excluded": [
     "docs/AGENTS.md",
     "docs/module-graph.md",
+    "docs/config-catalog.md",
     "docs/cordis-catalog/",
     "docs/tool-catalog/",
     "docs/persistence-catalog/",