docs: unwrap hard-wrapped Markdown to one line per paragraph

Hard line breaks mid-paragraph make docs harder to edit and diff — a
one-word change reflows and re-diffs the whole paragraph. Reflow all
tracked non-vendor Markdown (plus vendor/AGENTS.md) so each prose
paragraph is a single line; soft-wrapping is the editor's job. Fenced
code, tables, and list structure are preserved (wrapped list items fold
to one line per bullet). Documents the convention in AGENTS.md.
This commit is contained in:
Tianyi Cui
2026-06-13 18:39:20 +08:00
parent e98c1c5d42
commit 066f94c7e0
39 changed files with 348 additions and 1206 deletions

View File

@@ -4,31 +4,17 @@ Status: accepted (2026-06-11)
## Context
DeepSeek Code is built on the Cordis framework. Cordis core was at 4.0.0-rc.6
(a release candidate) when this repo started; the harness depends on framework
internals (fiber lifecycle, effect disposal, waterfall dispatch) whose exact
behavior matters to the agent loop's correctness guarantees.
DeepSeek Code is built on the Cordis framework. Cordis core was at 4.0.0-rc.6 (a release candidate) when this repo started; the harness depends on framework internals (fiber lifecycle, effect disposal, waterfall dispatch) whose exact behavior matters to the agent loop's correctness guarantees.
## Decision
Copy the needed Cordis packages (core, loader, include, group, timer, hmr,
logger-console) and the cordiverse foundation libraries (cosmokit,
schemastery) into `vendor/` as source, flattened, keeping their original npm
names so workspace resolution is transparent. Truly third-party dependencies
(js-yaml, chokidar, @standard-schema/spec, …) stay on npm.
Copy the needed Cordis packages (core, loader, include, group, timer, hmr, logger-console) and the cordiverse foundation libraries (cosmokit, schemastery) into `vendor/` as source, flattened, keeping their original npm names so workspace resolution is transparent. Truly third-party dependencies (js-yaml, chokidar, @standard-schema/spec, …) stay on npm.
`vendor/README.md` is the manifest: upstream repo + commit SHA per package and
an exhaustive local-modification log. A pre-commit guard
(`scripts/check-vendor-manifest.sh`) rejects vendored-source changes that
don't update the manifest in the same commit.
`vendor/README.md` is the manifest: upstream repo + commit SHA per package and an exhaustive local-modification log. A pre-commit guard (`scripts/check-vendor-manifest.sh`) rejects vendored-source changes that don't update the manifest in the same commit.
## Consequences
- The harness fully owns its framework layer: auditable, patchable, pinned —
an RC upstream can't break us, and we can fix framework bugs in-tree.
- Upstream sync is manual (documented procedure in the manifest). The
modification log keeps the diff surface known.
- Vendored packages keep upstream code style; lint/strictness gates exclude
them (their tsconfigs relax our newer compiler flags locally).
- One local patch exists from day one: hmr's locale-YAML imports removed (the
runtime YAML import hook isn't vendored).
- The harness fully owns its framework layer: auditable, patchable, pinned — an RC upstream can't break us, and we can fix framework bugs in-tree.
- Upstream sync is manual (documented procedure in the manifest). The modification log keeps the diff surface known.
- Vendored packages keep upstream code style; lint/strictness gates exclude them (their tsconfigs relax our newer compiler flags locally).
- One local patch exists from day one: hmr's locale-YAML imports removed (the runtime YAML import hook isn't vendored).

View File

@@ -4,35 +4,21 @@ Status: accepted (2026-06-11)
## Context
The product principle (see the 微内核Harness实现思路 design doc) is
"everything is a plugin": hooks, /goal, /loop, dynamic workflows, compaction,
sandboxing, permissions, UI, persistence, MCP, skills must all be writable as
plugins without modifying the core. Candidate mechanisms considered: a
purpose-built middleware stack (koa-compose style), an explicit phase state
machine plugins can insert into, or Cordis's native event system.
The product principle (see the 微内核Harness实现思路 design doc) is "everything is a plugin": hooks, /goal, /loop, dynamic workflows, compaction, sandboxing, permissions, UI, persistence, MCP, skills must all be writable as plugins without modifying the core. Candidate mechanisms considered: a purpose-built middleware stack (koa-compose style), an explicit phase state machine plugins can insert into, or Cordis's native event system.
## Decision
Pure Cordis event taxonomy. The loop's extension seams are typed events with
deliberate dispatch modes:
Pure Cordis event taxonomy. The loop's extension seams are typed events with deliberate dispatch modes:
- **waterfall** (around-middleware) where plugins mutate or veto:
`agent/request`, `agent/step-result`, `agent/turn-continuation`,
`tools/execute`, `llm/stream`, `llm/generate`, `system-prompt/assemble`.
- **emit** (sync fire-and-forget) for notifications: turn/step boundaries,
stream chunks, lifecycle, errors.
- **waterfall** (around-middleware) where plugins mutate or veto: `agent/request`, `agent/step-result`, `agent/turn-continuation`, `tools/execute`, `llm/stream`, `llm/generate`, `system-prompt/assemble`.
- **emit** (sync fire-and-forget) for notifications: turn/step boundaries, stream chunks, lifecycle, errors.
- **parallel** (awaited) for the one durability checkpoint: `session/flush`.
The event vocabulary lives in interface packages (dsh-agent declares the
agent/* events); `@deepseek-ai/dsh-agent-loop` is the only concrete plugin and
is itself swappable — nothing outside it may depend on it.
The event vocabulary lives in interface packages (dsh-agent declares the agent/* events); `@deepseek-ai/dsh-agent-loop` is the only concrete plugin and is itself swappable — nothing outside it may depend on it.
## Consequences
- Every MVP feature maps to a listener (the "plugin sanity checklist" in
docs/architecture.md is the proof obligation, kept current).
- Every MVP feature maps to a listener (the "plugin sanity checklist" in docs/architecture.md is the proof obligation, kept current).
- HMR and disposal come free: listeners and registrations are Cordis effects.
- Waterfall semantics (call `next()` or short-circuit) are non-obvious and
must be taught — documented in AGENTS.md and covered by composition tests.
- The loop must be defensive: plugin exceptions are contained at turn level,
steering from any seam is never stranded (regression-tested).
- Waterfall semantics (call `next()` or short-circuit) are non-obvious and must be taught — documented in AGENTS.md and covered by composition tests.
- The loop must be defensive: plugin exceptions are contained at turn level, steering from any seam is never stranded (regression-tested).

View File

@@ -4,35 +4,19 @@ Status: accepted (2026-06-11)
## Context
The MVP requires strict event-based tracing with fully replayable sessions
(严格的基于事件的trace、logging系统session完全可回放). Two models were
considered: a mutable message array with events fired as notifications
(simpler, but state and log can diverge), or event-sourcing where the log IS
the state.
The MVP requires strict event-based tracing with fully replayable sessions (严格的基于事件的trace、logging系统session完全可回放). Two models were considered: a mutable message array with events fired as notifications (simpler, but state and log can diverge), or event-sourcing where the log IS the state.
## Decision
A `Session` is an append-only log of typed `SessionEvent`s — the single
source of truth. The LLM message history is *derived* from the log
(`deriveMessages()`); raw stream chunks are logged for token-level replay
fidelity while the assembled `assistant/message` event is authoritative for
derivation. Replay/fork = seed a new session with an existing log.
A `Session` is an append-only log of typed `SessionEvent`s — the single source of truth. The LLM message history is *derived* from the log (`deriveMessages()`); raw stream chunks are logged for token-level replay fidelity while the assembled `assistant/message` event is authoritative for derivation. Replay/fork = seed a new session with an existing log.
Appends are synchronous (the hot path never blocks on I/O); `session/event`
is a sync notification; persistence plugins buffer write-behind and drain at
the awaited `session/flush` checkpoint fired at every turn end.
Appends are synchronous (the hot path never blocks on I/O); `session/event` is a sync notification; persistence plugins buffer write-behind and drain at the awaited `session/flush` checkpoint fired at every turn end.
Ordering contract: the loop appends to the session *before* emitting the
corresponding Cordis event, and the `agent/step-result` waterfall runs before
the `assistant/message` append so the log records what tool dispatch actually
used (post-review fix; regression-tested).
Ordering contract: the loop appends to the session *before* emitting the corresponding Cordis event, and the `agent/step-result` waterfall runs before the `assistant/message` append so the log records what tool dispatch actually used (post-review fix; regression-tested).
## Consequences
- Replay, trace, and telemetry are structurally guaranteed, not bolted on.
- Persistence stays a plugin concern; the in-memory store ships in dsh-session.
- The event vocabulary is merge-extensible (plugins add e.g. compaction
events); it carries a TODO(review) marker until the first persistence
plugin and real adapter exercise it.
- Derivation cost grows with log length — compaction (future plugin) is the
intended mitigation, not log mutation.
- The event vocabulary is merge-extensible (plugins add e.g. compaction events); it carries a TODO(review) marker until the first persistence plugin and real adapter exercise it.
- Derivation cost grows with log length — compaction (future plugin) is the intended mitigation, not log mutation.

View File

@@ -4,34 +4,16 @@ Status: accepted (2026-06-11)
## Context
The harness needs one internal language for messages that the loop, session
log, and all plugins speak. Options: mirror the DeepSeek/OpenAI
chat-completions shape (zero mapping for the first provider, awkward for rich
content), adopt Anthropic's Messages block structure verbatim (battle-tested,
but our canonical types would mirror a third-party API we don't target
first), or own a vocabulary.
The harness needs one internal language for messages that the loop, session log, and all plugins speak. Options: mirror the DeepSeek/OpenAI chat-completions shape (zero mapping for the first provider, awkward for rich content), adopt Anthropic's Messages block structure verbatim (battle-tested, but our canonical types would mirror a third-party API we don't target first), or own a vocabulary.
## Decision
Own it: messages are arrays of typed content blocks (`text`, `reasoning`,
`tool-call`, `tool-result`, `image`), with the union derived from the
merge-extensible `ContentBlockMap` so plugins add block types via declaration
merging. The same merge-extensible-map pattern types every "stringly" field
(`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming
is a raw chunk protocol; `BlockAssembler` is the single shared assembly
implementation. Adapters translate to provider wire formats — mapping cost
lives in adapters, where it belongs.
Own it: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`, `image`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs.
In-session context injection (`context/message`, `steering/message`) renders
as tagged user-role envelopes (the system-reminder pattern) rather than a new
role, so adapters carry zero burden. TODO(review): revisit once the DeepSeek
V4 adapter exists.
In-session context injection (`context/message`, `steering/message`) renders as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. TODO(review): revisit once the DeepSeek V4 adapter exists.
## Consequences
- Reasoning, prefill, cache hints, and multimodal content all have a home
without provider contortions.
- Every adapter pays a translation cost; the streaming protocol carries a
TODO(review) marker until the first real adapter validates it.
- IDs that cross package boundaries are branded (`CallId`, `SessionId`,
`AgentId`) — nominal typing at zero runtime cost.
- Reasoning, prefill, cache hints, and multimodal content all have a home without provider contortions.
- Every adapter pays a translation cost; the streaming protocol carries a TODO(review) marker until the first real adapter validates it.
- IDs that cross package boundaries are branded (`CallId`, `SessionId`, `AgentId`) — nominal typing at zero runtime cost.

View File

@@ -4,32 +4,16 @@ Status: accepted (2026-06-11)
## Context
Tool parameters must reach the model as standard JSON Schema (the wire
format), and tool authors deserve typed `execute(args)` without casts. The
repo already vendors schemastery (used for plugin Config), so reusing it was
the obvious candidate. The user also explicitly preferred per-property
`required: true` booleans over JSON Schema's separate `required` array.
Tool parameters must reach the model as standard JSON Schema (the wire format), and tool authors deserve typed `execute(args)` without casts. The repo already vendors schemastery (used for plugin Config), so reusing it was the obvious candidate. The user also explicitly preferred per-property `required: true` booleans over JSON Schema's separate `required` array.
## Decision
A small custom DSL in dsh-tools: `SchemaSpec` (per-property specs with
`required: true` booleans), type-level `InferArgs<S>` mapping a spec to the
argument type (required keys non-optional, others genuinely optional via `?`),
a runtime `schemaSpecToJsonSchema()` converter, and `defineTool()` tying them
together. Raw JSON-Schema `ToolDefinition`s remain accepted by
`ToolRegistry.register()` — that's how MCP-sourced tools arrive.
A small custom DSL in dsh-tools: `SchemaSpec` (per-property specs with `required: true` booleans), type-level `InferArgs<S>` mapping a spec to the argument type (required keys non-optional, others genuinely optional via `?`), a runtime `schemaSpecToJsonSchema()` converter, and `defineTool()` tying them together. Raw JSON-Schema `ToolDefinition`s remain accepted by `ToolRegistry.register()` — that's how MCP-sourced tools arrive.
Schemastery was evaluated and rejected for this use: it targets validation /
transformation against StandardSchema, not JSON Schema *generation*, so it
would add indirection without producing the wire format cleanly.
Schemastery was evaluated and rejected for this use: it targets validation / transformation against StandardSchema, not JSON Schema *generation*, so it would add indirection without producing the wire format cleanly.
## Consequences
- First-party tool authors get zero-cast typed args; the type gymnastics cost
stays inside the core package (sanctioned by the AGENTS.md type-safety
policy).
- The DSL is deliberately small (string/number/boolean/object/array, enum,
default, nested properties/items). Gaps vs full JSON Schema (unions,
formats, constraints) are accepted until real tools demand them.
- The InferArgs mapping is regression-tested at the type level (expectTypeOf)
after an early optionality bug shipped and was caught by review.
- First-party tool authors get zero-cast typed args; the type gymnastics cost stays inside the core package (sanctioned by the AGENTS.md type-safety policy).
- The DSL is deliberately small (string/number/boolean/object/array, enum, default, nested properties/items). Gaps vs full JSON Schema (unions, formats, constraints) are accepted until real tools demand them.
- The InferArgs mapping is regression-tested at the type level (expectTypeOf) after an early optionality bug shipped and was caught by review.

View File

@@ -4,28 +4,14 @@ Status: accepted (2026-06-11)
## Context
On the wire, tool schemas travel in a dedicated `tools` field of the model
request, not in prompt text. Architecturally, though, "what the model is told
it can do" is one coherent concern: prompt sections and the tool list are
assembled from the same plugin contributions and consumed at the same moment.
The alternative — the loop querying the tool registry separately from the
prompt service — splits one concern across two seams.
On the wire, tool schemas travel in a dedicated `tools` field of the model request, not in prompt text. Architecturally, though, "what the model is told it can do" is one coherent concern: prompt sections and the tool list are assembled from the same plugin contributions and consumed at the same moment. The alternative — the loop querying the tool registry separately from the prompt service — splits one concern across two seams.
## Decision
`PromptAssembly { sections, tools }`: the system-prompt service collects
ordered text sections AND tool schemas (the tool registry auto-contributes a
provider). The loop consumes one assembly per step; adapters map `sections`
to the provider's system slot and `tools` to the wire `tools` field. The
`system-prompt/assemble` waterfall is therefore a single interception point
for everything the model is told up front — tool filtering (ToolSearch /
progressive disclosure) is an assembly rewrite, same as prompt edits.
`PromptAssembly { sections, tools }`: the system-prompt service collects ordered text sections AND tool schemas (the tool registry auto-contributes a provider). The loop consumes one assembly per step; adapters map `sections` to the provider's system slot and `tools` to the wire `tools` field. The `system-prompt/assemble` waterfall is therefore a single interception point for everything the model is told up front — tool filtering (ToolSearch / progressive disclosure) is an assembly rewrite, same as prompt edits.
## Consequences
- One waterfall governs the model's standing context; plugins like plan mode
can swap prompt text and visible tools in one listener.
- The assembly interface is merge-extensible for future slots (no untyped
`extras` bag — extension is declaration merging).
- Slight conceptual surprise (schemas in a "prompt" service) is documented
here and in the package README.
- One waterfall governs the model's standing context; plugins like plan mode can swap prompt text and visible tools in one listener.
- The assembly interface is merge-extensible for future slots (no untyped `extras` bag — extension is declaration merging).
- Slight conceptual surprise (schemas in a "prompt" service) is documented here and in the package README.

View File

@@ -4,33 +4,20 @@ Status: accepted (2026-06-11)
## Context
This codebase is developed primarily by coding agents. Agents follow enforced
gates far more reliably than prose conventions, and "a lot of work" is not a
cost argument when agents do the labor. Early evidence: tests that didn't
typecheck shipped (vitest doesn't typecheck) and were only caught by a review.
This codebase is developed primarily by coding agents. Agents follow enforced gates far more reliably than prose conventions, and "a lot of work" is not a cost argument when agents do the labor. Early evidence: tests that didn't typecheck shipped (vitest doesn't typecheck) and were only caught by a review.
## Decision
Every AGENTS.md promise gets a command that exits non-zero, wired into git
hooks and CI both calling the same package.json scripts:
Every AGENTS.md promise gets a command that exits non-zero, wired into git hooks and CI both calling the same package.json scripts:
- Max-strict TypeScript (`noUncheckedIndexedAccess`,
`exactOptionalPropertyTypes`, …); tests and examples typecheck in CI via
`tsconfig.typecheck.json` (vendored packages resolve as built declarations).
- ESLint strict-type-checked + @stylistic (the house style, enforced);
vendored code excluded.
- Per-file 100% coverage on `packages/*/src` (v8); unreachable defensive
guards carry `/* v8 ignore */ ` with stated reasons instead of deletion.
- knip (dead code/deps), publint (package correctness), yarn constraints
(workspace rules: private, cordis peer+dev, uniform version, ESM).
- lefthook pre-commit (lint staged, typecheck, vendor-manifest guard) and
pre-push (tests, hygiene); CI runs the full matrix on node 24/26 plus a
demo smoke test driving the echo-agent end to end.
- Max-strict TypeScript (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, …); tests and examples typecheck in CI via `tsconfig.typecheck.json` (vendored packages resolve as built declarations).
- ESLint strict-type-checked + @stylistic (the house style, enforced); vendored code excluded.
- Per-file 100% coverage on `packages/*/src` (v8); unreachable defensive guards carry `/* v8 ignore */ ` with stated reasons instead of deletion.
- knip (dead code/deps), publint (package correctness), yarn constraints (workspace rules: private, cordis peer+dev, uniform version, ESM).
- lefthook pre-commit (lint staged, typecheck, vendor-manifest guard) and pre-push (tests, hygiene); CI runs the full matrix on node 24/26 plus a demo smoke test driving the echo-agent end to end.
## Consequences
- Conventions survive agent turnover; violations fail fast and locally.
- The gates themselves are code to maintain; config changes are reviewed like
any change.
- 100%-coverage pressure can produce assertion-free tests — mutation testing
is the planned counterweight (see RFC 002).
- The gates themselves are code to maintain; config changes are reviewed like any change.
- 100%-coverage pressure can produce assertion-free tests — mutation testing is the planned counterweight (see RFC 002).

View File

@@ -4,49 +4,21 @@ Status: accepted (2026-06-11)
## Context
The initial build used **dumble**, the cordiverse zero-config esbuild wrapper
that upstream Cordis itself builds with — maximum alignment with the vendored
packages' conventions (it reads each package.json and infers entries/formats
from the `exports` field). But dumble is a liability as a load-bearing tool in
this repo: v0.2.x, ~530 npm downloads/week, effectively one maintainer, and we
were invoking it through a custom orchestration script (`scripts/build.ts`)
because it has no workspace mode.
The initial build used **dumble**, the cordiverse zero-config esbuild wrapper that upstream Cordis itself builds with — maximum alignment with the vendored packages' conventions (it reads each package.json and infers entries/formats from the `exports` field). But dumble is a liability as a load-bearing tool in this repo: v0.2.x, ~530 npm downloads/week, effectively one maintainer, and we were invoking it through a custom orchestration script (`scripts/build.ts`) because it has no workspace mode.
Build output currently matters only for `yarn build` + publint (nothing
publishes yet; dev/test/demo run unbuilt via tsx), so the switching cost is at
its lowest now and only grows once packages publish.
Build output currently matters only for `yarn build` + publint (nothing publishes yet; dev/test/demo run unbuilt via tsx), so the switching cost is at its lowest now and only grows once packages publish.
## Decision
Replace dumble with **tsdown** (rolldown-based, ~2.5M downloads/week,
VoidZero-backed, actively released):
Replace dumble with **tsdown** (rolldown-based, ~2.5M downloads/week, VoidZero-backed, actively released):
- Root `tsdown.config.ts` with `workspace: ['vendor/*', 'packages/*']`
(explicit globs, not `workspace: true`, which would also pick up
`examples/*` — they have package.json files but are not yarn workspaces).
- Shared shape: entry `src/index.ts`, `outDir: 'lib'`, ESM, `platform: node`,
`target: es2024`, `fixedExtension: false` (keeps `.js` for
`"type": "module"` packages), `dts: false` (tsc -b owns declarations),
`clean: false` (lib/ holds tsc's .d.ts output).
- Two per-package overrides in vendor/ (ours, like the regenerated tsconfigs;
logged in vendor/README.md): schemastery (dual `.mjs`/`.cjs` via
`outExtensions`), logger-console (two single-entry passes so the shared
base class is inlined into each entry instead of a hash-named chunk,
matching upstream's published shape).
- Root `tsdown.config.ts` with `workspace: ['vendor/*', 'packages/*']` (explicit globs, not `workspace: true`, which would also pick up `examples/*` — they have package.json files but are not yarn workspaces).
- Shared shape: entry `src/index.ts`, `outDir: 'lib'`, ESM, `platform: node`, `target: es2024`, `fixedExtension: false` (keeps `.js` for `"type": "module"` packages), `dts: false` (tsc -b owns declarations), `clean: false` (lib/ holds tsc's .d.ts output).
- Two per-package overrides in vendor/ (ours, like the regenerated tsconfigs; logged in vendor/README.md): schemastery (dual `.mjs`/`.cjs` via `outExtensions`), logger-console (two single-entry passes so the shared base class is inlined into each entry instead of a hash-named chunk, matching upstream's published shape).
- `scripts/build.ts` deleted; `yarn build` = `tsc -b && tsdown`.
Alternatives considered: **direct esbuild script** (most established engine,
zero wrapper risk, but hand-maintains the per-package spec table tsdown's
workspace mode gives us); **pkgroll** (closest drop-in philosophically, but
78k dl/wk and Rollup-based — strictly weaker maintenance story than tsdown);
**keep dumble** (perfect upstream alignment, unacceptable bus factor).
Alternatives considered: **direct esbuild script** (most established engine, zero wrapper risk, but hand-maintains the per-package spec table tsdown's workspace mode gives us); **pkgroll** (closest drop-in philosophically, but 78k dl/wk and Rollup-based — strictly weaker maintenance story than tsdown); **keep dumble** (perfect upstream alignment, unacceptable bus factor).
## Consequences
Output file lists are byte-for-byte-list identical to dumble's (verified by
snapshot diff at migration time); externals still come from each package's
dependencies/peerDependencies. We give up dumble's exports-field inference —
new packages with non-default shapes need a per-package `tsdown.config.ts`
instead of just package.json fields. Future option: tsdown could also absorb
declaration bundling (isolatedDeclarations) if `tsc -b` ever becomes the
bottleneck; that would be a new ADR.
Output file lists are byte-for-byte-list identical to dumble's (verified by snapshot diff at migration time); externals still come from each package's dependencies/peerDependencies. We give up dumble's exports-field inference — new packages with non-default shapes need a per-package `tsdown.config.ts` instead of just package.json fields. Future option: tsdown could also absorb declaration bundling (isolatedDeclarations) if `tsc -b` ever becomes the bottleneck; that would be a new ADR.

View File

@@ -1,12 +1,8 @@
# Architecture Decision Records
Short, immutable records of the *why* behind decisions that shape this
codebase. Code and docs say what the system does; ADRs say why it does it
that way and what we gave up.
Short, immutable records of the *why* behind decisions that shape this codebase. Code and docs say what the system does; ADRs say why it does it that way and what we gave up.
Format: one file per decision, numbered, with Status / Context / Decision /
Consequences. An ADR is never edited into a different decision — supersede it
with a new one and cross-link.
Format: one file per decision, numbered, with Status / Context / Decision / Consequences. An ADR is never edited into a different decision — supersede it with a new one and cross-link.
| # | Title | Status |
|---|---|---|

View File

@@ -1,16 +1,10 @@
# DeepSeek Harness Architecture
This document describes the phase-1 architecture of the DeepSeek Harness — the
foundation of **DeepSeek Code**. The governing principle, from the
[microkernel design discussion][microkernel-doc], is:
This document describes the phase-1 architecture of the DeepSeek Harness — the foundation of **DeepSeek Code**. The governing principle, from the [microkernel design discussion][microkernel-doc], is:
> **Microkernel approach. Everything is a plugin.**
The harness core is deliberately tiny: a handful of abstract services plus one
concrete plugin (the agent loop). Every product feature — tools, hooks,
compaction, sandboxing, UI, persistence, sub-agents, MCP, skills — is meant to
be written as a plugin against the extension surface described here, without
modifying the loop.
The harness core is deliberately tiny: a handful of abstract services plus one concrete plugin (the agent loop). Every product feature — tools, hooks, compaction, sandboxing, UI, persistence, sub-agents, MCP, skills — is meant to be written as a plugin against the extension surface described here, without modifying the loop.
Requirement context: [Coding Harness MVP 需求分析][mvp-doc].
@@ -39,9 +33,7 @@ Requirement context: [Coding Harness MVP 需求分析][mvp-doc].
└─────────────────────────────────────────────────────────────┘
```
Dependency rule: plugins depend on interface packages, never on
`dsh-agent-loop`. The loop itself is swappable — UI/hook/tool plugins keep
working against the `dsh-agent` vocabulary if the loop is replaced.
Dependency rule: plugins depend on interface packages, never on `dsh-agent-loop`. The loop itself is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced.
## Service map
@@ -55,34 +47,17 @@ working against the `dsh-agent` vocabulary if the loop is replaced.
| `ctx.agentLoop` | `AgentLoop` | dsh-agent-loop | creates `LoopAgent`s and drives their loops |
| `ctx.bash` | `BashExecutor` (abstract) | dsh-bash | bash execution seam: foreground runs + background tasks |
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.
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.
## Capability seams: interface / implementation / consumer
Swappable capabilities are split into **three packages** so each part evolves
independently. The bash capability is the template:
Swappable capabilities are split into **three packages** so each part evolves independently. The bash capability is the template:
1. **Interface** (`dsh-bash`) — an abstract service plus the vocabulary types
(`BashExecutor`, `BashRunResult`, `BashTask`, …). Defines the contract,
owns the `ctx.bash` key, depends only on cordis.
2. **Implementation** (`dsh-bash-local`) — a concrete subclass loaded as a
plugin (local subprocesses, process-group kills, spill-file truncation).
Sandboxed, containerized, or remote backends are sibling packages
implementing the same interface.
3. **Consumer** (`dsh-tool-bash`) — what the model and other plugins program
against (the `bash`/`bash_output`/`bash_kill` tool schemas). Consumers
`inject` the interface's ctx key and never import implementation types.
1. **Interface** (`dsh-bash`) — an abstract service plus the vocabulary types (`BashExecutor`, `BashRunResult`, `BashTask`, …). Defines the contract, owns the `ctx.bash` key, depends only on cordis.
2. **Implementation** (`dsh-bash-local`) — a concrete subclass loaded as a plugin (local subprocesses, process-group kills, spill-file truncation). Sandboxed, containerized, or remote backends are sibling packages implementing the same interface.
3. **Consumer** (`dsh-tool-bash`) — what the model and other plugins program against (the `bash`/`bash_output`/`bash_kill` tool schemas). Consumers `inject` the interface's ctx key and never import implementation types.
The LLM seam has the same topology folded differently: `dsh-llm` carries the
interface (`LlmAdapter`) AND the consumer surface (`ctx.llm.stream()`), with
adapters as implementation packages — there the consumer is the loop itself,
not a swappable schema surface. Use the full three-package split when the
consumer is independently replaceable; keep interface + consumer together
when they are one concern. Don't split preemptively: a capability with one
conceivable implementation and one consumer stays one package until proven
otherwise.
The LLM seam has the same topology folded differently: `dsh-llm` carries the interface (`LlmAdapter`) AND the consumer surface (`ctx.llm.stream()`), with adapters as implementation packages — there the consumer is the loop itself, not a swappable schema surface. Use the full three-package split when the consumer is independently replaceable; keep interface + consumer together when they are one concern. Don't split preemptively: a capability with one conceivable implementation and one consumer stays one package until proven otherwise.
> **"Capability" — two unrelated meanings.** (1) The *seam pattern* above
> ("one plugin provides a capability, another needs it") is realized by
@@ -98,103 +73,55 @@ otherwise.
## The vocabulary (dsh-llm)
Messages are arrays of typed **content blocks** (`text`, `reasoning`,
`tool-call`, `tool-result`, `image`); the union is derived from the
merge-extensible `ContentBlockMap`, so plugins can add block types via
declaration merging. The same merge-extensible-map pattern is used for
`MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason` — typed
sum types instead of strings.
Messages are arrays of typed **content blocks** (`text`, `reasoning`, `tool-call`, `tool-result`, `image`); the union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. The same merge-extensible-map pattern is used for `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason` — typed sum types instead of strings.
Streaming is a raw chunk protocol (`block-start`, `text-delta`,
`reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`).
`BlockAssembler` is the single shared implementation that assembles chunks
into blocks/messages; the loop logs raw chunks (replay fidelity) while feeding
the same chunks through an assembler.
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages; the loop logs raw chunks (replay fidelity) while feeding the same chunks through an assembler.
`LlmAdapter` is the provider seam: subclass, implement `stream()`, call
`ctx.llm.registerAdapter(models, adapter)`. Two real adapters implement it —
`dsh-llm-deepseek` (hand-rolled fetch/SSE against the DeepSeek API) and
`dsh-llm-pi-ai` (the same endpoint through the `@earendil-works/pi-ai`
library). They exist as a pair deliberately: two independent internals over
one contract verified the StreamChunk protocol, which is now documented (in
`dsh-llm/src/types.ts`) with the conventions that review pinned down — usage
before finish, nothing after finish, raw-string tool arguments, and the two
sanctioned error paths (thrown vs `finish {kind:'error'}`).
`LlmAdapter` is the provider seam: subclass, implement `stream()`, call `ctx.llm.registerAdapter(models, adapter)`. Two real adapters implement it — `dsh-llm-deepseek` (hand-rolled fetch/SSE against the DeepSeek API) and `dsh-llm-pi-ai` (the same endpoint through the `@earendil-works/pi-ai` library). They exist as a pair deliberately: two independent internals over one contract verified the StreamChunk protocol, which is now documented (in `dsh-llm/src/types.ts`) with the conventions that review pinned down — usage before finish, nothing after finish, raw-string tool arguments, and the two sanctioned error paths (thrown vs `finish {kind:'error'}`).
## Event-sourced sessions (dsh-session)
A `Session` is an append-only log of typed `SessionEvent`s — the single source
of truth. The LLM message history is *derived* from the log
(`deriveMessages()`):
A `Session` is an append-only log of typed `SessionEvent`s — the single source of truth. The LLM message history is *derived* from the log (`deriveMessages()`):
- `user/message` → user message
- `assistant/message` → assistant message (raw `assistant/chunk` events are
replay/UI data and are skipped in derivation)
- `assistant/message` → assistant message (raw `assistant/chunk` events are replay/UI data and are skipped in derivation)
- `tool/result` → user message carrying a `tool-result` block
- `context/message`, `steering/message` → user-role messages wrapped in a
tagged envelope (`<context source="…">…</context>`) at their chronological
position — the "system-reminder" pattern; models distinguish them from real
user prompts by the envelope. **TODO(review)**: revisit the envelope once a
real adapter exists.
- `context/message`, `steering/message` → user-role messages wrapped in a tagged envelope (`<context source="…">…</context>`) at their chronological position — the "system-reminder" pattern; models distinguish them from real user prompts by the envelope. **TODO(review)**: revisit the envelope once a real adapter exists.
Replay/fork = `ctx.sessions.create(id, seedEvents)`. Trace/telemetry = listen
to `session/event`.
Replay/fork = `ctx.sessions.create(id, seedEvents)`. Trace/telemetry = listen to `session/event`.
**Durability seam**: `session/event` is a synchronous notification;
persistence plugins buffer (write-behind) and drain at the awaited
`session/flush` checkpoint the loop fires at every turn end (see
`examples/echo-agent/src/session-jsonl.ts` for the pattern).
**TODO**: real persistence backends (JSONL per session dir, sqlite) are a
future phase.
**Durability seam**: `session/event` is a synchronous notification; persistence plugins buffer (write-behind) and drain at the awaited `session/flush` checkpoint the loop fires at every turn end (see `examples/echo-agent/src/session-jsonl.ts` for the pattern). **TODO**: real persistence backends (JSONL per session dir, sqlite) are a future phase.
## Prompt assembly (dsh-system-prompt)
Plugins contribute `PromptSection`s (named, ordered, static or computed) and
tool-schema providers. `assemble()` returns a `PromptAssembly { sections,
tools }` through the `system-prompt/assemble` waterfall.
Plugins contribute `PromptSection`s (named, ordered, static or computed) and tool-schema providers. `assemble()` returns a `PromptAssembly { sections, tools }` through the `system-prompt/assemble` waterfall.
Tool schemas are deliberately **part of the assembly**: "what the model is
told it can do" is one coherent thing managed here, even though adapters
transmit schemas as the wire-level `tools` field rather than prompt text.
Tool schemas are deliberately **part of the assembly**: "what the model is told it can do" is one coherent thing managed here, even though adapters transmit schemas as the wire-level `tools` field rather than prompt text.
## Tool pipeline (dsh-tools)
`ToolRegistry.register()` takes schema + `execute()`. The registry feeds its
schemas into the system-prompt assembly automatically.
`ToolRegistry.register()` takes schema + `execute()`. The registry feeds its schemas into the system-prompt assembly automatically.
`execute()` runs through the **`tools/execute` waterfall** — the single seam
where sandbox, permission, hooks, and plan-mode plugins wrap or veto a call.
This collapses Claude Code's validate → PreToolUse → permission → execute →
PostToolUse pipeline into ordered waterfall listeners.
`execute()` runs through the **`tools/execute` waterfall** — the single seam where sandbox, permission, hooks, and plan-mode plugins wrap or veto a call. This collapses Claude Code's validate → PreToolUse → permission → execute → PostToolUse pipeline into ordered waterfall listeners.
**TODO**: tool shapes get revisited when real tools land (e.g. a
concurrency-safety hint for parallel execution; phase 1 executes tool calls
sequentially).
**TODO**: tool shapes get revisited when real tools land (e.g. a concurrency-safety hint for parallel execution; phase 1 executes tool calls sequentially).
## Agents (dsh-agent) and the loop (dsh-agent-loop)
`Agent` is the handle every plugin programs against:
- `send(content)` — queued message; starts a turn when idle, else next turn
- `steer(content)` — mid-turn injection, drained **between steps**; behaves
like `send` when idle
- `inject(content)` — in-session context (`context/message` event) without
triggering a turn; the next request sees it (Claude Code attachment /
system-reminder analog)
- `steer(content)` — mid-turn injection, drained **between steps**; behaves like `send` when idle
- `inject(content)` — in-session context (`context/message` event) without triggering a turn; the next request sees it (Claude Code attachment / system-reminder analog)
- `abort(reason)` — aborts the in-flight step via `AbortSignal`
- `session`, `status`, `options`
**TODO(sub-agents)**: `spawn`/`fork` land on `AgentLoop.create()` — fork seeds
the child Session with the parent's event log, spawn starts fresh; children
are ordinary `Agent` handles so `steer()` and event subscription work
uniformly. Inter-agent channels beyond these primitives are deliberately
deferred.
**TODO(sub-agents)**: `spawn`/`fork` land on `AgentLoop.create()` — fork seeds the child Session with the parent's event log, spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Inter-agent channels beyond these primitives are deliberately deferred.
### Loop lifecycle (session / turn / step)
- **Session**: the whole event log of one agent.
- **Turn**: triggered by ≥1 queued message; runs steps until the model stops
requesting tools and no plugin requests continuation.
- **Turn**: triggered by ≥1 queued message; runs steps until the model stops requesting tools and no plugin requests continuation.
- **Step**: one model request + its tool executions.
```
@@ -231,14 +158,7 @@ forever:
emit agent/status(idle) unless more queued
```
Error containment: a throwing `agent/turn-continuation` listener or a
rejecting `session/flush` ends the **turn** with an `error` event — never the
driver loop. An adapter that ends its stream with a `finish {kind:'error'}`
or `{kind:'aborted'}` chunk (the in-band error path, for adapters that can't
throw mid-stream) is likewise translated into a step error, so the turn ends
`error`/`aborted` instead of logging a normal `completed` assistant message.
`abort()` is honored mid-stream **and** between tool calls; disposal mid-turn
ends the turn with reason `disposed` and emits `agent/status('disposed')`.
Error containment: a throwing `agent/turn-continuation` listener or a rejecting `session/flush` ends the **turn** with an `error` event — never the driver loop. An adapter that ends its stream with a `finish {kind:'error'}` or `{kind:'aborted'}` chunk (the in-band error path, for adapters that can't throw mid-stream) is likewise translated into a step error, so the turn ends `error`/`aborted` instead of logging a normal `completed` assistant message. `abort()` is honored mid-stream **and** between tool calls; disposal mid-turn ends the turn with reason `disposed` and emits `agent/status('disposed')`.
### Event taxonomy
@@ -262,24 +182,17 @@ Declared in `@deepseek-ai/dsh-agent` (so nothing depends on the loop package).
### Cordis waterfall semantics (important)
`ctx.waterfall` is **around-middleware**, not a value reducer. Each listener
receives `(...args, next)`:
`ctx.waterfall` is **around-middleware**, not a value reducer. Each listener receives `(...args, next)`:
- call `next()` to delegate to later listeners (and ultimately the core
behavior), possibly wrapping it;
- call `next()` to delegate to later listeners (and ultimately the core behavior), possibly wrapping it;
- return a value **without** calling `next()` to short-circuit (veto);
- listeners run in registration order; `prepend: true` jumps the queue.
Composition caveat: values propagate through `next()`'s **return value**.
Mutating the passed-in object works when later listeners receive the same
reference, but a listener that returns a *new* object makes earlier mutations
invisible downstream. Prefer mutate-then-`next()` for cooperative middleware;
return a replacement only when you mean to take over the result.
Composition caveat: values propagate through `next()`'s **return value**. Mutating the passed-in object works when later listeners receive the same reference, but a listener that returns a *new* object makes earlier mutations invisible downstream. Prefer mutate-then-`next()` for cooperative middleware; return a replacement only when you mean to take over the result.
## Plugin sanity checklist
Every MVP feature (including the TODO-marked ones), with the mechanism that
implements it **without modifying the loop**:
Every MVP feature (including the TODO-marked ones), with the mechanism that implements it **without modifying the loop**:
| MVP feature | Plugin mechanism |
|---|---|
@@ -334,9 +247,7 @@ export function apply(ctx: Context) {
}
```
(Raw JSON-Schema `ToolDefinition`s are still accepted by
`ctx.tools.register()` directly — that's how MCP-sourced tools arrive.
`defineTool` is the typed sugar for first-party tools.)
(Raw JSON-Schema `ToolDefinition`s are still accepted by `ctx.tools.register()` directly — that's how MCP-sourced tools arrive. `defineTool` is the typed sugar for first-party tools.)
### A hook plugin (permission gate)
@@ -371,31 +282,18 @@ export function apply(ctx: Context) {
}
```
Two complete runnable wirings exist: [`examples/echo-agent`](../examples/echo-agent)
(mock model + echo tool — the all-mock skeleton check) and
[`examples/coding-agent`](../examples/coding-agent) (DeepSeek V4 + the bash
tool suite — the real thing; `yarn demo:coding`). Both load from `cordis.yml`
with HMR.
Two complete runnable wirings exist: [`examples/echo-agent`](../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check) and [`examples/coding-agent`](../examples/coding-agent) (DeepSeek V4 + the bash tool suite — the real thing; `yarn demo:coding`). Both load from `cordis.yml` with HMR.
Step-by-step guides live in [`docs/cookbook`](./cookbook): adding a package,
adding a tool, adding an LLM adapter.
Step-by-step guides live in [`docs/cookbook`](./cookbook): adding a package, adding a tool, adding an LLM adapter.
## Deferred work (TODO)
Tracked here deliberately — each is designed-for but not implemented:
- **Restructure this document** — it has grown long; split it into focused
sections (or per-area files) so readers can navigate it without scrolling
the whole thing.
- **Sub-agent spawn/fork semantics** (seam: `AgentLoop.create()`); inter-agent
channels beyond `send`/`steer`/events.
- **Persistence backends** (JSONL session dirs, sqlite) on the
`session/event` + `session/flush` seam.
- **Compaction implementation** (auto thresholds, summarization prompts) on
the `agent/request` seam, with its session-event types added by declaration
merging.
- **Restructure this document** — it has grown long; split it into focused sections (or per-area files) so readers can navigate it without scrolling the whole thing.
- **Sub-agent spawn/fork semantics** (seam: `AgentLoop.create()`); inter-agent channels beyond `send`/`steer`/events.
- **Persistence backends** (JSONL session dirs, sqlite) on the `session/event` + `session/flush` seam.
- **Compaction implementation** (auto thresholds, summarization prompts) on the `agent/request` seam, with its session-event types added by declaration merging.
- **Parallel tool execution** (concurrency-safety hints on ToolDefinition).
- **Session branching/tree** (pi-style entry tree) if needed beyond seed-based
forking.
- **Session event vocabulary review** once the loop and a persistence plugin
coexist (`TODO(review)` in dsh-session).
- **Session branching/tree** (pi-style entry tree) if needed beyond seed-based forking.
- **Session event vocabulary review** once the loop and a persistence plugin coexist (`TODO(review)` in dsh-session).

View File

@@ -1,7 +1,6 @@
# Cookbook: adding a workspace package
The file-by-file checklist for a new `@deepseek-ai/dsh-<name>` package.
(Verified by the bash and adapter packages; if it drifts, fix it here.)
The file-by-file checklist for a new `@deepseek-ai/dsh-<name>` package. (Verified by the bash and adapter packages; if it drifts, fix it here.)
## 1. Create the package
@@ -16,11 +15,7 @@ packages/<name>/
README.md # service API, events, extension points, design notes
```
package.json invariants (enforced by `yarn constraints` / yarn.config.cjs):
`private: true`, `version: 0.0.1`, `type: module`, `cordis` in BOTH
peerDependencies and devDependencies (same range). Mirror every dsh peer
dependency in devDependencies. `schemastery` goes in `dependencies` (it is a
runtime validator), matching agent-loop.
package.json invariants (enforced by `yarn constraints` / yarn.config.cjs): `private: true`, `version: 0.0.1`, `type: module`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop.
## 2. Register it in the root configs
@@ -32,14 +27,11 @@ runtime validator), matching agent-loop.
| `scripts/publint-all.ts` | add `'packages/<name>'` to the array |
| `knip.json` | only if the package has non-`*.spec.ts` entries (e.g. `*.e2e.ts` → add a per-workspace override like `packages/llm-deepseek`) |
Covered automatically by globs — no edits needed: root `package.json`
workspaces, `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`.
Covered automatically by globs — no edits needed: root `package.json` workspaces, `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`.
## 3. Decide the package topology
For a swappable capability, split interface / implementation / consumer into
separate packages (see docs/architecture.md § "Capability seams" — the bash
trio is the template). A single-purpose plugin stays one package.
For a swappable capability, split interface / implementation / consumer into separate packages (see docs/architecture.md § "Capability seams" — the bash trio is the template). A single-purpose plugin stays one package.
## 4. Verify
@@ -50,6 +42,4 @@ yarn test:coverage # 100% per-file over src (types.ts exempt)
yarn build && yarn knip && yarn publint
```
Test expectations: every registry/registration needs an HMR-safety test
(register from a child fiber, dispose it, assert cleanup). Excessive tests
are welcome — see AGENTS.md.
Test expectations: every registry/registration needs an HMR-safety test (register from a child fiber, dispose it, assert cleanup). Excessive tests are welcome — see AGENTS.md.

View File

@@ -1,8 +1,6 @@
# Cookbook: adding a tool
How to give the model a new capability. Reference implementations:
`examples/echo-agent/src/echo-tool.ts` (minimal) and
`packages/tool-bash` (production-grade, three-package seam).
How to give the model a new capability. Reference implementations: `examples/echo-agent/src/echo-tool.ts` (minimal) and `packages/tool-bash` (production-grade, three-package seam).
## The minimal shape
@@ -30,33 +28,18 @@ export function apply(ctx: Context) {
}
```
Registration is effect-based: disposing the plugin fiber unregisters the
tool (write the HMR test). Schemas flow into the system-prompt assembly
automatically.
Registration is effect-based: disposing the plugin fiber unregisters the tool (write the HMR test). Schemas flow into the system-prompt assembly automatically.
## Rules of the execute() contract
- **Validate args at runtime.** `defineTool`'s `InferArgs` typing is
compile-time only; at runtime `arguments` is whatever JSON the model
emitted. Check every field; throw a descriptive Error for bad input.
- **Throwing means isError.** The registry catches anything `execute()`
throws and returns `{isError: true}` to the model. Use that for
infrastructure failures (bad input, spawn errors, aborts) — but REPORT
domain failures in the result text instead (e.g. tool-bash returns
`[exit code: 9]` with `isError: false`: the model decides what a failing
command means).
- **Validate args at runtime.** `defineTool`'s `InferArgs` typing is compile-time only; at runtime `arguments` is whatever JSON the model emitted. Check every field; throw a descriptive Error for bad input.
- **Throwing means isError.** The registry catches anything `execute()` throws and returns `{isError: true}` to the model. Use that for infrastructure failures (bad input, spawn errors, aborts) — but REPORT domain failures in the result text instead (e.g. tool-bash returns `[exit code: 9]` with `isError: false`: the model decides what a failing command means).
- **Honor `exec.signal`.** Cancel in-flight work when it fires.
- **Use `exec.agent` for async notifications.** `agent.inject(content,
{source: {kind: 'plugin', plugin: '<name>'}})` appends durable context the
NEXT model request sees — it is not a wake-up (an idle agent stays idle).
Guard against disposed agents (try/catch).
- **Use `exec.agent` for async notifications.** `agent.inject(content, {source: {kind: 'plugin', plugin: '<name>'}})` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch).
## Long-running work
Follow tool-bash's background pattern: a `run_in_background` flag returns a
task id immediately; companion tools poll incrementally and kill; completion
notices arrive via `agent.inject()`. Bound buffers and spill full output to
disk so nothing is silently lost.
Follow tool-bash's background pattern: a `run_in_background` flag returns a task id immediately; companion tools poll incrementally and kill; completion notices arrive via `agent.inject()`. Bound buffers and spill full output to disk so nothing is silently lost.
> TODO: each tool reimplements this background pattern by hand today. At some
> point we need a generic long-running-tool layer that handles task ids,
@@ -64,14 +47,8 @@ disk so nothing is silently lost.
## Permissions / sandboxing
Prefer not to build policy into the tool. The seam is the `tools/execute` waterfall
(veto or wrap — see the permission-gate example in docs/architecture.md), or
a sandboxing implementation behind the tool's executor seam.
Prefer not to build policy into the tool. The seam is the `tools/execute` waterfall (veto or wrap — see the permission-gate example in docs/architecture.md), or a sandboxing implementation behind the tool's executor seam.
## Tests every tool needs
Arg-validation rejections, result shaping for every outcome, the HMR
disposal test, and — for tools with side effects — an integration spec that
drives the tool through the agent loop with a scripted `MockAdapter`
(`packages/agent-loop/tests/mock-adapter.ts`), asserting the `tool/call` /
`tool/result` session events.
Arg-validation rejections, result shaping for every outcome, the HMR disposal test, and — for tools with side effects — an integration spec that drives the tool through the agent loop with a scripted `MockAdapter` (`packages/agent-loop/tests/mock-adapter.ts`), asserting the `tool/call` / `tool/result` session events.

View File

@@ -1,10 +1,6 @@
# Cookbook: adding an LLM adapter
How to connect a new model provider. Reference implementations:
`packages/llm-deepseek` (hand-rolled HTTP/SSE) and `packages/llm-pi-ai`
(wrapping an LLM library). Read the `StreamChunk` doc in
`packages/llm/src/types.ts` first — it records the protocol conventions both
adapters were verified against.
How to connect a new model provider. Reference implementations: `packages/llm-deepseek` (hand-rolled HTTP/SSE) and `packages/llm-pi-ai` (wrapping an LLM library). Read the `StreamChunk` doc in `packages/llm/src/types.ts` first — it records the protocol conventions both adapters were verified against.
## The shape
@@ -22,54 +18,26 @@ export function apply(ctx: Context, config: Config) {
}
```
Registration is effect-based (HMR-safe); one adapter per model name —
duplicates throw. Secrets are cordis-native: schemastery Config with env
fallbacks, fed from cordis.yml via `!!js process.env.MY_KEY`. Never read
ad-hoc key files in code.
Registration is effect-based (HMR-safe); one adapter per model name — duplicates throw. Secrets are cordis-native: schemastery Config with env fallbacks, fed from cordis.yml via `!!js process.env.MY_KEY`. Never read ad-hoc key files in code.
## Protocol obligations (the contract two implementations verified)
- Emit `usage` BEFORE `finish`; emit NOTHING after `finish`. The robust way:
buffer finish/usage until the provider's end-of-stream marker, then flush
(handles providers that send trailing usage-only chunks).
- Tool-call `arguments` are RAW JSON strings end-to-end; stream fragments as
`argumentsDelta`. If your provider hands back parsed objects, re-stringify
at `block-end`.
- Allocate block `index`es in first-seen stream order; reuse the index for
every delta of the same block.
- Errors have exactly two sanctioned paths: THROW from `stream()` (transport
and protocol failures — use `LlmError` with a stable code), or end the
stream with `finish {kind: 'error' | 'aborted'}` (provider in-band
failures). Consumers handle both; pick per failure class and document it.
- Emit `usage` BEFORE `finish`; emit NOTHING after `finish`. The robust way: buffer finish/usage until the provider's end-of-stream marker, then flush (handles providers that send trailing usage-only chunks).
- Tool-call `arguments` are RAW JSON strings end-to-end; stream fragments as `argumentsDelta`. If your provider hands back parsed objects, re-stringify at `block-end`.
- Allocate block `index`es in first-seen stream order; reuse the index for every delta of the same block.
- Errors have exactly two sanctioned paths: THROW from `stream()` (transport and protocol failures — use `LlmError` with a stable code), or end the stream with `finish {kind: 'error' | 'aborted'}` (provider in-band failures). Consumers handle both; pick per failure class and document it.
- Honor `options.signal` (pass it to fetch / your SDK).
- `prefill` and other unsupported `GenerateOptions` fields: throw
`LlmError(..., 'UNSUPPORTED')` rather than silently dropping.
- `prefill` and other unsupported `GenerateOptions` fields: throw `LlmError(..., 'UNSUPPORTED')` rather than silently dropping.
Provider-specific request knobs (thinking modes, effort levels) belong in
the ADAPTER's Config, not in `GenerateOptions` — the core vocabulary stays
provider-neutral.
Provider-specific request knobs (thinking modes, effort levels) belong in the ADAPTER's Config, not in `GenerateOptions` — the core vocabulary stays provider-neutral.
## Structure that worked
Split the adapter into testable stages (llm-deepseek's layout): wire types
(`types.ts`, coverage-exempt) → request serializer → SSE/transport parser →
chunk-translation state machine → a thin adapter class wiring them. Each
stage gets its own unit suite.
Split the adapter into testable stages (llm-deepseek's layout): wire types (`types.ts`, coverage-exempt) → request serializer → SSE/transport parser → chunk-translation state machine → a thin adapter class wiring them. Each stage gets its own unit suite.
## Testing
- **Unit: mock the provider, not the harness.** A scripted `node:http`
server speaking the provider's wire format covers happy paths, every error
status, malformed payloads, premature closes, and aborts — no network, and
it drives the 100% per-file coverage gate. Works for SDK-backed adapters
too (point the SDK's baseURL at the mock).
- **Hostile framing tests.** Split stream payloads at arbitrary byte
positions (including mid-UTF-8) — real networks do.
- **E2E: `tests/*.e2e.ts`** under `yarn test:e2e`, gated with
`describe.skipIf(!process.env.MY_KEY)` so CI (no secrets) stays green.
Cover each model × each provider mode you map (thinking on/off, effort
levels), a tool-call round trip INCLUDING the follow-up turn with results
in history, and loose assertions only (substring/structure, bounded
maxTokens — real models are nondeterministic).
- Register the e2e file pattern in `knip.json` (per-workspace `entry`
override) or knip flags it unused.
- **Unit: mock the provider, not the harness.** A scripted `node:http` server speaking the provider's wire format covers happy paths, every error status, malformed payloads, premature closes, and aborts — no network, and it drives the 100% per-file coverage gate. Works for SDK-backed adapters too (point the SDK's baseURL at the mock).
- **Hostile framing tests.** Split stream payloads at arbitrary byte positions (including mid-UTF-8) — real networks do.
- **E2E: `tests/*.e2e.ts`** under `yarn test:e2e`, gated with `describe.skipIf(!process.env.MY_KEY)` so CI (no secrets) stays green. Cover each model × each provider mode you map (thinking on/off, effort levels), a tool-call round trip INCLUDING the follow-up turn with results in history, and loose assertions only (substring/structure, bounded maxTokens — real models are nondeterministic).
- Register the e2e file pattern in `knip.json` (per-workspace `entry` override) or knip flags it unused.

View File

@@ -4,44 +4,21 @@ Status: proposed
## Problem
Example-based tests pin the cases we thought of. The harness's core is
protocol-shaped — chunk streams, event logs, schema conversion — where the
input space is combinatorial and the interesting bugs live in interleavings
nobody wrote an example for (the `streamBlocks` ordering bug survived 100%
line coverage of the happy paths).
Example-based tests pin the cases we thought of. The harness's core is protocol-shaped — chunk streams, event logs, schema conversion — where the input space is combinatorial and the interesting bugs live in interleavings nobody wrote an example for (the `streamBlocks` ordering bug survived 100% line coverage of the happy paths).
## Proposal
Adopt fast-check (vitest integration) with generators for our vocabulary:
- **BlockAssembler**: arbitrary chunk sequences (valid and malformed —
duplicate indices, stragglers after block-end, missing block-start).
Invariants: `flushReady() + flushRemaining() ≡ blocks()` in order;
`streamBlocks ≡ generate().message.content`; memory bounded (partials map
size ≤ distinct indices); idempotent re-assembly.
- **Session**: arbitrary event logs (seeded generators over SessionEventMap).
Invariants: `deriveMessages` deterministic; replay-from-seed produces
identical derivation; seq strictly monotonic; derived history unaffected by
non-message events.
- **Schema DSL**: arbitrary SchemaSpecs. Invariants: generated JSON Schema's
`required` array equals the `required: true` keys at every nesting level;
conversion is total (never throws); generated args satisfying `InferArgs`
validate against the generated schema (once RFC 005's validator exists —
the two RFCs compose).
- **Inbox/loop**: arbitrary send/steer/abort schedules against a scripted
adapter. Invariants: no message lost (every send/steer appears in the log
exactly once), turn numbers strictly increase, status transitions follow
idle→running→idle/disposed.
- **BlockAssembler**: arbitrary chunk sequences (valid and malformed — duplicate indices, stragglers after block-end, missing block-start). Invariants: `flushReady() + flushRemaining() ≡ blocks()` in order; `streamBlocks ≡ generate().message.content`; memory bounded (partials map size ≤ distinct indices); idempotent re-assembly.
- **Session**: arbitrary event logs (seeded generators over SessionEventMap). Invariants: `deriveMessages` deterministic; replay-from-seed produces identical derivation; seq strictly monotonic; derived history unaffected by non-message events.
- **Schema DSL**: arbitrary SchemaSpecs. Invariants: generated JSON Schema's `required` array equals the `required: true` keys at every nesting level; conversion is total (never throws); generated args satisfying `InferArgs` validate against the generated schema (once RFC 005's validator exists — the two RFCs compose).
- **Inbox/loop**: arbitrary send/steer/abort schedules against a scripted adapter. Invariants: no message lost (every send/steer appears in the log exactly once), turn numbers strictly increase, status transitions follow idle→running→idle/disposed.
## Plan
One `tests/properties.spec.ts` per package; fast-check as devDependency;
numRuns tuned so the suite stays under ~10s locally, with a nightly CI job
running 100× the iterations. Failures persist their seed in the report so
agents can reproduce deterministically.
One `tests/properties.spec.ts` per package; fast-check as devDependency; numRuns tuned so the suite stays under ~10s locally, with a nightly CI job running 100× the iterations. Failures persist their seed in the report so agents can reproduce deterministically.
## Risks
Generator quality determines value — invest in generators that produce
*realistic-but-adversarial* streams, not uniform noise. Property flake from
timeouts must be treated as a finding, not retried away.
Generator quality determines value — invest in generators that produce *realistic-but-adversarial* streams, not uniform noise. Property flake from timeouts must be treated as a finding, not retried away.

View File

@@ -4,35 +4,23 @@ Status: proposed
## Problem
The per-file 100% coverage gate (ADR 0007) proves every line *executes* under
test — not that any assertion would notice if the line were wrong. Under
agent-written tests, coverage pressure can produce execution-without-assertion.
Mutation testing measures what coverage cannot: whether the suite *kills*
deliberately injected bugs.
The per-file 100% coverage gate (ADR 0007) proves every line *executes* under test — not that any assertion would notice if the line were wrong. Under agent-written tests, coverage pressure can produce execution-without-assertion. Mutation testing measures what coverage cannot: whether the suite *kills* deliberately injected bugs.
## Proposal
Stryker (`@stryker-mutator/vitest-runner`) over `packages/*/src`:
- **PR-scoped incremental runs** (changed files only) as a CI job — fast
enough to gate merges once tuned.
- **Nightly full runs** with a tracked mutation score; start by recording,
then set the threshold at the observed baseline and ratchet upward (same
policy as coverage: thresholds only ever tighten).
- Surviving mutants are work items: an agent picks a survivor, writes the
killing test, repeats — a well-shaped autonomous loop.
- Equivalent mutants (provably behavior-preserving) get annotated exclusions
with reasons, mirroring the `/* v8 ignore */` policy.
- **PR-scoped incremental runs** (changed files only) as a CI job — fast enough to gate merges once tuned.
- **Nightly full runs** with a tracked mutation score; start by recording, then set the threshold at the observed baseline and ratchet upward (same policy as coverage: thresholds only ever tighten).
- Surviving mutants are work items: an agent picks a survivor, writes the killing test, repeats — a well-shaped autonomous loop.
- Equivalent mutants (provably behavior-preserving) get annotated exclusions with reasons, mirroring the `/* v8 ignore */` policy.
## Plan
1. Add Stryker config scoped to one package (llm — smallest, most algorithmic)
and measure runtime.
1. Add Stryker config scoped to one package (llm — smallest, most algorithmic) and measure runtime.
2. Expand to all packages; record baseline scores in the config.
3. Wire the nightly job; add the incremental PR job once runtime is acceptable.
## Risks
Runtime: mutation testing is expensive; per-file 100% coverage helps (every
mutant is at least reached). If PR-scoped runs stay too slow, keep them
nightly-only and rely on the score ratchet.
Runtime: mutation testing is expensive; per-file 100% coverage helps (every mutant is at least reached). If PR-scoped runs stay too slow, keep them nightly-only and rely on the score ratchet.

View File

@@ -4,37 +4,20 @@ Status: proposed
## Problem
Several loop tests synchronize with `setTimeout(30)` sleeps — flakiness debt
that wastes agent cycles on retries and can mask ordering bugs. Separately,
our core architectural promise (any session log replays to identical derived
history) is asserted in two tests but is cheap to assert *everywhere*. And
the inbox wakeup race was verified by hand exactly once; nothing re-verifies
it continuously.
Several loop tests synchronize with `setTimeout(30)` sleeps — flakiness debt that wastes agent cycles on retries and can mask ordering bugs. Separately, our core architectural promise (any session log replays to identical derived history) is asserted in two tests but is cheap to assert *everywhere*. And the inbox wakeup race was verified by hand exactly once; nothing re-verifies it continuously.
## Proposal
Three measures:
1. **No wall-clock sleeps in tests.** Replace `setTimeout(N)` waits with
event-driven waits (the existing `waitForIdle` pattern, extended to
`waitForStatus`, `waitForEvent(n)`) or vitest fake timers where time
itself is under test. Enforce with a lint rule banning `setTimeout` in
`packages/*/tests` outside an allowlisted helper module.
2. **Universal replay fixture.** A shared test helper wraps the loop harness
so that after every test, the agent's session log is replayed into a fresh
Session and `deriveMessages()` equality is asserted automatically. The
invariant then gets checked hundreds of times per CI run across every
scenario the suite produces, not twice.
3. **Nightly race stress.** A CI job running the agent-loop and inbox suites
with `vitest --repeat=200` (and `--shuffle`) to flush scheduling-dependent
failures; any flake found is a bug to fix, never a retry.
1. **No wall-clock sleeps in tests.** Replace `setTimeout(N)` waits with event-driven waits (the existing `waitForIdle` pattern, extended to `waitForStatus`, `waitForEvent(n)`) or vitest fake timers where time itself is under test. Enforce with a lint rule banning `setTimeout` in `packages/*/tests` outside an allowlisted helper module.
2. **Universal replay fixture.** A shared test helper wraps the loop harness so that after every test, the agent's session log is replayed into a fresh Session and `deriveMessages()` equality is asserted automatically. The invariant then gets checked hundreds of times per CI run across every scenario the suite produces, not twice.
3. **Nightly race stress.** A CI job running the agent-loop and inbox suites with `vitest --repeat=200` (and `--shuffle`) to flush scheduling-dependent failures; any flake found is a bug to fix, never a retry.
## Plan
Land 1 and 2 together (they touch the same helpers); add the nightly job
after the suite is sleep-free so repeats are fast.
Land 1 and 2 together (they touch the same helpers); add the nightly job after the suite is sleep-free so repeats are fast.
## Risks
Fake timers interact subtly with Promise scheduling in the loop — prefer
event-driven waits; reserve fake timers for timer-service behavior itself.
Fake timers interact subtly with Promise scheduling in the loop — prefer event-driven waits; reserve fake timers for timer-service behavior itself.

View File

@@ -4,40 +4,24 @@ Status: proposed
## Problem
Two architectural guarantees currently live only in prose: (1) nothing
depends on the concrete loop package (the microkernel promise, ADR 0002), and
(2) every LlmAdapter speaks the chunk protocol correctly. Both should be
mechanical (ADR 0007).
Two architectural guarantees currently live only in prose: (1) nothing depends on the concrete loop package (the microkernel promise, ADR 0002), and (2) every LlmAdapter speaks the chunk protocol correctly. Both should be mechanical (ADR 0007).
## Proposal
**dependency-cruiser** with rules:
- `packages/*` (except agent-loop's own tests and examples/) must not import
`@deepseek-ai/dsh-agent-loop`.
- No cross-package deep imports (`@deepseek-ai/dsh-*/src/...` paths) — public
entry points only.
- `packages/*` (except agent-loop's own tests and examples/) must not import `@deepseek-ai/dsh-agent-loop`.
- No cross-package deep imports (`@deepseek-ai/dsh-*/src/...` paths) — public entry points only.
- No import cycles anywhere in packages/.
- `vendor/*` must not import from `packages/*`.
- Layering: dsh-llm imports nothing from other dsh packages; dsh-session only
dsh-llm; etc. (the dependency table in packages/README.md, enforced).
- Layering: dsh-llm imports nothing from other dsh packages; dsh-session only dsh-llm; etc. (the dependency table in packages/README.md, enforced).
**Adapter conformance kit** in dsh-llm (`@deepseek-ai/dsh-llm/conformance`):
a reusable vitest suite parameterized by an adapter factory, asserting the
chunk-protocol contract — index monotonicity per block, no deltas after
`block-end` for an index, exactly one `finish`, usage at most once, every
`tool-call-delta` carries the call id, abort honored promptly. Run it against
the mocks now; the DeepSeek V4 adapter inherits it on day one. Optionally a
dev-mode `strictAdapter()` wrapper enforcing the same at runtime behind a
debug flag (pairs with RFC 005's invariants).
**Adapter conformance kit** in dsh-llm (`@deepseek-ai/dsh-llm/conformance`): a reusable vitest suite parameterized by an adapter factory, asserting the chunk-protocol contract — index monotonicity per block, no deltas after `block-end` for an index, exactly one `finish`, usage at most once, every `tool-call-delta` carries the call id, abort honored promptly. Run it against the mocks now; the DeepSeek V4 adapter inherits it on day one. Optionally a dev-mode `strictAdapter()` wrapper enforcing the same at runtime behind a debug flag (pairs with RFC 005's invariants).
## Plan
dependency-cruiser config + CI step first (an hour of work, permanent
guarantee); the conformance kit lands with its first consumer test against
MockAdapter, and is a prerequisite for the V4 adapter phase.
dependency-cruiser config + CI step first (an hour of work, permanent guarantee); the conformance kit lands with its first consumer test against MockAdapter, and is a prerequisite for the V4 adapter phase.
## Risks
Dep-cruiser rule maintenance as packages are added — keep rules pattern-based
(`dsh-*`) rather than enumerated.
Dep-cruiser rule maintenance as packages are added — keep rules pattern-based (`dsh-*`) rather than enumerated.

View File

@@ -6,44 +6,20 @@ Status: proposed
Three gaps where compile-time guarantees stop:
1. Tool args are model-generated JSON — `defineTool`'s `InferArgs<S>` claim
is only as true as the model's output. Today a malformed call reaches
`execute` untyped-in-practice.
2. Tool errors flatten to a text block; name/code/stack are lost, so future
sandbox/retry plugins can't distinguish ENOENT from EACCES, and the model
gets less actionable feedback than it could.
3. Loop ordering invariants (seq monotonicity, step/turn event nesting,
turn-number continuity) are asserted only where tests look.
1. Tool args are model-generated JSON — `defineTool`'s `InferArgs<S>` claim is only as true as the model's output. Today a malformed call reaches `execute` untyped-in-practice.
2. Tool errors flatten to a text block; name/code/stack are lost, so future sandbox/retry plugins can't distinguish ENOENT from EACCES, and the model gets less actionable feedback than it could.
3. Loop ordering invariants (seq monotonicity, step/turn event nesting, turn-number continuity) are asserted only where tests look.
## Proposal
1. **Schema validation in defineTool**: before `execute`, validate parsed
args against the SchemaSpec (the converter already encodes the structure —
a small interpreter walks it: presence of required keys, primitive type
checks, enum membership, recursion into objects/arrays). On mismatch,
return an `isError` ToolExecutionResult describing the violation — the
model can self-correct. Raw-registered tools (MCP) keep validating their
own input.
2. **Structured error taxonomy**: per-package error classes extending a
common `HarnessError` (name, `code`, `cause` chaining).
`ToolExecutionResult` gains optional `error: { name, code }` alongside the
model-facing text. The loop's `errorData` consumes it; session `error`
events carry the code. This also properly fixes the non-Error-throw
message degradation found in review.
3. **Dev-mode invariants**: a `dsh-invariants` debug plugin (everything is a
plugin — it's just listeners) asserting, when enabled: session seq strictly
increases; `step/start` precedes its chunks; `turn/start`/`turn/end` pair
and nest; tool/call has a matching tool/result; status transitions are
legal. Enabled in tests and the demo; off in production. Doubles as
executable documentation of the event contract.
1. **Schema validation in defineTool**: before `execute`, validate parsed args against the SchemaSpec (the converter already encodes the structure — a small interpreter walks it: presence of required keys, primitive type checks, enum membership, recursion into objects/arrays). On mismatch, return an `isError` ToolExecutionResult describing the violation — the model can self-correct. Raw-registered tools (MCP) keep validating their own input.
2. **Structured error taxonomy**: per-package error classes extending a common `HarnessError` (name, `code`, `cause` chaining). `ToolExecutionResult` gains optional `error: { name, code }` alongside the model-facing text. The loop's `errorData` consumes it; session `error` events carry the code. This also properly fixes the non-Error-throw message degradation found in review.
3. **Dev-mode invariants**: a `dsh-invariants` debug plugin (everything is a plugin — it's just listeners) asserting, when enabled: session seq strictly increases; `step/start` precedes its chunks; `turn/start`/`turn/end` pair and nest; tool/call has a matching tool/result; status transitions are legal. Enabled in tests and the demo; off in production. Doubles as executable documentation of the event contract.
## Plan
2 first (taxonomy is a dependency of 1's error shape), then 1, then 3.
Property tests (RFC 001) then close the loop: generated args ↔ validator ↔
InferArgs agreement.
2 first (taxonomy is a dependency of 1's error shape), then 1, then 3. Property tests (RFC 001) then close the loop: generated args ↔ validator ↔ InferArgs agreement.
## Risks
Validator/InferArgs drift — covered by the RFC 001 composition property.
Validation cost per call is negligible next to a model call.
Validator/InferArgs drift — covered by the RFC 001 composition property. Validation cost per call is negligible next to a model call.

View File

@@ -4,36 +4,18 @@ Status: proposed
## Problem
AGENTS.md policy says docs and code must stay strictly in sync, but sync is
verified by eyeball. Review has already caught drift twice (a cookbook
example contradicting the type policy; a README citing the wrong
registerAdapter call). Public API changes are similarly invisible — nothing
makes "this commit changed the public surface" an explicit, reviewable fact.
AGENTS.md policy says docs and code must stay strictly in sync, but sync is verified by eyeball. Review has already caught drift twice (a cookbook example contradicting the type policy; a README citing the wrong registerAdapter call). Public API changes are similarly invisible — nothing makes "this commit changed the public surface" an explicit, reviewable fact.
## Proposal
1. **Typecheck documentation code blocks.** A script extracts fenced ```ts
blocks from README.md / docs/architecture.md / packages/*/README.md into a
temp project resolving workspace packages, and runs tsc. Blocks that are
intentionally elided get an explicit `ts ignore-check` info string —
opt-out is visible in the source. (twoslash is the fancier alternative;
start with plain extraction.)
2. **Generate or verify the event-taxonomy table.** The table in
docs/architecture.md duplicates the `Events` declarations. Either generate
it from source (ts-morph walk over the `declare module 'cordis'` blocks)
or CI-assert that every declared event name appears in the table and vice
versa.
3. **API reports.** api-extractor (or `tsc --emitDeclarationOnly` + a
normalized public-surface dump) producing a checked-in `etc/<pkg>.api.md`
per package; CI fails if regeneration differs. Every public-API change
becomes a diff line a reviewer (or review agent) must see.
1. **Typecheck documentation code blocks.** A script extracts fenced ```ts blocks from README.md / docs/architecture.md / packages/*/README.md into a temp project resolving workspace packages, and runs tsc. Blocks that are intentionally elided get an explicit `ts ignore-check` info string — opt-out is visible in the source. (twoslash is the fancier alternative; start with plain extraction.)
2. **Generate or verify the event-taxonomy table.** The table in docs/architecture.md duplicates the `Events` declarations. Either generate it from source (ts-morph walk over the `declare module 'cordis'` blocks) or CI-assert that every declared event name appears in the table and vice versa.
3. **API reports.** api-extractor (or `tsc --emitDeclarationOnly` + a normalized public-surface dump) producing a checked-in `etc/<pkg>.api.md` per package; CI fails if regeneration differs. Every public-API change becomes a diff line a reviewer (or review agent) must see.
## Plan
1 is a standalone script + CI step. 3 next (it also documents the surface for
plugin authors). 2 last — verify-don't-generate is likely sufficient.
1 is a standalone script + CI step. 3 next (it also documents the surface for plugin authors). 2 last — verify-don't-generate is likely sufficient.
## Risks
Doc blocks often show fragments; the ignore-check escape hatch must stay rare
or the gate is theater — lint the ratio if needed.
Doc blocks often show fragments; the ignore-check escape hatch must stay rare or the gate is theater — lint the ratio if needed.

View File

@@ -4,38 +4,19 @@ Status: proposed
## Problem
The vendor manifest (ADR 0001) is enforced at commit time in the *forward*
direction (vendored change ⇒ manifest update) but nothing verifies the
manifest's *claims*: that vendor/ actually equals upstream-at-SHA plus
exactly the logged modifications. And the handful of true npm dependencies
have no advisory monitoring or update cadence.
The vendor manifest (ADR 0001) is enforced at commit time in the *forward* direction (vendored change ⇒ manifest update) but nothing verifies the manifest's *claims*: that vendor/ actually equals upstream-at-SHA plus exactly the logged modifications. And the handful of true npm dependencies have no advisory monitoring or update cadence.
## Proposal
1. **Vendor drift check** (nightly CI): clone the upstream repos at the
manifest SHAs (shallow), copy the corresponding package sources, and diff
against `vendor/*/src`. The job fails unless the diff matches the logged
local modifications (kept as a checked-in patch file per modification —
the log entries become verifiable artifacts rather than prose).
2. **Dependency advisories**: osv-scanner (or `yarn npm audit`) job on the
lockfile, scheduled + on lockfile-touching PRs.
3. **License inventory**: a script asserting every vendored package carries
its LICENSE and that package.json `license` fields match the inventory in
vendor/README.md (we mix vendored MIT with our BSD-3) — CI step.
4. **Renovate** (or a scheduled agent task) proposing npm dependency updates
in small PRs that ride the full gate suite; vendored packages are excluded
(their updates follow the manifest sync procedure, ideally as a
semi-automated agent workflow: fetch upstream, re-apply patches, run
gates, open PR with the manifest table updated).
1. **Vendor drift check** (nightly CI): clone the upstream repos at the manifest SHAs (shallow), copy the corresponding package sources, and diff against `vendor/*/src`. The job fails unless the diff matches the logged local modifications (kept as a checked-in patch file per modification — the log entries become verifiable artifacts rather than prose).
2. **Dependency advisories**: osv-scanner (or `yarn npm audit`) job on the lockfile, scheduled + on lockfile-touching PRs.
3. **License inventory**: a script asserting every vendored package carries its LICENSE and that package.json `license` fields match the inventory in vendor/README.md (we mix vendored MIT with our BSD-3) — CI step.
4. **Renovate** (or a scheduled agent task) proposing npm dependency updates in small PRs that ride the full gate suite; vendored packages are excluded (their updates follow the manifest sync procedure, ideally as a semi-automated agent workflow: fetch upstream, re-apply patches, run gates, open PR with the manifest table updated).
## Plan
3 is trivial — do first. 1 requires network access from CI to the upstream
repos (private — needs a token) and converting the two existing logged
modifications into patch files. 2 and 4 are config.
3 is trivial — do first. 1 requires network access from CI to the upstream repos (private — needs a token) and converting the two existing logged modifications into patch files. 2 and 4 are config.
## Risks
Upstream repos are private mirrors; CI credentials and availability are the
main friction for the drift check. If blocked, run it as a local scheduled
agent task instead of CI.
Upstream repos are private mirrors; CI credentials and availability are the main friction for the drift check. If blocked, run it as a local scheduled agent task instead of CI.

View File

@@ -4,41 +4,21 @@ Status: proposed
## Problem
The session log is append-only by contract, but `session.events` returns
`readonly SessionEvent[]` whose *elements* are mutable: a plugin can reach in
and rewrite history (`events[0].data.content.push(...)`), silently breaking
replay equivalence and the derived-history guarantee. The same applies to
derived messages and prompt assemblies passed through waterfalls — mutation
is sometimes the intended idiom (waterfall middleware mutates the request)
and sometimes corruption (mutating a *logged* event), and the types don't
distinguish.
The session log is append-only by contract, but `session.events` returns `readonly SessionEvent[]` whose *elements* are mutable: a plugin can reach in and rewrite history (`events[0].data.content.push(...)`), silently breaking replay equivalence and the derived-history guarantee. The same applies to derived messages and prompt assemblies passed through waterfalls — mutation is sometimes the intended idiom (waterfall middleware mutates the request) and sometimes corruption (mutating a *logged* event), and the types don't distinguish.
## Proposal
Make immutability part of the type where mutation is corruption:
- `SessionEvent` data becomes `DeepReadonly` on the way OUT of a session
(`events`, `session/event` listeners); `append()` keeps taking plain
mutable input. A `DeepReadonly<T>` utility type lands in dsh-llm next to
the brand/never helpers.
- `deriveMessages()` returns deep-readonly messages; the loop clones before
handing a mutable request to the `agent/request` waterfall (mutation there
is sanctioned — the clone makes the boundary explicit and cheap, once per
step).
- `PromptAssembly` stays mutable through its waterfall (sanctioned) but the
registry's internal section list is cloned per assembly (already true).
- Optionally, dev-mode `Object.freeze` of event data behind the RFC 005
invariants flag, so sanctioned-mutation violations throw in tests rather
than corrupting silently.
- `SessionEvent` data becomes `DeepReadonly` on the way OUT of a session (`events`, `session/event` listeners); `append()` keeps taking plain mutable input. A `DeepReadonly<T>` utility type lands in dsh-llm next to the brand/never helpers.
- `deriveMessages()` returns deep-readonly messages; the loop clones before handing a mutable request to the `agent/request` waterfall (mutation there is sanctioned — the clone makes the boundary explicit and cheap, once per step).
- `PromptAssembly` stays mutable through its waterfall (sanctioned) but the registry's internal section list is cloned per assembly (already true).
- Optionally, dev-mode `Object.freeze` of event data behind the RFC 005 invariants flag, so sanctioned-mutation violations throw in tests rather than corrupting silently.
## Plan
Introduce `DeepReadonly`, flip the session read paths, fix resulting
compile errors in consumers (expected: a handful in tests), add the
freeze-in-dev option alongside RFC 005's invariants plugin.
Introduce `DeepReadonly`, flip the session read paths, fix resulting compile errors in consumers (expected: a handful in tests), add the freeze-in-dev option alongside RFC 005's invariants plugin.
## Risks
`DeepReadonly` types can produce noisy errors at waterfall boundaries where
mutation IS the API — keep the mutable/readonly boundary exactly at "logged
vs in-flight" and document it in the session README.
`DeepReadonly` types can produce noisy errors at waterfall boundaries where mutation IS the API — keep the mutable/readonly boundary exactly at "logged vs in-flight" and document it in the session README.

View File

@@ -1,9 +1,6 @@
# RFCs
Proposals for substantial future work — reviewed before implementation,
unlike ADRs (which record decisions already made). Each RFC groups a related
set of ideas from the quality/robustness proposal (2026-06-11); statuses
move proposed → accepted → implemented (then usually graduate to an ADR).
Proposals for substantial future work — reviewed before implementation, unlike ADRs (which record decisions already made). Each RFC groups a related set of ideas from the quality/robustness proposal (2026-06-11); statuses move proposed → accepted → implemented (then usually graduate to an ADR).
| # | Title | Status |
|---|---|---|