feat: generated plugin config catalog (docs/config-catalog.md)

scripts/gen-config-catalog.ts walks every packages/<group>/<pkg> entry with
the TypeScript compiler API and emits docs/config-catalog.md: per loadable
plugin, the verbatim config declaration (JSDoc included) its apply/constructor
receives in a ts config-catalog fence, the inject requirements, resolved links
for every referenced type (package-local types pasted transitively, other
plugins' config types as intra-page anchors, LINK_MAP names to
core-data-structures, workspace types to source), and terse classification
lists for config-free plugins, abstract seams, and libraries — classification
is total, so a new package cannot go undocumented.

The walk enforces per-field JSDoc prose on every pasted declaration and
statically cross-checks the schemastery schema (z.object keys, z.intersect
composition across packages): every schema-validated key must be a declared
member of the config type. One violation existed repo-wide — the agents[].id
field in dsh-agent-loop — fixed by adding its JSDoc (which shifts the
cordis-catalog services page's source pointers; regenerated).

verify-config-catalog (--check) joins doc-sync; doc-typecheck learns the
ts config-catalog fence; gen-cordis-catalog exports its JSDoc/pointer helpers
and LINK_MAP for reuse. Negative-path spec in
packages/core/agent-core/tests/gen-config-catalog.spec.ts mirrors the
gen-cordis-catalog spec. Decision record:
docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md (includes
the deliberate acceptance of README ## Config overlap).
This commit is contained in:
Tianyi Cui
2026-07-06 21:57:17 +08:00
parent 1c999804d8
commit b0c7eadd23
11 changed files with 1821 additions and 16 deletions
+1 -1
View File
@@ -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
View File
@@ -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). 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 must be a declared member — 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
View File
@@ -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
View File
@@ -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 |
### Testing
@@ -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. 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.** When a plugin declares a schemastery schema (`export const Config` / `static Config`), the generator walks it statically — object-literal keys, chained refinements, and `z.intersect` composition across workspace packages — and every schema-validated top-level key must be a declared member of the config type, so the paste cannot hide a loader-accepted field. The reverse 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
View File
@@ -41,6 +41,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",
@@ -48,7 +50,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",
@@ -0,0 +1,318 @@
/**
* 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'/)
})
})
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('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('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
View File
@@ -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
View File
@@ -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
+672
View File
@@ -0,0 +1,672 @@
/**
* 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, never silently unexplained.
* - The runtime schemastery schema (`Config` export or `static Config`),
* when present, is walked statically (`z.object` keys, `z.intersect`
* composition across packages) and every schema-validated top-level key
* must be a declared member of the config type — the paste cannot hide a
* loader-accepted field. The reverse 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 of the runtime schema, `null` when no schema exists or
* composition is still pending resolution (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)
}
/** Top-level property names of the config type (for the schema-subset check). */
function topLevelMembers(decl: TypeDecl): Set<string> | null {
if (ts.isInterfaceDeclaration(decl)) {
return new Set(decl.members.filter(ts.isPropertySignature).map(m => m.name.getText()))
}
if (ts.isTypeLiteralNode(decl.type)) {
return new Set(decl.type.members.filter(ts.isPropertySignature).map(m => m.name.getText()))
}
return null
}
/** 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 top-level object
* keys plus the packages whose schemas an intersect composes. 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.
*/
function walkSchemaExpr(
ctx: FileCtx,
expr: ts.Expression,
where: string,
violations: string[],
): { keys: string[]; composes: string[] } {
const keys: string[] = []
const composes: string[] = []
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)) {
keys.push(ts.isStringLiteral(prop.name) ? prop.name.text : prop.name.getText(ctx.sf))
} 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[] = []
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 }
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>()
const pastedNames = new Set<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
if (pastedNames.has(name)) continue
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.`)
else refs.set(name, resolved.ref)
continue
}
pastedNames.add(name)
pastes.push({ text: pasteText(resolved.ctx, resolved.decl), source: pointer(resolved.ctx.rel, resolved.ctx.sf, resolved.decl) })
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) || pastedNames.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' keys in, then check every
// schema-validated key is a declared member of the config type.
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 ? findTypeDecl(mainCtx, entry.configTypeName) : null
const members = mainDecl ? topLevelMembers(mainDecl) : null
if (!members) {
violations.push(`${entry.pkg}: cannot enumerate the members of config type '${entry.configTypeName}' for the schema-subset check.`)
continue
}
for (const key of allKeys) {
if (!members.has(key)) {
violations.push(`${entry.pkg}: schema validates key '${key}' 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). 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 must be a declared member — 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
View File
@@ -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(/\*\/$/, '')