From b0c7eadd23148a621b79b33f106343a3dec3ff41 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:57:17 +0800 Subject: [PATCH] feat: generated plugin config catalog (docs/config-catalog.md) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/gen-config-catalog.ts walks every packages// 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). --- docs/AGENTS.md | 2 +- docs/config-catalog.md | 761 ++++++++++++++++++ docs/cordis-catalog/services.md | 2 +- docs/rfc/INDEX.md | 1 + .../2026-07-06-generated-config-catalog.md | 38 + package.json | 4 +- .../tests/gen-config-catalog.spec.ts | 318 ++++++++ packages/core/agent-loop/src/index.ts | 1 + scripts/doc-typecheck.ts | 19 +- scripts/gen-config-catalog.ts | 672 ++++++++++++++++ scripts/gen-cordis-catalog.ts | 19 +- 11 files changed, 1821 insertions(+), 16 deletions(-) create mode 100644 docs/config-catalog.md create mode 100644 docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md create mode 100644 packages/core/agent-core/tests/gen-config-catalog.spec.ts create mode 100644 scripts/gen-config-catalog.ts diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 72d879bc69..3b8e88b892 100644 --- a/docs/AGENTS.md +++ b/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. diff --git a/docs/config-catalog.md b/docs/config-catalog.md new file mode 100644 index 0000000000..4421b36851 --- /dev/null +++ b/docs/config-catalog.md @@ -0,0 +1,761 @@ + + +# 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-`). */ + id: AgentId + /** + * If set, the config agent RESUMES this persisted session id instead of + * starting a fresh `${id}-session-`. 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 + /** + * 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 + /** + * 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)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0221946118..f0ae4ea77e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -21,7 +21,7 @@ createAgent(options: CreateAgentOptions): AgentHandle async resume(options: ResumeAgentOptions): Promise ``` -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` diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 921c6389e8..17a51b02de 100644 --- a/docs/rfc/INDEX.md +++ b/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 | ### Testing diff --git a/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md new file mode 100644 index 0000000000..cb9b885d58 --- /dev/null +++ b/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. 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//` 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. diff --git a/package.json b/package.json index 7453a022e9..0791565ae1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/core/agent-core/tests/gen-config-catalog.spec.ts b/packages/core/agent-core/tests/gen-config-catalog.spec.ts new file mode 100644 index 0000000000..0db1b30c9e --- /dev/null +++ b/packages/core/agent-core/tests/gen-config-catalog.spec.ts @@ -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): 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, 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 + 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 = 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 = 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 + 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 +/** 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 + 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 +/** 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`') + }) +}) diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 9eaa61624a..92bbe7b8dc 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/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-`). */ id: AgentId /** * If set, the config agent RESUMES this persisted session id instead of diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index c6a5510948..6f40cccd0a 100644 --- a/scripts/doc-typecheck.ts +++ b/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 diff --git a/scripts/gen-config-catalog.ts b/scripts/gen-config-catalog.ts new file mode 100644 index 0000000000..9373223c88 --- /dev/null +++ b/scripts/gen-config-catalog.ts @@ -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//`, 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, `/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 +} + +/** 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): 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() + 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, + 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): 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, 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 | 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//` 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() + 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() + const pastedNames = new Set() + 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() + 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() + 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 { + 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[] { + 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[] = [ + '', + '', + '# 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() +} diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 4a7a417e02..a64d9bbba4 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/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 = { +export const LINK_MAP: Record = { 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(/\*\/$/, '')