diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 0b3a28794f..d97bb1bd77 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -34,7 +34,7 @@ These come straight from the source docs above. They are not discretionary; abse 1. **Docs in sync.** If the PR changes a config key, default, error code, wire field, or event name, it must update the package README + module/JSDoc in the same diff. The `doc-sync` gate (check #4) does not catch prose drift in config keys, defaults, error codes, or wire fields — that is on the reviewer, but it is still required, not optional. 2. **Core-data-structures catalog in sync.** If the PR adds, removes, or reshapes a type the [core-data-structures catalog](../../../docs/core-data-structures/core.md) documents — a new `…Map` variant, a new content-block/session-event type, a field on `GenerateOptions`/`Agent`/`ToolDefinition`/a bash type, or a whole new core/seam type — it must update that catalog in the same diff (prose + any verbatim ` ```ts type-equiv ` block + the 1:1 `scripts/type-equiv.manifest.json`). The `verify-type-equiv` gate (part of `doc-sync`) catches a *drifted paste* of an already-documented type, but it cannot tell you a brand-new core type went undocumented — that judgment is yours. Confirm a genuinely spine-level type landed in core.md and a new capability's vocabulary on a sub-page, per the spine-vs-seam line in [core.md § What counts as "core"](../../../docs/core-data-structures/core.md#what-counts-as-core). A pure internal type with no cross-package reach needs no catalog entry — say so if it's a judgment call. 3. **HMR-safety test.** Any new registry/registration needs a test that disposes the contributing fiber and asserts cleanup (packages/AGENTS.md). Its absence blocks merge. -4. **Quality gates pass.** typecheck, lint, test, test:coverage (100% per-file on `packages/*/src`), knip, build, publint, constraints, `doc-sync` (doc-typecheck + verify-event-taxonomy + verify-md-wrap + verify-md-links + verify-type-equiv), module-graph freshness (the quality-gates RFC). Don't re-review what a gate already enforces — trust the gate and spend attention on what it can't check. Note that the `doc-sync` gate only covers compilable `ts` blocks, the event-taxonomy table, markdown wrapping/links, and verbatim type-equiv blocks; prose drift (checks #1 and #2) is *additional* manual review on top of it, not covered by it. +4. **Quality gates pass.** typecheck, lint, test, test:coverage (100% per-file on `packages/*/src`), knip, build, publint, constraints, `doc-sync` (doc-typecheck + verify-cordis-catalog + verify-md-wrap + verify-md-links + verify-type-equiv), module-graph freshness (the quality-gates RFC). Don't re-review what a gate already enforces — trust the gate and spend attention on what it can't check. Note that the `doc-sync` gate only covers compilable `ts` blocks, the generated cordis events/services catalog, markdown wrapping/links, and verbatim type-equiv blocks; prose drift (checks #1 and #2) is *additional* manual review on top of it, not covered by it. ## Reviewer-only checks (gates can't catch these — judgment required) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b9a9fca4f..562ffca1bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,10 +45,11 @@ jobs: # Doc-sync gates (doc-sync-enforcement RFC). doc-typecheck compiles the fenced ts blocks in # the docs and resolves vendor packages via their built declarations, which - # the typecheck step above emits — so it runs after typecheck. The event - # taxonomy check and the markdown wrap check only read source. Same - # `doc-sync` script the pre-push hook runs (quality-gates RFC: one source of truth). - - name: Doc-sync gates (doc code blocks + event taxonomy + markdown wrap) + # the typecheck step above emits — so it runs after typecheck. The cordis + # catalog freshness check, type-equiv check, and markdown wrap/link checks + # only read source. Same `doc-sync` script the pre-push hook runs + # (quality-gates RFC: one source of truth). + - name: Doc-sync gates (doc code blocks + cordis catalog + type-equiv + markdown wrap/links) run: pnpm run doc-sync # Module-graph freshness: regenerate docs/module-graph.md from the diff --git a/AGENTS.md b/AGENTS.md index 0ced918950..4d111ff672 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,11 +104,12 @@ pnpm run publint # package.json publish-correctness check (publishable pa pnpm run hygiene # knip + publint + workspace constraints pnpm run doc-typecheck # typecheck every ```ts block in README.md, docs/**/*.md, # packages/*/*.md (doc/code drift gate) -pnpm run verify-event-taxonomy # assert the event-taxonomy table in docs/architecture.md - # matches the interface Events declarations in source +pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events-and-services.md + # (events + services) from the interface Events / Context source +pnpm run verify-cordis-catalog # assert that generated catalog is not stale pnpm run verify-md-wrap # assert no hard-wrapped prose paragraphs in README.md, # docs/**/*.md, packages/*/*.md, AGENTS.md (one line per paragraph) -pnpm run doc-sync # doc-typecheck + verify-event-taxonomy + verify-md-wrap + verify-md-links + verify-type-equiv (CI runs this) +pnpm run doc-sync # doc-typecheck + verify-cordis-catalog + verify-md-wrap + verify-md-links + verify-type-equiv (CI runs this) pnpm run demo:echo # run examples/echo-agent (no API key; type "echo hi" to # see a tool call) — the mock skeleton pnpm run demo:coding # run examples/coding-agent — the real agent (needs @@ -174,7 +175,9 @@ This codebase aims to be **very type-safe and well documented** for maintainabil In the **core** packages (`packages/llm`, `packages/tools`, `packages/agent`, `packages/agent-loop`, `packages/session`, `packages/system-prompt`), **type gymnastics are acceptable when they improve the DX of plugin authors** for common plugin types. The `defineTool` typed schema DSL in `dsh-tools` is the canonical example: the `SchemaSpec` to `InferArgs` type-level mapping gives tool authors zero-cast typed `execute` args, and the cost of the conditional types stays inside the core package. -Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-event-taxonomy` + `verify-md-wrap` + `verify-md-links` + `verify-type-equiv`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, verifies the event-taxonomy table against source, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, and checks that every ` ```ts type-equiv ` doc block still matches its source type — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. +Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-cordis-catalog` + `verify-md-wrap` + `verify-md-links` + `verify-type-equiv`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, regenerates the cordis events/services catalog from source and fails if the committed copy is stale, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, and checks that every ` ```ts type-equiv ` doc block still matches its source type — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. + +**Tag every new event with `@mode`.** The cordis events/services catalog ([docs/cordis-catalog/events-and-services.md](docs/cordis-catalog/events-and-services.md)) is GENERATED from source by `scripts/gen-cordis-catalog.ts` — never hand-edit it; run `pnpm run gen-cordis-catalog` and commit the result. When you add an event to an `interface Events` block, its JSDoc MUST carry a `@mode emit|waterfall|parallel` tag (the generator hard-errors without it): use `waterfall` when the signature ends with a `next: () => …` parameter (the listener transforms or vetoes via `next()`), `parallel` when the loop awaits a fan-out with no veto (e.g. an awaited `Promise | void` checkpoint like `session/flush`), and `emit` for plain fire-and-forget notifications. The generator also cross-checks the tag against the signature where the shape is conclusive (a trailing `next` ⇒ waterfall) and hard-errors on a contradiction. Write the rest of the event's JSDoc to stand alone — it is the catalog entry's prose. The generated catalog is what supersedes the old hand-maintained event-taxonomy table. **The core-data-structures catalog is a maintained surface, not a write-once artifact.** [docs/core-data-structures/](docs/core-data-structures/core.md) catalogs the spine vocabulary (core.md) and the per-seam types (sub-pages). When a change adds, removes, or reshapes a type the catalog documents — a new `…Map` variant, a new content-block or session-event type, a field on `GenerateOptions`/`Agent`/`ToolDefinition`/a bash type, or a whole new core/seam type — update the catalog in the SAME change: edit the prose, and for a pasted ` ```ts type-equiv ` block, re-copy it verbatim and keep `scripts/type-equiv.manifest.json` 1:1 with the blocks. The `verify-type-equiv` gate catches a *drifted paste* of an already-documented type, but it canNOT tell you a brand-new core type was never documented — that judgment is on the author and the reviewer. The definition of "core" (the spine-vs-seam line) is in [core.md § What counts as "core"](docs/core-data-structures/core.md#what-counts-as-core); a genuinely spine-level new type belongs in core.md, a new capability's vocabulary on a sub-page. See [development.md](docs/development.md#documenting-types-verbatim-ts-type-equiv) for the `ts type-equiv` mechanics. diff --git a/docs/architecture.md b/docs/architecture.md index 799d543ded..e4b9014904 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,6 +56,8 @@ Dependency rule: plugins depend on interface packages, never on `dsh-agent-loop` All registrations (`registerAdapter`, `section`, `tools`, `register`, …) go through `ctx.effect()` and return disposers, so plugin hot-reload (vendored HMR) and fiber disposal clean up automatically. +For each service's full public interface (every method signature, generated from source), plus the inherited cordis-core/loader/hmr/timer surface a plugin also sees, see the `## Services` section of [cordis-catalog/events-and-services.md](cordis-catalog/events-and-services.md). This table is the at-a-glance role summary; that catalog is the exhaustive reference. + ## Capability seams: interface / implementation / consumer Swappable capabilities are split into **three packages** so each part evolves independently. The bash capability is the template: @@ -167,26 +169,7 @@ A failure that happens once the turn is already closed has no in-turn position f ### Event taxonomy -The `agent/*` events are declared in `@deepseek-ai/dsh-agent` (so nothing depends on the loop package); each other service declares its own events (`tools/*`, `llm/*`, `system-prompt/*`, `session/*`). The table below is CI-verified against the `interface Events` declarations in source (`scripts/verify-event-taxonomy.ts`). - -| Event | Mode | Purpose | -|---|---|---| -| `agent/created` / `agent/disposed` / `agent/status` / `agent/queued` | emit | lifecycle + inbox notifications | -| `agent/turn-start` / `agent/turn-end` / `agent/step-start` / `agent/step-end` | emit | boundaries | -| `agent/request` | **waterfall** | mutate the final `GenerateOptions` before the model call | -| `agent/stream-chunk` | emit | token-level UI/log feed | -| `agent/step-result` | **waterfall** | post-process the assistant message before tool dispatch | -| `agent/steering` | emit | steering content injected | -| `agent/turn-continuation` | **waterfall** | override the continue/stop decision | -| `agent/error` | emit | step/turn errors | -| `tools/execute` (dsh-tools) | **waterfall** | wrap/veto/sandbox tool execution | -| `tools/change` (dsh-tools) | emit | a tool was registered/unregistered | -| `llm/stream` / `llm/generate` (dsh-llm) | **waterfall** | model-call interception | -| `llm/adapter-change` (dsh-llm) | emit | an adapter was registered/unregistered | -| `system-prompt/assemble` (dsh-system-prompt) | **waterfall** | mutate the assembly | -| `system-prompt/change` (dsh-system-prompt) | emit | a section/tool-provider changed | -| `session/created` / `session/event` (dsh-session) | emit | session lifecycle + log feed | -| `session/flush` (dsh-session) | parallel (awaited) | durability checkpoint | +The `agent/*` events are declared in `@deepseek-ai/dsh-agent` (so nothing depends on the loop package); each other service declares its own events (`tools/*`, `llm/*`, `system-prompt/*`, `session/*`). The full catalog — every event's exact signature, dispatch mode, and prose — is **generated from source** and lives in [cordis-catalog/events-and-services.md](cordis-catalog/events-and-services.md) (the `## Events` section), alongside the `ctx.` service interfaces. That file is regenerated by `scripts/gen-cordis-catalog.ts` and frozen by the `verify-cordis-catalog` freshness gate (part of `doc-sync`), so it cannot drift from the `interface Events` declarations. ### Cordis waterfall semantics (important) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md new file mode 100644 index 0000000000..efbc51df46 --- /dev/null +++ b/docs/cordis-catalog/events-and-services.md @@ -0,0 +1,482 @@ + + +# Cordis Events & Services Catalog + +An index reference to the **wiring** a plugin author works against: every cordis event you can listen to (exact signature + dispatch mode) and every `ctx.` service you can call (exact public interface). It complements [core-data-structures/](../core-data-structures/core.md), which catalogs the *data structures* these signatures move around — this page is the verbs, that page is the nouns. + +This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them. + +The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer surface a plugin also sees — pinned vendor source, summarized tersely. + +## Events + +Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 24 events across 5 scopes. + +### `agent/*` + +#### `agent/created` — emit + +An agent was registered in the AgentRegistry and is ready to receive messages. + +```ts cordis-catalog +'agent/created'(agent: Agent): void +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:140`](../../packages/agent/src/types.ts) + +#### `agent/disposed` — emit + +An agent was disposed and removed from the registry; its fiber and any in-flight turn have been torn down. + +```ts cordis-catalog +'agent/disposed'(agent: Agent): void +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:146`](../../packages/agent/src/types.ts) + +#### `agent/error` — emit + +A step or turn errored. The loop reports a failure here (plus the logger) even when the error has no in-turn position for a session `error` event. + +```ts cordis-catalog +'agent/error'(agent: Agent, turn: number, step: number, error: Error): void +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:223`](../../packages/agent/src/types.ts) + +#### `agent/queued` — emit + +A message entered the agent's inbox (queued or steering). `source` is the resolved source (defaults applied), not the caller's raw options. + +```ts cordis-catalog +'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void +``` + +Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:159`](../../packages/agent/src/types.ts) + +#### `agent/request` — waterfall + +Waterfall: mutate the fully-assembled GenerateOptions before the model call (hooks, compaction, model switching, tool filtering, …). Call `next()` to delegate, or return without it to short-circuit. + +```ts cordis-catalog +'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise): Promise +``` + +Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:192`](../../packages/agent/src/types.ts) + +#### `agent/status` — emit + +Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle off this transition, never off a status you just requested — `send()` does not flip status to `running` before it returns. + +```ts cordis-catalog +'agent/status'(agent: Agent, status: AgentStatus): void +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:153`](../../packages/agent/src/types.ts) + +#### `agent/steering` — emit + +Steering content was injected into a running turn. + +```ts cordis-catalog +'agent/steering'(agent: Agent, turn: number, content: ContentBlock[], source: MessageSource): void +``` + +Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:217`](../../packages/agent/src/types.ts) + +#### `agent/step-end` — emit + +A step ended. + +```ts cordis-catalog +'agent/step-end'(agent: Agent, turn: number, step: number): void +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:183`](../../packages/agent/src/types.ts) + +#### `agent/step-result` — waterfall + +Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …). + +```ts cordis-catalog +'agent/step-result'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise +``` + +Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:198`](../../packages/agent/src/types.ts) + +#### `agent/step-start` — emit + +A step (one model call plus its tool dispatch) began. `step` is 1-based within the turn; a turn runs one or more steps. + +```ts cordis-catalog +'agent/step-start'(agent: Agent, turn: number, step: number): void +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:178`](../../packages/agent/src/types.ts) + +#### `agent/stream-chunk` — emit + +A raw StreamChunk arrived from the model (token-level UI/log feed). + +```ts cordis-catalog +'agent/stream-chunk'(agent: Agent, turn: number, step: number, chunk: StreamChunk): void +``` + +Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) + +Source: [`packages/agent/src/types.ts:212`](../../packages/agent/src/types.ts) + +#### `agent/turn-continuation` — waterfall + +Waterfall: override the turn-continuation decision. The default (computed by the loop) is `hadToolCalls || steeringInjected`. Listeners can force-continue (/goal, /loop) or force-stop (budget guards). + +```ts cordis-catalog +'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: boolean, next: () => Promise): Promise +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:205`](../../packages/agent/src/types.ts) + +#### `agent/turn-end` — emit + +A turn ended. `reason` distinguishes a clean stop from a truncated or aborted one (`completed` | `aborted` | `error` | `disposed` | `max-tokens`). + +```ts cordis-catalog +'agent/turn-end'(agent: Agent, turn: number, reason: TurnEndReason): void +``` + +Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) + +Source: [`packages/agent/src/types.ts:172`](../../packages/agent/src/types.ts) + +#### `agent/turn-start` — emit + +A turn began. `turn` is the 1-based turn number within the session. + +```ts cordis-catalog +'agent/turn-start'(agent: Agent, turn: number): void +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/agent/src/types.ts:166`](../../packages/agent/src/types.ts) + +### `llm/*` + +#### `llm/adapter-change` — emit + +An adapter was registered or unregistered (the model→adapter map changed). + +```ts cordis-catalog +'llm/adapter-change'(): void +``` + +Source: [`packages/llm/src/index.ts:43`](../../packages/llm/src/index.ts) + +#### `llm/generate` — waterfall + +Waterfall around every non-streaming model call. Bound to the LlmService; call `next()` to delegate to the adapter. + +```ts cordis-catalog +'llm/generate'(this: LlmService, options: GenerateOptions, next: () => Promise): Promise +``` + +Types: [GenerateOptions](../core-data-structures/core.md) · [GenerateResult](../core-data-structures/core.md) + +Source: [`packages/llm/src/index.ts:38`](../../packages/llm/src/index.ts) + +#### `llm/stream` — waterfall + +Waterfall around every streaming model call (retry, caching, routing). Bound to the LlmService; call `next()` to reach the resolved adapter's stream, or yield your own chunks to short-circuit. + +```ts cordis-catalog +'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable +``` + +Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) + +Source: [`packages/llm/src/index.ts:32`](../../packages/llm/src/index.ts) + +### `session/*` + +#### `session/created` — emit + +A session was created in the store. + +```ts cordis-catalog +'session/created'(session: Session): void +``` + +Source: [`packages/session/src/index.ts:30`](../../packages/session/src/index.ts) + +#### `session/event` — emit + +An event was appended to a session log (sync, fire-and-forget). This is the per-append feed a UI or invariant plugin tails. + +```ts cordis-catalog +'session/event'(session: Session, event: SessionEvent): void +``` + +Types: [SessionEvent](../core-data-structures/core.md) + +Source: [`packages/session/src/index.ts:36`](../../packages/session/src/index.ts) + +#### `session/flush` — parallel + +Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flush', session)` at every turn end; persistence plugins (JSONL, SQLite) drain their write-behind buffers here and on fiber dispose. Awaited (parallel), not a waterfall: every listener runs and the loop waits for all of them, but none can veto. + +```ts cordis-catalog +'session/flush'(session: Session): Promise | void +``` + +Source: [`packages/session/src/index.ts:45`](../../packages/session/src/index.ts) + +### `system-prompt/*` + +#### `system-prompt/assemble` — waterfall + +Waterfall around prompt assembly — mutate or extend the PromptAssembly (sections + tool schemas) before it is rendered. Bound to the SystemPrompt service; call `next()` to delegate. + +```ts cordis-catalog +'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise): Promise +``` + +Source: [`packages/system-prompt/src/index.ts:24`](../../packages/system-prompt/src/index.ts) + +#### `system-prompt/change` — emit + +A section or tool provider was registered or unregistered (the assembly inputs changed). + +```ts cordis-catalog +'system-prompt/change'(): void +``` + +Source: [`packages/system-prompt/src/index.ts:30`](../../packages/system-prompt/src/index.ts) + +### `tools/*` + +#### `tools/change` — emit + +A tool was registered or unregistered (the available tool set changed). + +```ts cordis-catalog +'tools/change'(): void +``` + +Source: [`packages/tools/src/index.ts:48`](../../packages/tools/src/index.ts) + +#### `tools/execute` — waterfall + +Waterfall around every tool execution — the single seam where sandbox, permission, hook, and plan-mode plugins wrap or veto a call. Listeners receive `(exec, next)`: call `next()` to proceed (possibly around your own logic), or return a ToolExecutionResult without calling `next()` to short-circuit (veto). + +```ts cordis-catalog +'tools/execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise): Promise +``` + +Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) + +Source: [`packages/tools/src/index.ts:43`](../../packages/tools/src/index.ts) + +## Services + +The 8 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. + +### `ctx.agentLoop` — `AgentLoop` + +The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loops, and registers them in `ctx.agents`. Also implements the AgentFactory seam, so plugins create/resume agents through `ctx.agents` (the interface) without depending on this concrete package. + +The loop itself is deliberately thin — every behavior beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy declared in @deepseek-ai/dsh-agent. + +```ts cordis-catalog +create(id: string, options: AgentOptions = {}): ReactLoopAgent +createAgent(options: CreateAgentOptions): AgentHandle +async resume(options: ResumeAgentOptions): Promise +``` + +Source: [`packages/agent-loop/src/index.ts:60`](../../packages/agent-loop/src/index.ts) + +### `ctx.agents` — `AgentRegistry` + +Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package. Agent *creation* is provided by whichever plugin implements the AgentFactory (phase 1: `@deepseek-ai/dsh-agent-loop`), registered via setFactory. + +```ts cordis-catalog +setFactory(factory: AgentFactory): () => void +create(options: CreateAgentOptions): AgentHandle +async resume(options: ResumeAgentOptions): Promise +register(agent: Agent): () => void +get(id: string): Agent | undefined +list(): Agent[] +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/agent/src/index.ts:105`](../../packages/agent/src/index.ts) + +### `ctx.bash` — `BashExecutor` (abstract seam) + +Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.bash` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). + +Semantics every implementation must honor: + +- run REJECTS only for infrastructure failures (unusable workdir, missing shell, pre-aborted signal). Nonzero exits, timeout kills, and abort kills RESOLVE with a descriptive BashRunResult — reporting a failed command is the tool layer's job, not an exception. +- start returns immediately; no timeout applies to background tasks (callers stop them via kill or the spec's AbortSignal). Completion must fire the onTaskDone listeners exactly once per task, and must NOT fire after the service is disposed. +- readOutput is incremental: consecutive reads never re-deliver output. Implementations bound their buffers; reads that lost data flag `lossy` and point at full-stream spill files when available. +- Disposal kills every running task and awaits their exit (no orphan processes survive `fiber.dispose()`). + +```ts cordis-catalog +abstract resolve(request: BashExecRequest): BashExecSpec +abstract run(spec: BashExecSpec): Promise +abstract start(spec: BashExecSpec): BashTask +abstract get(id: string): BashTask | undefined +abstract ownerOf(id: string): string | undefined +abstract list(): BashTask[] +abstract readOutput(id: string): BashTaskRead +abstract kill(id: string): boolean +onTaskDone(listener: BashTaskListener): () => void +protected notifyTaskDone(task: BashTask): void +``` + +Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) · [BashTask](../core-data-structures/bash.md) + +Source: [`packages/bash/src/index.ts:58`](../../packages/bash/src/index.ts) + +### `ctx.llm` — `LlmService` + +The abstract `llm` service: an adapter registry plus streaming / non-streaming call surfaces, both interceptable via waterfall events. + +```ts cordis-catalog +registerAdapter(models: string[], adapter: LlmAdapter): () => void +models(): string[] +stream(options: GenerateOptions): AsyncIterable +async * streamBlocks(options: GenerateOptions): AsyncIterable +generate(options: GenerateOptions): Promise +``` + +Types: [ContentBlock](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [GenerateResult](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) + +Source: [`packages/llm/src/index.ts:81`](../../packages/llm/src/index.ts) + +### `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) + +Abstract durable session-persistence service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.sessionPersistence` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). + +Contracts every implementation MUST honor (a DB backend asserts them inside a transaction; a file backend appends at EOF): + +- **Append-only; a crashed turn is closed, not truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. A crash can leave an unclosed final turn whose events are real (and possibly large); load preserves them and closes the orphaned turn with synthetic boundary events (see load). Only a never-fully-written torn tail fragment is discarded. +- **Contiguous seq.** A persisted log is contiguous: `events[i].seq === i`. load rejects a parse error or a `seq` gap in the COMMITTED region (unloadable); append's first event `seq` MUST equal the backend's stored next-seq (after `load` has balanced any interrupted turn). +- **JSON-serializable data.** `SessionEventMap` is merge-extensible and `event.data` is typed only as `SessionEventMap[K]`, so append REJECTS non-JSON-serializable data with an error naming the offending event type. A backend snapshots (serializes/clones) each event when it buffers, since `session.events` hands out the live mutable object. +- **Durability.** append returns only once the batch is durable (the file backend fsyncs; a DB commits). create MAY defer the physical write until the first append (lazy materialization). + +```ts cordis-catalog +abstract create(meta: SessionHeader): Promise +abstract append(id: SessionId, events: readonly SessionEvent[]): Promise +abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +abstract list(): Promise +abstract has(id: SessionId): Promise +abstract delete(id: SessionId): Promise +``` + +Types: [SessionEvent](../core-data-structures/core.md) + +Source: [`packages/session-persistence/src/index.ts:98`](../../packages/session-persistence/src/index.ts) + +### `ctx.sessions` — `SessionStore` + +In-memory session store (`ctx.sessions`). + +Persistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose. + +```ts cordis-catalog +create(id?: string, options?: CreateSessionOptions): Session +prepare(id?: string, options?: CreateSessionOptions): Session +enter(session: Session): () => void +announce(session: Session): void +get(id: string): Session | undefined +list(): Session[] +``` + +Source: [`packages/session/src/index.ts:222`](../../packages/session/src/index.ts) + +### `ctx.systemPrompt` — `SystemPrompt` + +Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections and tool-schema providers; the agent loop calls `assemble()` once per step. + +```ts cordis-catalog +section(section: PromptSection): () => void +tools(provider: () => ToolSchema[]): () => void +assemble(): Promise +``` + +Source: [`packages/system-prompt/src/index.ts:71`](../../packages/system-prompt/src/index.ts) + +### `ctx.tools` — `ToolRegistry` + +Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/execute` waterfall. The registry contributes its schemas into the system-prompt assembly. + +```ts cordis-catalog +register(definition: ToolDefinition): () => void +get(name: string): ToolDefinition | undefined +schemas(): ToolSchema[] +async execute(exec: ToolExecution): Promise +``` + +Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) + +Source: [`packages/tools/src/index.ts:277`](../../packages/tools/src/index.ts) + +## Inherited tier (cordis core + loader/hmr/timer) + +The framework surface every plugin inherits, beyond the harness vocabulary above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the catalog is a complete picture of what `ctx` and the event bus offer, without elevating framework internals to the harness tier's prominence. + +### Inherited events + +- `internal/plugin` — A plugin fiber was created. ([`vendor/cordis/src/events.ts:197`](../../vendor/cordis/src/events.ts)) +- `internal/status` — A fiber changed lifecycle state. ([`vendor/cordis/src/events.ts:198`](../../vendor/cordis/src/events.ts)) +- `internal/service` — Interception hook for a service binding (no core producer). ([`vendor/cordis/src/events.ts:199`](../../vendor/cordis/src/events.ts)) +- `internal/update` — Waterfall: a fiber config update is being applied. ([`vendor/cordis/src/events.ts:200`](../../vendor/cordis/src/events.ts)) +- `internal/get` — Waterfall: a service is being read from the store. ([`vendor/cordis/src/events.ts:201`](../../vendor/cordis/src/events.ts)) +- `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:202`](../../vendor/cordis/src/events.ts)) +- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:203`](../../vendor/cordis/src/events.ts)) +- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:204`](../../vendor/cordis/src/events.ts)) +- `hmr/change` — A watched source file changed on disk. ([`vendor/hmr/src/index.ts:20`](../../vendor/hmr/src/index.ts)) +- `hmr/reload` — Plugins are being reloaded after a change. ([`vendor/hmr/src/index.ts:21`](../../vendor/hmr/src/index.ts)) +- `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:23`](../../vendor/loader/src/index.ts)) +- `loader/config-update` — The loader config tree changed. ([`vendor/loader/src/index.ts:24`](../../vendor/loader/src/index.ts)) +- `loader/entry-init` — A config entry is being initialized. ([`vendor/loader/src/index.ts:25`](../../vendor/loader/src/index.ts)) +- `loader/partial-dispose` — An entry is being partially disposed on reload. ([`vendor/loader/src/index.ts:26`](../../vendor/loader/src/index.ts)) +- `loader/patch-context` — A context is being patched during a reload. ([`vendor/loader/src/index.ts:27`](../../vendor/loader/src/index.ts)) + +### Inherited `ctx` members + +- `ctx.on / ctx.once` — Register an event listener (disposable). ([`vendor/cordis/src/events.ts:29`](../../vendor/cordis/src/events.ts)) +- `ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall` — Dispatch an event (sync / awaited / first-non-nullish / veto-chain). ([`vendor/cordis/src/events.ts:29`](../../vendor/cordis/src/events.ts)) +- `ctx.plugin / ctx.inject` — Load a plugin / declare required services. ([`vendor/cordis/src/registry.ts:144`](../../vendor/cordis/src/registry.ts)) +- `ctx.effect` — Register a disposable side effect tied to the fiber. ([`vendor/cordis/src/fiber.ts:9`](../../vendor/cordis/src/fiber.ts)) +- `ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin` — Low-level service-store access and binding. ([`vendor/cordis/src/reflect.ts:7`](../../vendor/cordis/src/reflect.ts)) +- `ctx.extend / ctx.isolate / ctx.intercept` — Derive a child context (scoped services / isolation / interception). ([`vendor/cordis/src/context.ts:35`](../../vendor/cordis/src/context.ts)) +- `ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger` — Ambient handles onto the running context graph. ([`vendor/cordis/src/context.ts:16`](../../vendor/cordis/src/context.ts)) +- `ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)` — Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick). ([`vendor/timer/src/index.ts:4`](../../vendor/timer/src/index.ts)) +- `ctx.loader` — The config Loader that booted the app (present under the loader). ([`vendor/loader/src/index.ts:30`](../../vendor/loader/src/index.ts)) +- `ctx.hmr` — The hot-module-reload watcher (present under the hmr plugin). ([`vendor/hmr/src/index.ts:15`](../../vendor/hmr/src/index.ts)) diff --git a/docs/development.md b/docs/development.md index d5796d32a8..3199893c3d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -93,17 +93,18 @@ pnpm run typecheck # build declarations, then typecheck source, tests, and pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs -pnpm run verify-event-taxonomy # compare docs/architecture.md event names with source +pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events-and-services.md from source +pnpm run verify-cordis-catalog # fail if the cordis events/services catalog is stale pnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown pnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type -pnpm run doc-sync # doc-typecheck, event taxonomy, markdown wrap/link, and type-equiv verification +pnpm run doc-sync # doc-typecheck, cordis-catalog freshness, markdown wrap/link, and type-equiv verification pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps pnpm run verify-module-graph # fail if docs/module-graph.md is stale pnpm run build # build declarations and JS bundles pnpm run hygiene # knip, publint, and workspace constraints ``` -When changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, event-taxonomy drift, and hard-wrapped markdown prose, but broader prose/API sync still needs review. +When changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, cordis events/services catalog drift, and hard-wrapped markdown prose, but broader prose/API sync still needs review. ## Demos diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 0fe36dca75..5b53345345 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -62,6 +62,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Drop the mutable session summary](implemented/2026-06-19-drop-mutable-session-summary.md) | 2026-06-19 | | [Shared persistence write coordinator](implemented/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 | | [Agent lifecycle and ownership seams](implemented/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 | +| [Generated cordis events + services catalog](implemented/2026-06-20-generated-cordis-catalog.md) | 2026-06-20 | ## Rejected diff --git a/docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md b/docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md index 68e6f0a03d..9abc7c08b0 100644 --- a/docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md +++ b/docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md @@ -13,7 +13,7 @@ AGENTS.md promises that docs and code stay strictly in sync, but the promise was Two gates, mirroring the existing `scripts/` style (tsx ESM, one job each): 1. **`doc-typecheck`** extracts every fenced ` ```ts ` block from `README.md`, `docs/**`, and `packages/*/README.md`, writes them to a temp project, and compiles with `tsc --noEmit`. The temp tsconfig copies only resolution-relevant options and the workspace `paths` map from `tsconfig.typecheck.json` (vendor → built `lib`, harness → `src`) — resolving vendor to `lib` is essential, or tsc type-checks raw vendor source and floods the run. A block that is a deliberate sketch opts out with an explicit ` ```ts ignore-check ` info string; the script reports the opt-out ratio and fails if it exceeds half, so the escape hatch can't quietly become the norm. -2. **`verify-event-taxonomy`** extracts the event names from the `interface Events` blocks across `packages/*/src` and from the taxonomy table in `docs/architecture.md`, and asserts the two sets match exactly. Verify, don't generate: the table keeps its hand-written Mode/Purpose columns; only the set of names is checked. (Landing this surfaced three events the table had been missing — `tools/change`, `llm/adapter-change`, `system-prompt/change`.) +2. **`verify-event-taxonomy`** extracts the event names from the `interface Events` blocks across `packages/*/src` and from the taxonomy table in `docs/architecture.md`, and asserts the two sets match exactly. Verify, don't generate: the table keeps its hand-written Mode/Purpose columns; only the set of names is checked. (Landing this surfaced three events the table had been missing — `tools/change`, `llm/adapter-change`, `system-prompt/change`.) **Superseded** by [the generated cordis catalog](2026-06-20-generated-cordis-catalog.md): this gate and its `architecture.md` table are retired in favor of a fully-generated `docs/cordis-catalog/events-and-services.md` and its `verify-cordis-catalog` freshness gate. The other gates here (`doc-typecheck`, and the `verify-md-wrap` amendment below) are unaffected. Both run via a shared `doc-sync` package.json script that the lefthook pre-push hook and CI both invoke ([mechanical quality gates](2026-06-11-quality-gates.md): hooks and CI call the same scripts, so the gate fires locally before a push — not only after it). They run after `pnpm run typecheck` (which emits the vendor `lib/` that doc-typecheck resolves against). API-extractor golden reports ([the deferred API-extractor-reports proposal](../proposed/2026-06-11-api-extractor-reports.md)) were deliberately **deferred** — low value for an internal monorepo where reviewers already see the source diff, and a heavy, finicky dependency. diff --git a/docs/rfc/implemented/2026-06-20-generated-cordis-catalog.md b/docs/rfc/implemented/2026-06-20-generated-cordis-catalog.md new file mode 100644 index 0000000000..4f2ba01fcb --- /dev/null +++ b/docs/rfc/implemented/2026-06-20-generated-cordis-catalog.md @@ -0,0 +1,35 @@ +# RFC: Generated cordis events + services catalog + +Status: implemented (accepted 2026-06-20) + + + +## Context + +A plugin author needs two reference surfaces that no single document gave them: every cordis **event** they can listen to (with its exact signature and dispatch mode) and every `ctx.` **service** they can call (with its exact interface). The pieces existed but were scattered — a hand-maintained event-taxonomy *table* in `docs/architecture.md` (names + prose Mode/Purpose, name-set-checked by `verify-event-taxonomy`), a Service-map table (8 rows of role prose), and the `interface Events` / `interface Context` declarations themselves. The taxonomy table also could not catch a brand-new *undocumented* event: a name-set verifier only checks the names that are already in the table on both sides. + +This is the wiring-axis complement to the [core-data-structures catalog](../../core-data-structures/core.md): that one catalogs the *data structures* the loop moves around (verified hand-pastes); this one catalogs the *events and services* that move them. + +## Decision + +Generate the catalog from source instead of hand-maintaining a table and verifying a subset. + +`scripts/gen-cordis-catalog.ts` walks the `interface Events` and `interface Context` declarations (plus the service classes) with the TypeScript compiler API and emits `docs/cordis-catalog/events-and-services.md` — one `## Events` section (grouped by scope, each event rendered as signature + mode badge + its source JSDoc) and one `## Services` section (each `ctx.` with its public method signatures + class JSDoc). It mirrors the `gen-module-graph` pattern exactly: `--write` regenerates, `--check` fails if the committed file is stale, output is deterministic (sorted), and the file is a build artifact that is never hand-edited. `verify-cordis-catalog` (the `--check`) runs inside `doc-sync`, so the freshness gate fires in the same lefthook pre-push and CI paths as every other doc gate. + +Pure generation is correct here because the codebase is disciplined enough that the AST is the whole truth: every event/service name is a string literal that round-trips to a static declaration — there are no dynamically-named events and no runtime-only services. So a generated doc cannot be wrong, and it closes the undocumented-event gap structurally (generation enumerates source rather than checking a hand-written subset). + +Specific choices: + +- **`@mode` tag, cross-checked.** Each harness event's JSDoc carries an explicit `@mode emit|waterfall|parallel` tag; the generator hard-errors on a missing tag. Where the signature shape is conclusive — a trailing `next: () => …` parameter is structurally a waterfall — it asserts the tag agrees and hard-errors on a contradiction. The emit-vs-parallel distinction is not structurally visible (`session/flush` returns `Promise | void` with no `next`), so it is trusted from the tag. The authoring rule lives in [AGENTS.md](../../../AGENTS.md). +- **Tiered scope.** The harness tier (the 8 `@deepseek-ai/dsh-*` services + their events) is rendered in full from source. The inherited tier (cordis-core `ctx.on/emit/effect/provide/…` + the `internal/*` events + loader/hmr/timer) is pinned vendor source a plugin also sees; it is rendered tersely (name + one-line + source pointer) from a curated table in the generator, NOT walked from the vendor AST — the cordis-core `Context` mixes true ctx members with non-service fields (`root`, `baseUrl`, `logger`), and the vendor surface changes only on a deliberate vendor sync. +- **Cross-links to the data-structure catalog.** A type name in a signature (`GenerateOptions`, `StreamChunk`, `ToolDefinition`, …) links to the core-data-structures page that documents it. The map is a small hand-curated const in the generator — NOT `type-equiv.manifest.json`, which documents the `…Map` symbols while signatures reference the derived union names, and lists a few symbols on two pages. +- **A dedicated fence.** Signature blocks use a ` ```ts cordis-catalog ` info string that `doc-typecheck` recognizes and skips (a bare signature fragment is not standalone-compilable), excluded from the opt-out ratio — the same treatment `type-equiv` blocks get. + +This **supersedes the event-taxonomy half** of [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md): `verify-event-taxonomy` and its `docs/architecture.md` table are retired (the architecture.md heading stays, its body now points at the catalog; the Service-map role table stays as curated prose). The verify-don't-generate principle that RFC chose for the taxonomy is reversed *for this surface only* — the data here is mechanically complete, so generation is strictly stronger (full signatures, cannot drift, catches undocumented events) than a name-set check of a hand-table. doc-typecheck, verify-md-wrap, verify-md-links, and verify-type-equiv are unchanged. + +## Consequences + +- The catalog cannot drift: a source change that the committed file doesn't reflect fails `verify-cordis-catalog` in the pre-push hook and CI. A new event with no `@mode` tag, or a tag that contradicts its signature, fails the generator outright. +- Event prose now has a single home — the JSDoc at the declaration. Thin JSDoc yields a thin catalog entry, which pressures authors to document at the source (the generator is a forcing function for the AGENTS.md "every export has a semantic JSDoc" rule). +- The inherited tier is hand-summarized, so a vendor sync that adds/renames a cordis-core event or `ctx` member needs a matching edit to the curated table in `gen-cordis-catalog.ts`. This is the deliberate cost of not walking pinned vendor source; it changes rarely and is called out in the generator. +- `verify-event-taxonomy.ts` is deleted and the `docs/architecture.md` event table is gone; anyone who linked to a specific table row now lands on the generated catalog instead. diff --git a/package.json b/package.json index e171048edf..eeb5dd7d07 100644 --- a/package.json +++ b/package.json @@ -24,14 +24,15 @@ "knip": "knip", "publint": "tsx scripts/publint-all.ts", "doc-typecheck": "tsx scripts/doc-typecheck.ts", - "verify-event-taxonomy": "tsx scripts/verify-event-taxonomy.ts", "verify-md-wrap": "tsx scripts/verify-md-wrap.ts", "verify-md-links": "tsx scripts/verify-md-links.ts", "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", + "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", + "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", "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-event-taxonomy && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-type-equiv", + "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-type-equiv", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints", "demo:echo": "node --expose-internals --import tsx examples/echo-agent/start.ts", "demo:coding": "node --expose-internals --import tsx examples/coding-agent/start.ts", diff --git a/packages/AGENTS.md b/packages/AGENTS.md index ae7c7d5a17..735e23a156 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -13,6 +13,6 @@ Naming notes: - A *service* `src/index.ts` exports the service class as `export default` + all public types; a *function/namespace plugin* `src/index.ts` exports `name`/`inject`/`Config`/`apply` as named exports and NO default (see the plugin-export-shape rule above) - `src/types.ts` contain only types — no runtime code - Tests live at package level under `tests/`, not `src/__tests__/` -- A package's README and module/JSDoc comments are part of the change: when you alter behavior (config keys, defaults, error codes, wire fields), update them in the same commit. CI runs `pnpm run doc-sync`, which typechecks fenced `ts` blocks in `packages/*/*.md`, verifies the event-taxonomy table, and checks markdown wrapping across this file too — but it does NOT catch prose drift (config keys, defaults, error codes), so those stay on the author. +- A package's README and module/JSDoc comments are part of the change: when you alter behavior (config keys, defaults, error codes, wire fields), update them in the same commit. CI runs `pnpm run doc-sync`, which typechecks fenced `ts` blocks in `packages/*/*.md`, regenerates the cordis events/services catalog from the `interface Events` / `interface Context` declarations (failing if the committed copy is stale), and checks markdown wrapping across this file too — but it does NOT catch prose drift (config keys, defaults, error codes), so those stay on the author. A new event needs an `@mode` tag on its JSDoc (the catalog generator hard-errors without it — see the root AGENTS.md). Read the per-package README.md for package-specific details: service API, events, extension points, TODOs. diff --git a/packages/agent/tests/gen-cordis-catalog.spec.ts b/packages/agent/tests/gen-cordis-catalog.spec.ts new file mode 100644 index 0000000000..66edb9983f --- /dev/null +++ b/packages/agent/tests/gen-cordis-catalog.spec.ts @@ -0,0 +1,83 @@ +/** + * Negative-path tests for the cordis catalog generator (`scripts/gen-cordis-catalog.ts`). + * + * The generated catalog is frozen by a regenerate-and-diff freshness gate, so + * the freshness half is exercised by `pnpm run verify-cordis-catalog` in CI. + * What a freshness diff CANNOT prove is that the generator REJECTS malformed + * source the way it promises to — a missing `@mode` tag, or a tag that + * contradicts the signature shape. These tests drive `collectEvents()` against + * synthetic fixture packages to prove each guard fires (and that a well-formed + * event passes), mirroring the drift-guard negative tests for verify-type-equiv. + */ + +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { collectEvents } from '../../../scripts/gen-cordis-catalog.ts' + +/** Write a fixture package exposing one `interface Events` block and return the + * scan root to hand `collectEvents`. */ +function fixtureRoot(eventsBlock: string): string { + const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-')) + const dir = join(root, 'packages', 'fix', 'src') + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, 'index.ts'), + `declare module 'cordis' {\n interface Events {\n${eventsBlock}\n }\n}\n`, + ) + return root +} + +const roots: string[] = [] +const make = (block: string): string => { + const r = fixtureRoot(block) + roots.push(r) + return r +} + +afterEach(() => { + while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }) +}) + +describe('gen-cordis-catalog collectEvents', () => { + it('extracts a well-formed event with its @mode and JSDoc', () => { + const events = collectEvents(make( + ' /**\n * A thing happened.\n * @mode emit\n */\n \'fix/happened\'(id: string): void', + )) + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ name: 'fix/happened', scope: 'fix', mode: 'emit', doc: 'A thing happened.' }) + }) + + it('classifies a trailing-next signature as a waterfall', () => { + const events = collectEvents(make( + ' /**\n * Intercept it.\n * @mode waterfall\n */\n \'fix/intercept\'(x: number, next: () => Promise): Promise', + )) + expect(events[0]?.mode).toBe('waterfall') + }) + + it('accepts a parallel (awaited, no next) event by trusting the tag', () => { + const events = collectEvents(make( + ' /**\n * Flush.\n * @mode parallel\n */\n \'fix/flush\'(): Promise | void', + )) + expect(events[0]?.mode).toBe('parallel') + }) + + it('hard-errors when an event is missing its @mode tag', () => { + expect(() => collectEvents(make( + ' /** No mode here. */\n \'fix/untagged\'(id: string): void', + ))).toThrow(/missing an @mode tag/) + }) + + it('hard-errors when @mode contradicts a trailing-next (waterfall) shape', () => { + expect(() => collectEvents(make( + ' /**\n * Mislabeled.\n * @mode emit\n */\n \'fix/wrong\'(x: number, next: () => Promise): Promise', + ))).toThrow(/trailing 'next' parameter .* tagged '@mode emit'/) + }) + + it('hard-errors when @mode waterfall has no trailing next to delegate to', () => { + expect(() => collectEvents(make( + ' /**\n * Not actually a waterfall.\n * @mode waterfall\n */\n \'fix/nonext\'(id: string): void', + ))).toThrow(/tagged '@mode waterfall' but has no trailing 'next'/) + }) +}) diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 80bdc6cc75..905adfb630 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -9,10 +9,13 @@ * compilable code 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, - * ` ```ts type-equiv `, marks a verbatim paste of a source type definition that - * `scripts/verify-type-equiv.ts` drift-checks against the source symbol; it is - * skipped here and EXCLUDED from the opt-out ratio (a separately-checked - * category, not an unchecked sketch). + * doc-typecheck.ts recognizes two more fence variants and skips both (each is a + * separately-checked category, not an unchecked sketch, so neither counts in the + * opt-out ratio): ` ```ts type-equiv ` is a verbatim source-type paste that + * `scripts/verify-type-equiv.ts` drift-checks, and ` ```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). * * Run: `tsx scripts/doc-typecheck.ts`. */ @@ -34,8 +37,13 @@ const root = resolve(import.meta.dirname, '..') * source symbol. Skipped HERE (it is not standalone-compilable — no imports) * and EXCLUDED from the opt-out ratio: it is a separate fully-checked * category, not an unchecked sketch. + * - `cordis-catalog` (` ```ts cordis-catalog `) — a generated event/service + * signature fragment in the cordis catalog. Skipped HERE for the same reason + * (a bare signature fragment has no imports and does not stand alone) and + * EXCLUDED from the opt-out ratio: the catalog is generated and frozen by + * `scripts/gen-cordis-catalog.ts` + its `--check` freshness gate. */ -type BlockKind = 'check' | 'ignore' | 'type-equiv' +type BlockKind = 'check' | 'ignore' | 'type-equiv' | 'cordis-catalog' /** One extracted code block. */ interface Block { @@ -46,7 +54,7 @@ interface Block { code: string } -/** Extract every ```ts / ```ts ignore-check / ```ts type-equiv block from one Markdown file. */ +/** Extract every ts / ts ignore-check / ts type-equiv / ts cordis-catalog block from one Markdown file. */ function extractBlocks(absPath: string): Block[] { const text = readFileSync(absPath, 'utf8') const lines = text.split('\n') @@ -72,7 +80,8 @@ function extractBlocks(absPath: string): Block[] { info === 'ts' ? 'check' : info === 'ts ignore-check' ? 'ignore' : info === 'ts type-equiv' ? 'type-equiv' - : null + : info === 'ts cordis-catalog' ? 'cordis-catalog' + : null if (kind) open = { line: i + 1, kind, body: [] } }) return blocks @@ -127,10 +136,11 @@ files.sort() const all = files.flatMap(extractBlocks) const checked = all.filter(b => b.kind === 'check') const ignored = all.filter(b => b.kind === 'ignore') -// `type-equiv` blocks are verified by verify-type-equiv.ts, not here: neither -// compiled nor counted toward the opt-out ratio (they are a separate -// fully-checked category, not an unchecked sketch). The ratio's denominator is -// therefore the compile-eligible blocks only. +// `type-equiv` and `cordis-catalog` blocks are verified elsewhere +// (verify-type-equiv.ts and the gen-cordis-catalog `--check` freshness gate), +// not here: neither compiled nor counted toward the opt-out ratio (each is a +// separate fully-checked category, not an unchecked sketch). The ratio's +// denominator is therefore the compile-eligible blocks only. const ratioDenominator = checked.length + ignored.length if (checked.length === 0) { @@ -164,7 +174,8 @@ try { } const ratio = ignored.length / ratioDenominator - console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${all.length - ratioDenominator} type-equiv (checked by verify-type-equiv).`) + const skipped = all.length - ratioDenominator + console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/cordis-catalog (checked elsewhere).`) // Guard against the escape hatch becoming the norm. if (ratioDenominator >= 4 && ratio > 0.5) { console.error(`doc-typecheck: too many blocks opt out of checking (${ignored.length}/${ratioDenominator}). Make them compile or delete them.`) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts new file mode 100644 index 0000000000..e0eac52d16 --- /dev/null +++ b/scripts/gen-cordis-catalog.ts @@ -0,0 +1,458 @@ +/** + * Generate (and verify) the cordis events + services catalog in + * docs/cordis-catalog/events-and-services.md. + * + * The catalog is the WIRING-axis reference: every cordis event a plugin can + * listen to (exact signature + dispatch mode) and every `ctx.` service it + * can call (exact public interface). It complements the core-data-structures + * catalog (the VOCABULARY axis — the types these signatures move around). + * + * Unlike the core-data-structures docs (a hand-paste drift-checked by + * verify-type-equiv), this file is FULLY GENERATED from source — never + * hand-edit it. The codebase is disciplined enough that a pure-AST pass + * captures the whole truthful surface: every event/service is a string literal + * that round-trips to a static `interface Events` / `interface Context` + * declaration (no dynamically-named events, no runtime-only services). So the + * committed file is a build artifact and a regenerate-and-diff freshness check + * (`--check`) makes drift structurally impossible — which also closes the gap a + * name-set verifier could not: a brand-new UNDOCUMENTED event cannot slip + * through, because generation enumerates source rather than checking a subset. + * + * `tsx scripts/gen-cordis-catalog.ts` → write the catalog + * `tsx scripts/gen-cordis-catalog.ts --check` → exit 1 if the committed file + * is stale (CI / pre-push gate) + * + * The HARNESS tier (the `@deepseek-ai/dsh-*` events + services) is rendered in + * full from source: signature, the `@mode` badge, and the declaration's JSDoc. + * Every harness event MUST carry an `@mode emit|waterfall|parallel` tag — the + * generator hard-errors on a missing tag, and where the signature shape is + * conclusive (a trailing `next: () => …` parameter is structurally a waterfall) + * it asserts the tag agrees and hard-errors on a contradiction. The INHERITED + * tier (cordis core + loader/hmr/timer) is pinned vendor source a plugin author + * also sees; it is rendered tersely (name + one-line + source pointer) from a + * curated table in this script, NOT elevated to the harness tier's prominence. + * + * Signature fences use the ` ```ts cordis-catalog ` info string: doc-typecheck + * recognizes it and skips compilation (the signatures are fragments, not + * standalone-compilable, like the ` ```ts type-equiv ` blocks). + */ + +import { globSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import ts from 'typescript' + +const root = resolve(import.meta.dirname, '..') +const OUT = 'docs/cordis-catalog/events-and-services.md' + +/** The fenced-block info string for generated signature blocks (skipped by + * doc-typecheck, since a bare signature fragment is not standalone-compilable). */ +const FENCE = 'ts cordis-catalog' + +/** A dispatch mode, rendered as the badge after an event name. */ +type Mode = 'emit' | 'waterfall' | 'parallel' + +/** + * Cross-link map: a type name that appears in a signature → the + * core-data-structures page that documents it (path relative to OUT's folder). + * Hand-curated and catalog-owned, NOT derived from type-equiv.manifest.json — + * 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. + */ +const LINK_MAP: Record = { + Agent: 'core.md', + ContentBlock: 'core.md', + Message: 'core.md', + MessageSource: 'core.md', + GenerateOptions: 'core.md', + GenerateResult: 'core.md', + SessionEvent: 'core.md', + StreamChunk: 'llm-streaming.md', + TurnEndReason: 'session.md', + ToolDefinition: 'tools.md', + ToolExecution: 'tools.md', + ToolExecutionResult: 'tools.md', + BashExecRequest: 'bash.md', + BashExecSpec: 'bash.md', + BashRunResult: 'bash.md', + BashTask: 'bash.md', +} + +/** One harness event, extracted from an `interface Events` block. */ +interface EventEntry { + /** Scoped name, e.g. `agent/request`. */ + name: string + /** The scope prefix, e.g. `agent` (everything before the first `/`). */ + scope: string + /** Full signature text (the method-signature member, JSDoc stripped). */ + signature: string + /** Dispatch mode from the `@mode` tag. */ + mode: Mode + /** Description prose (JSDoc minus the `@mode` tag), one line per paragraph. */ + doc: string + /** Source pointer `packages/…/file.ts:line` of the declaration. */ + source: string +} + +/** One harness service, extracted from an `interface Context` block. */ +interface ServiceEntry { + /** The `ctx.` name, e.g. `llm`. */ + key: string + /** The service class/interface name, e.g. `LlmService`. */ + type: string + /** Whether the service class is abstract (a seam interface). */ + abstract: boolean + /** Class-level JSDoc prose, one line per paragraph. */ + doc: string + /** Public method signatures (bodies stripped), in source order. */ + methods: string[] + /** Source pointer of the class declaration. */ + source: string +} + +/** A terse inherited-tier entry (pinned vendor surface). */ +interface InheritedEntry { + name: string + summary: string + /** Source pointer `vendor/…:line`. */ + source: string +} + +/** Repo-relative source pointer `file:line` for a node's first character. */ +function pointer(rel: string, sf: ts.SourceFile, node: ts.Node): string { + const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf)) + return `${rel}:${line + 1}` +} + +/** The raw `/** … *​/` JSDoc block immediately preceding a node, or '' if none. */ +function rawJsDoc(text: string, node: ts.Node): string { + const ranges = ts.getLeadingCommentRanges(text, node.getFullStart()) ?? [] + const jsdoc = ranges.filter(r => text.slice(r.pos, r.pos + 3) === '/**').at(-1) + return jsdoc ? text.slice(jsdoc.pos, jsdoc.end) : '' +} + +/** + * Parse a raw JSDoc block into description prose + the `@mode` tag (when + * present). Output obeys the repo's markdown conventions so the generated file + * passes verify-md-wrap: each prose paragraph collapses to ONE physical line, + * and a `-` bullet list is preserved with each item on its own single line + * (continuation lines folded in). `{@link Foo}` unwraps to `Foo`; `@`-tag lines + * other than `@mode` end the current prose run. + */ +function parseJsDoc(raw: string): { doc: string; mode: Mode | null } { + const inner = raw + .replace(/^\/\*\*/, '') + .replace(/\*\/$/, '') + .split('\n') + .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, '')) + let mode: Mode | null = null + const blocks: string[] = [] + let para: string[] = [] + let list: string[] = [] + let item: string[] = [] + const join = (parts: string[]): string => parts.join(' ').replace(/\s+/g, ' ').trim() + const flushItem = (): void => { + if (item.length) list.push(join(item)) + item = [] + } + const flushList = (): void => { + flushItem() + if (list.length) blocks.push(list.join('\n')) // one block, items on own lines + list = [] + } + const flushPara = (): void => { + flushList() + if (para.length) blocks.push(join(para)) + para = [] + } + for (const line of inner) { + const m = /^@mode\s+(emit|waterfall|parallel)\s*$/.exec(line) + if (m) { mode = m[1] as Mode; continue } + if (line.startsWith('@')) { flushPara(); continue } // other tags end the prose + if (line.trim() === '') { flushPara(); continue } + if (/^-\s+/.test(line)) { + // A list item starts: a pending paragraph (e.g. an intro line directly + // above the list, no blank between) flushes FIRST so it renders above. + flushItem() + if (para.length) { blocks.push(join(para)); para = [] } + item.push(line) + continue + } + if (item.length) { item.push(line); continue } // continuation of current item + para.push(line) + } + flushPara() + const doc = blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim() + return { doc, mode } +} + +/** Find the `declare module 'cordis'` body in a source file, or null. */ +function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null { + for (const stmt of sf.statements) { + if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === 'cordis') { + if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body + } + } + return null +} + +/** The signature text of a method-signature member (everything but a body). */ +function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.SourceFile): string { + const full = member.getText(sf) + const body = (member as { body?: ts.Node }).body + const sig = body ? full.slice(0, full.length - body.getText(sf).length) : full + return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim() +} + +/** Walk every harness `interface Events` block and extract its events. + * `scanRoot` defaults to the repo root; tests pass a fixture dir. */ +export function collectEvents(scanRoot: string = root): EventEntry[] { + const entries: EventEntry[] = [] + for (const rel of globSync('packages/*/src/*.ts', { cwd: scanRoot }).sort()) { + const abs = resolve(scanRoot, rel) + const text = readFileSync(abs, 'utf8') + if (!text.includes('interface Events')) continue + const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true) + const body = cordisModuleBody(sf) + if (!body) continue + for (const stmt of body.statements) { + if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue + for (const member of stmt.members) { + if (!ts.isMethodSignature(member)) continue + const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf) + const signature = memberSignature(member, sf) + const { doc, mode } = parseJsDoc(rawJsDoc(text, member)) + const src = pointer(rel, sf, member) + if (!mode) { + throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is missing an @mode tag. Add '@mode emit|waterfall|parallel' to its JSDoc (see AGENTS.md).`) + } + // Conclusive structural check: a trailing `next: () => …` parameter is a + // waterfall. (emit vs parallel is not structurally distinguishable, so + // it is trusted from the tag.) + const last = member.parameters.at(-1) + const hasNext = !!last && last.name.getText(sf) === 'next' + if (hasNext && mode !== 'waterfall') { + throw new Error(`gen-cordis-catalog: event '${name}' (${src}) has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`) + } + if (!hasNext && mode === 'waterfall') { + throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`) + } + entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src }) + } + } + } + return entries +} + +/** Walk every harness `interface Context` block + its service class. + * `scanRoot` defaults to the repo root; tests pass a fixture dir. */ +export function collectServices(scanRoot: string = root): ServiceEntry[] { + const entries: ServiceEntry[] = [] + for (const rel of globSync('packages/*/src/index.ts', { cwd: scanRoot }).sort()) { + const abs = resolve(scanRoot, rel) + const text = readFileSync(abs, 'utf8') + if (!text.includes('interface Context')) continue + const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true) + const body = cordisModuleBody(sf) + if (!body) continue + // The ctx key → type mapping(s) declared in this file's interface Context. + const keyToType = new Map() + for (const stmt of body.statements) { + if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue + for (const member of stmt.members) { + if (!ts.isPropertySignature(member) || !member.type) continue + const key = member.name.getText(sf) + keyToType.set(key, member.type.getText(sf)) + } + } + if (keyToType.size === 0) continue + // Find each service class declared in the same file and emit an entry. + for (const [key, type] of keyToType) { + const cls = sf.statements.find( + (s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type, + ) + if (!cls) continue // a Pick-mixin member (e.g. timer helpers), not a class here + const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false + const methods: string[] = [] + for (const member of cls.members) { + if (!ts.isMethodDeclaration(member)) continue + const isPrivate = member.modifiers?.some(m => m.kind === ts.SyntaxKind.PrivateKeyword) + || ts.isPrivateIdentifier(member.name) + const isStatic = member.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword) + if (isPrivate || isStatic) continue + const memberName = member.name.getText(sf) + if (memberName.startsWith('[')) continue // computed/symbol members + methods.push(memberSignature(member, sf)) + } + entries.push({ + key, + type, + abstract, + doc: parseJsDoc(rawJsDoc(text, cls)).doc, + methods, + source: pointer(rel, sf, cls), + }) + } + } + return entries.sort((a, b) => a.key.localeCompare(b.key)) +} + +/** + * The inherited tier — cordis core + loader/hmr/timer. Curated, terse, and + * hand-summarized because (a) it is pinned vendor source that changes only on a + * deliberate vendor sync, (b) the cordis-core `Context` mixes true ctx members + * with non-service fields (`root`, `baseUrl`, `logger`) that a blind walk would + * wrongly surface as services, and (c) the internal/* events carry no JSDoc to + * render. Source pointers are verified against vendor by `verify-md-links`' + * sibling check is N/A; keep them current on a vendor bump. + */ +const INHERITED_EVENTS: InheritedEntry[] = [ + { name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:197' }, + { name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:198' }, + { name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:199' }, + { name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:200' }, + { name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:201' }, + { name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:202' }, + { name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:203' }, + { name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:204' }, + { name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' }, + { name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' }, + { name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' }, + { name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' }, + { name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' }, + { name: 'loader/partial-dispose', summary: 'An entry is being partially disposed on reload.', source: 'vendor/loader/src/index.ts:26' }, + { name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:27' }, +] + +const INHERITED_SERVICES: InheritedEntry[] = [ + { name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:29' }, + { name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-non-nullish / veto-chain).', source: 'vendor/cordis/src/events.ts:29' }, + { name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:144' }, + { name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' }, + { name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' }, + { name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:35' }, + { name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.', source: 'vendor/cordis/src/context.ts:16' }, + { name: 'ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick).', source: 'vendor/timer/src/index.ts:4' }, + { name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' }, + { name: 'ctx.hmr', summary: 'The hot-module-reload watcher (present under the hmr plugin).', source: 'vendor/hmr/src/index.ts:15' }, +] + +/** Render the cross-link "Types:" line for a signature, or '' if none apply. */ +function typeLinks(signature: string): string { + const seen = new Set() + for (const name of Object.keys(LINK_MAP)) { + if (new RegExp(`\\b${name}\\b`).test(signature)) seen.add(name) + } + if (seen.size === 0) return '' + const links = [...seen].sort().map(n => `[${n}](../core-data-structures/${LINK_MAP[n]})`) + return `Types: ${links.join(' · ')}` +} + +/** Render one harness event entry. */ +function renderEvent(e: EventEntry): string[] { + const out = [`#### \`${e.name}\` — ${e.mode}`, ''] + if (e.doc) out.push(e.doc, '') + out.push('```' + FENCE, e.signature, '```', '') + const links = typeLinks(e.signature) + if (links) out.push(links, '') + out.push(`Source: [\`${e.source}\`](../../${e.source.split(':')[0]})`, '') + return out +} + +/** Render one harness service entry. */ +function renderService(s: ServiceEntry): string[] { + const kind = s.abstract ? ' (abstract seam)' : '' + const out = [`### \`ctx.${s.key}\` — \`${s.type}\`${kind}`, ''] + if (s.doc) out.push(s.doc, '') + if (s.methods.length) { + out.push('```' + FENCE, ...s.methods, '```', '') + const links = typeLinks(s.methods.join('\n')) + if (links) out.push(links, '') + } + out.push(`Source: [\`${s.source}\`](../../${s.source.split(':')[0]})`, '') + return out +} + +/** Render the full catalog (pure, deterministic given sorted inputs). */ +function render(events: EventEntry[], services: ServiceEntry[]): string { + const lines: string[] = [ + '', + '', + '# Cordis Events & Services Catalog', + '', + 'An index reference to the **wiring** a plugin author works against: every cordis event you can listen to (exact signature + dispatch mode) and every `ctx.` service you can call (exact public interface). It complements [core-data-structures/](../core-data-structures/core.md), which catalogs the *data structures* these signatures move around — this page is the verbs, that page is the nouns.', + '', + 'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them.', + '', + 'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer surface a plugin also sees — pinned vendor source, summarized tersely.', + '', + '## Events', + '', + `Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets \`next()\` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares ${events.length} events across ${new Set(events.map(e => e.scope)).size} scopes.`, + '', + ] + const scopes = [...new Set(events.map(e => e.scope))].sort() + for (const scope of scopes) { + lines.push(`### \`${scope}/*\``, '') + for (const e of events.filter(x => x.scope === scope).sort((a, b) => a.name.localeCompare(b.name))) { + lines.push(...renderEvent(e)) + } + } + lines.push( + '## Services', + '', + `The ${services.length} \`ctx.\` services the harness provides. An abstract seam (e.g. \`ctx.bash\`) is implemented by a separate package; the interface is what consumers code against.`, + '', + ) + for (const s of services) lines.push(...renderService(s)) + lines.push( + '## Inherited tier (cordis core + loader/hmr/timer)', + '', + 'The framework surface every plugin inherits, beyond the harness vocabulary above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the catalog is a complete picture of what `ctx` and the event bus offer, without elevating framework internals to the harness tier\'s prominence.', + '', + '### Inherited events', + '', + ) + for (const e of INHERITED_EVENTS) { + lines.push(`- \`${e.name}\` — ${e.summary} ([\`${e.source}\`](../../${e.source.split(':')[0]}))`) + } + lines.push('', '### Inherited `ctx` members', '') + for (const s of INHERITED_SERVICES) { + lines.push(`- \`${s.name}\` — ${s.summary} ([\`${s.source}\`](../../${s.source.split(':')[0]}))`) + } + lines.push('') + return lines.join('\n') +} + +/** CLI entry: `--write` (default) writes the catalog, `--check` fails if 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(collectEvents(), collectServices()) + 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-cordis-catalog: ${OUT} is up to date.`) + process.exit(0) + } + console.error(`gen-cordis-catalog: ${OUT} is stale. Run \`pnpm run gen-cordis-catalog\` and commit ${OUT}.`) + process.exit(1) + } + + writeFileSync(resolve(root, OUT), content) + console.log(`gen-cordis-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/verify-event-taxonomy.ts b/scripts/verify-event-taxonomy.ts deleted file mode 100644 index f133d9cf46..0000000000 --- a/scripts/verify-event-taxonomy.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Doc-sync gate (doc-sync-enforcement RFC, part 2): verify the event-taxonomy table in - * docs/architecture.md against the events actually declared in source. - * - * The table duplicates the `declare module 'cordis' { interface Events }` - * blocks across packages/* /src. This script extracts both sets of event names - * and asserts they match exactly — every declared event appears in the table, - * and the table names no event that isn't declared. Verify, don't generate - * (per the RFC): the table keeps its hand-written Mode/Purpose columns; only - * the set of names is checked. - * - * Run: `tsx scripts/verify-event-taxonomy.ts`. - */ - -import { readFileSync } from 'node:fs' -import { join, relative, resolve } from 'node:path' -import { glob } from 'node:fs/promises' - -const root = resolve(import.meta.dirname, '..') - -/** - * Remove `/* *​/` block comments and `//` line comments from TS source. Used to - * de-risk the brace walk in {@link declaredEvents} — a JSDoc `{@link}` tag would - * otherwise throw off the `{`/`}` depth counter. Good enough for our own source - * (no string literals contain `//` or comment-like brace sequences in an Events - * block); it is not a general tokenizer. - */ -function stripComments(text: string): string { - return text - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/(^|[^:])\/\/.*$/gm, '$1') -} - -/** - * Event names declared in source: the keys inside every `interface Events` - * block under packages/* /src. A declared event is a quoted `'scope/name'(` - * method signature at the start of a line within such a block. - */ -async function declaredEvents(): Promise> { - const found = new Map() - for await (const match of glob('packages/*/src/**/*.ts', { cwd: root })) { - const abs = resolve(root, match) - // Strip comments first so a JSDoc `{@link …}` tag (or a `// {` line) inside - // an Events block can't unbalance the brace walk below. Event names live in - // code, never in comments, so this loses nothing. - const text = stripComments(readFileSync(abs, 'utf8')) - // Walk `interface Events {` blocks brace-balanced and pull quoted keys. - const re = /interface\s+Events\s*\{/g - let m: RegExpExecArray | null - while ((m = re.exec(text)) !== null) { - let depth = 1 - let i = m.index + m[0].length - const start = i - while (i < text.length && depth > 0) { - const ch = text[i] - if (ch === '{') depth++ - else if (ch === '}') depth-- - i++ - } - const body = text.slice(start, i - 1) - // A declaration is a quoted event name followed by `(` (method form). - for (const k of body.matchAll(/['"]([a-z][a-z-]*\/[a-z-]+)['"]\s*\(/g)) { - const name = k[1] - if (name) found.set(name, relative(root, abs)) - } - } - } - return found -} - -/** Event names referenced in the architecture-doc taxonomy table (in `code`). */ -function tableEvents(): Set { - const text = readFileSync(join(root, 'docs/architecture.md'), 'utf8') - const lines = text.split('\n') - const heading = lines.findIndex(l => /^###\s+Event taxonomy/.test(l)) - if (heading === -1) throw new Error('verify-event-taxonomy: "### Event taxonomy" heading not found') - const names = new Set() - for (let i = heading + 1; i < lines.length; i++) { - const line = lines[i] ?? '' - if (/^###\s/.test(line)) break // next section ends the table - if (!line.includes('|')) continue - for (const code of line.matchAll(/`([^`]+)`/g)) { - // A cell may read "`a/b` / `c/d` (pkg)" — pull each scoped name. - for (const name of (code[1] ?? '').matchAll(/[a-z][a-z-]*\/[a-z-]+/g)) names.add(name[0]) - } - } - return names -} - -const declared = await declaredEvents() -const table = tableEvents() - -const declaredNames = new Set(declared.keys()) -const missingFromTable = [...declaredNames].filter(n => !table.has(n)).sort() -const missingFromSource = [...table].filter(n => !declaredNames.has(n)).sort() - -if (missingFromTable.length === 0 && missingFromSource.length === 0) { - console.log(`verify-event-taxonomy: ${declaredNames.size} events match the architecture-doc table.`) - process.exit(0) -} - -if (missingFromTable.length > 0) { - console.error('verify-event-taxonomy: declared in source but MISSING from the docs/architecture.md table:') - for (const n of missingFromTable) { - console.error(` ${n} (declared in ${declared.get(n) ?? '?'})`) - } -} -if (missingFromSource.length > 0) { - console.error('verify-event-taxonomy: named in the table but NOT declared in source (stale doc):') - for (const n of missingFromSource) { - console.error(` ${n}`) - } -} -process.exit(1)