# 从零构建一个"插件化 Agent Harness"（DeepSeek Harness 系）· 开发全流程教程

> **对照实现**：[deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) `v0.1.0-rc.5` @ `47f9438`（MIT）  
> **它的一句话**：*"It uses an architecture where **everything is a plugin**."*（`README.md:7`）  
> **这份教程要教你的**：不是"再写一个 agent 循环"，而是**先造一个能被插件替换掉任何部分的运行时，然后把 agent 循环本身也做成插件**。  
> **读法**：20 步，每步都有「为什么这么做 → 最小实现 → 怎么验收 → 真实 dsh 在哪儿」。代码是 TypeScript，可直接跑。  
> **前置**：读过本系列任意一份编程 Agent 分析（Claude Code / Codex / Open Design 任一）。不需要懂 Cordis——第 1–5 步会把它的五个观念从零造一遍。  
> **配套**：[DeepSeek Harness 源码分析](./项目分析/DeepSeek-Harness-源码分析.md)（241 包名册 + 协作图谱 + 工程制度）

---

## 目录

**Part 0 · 先看终点**  
- [先看终点：一个"插件化 harness"长什么样](#end)

**Part 1 · 造地基：五个观念（第 1–5 步）**  
- [第 0 步 · 场景：为什么不写一个 `main()` 循环](#s0)  
- [第 1 步 · context 是服务仓库](#s1)  
- [第 2 步 · `inject`：用服务需求代替启动顺序](#s2)  
- [第 3 步 · 可逆注册：effect 与 disposer](#s3)  
- [第 4 步 · 四种 dispatch：观察 / 改写 / 扇出 / 排队](#s4)  
- [第 5 步 · waterfall 是 around 中间件：`next()` 的契约](#s5)

**Part 2 · 造真相：会话日志（第 6–7 步）**  
- [第 6 步 · append-only 日志 + `deriveMessages()`](#s6)  
- [第 7 步 · 把「模型可见 ⟺ 已记录」变成运行时断言](#s7)

**Part 3 · 造循环：turn 与 step（第 8–13 步）**  
- [第 8 步 · turn 与 step：一个循环，两级边界](#s8)  
- [第 9 步 · inbox：两个投递口决定"欠不欠"](#s9)  
- [第 10 步 · `agent/pre-step`：决定模型看什么](#s10)  
- [第 11 步 · 工具注册表与三道 waterfall](#s11)  
- [第 12 步 · 单调守卫：审批为什么不能被后面的插件放宽](#s12)  
- [第 13 步 · 系统提示：每一步重新装配](#s13)

**Part 4 · 造能力：seam 三角色（第 14–17 步）**  
- [第 14 步 · capability seam：Definition / Provider / Consumer](#s14)  
- [第 15 步 · 换一个 provider，搬走整个执行世界](#s15)  
- [第 16 步 · 沙箱：把 argv 包起来](#s16)  
- [第 17 步 · 上下文经济：compaction 与 spill](#s17)

**Part 5 · 造产品：装配与纪律（第 18–20 步）**  
- [第 18 步 · 让循环继续：`agent/turn-stopping` 与 goal](#s18)  
- [第 19 步 · bundle 与 profile：把装配做成可打补丁的层](#s19)  
- [第 20 步 · 把纪律写成门禁：100% 覆盖 + `verify-*`](#s20)

**Part 6 · 收尾**  
- [🎬 完整回放：一句"帮我改个 bug"到底跑了什么](#replay)  
- [附录 A · 验收断言（每步怎么验）](#a1)  
- [附录 B · 十四个最容易翻的车](#a2)  
- [附录 C · 源码对照索引](#a3)  
- [结语：这条路线适合谁](#end2)

---

<h2 id="end">先看终点：一个"插件化 harness"长什么样</h2>

先把终点摆出来，后面 20 步都是在往这张图上填砖。

一个普通 agent 项目长这样：一个 `main()`，里面 while 循环，调模型、跑工具、拼提示词。想换模型就改 `main()`，想加权限就改 `main()`，想让别人扩展——没门。

DeepSeek Harness 的形状完全不同：

```mermaid
flowchart TB
    subgraph boot["启动期：把 YAML 变成插件树"]
        P["profile（web / headless）"] --> B1["bundle: base"]
        P --> B2["bundle: web-app"]
        B1 & B2 --> PATCH["cordis.patch.yml 逐层覆盖"]
        PATCH --> TREE["最终插件树"]
    end
    subgraph run["运行期：context 是服务仓库"]
        TREE --> CTX["ctx"]
        CTX -.提供.-> S1["ctx.sessions"]
        CTX -.提供.-> S2["ctx.llm"]
        CTX -.提供.-> S3["ctx.tools"]
        CTX -.提供.-> S4["ctx.agentLoop"]
        CTX -.提供.-> S5["ctx.shell / fs / sandbox …"]
    end
    S4 --> LOOP["agent 循环（它自己也只是个插件）"]
    LOOP --> LOG["append-only 会话日志"]
    LOG -->|deriveMessages| REQ["下一次模型请求"]
    S3 --> PIPE["工具三道 waterfall"]
```

三句话概括这个形状：

1. **没有特权内核可打补丁**。原文："*There is no privileged core to patch: you extend dsh by mounting a plugin beside the others*"（`docs/architecture.md:13`）。模型适配器、工具注册表、会话日志、**乃至 agent 循环本身**，全都是插件。
2. **配置就是架构**。跑 `dsh --profile web --dump-config` 会打印出这台机器真正启动的整棵树，**"Any row it prints can be replaced by a patch of your own"**（`docs/architecture.md:35`）。
3. **会话日志是唯一真相**。模型看到的每一个字节都必须能从日志重建，这条被写成了运行时断言。

学完这 20 步，你会有一个约 700 行的 `minidsh`，具备：服务仓库 + 依赖注入 + 四种事件 + 可逆注册 + append-only 日志 + turn/step 双层循环 + 工具三段流水线 + 一个可换 provider 的 shell seam + 分层配置装配。然后你读真实的 dsh 会像读自己写的东西。

> 📌 **提示**：这份教程的代码用 TypeScript 手写一个极小的插件框架，**不是** 直接教 Cordis API。原因很简单——你要理解的是"为什么需要这些原语"，而不是"这些原语的参数表"。真实 API 在每步末尾的「真实 dsh 在哪儿」里对照。

---

<div class="part-band"><span class="band-k">Part 1 · 第 1–5 步</span>造地基：五个观念</div>

<h2 id="s0">第 0 步 · 场景：为什么不写一个 `main()` 循环</h2>

先给自己一个具体任务，后面每一步都拿它检验：

> **用户说**：「帮我把 `src/parser.ts` 里那个把空字符串当合法输入的 bug 修掉，改完跑测试。」

这句话要走完的路是：读文件 → 想 → 改文件 → 跑 shell → 看结果 → 可能再改 → 回答。

如果你写一个 `main()`：

```ts
// 每个人第一次都会这么写
async function main(prompt: string) {
  const messages = [{ role: 'user', content: prompt }]
  while (true) {
    const reply = await callModel(messages, TOOLS)
    messages.push(reply)
    if (!reply.toolCalls) return reply.content
    for (const call of reply.toolCalls) {
      messages.push(await runTool(call))
    }
  }
}
```

这段代码能跑，而且很多产品就是这么发货的。它的问题不在"能不能跑"，而在**每一个后续需求都要回来改它**：

| 后续需求 | 在 `main()` 方案里 | 代价 |
|---|---|---|
| 加权限确认 | 在 `runTool` 前插 if | 权限逻辑和执行逻辑缠在一起 |
| 换模型厂商 | 改 `callModel` | 每加一家就多一个分支 |
| 上下文超了要压缩 | 在 while 里插压缩 | 压缩策略与循环耦合 |
| 让别人写扩展 | 改不了，只能 fork | 生态为零 |
| 支持 Web + CLI + 自动化协议三种前端 | 三份 `main()` | 行为漂移 |
| 断线重连要恢复现场 | `messages` 在内存里，没了 | 只能重来 |

DeepSeek Harness 把这六件事一次性解掉的办法是**换地基**：不写 `main()`，而是写一个**服务仓库**，让上面六件事各自成为一个插件，挂在仓库旁边。

它的宪法把这条写成了硬规则（`AGENTS.md`）：

> **Plugins, not loop changes**: new behavior goes on documented extension points; changing `agent-loop` requires updating `docs/architecture.md`.

翻译：**改循环的门槛被故意抬高到"你得改架构文档"**。这是一句很凶的话——它等于说"你想改循环，先说服架构"。

### 这一步的收获

后面 20 步的每一步，我都会先问一句"如果写在 `main()` 里会怎样"。这个对照是理解 harness 型架构的唯一有效方法。

---

<h2 id="s1">第 1 步 · context 是服务仓库</h2>

### 为什么

我们要让"模型适配器"这种东西可以被替换。替换的前提是：**使用者不能 import 具体实现**。如果 `agent-loop.ts` 里写了 `import { DeepSeekAdapter } from './deepseek'`，那它就永远绑死了。

解法是加一层间接：使用者只知道一个**稳定的名字**（`ctx.llm`），不知道背后是谁。

Cordis 的第一个观念（`docs/cordis-primer.md:10`）：

> **A context is a repository of services.** A service claims a stable `ctx.<key>` such as `ctx.tools`, `ctx.llm`, or `ctx.sessions` from a context; other plugins find services via key instead of importing a concrete implementation.

### 最小实现

```ts
// minidsh/context.ts
type Disposer = () => void

/** 一个插件就是一个函数（可选带 inject / name 元数据）。 */
export interface Plugin {
  (ctx: Context): void | Disposer | Promise<void | Disposer>
  inject?: string[]
  name?: string
}

export class Context {
  /** 服务表：键是稳定名字，值是任意实现。 */
  private services = new Map<string, unknown>()
  /** 每个插件挂载后拿到的清理函数。 */
  private disposers: Disposer[] = []

  /** 声明一个服务。重复声明直接抛错——重名是配置错误，不是可容忍状态。 */
  provide<T>(key: string, impl: T): Disposer {
    if (this.services.has(key)) {
      throw new Error(`service "${key}" already provided`)
    }
    this.services.set(key, impl)
    return () => this.services.delete(key)
  }

  /** 按名字取服务。取不到就抛——静默返回 undefined 会让错误跑到很远的地方才炸。 */
  get<T>(key: string): T {
    const found = this.services.get(key)
    if (found === undefined) throw new Error(`service "${key}" not provided`)
    return found as T
  }

  has(key: string): boolean {
    return this.services.has(key)
  }
}
```

三个刻意的决定，每个都对应真实 dsh 的一条规则：

| 决定 | 理由 | dsh 对应 |
|---|---|---|
| 重复 `provide` 抛错 | 两个插件抢同一个键 = 装配错了，必须立刻知道 | "*mounting both fails loud on a duplicate service registration*"（`packages/shell/shell/src/index.ts:16-20`） |
| `get` 取不到抛错 | 静默 `undefined` 会让错误在几百行后才现形 | **Misconfiguration fails loud**（`AGENTS.md`） |
| `provide` 返回 disposer | 为第 3 步的"可逆"埋钩子 | "*a registry's `register()` returns the disposer*"（`AGENTS.md`） |

### 怎么验收

```ts
const ctx = new Context()
ctx.provide('llm', { name: 'fake' })
console.assert(ctx.get<{ name: string }>('llm').name === 'fake')
try { ctx.provide('llm', {}); console.assert(false, '应该抛错') } catch {}
try { ctx.get('nope'); console.assert(false, '应该抛错') } catch {}
```

### 真实 dsh 在哪儿

- 73 个服务键全在 `ctx` 上：`ctx.sessions` `ctx.llm` `ctx.tools` `ctx.agents` `ctx.agentLoop` `ctx.systemPrompt` `ctx.shell` `ctx.fs` `ctx.sandbox` `ctx.goals` `ctx.skills` `ctx.spillStore` …
- 服务键的声明方式是 TypeScript 的 declaration merging：

```ts
// packages/shell/shell/src/index.ts:40-44
declare module '@deepseek-ai/cordis' {
  interface Context {
    shell: ShellExecutor
  }
}
```

这一招的妙处是：**服务键的类型是全局可见的，但实现是运行时注入的**。你在任何包里写 `ctx.shell.run(...)` 都有完整类型提示，却没有 import 任何实现。

---

<h2 id="s2">第 2 步 · `inject`：用服务需求代替启动顺序</h2>

### 为什么

有了服务仓库，马上撞上第二个问题：**顺序**。

`tool-bash` 需要 `ctx.shell` 和 `ctx.tools` 都在了才能注册自己。如果你手工排启动顺序，那就得维护一张"谁先谁后"的表——插件一多，这张表就是灾难，而且外部插件根本不知道该插在哪。

Cordis 的第三个观念（`docs/cordis-primer.md:11`）：

> **Declare service dependency via `inject`.** A plugin that names required services waits until those services exist, so load order is expressed through service requirements rather than manual boot sequencing.

**"加载顺序通过服务需求表达，而不是手工启动排序"** ——这句话是整个架构的转折点。

### 最小实现

在 `Context` 上加一个待挂载队列：等依赖齐了再挂。

```ts
// minidsh/context.ts（续）
export class Context {
  // …前略
  private pending: Plugin[] = []

  /** 挂载一个插件。依赖未齐时进等待队列。 */
  async plugin(plug: Plugin): Promise<void> {
    this.pending.push(plug)
    await this.drain()
  }

  /** 反复扫描等待队列，挂载所有依赖已齐的插件，直到没有进展。 */
  private async drain(): Promise<void> {
    let progressed = true
    while (progressed) {
      progressed = false
      for (const plug of [...this.pending]) {
        const needs = plug.inject ?? []
        if (!needs.every(key => this.has(key))) continue
        this.pending.splice(this.pending.indexOf(plug), 1)
        const disposer = await plug(this)
        if (disposer) this.disposers.push(disposer)
        progressed = true   // 新服务可能解锁了别的插件
      }
    }
  }

  /** 依赖永远等不到的插件是装配错误，启动结束时必须报出来。 */
  assertSettled(): void {
    if (this.pending.length === 0) return
    const stuck = this.pending.map(p => `${p.name ?? 'anonymous'} needs [${(p.inject ?? []).join(', ')}]`)
    throw new Error(`plugins never activated:\n  ${stuck.join('\n  ')}`)
  }
}
```

注意 `drain()` 的 `progressed` 循环：挂载一个插件可能提供新服务，从而解锁另一个插件。**这就是"用需求表达顺序"的全部机制**——一个不到 20 行的不动点迭代，替掉了整张手工顺序表。

`assertSettled()` 同样重要。少了它，一个 `inject` 拼错字母的插件会**静默地永不加载**，而你要花两小时才发现。真实 dsh 有一整条门禁在防这个（`scripts/verify-cordis-config.ts`），并且宪法明写：

> **Misconfiguration fails loud** at load when self-contained, otherwise at the earliest resolvable point; **never silently skip a missing referent**.

### 怎么验收

故意乱序挂载，看它自己排好：

```ts
const ctx = new Context()
const toolBash: Plugin = (c) => { c.provide('tool:bash', { shell: c.get('shell') }) }
toolBash.inject = ['shell']; toolBash.name = 'tool-bash'

await ctx.plugin(toolBash)                       // 先挂消费者：进等待队列
console.assert(!ctx.has('tool:bash'), '还不该激活')
await ctx.plugin((c) => { c.provide('shell', { run: async () => 'ok' }) })
console.assert(ctx.has('tool:bash'), '依赖到齐后应自动激活')
ctx.assertSettled()
```

### 真实 dsh 在哪儿

```ts
// packages/shell/tool-bash/src/index.ts:30-31
export const name = 'tool-bash'
export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']
```

我对 241 个包做了一次全量普查，`inject` 的入度分布很能说明架构重心：

| 被 inject 最多的服务 | 次数 | 说明 |
|---|---|---|
| `tools` | 31 | 工具注册表是最大的汇聚点 |
| `systemPrompt` | 20 | 谁都要往提示词里塞一段 |
| `sessions` | 17 | 日志是真相 |
| `agents` | 13 | 活体 agent registry |
| `subagents` | 12 | 子智能体缝 |
| `llm` | 9 | 模型缝 |

> ⚠️ **一个真实事故**：`docs/postmortem/0001-acp-default-export-drops-inject.md` 记的就是这条机制被绕过的后果——一个包用了 `export default` 而不是命名导出，`inject` 声明**被丢掉**，于是插件在依赖未齐时就挂载了。单元测试全绿，产品坏了。后来的对策是在测试里显式断言 `expect('default' in mod).toBe(false)`。这个坑值得你在自己的框架里提前设防。

---

<h2 id="s3">第 3 步 · 可逆注册：effect 与 disposer</h2>

### 为什么

现在插件能挂了。但 agent harness 有个特殊需求：**卸载**。

三个场景逼你必须支持卸载：

1. **热重载**：开发时改一行提示词，不想重启整个进程。
2. **逐会话装配**：会话 A 用工具集 X，会话 B 用工具集 Y——B 结束时要把 Y 干净地摘掉。
3. **子 agent**：一个子 agent 活着时注册了几个专属工具，它死了这些工具必须消失。

如果注册是"往全局数组里 push"，这三件事全都做不了——你不知道该删哪几条。

Cordis 的第五个观念（`docs/cordis-primer.md:13`）：

> **Registrations are reversible effects.** Prompt sections, tool schemas, adapters, providers, and listeners are installed through `ctx.effect()` or `ctx.on()` so reload and teardown unwind them predictably.

dsh 的宪法把它顶到了顶格（`AGENTS.md`）：

> **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.

### 最小实现

```ts
// minidsh/context.ts（续）
export class Context {
  // …前略

  /**
   * 登记一个可逆副作用。返回的 disposer 既交给调用方，也记在本 context 上，
   * 所以「插件自己撤销」和「整棵树拆卸」两条路都能收干净。
   */
  effect(install: () => Disposer): Disposer {
    const undo = install()
    let done = false
    const disposer = () => {
      if (done) return          // 幂等：重复 dispose 不该炸
      done = true
      undo()
    }
    this.disposers.push(disposer)
    return disposer
  }

  /** 逆序拆卸：后注册的先撤，和资源获取顺序对称。 */
  dispose(): void {
    for (const d of this.disposers.reverse()) d()
    this.disposers = []
    this.services.clear()
  }
}
```

两个细节：

- **幂等**：`done` 标志防止双重撤销。真实世界里 disposer 会被调用两次（插件自己撤 + 树拆卸），不幂等就会炸在拆卸路径上，而那是最难调试的地方。
- **逆序**：`reverse()`。注册顺序是 A→B→C，拆卸顺序必须是 C→B→A，因为 C 可能依赖 B 建立的状态。dsh 的宪法专门写了这条：「*If teardown order matters, keep the related work in one effect so disposal unwinds in the intended sequence*」（`docs/cordis-primer.md:44`）。

### 怎么验收

真实 dsh 要求**每个注册表都有一个 HMR 安全测试**（`docs/testing.md:9`）：

> Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup).

照抄这个思路：

```ts
const ctx = new Context()
const log: string[] = []
await ctx.plugin((c) => {
  c.effect(() => { log.push('tool+'); return () => log.push('tool-') })
  c.effect(() => { log.push('prompt+'); return () => log.push('prompt-') })
})
ctx.dispose()
console.assert(log.join(',') === 'tool+,prompt+,prompt-,tool-', '必须逆序撤销')
```

### 真实 dsh 在哪儿

`packages/core/tools/src/index.ts` 的注册表 `register()` 返回 disposer；`packages/core/agent-loop/src/index.ts:39` 有个专门的 `FactoryOwnership` 类做「工厂级所有权：活体 agent 拆卸 + 配置启动工作」。

`agent-loop` 的 `@module` JSDoc 一句话点题：

> Concrete agent-loop plugin: creates scoped ReactLoopAgents, publishes them through the agent/session registries, and **owns their ordered teardown**.

**"owns their ordered teardown"**——有序拆卸被写进了模块职责的第一句话。这在 agent 项目里非常罕见，大多数项目的拆卸路径是"进程退出算了"。

---

<h2 id="s4">第 4 步 · 四种 dispatch：观察 / 改写 / 扇出 / 排队</h2>

### 为什么

插件之间要通信。最容易想到的是 `EventEmitter`：`emit('tool-call', args)`。

但 agent harness 里的"事件"有四种**语义完全不同**的需求：

1. 我只想**知道**发生了什么（UI 更新日志）→ 不需要返回值，不需要等。
2. 我想**改写**这件事（权限插件把 allow 改成 deny）→ 需要返回值，需要能拦。
3. 我想让**所有人并行处理**（多个持久化后端同时写盘）→ 需要等全部完成。
4. 我想让大家**按顺序表态**（多个策略依次决定）→ 需要等，且要顺序。

一个 `EventEmitter` 塞不进这四种语义。混用的结果是：某个 listener 偷偷返回了值但没人用，或者某个异步 listener 没被 await 就丢了。

Cordis 把这四种做成四个 dispatch 模式，并且——**这是最关键的一句**——**dispatch 模式是事件公共契约的一部分**（`docs/cordis-primer.md:26`）：

> The dispatch mode is part of the event's public contract. New harness events document it with an `@mode` tag so the generated catalog can check declarations against dispatch sites.

| 模式 | await？ | 顺序 | 有返回值？ | 用来做 |
|---|---|---|---|---|
| `emit` | 否 | 注册序 | 否 | 观察（UI、日志、遥测） |
| `waterfall` | 否 | 注册序 | **是** | 改写、拦截、策略 |
| `parallel` | 是 | 并行 | 否 | 扇出（多后端持久化） |
| `serial` | 是 | 注册序 | 是 | 依次表态 |

### 最小实现

```ts
// minidsh/events.ts
type Listener = (...args: any[]) => any

export class EventBus {
  private listeners = new Map<string, Listener[]>()

  /** 注册监听。prepend 用于必须跑在普通注册之前的少数情况。 */
  on(event: string, fn: Listener, prepend = false): () => void {
    const list = this.listeners.get(event) ?? []
    prepend ? list.unshift(fn) : list.push(fn)
    this.listeners.set(event, list)
    return () => {
      const cur = this.listeners.get(event) ?? []
      const i = cur.indexOf(fn)
      if (i >= 0) cur.splice(i, 1)
    }
  }

  private of(event: string): Listener[] {
    return [...(this.listeners.get(event) ?? [])]
  }

  /** 观察：不等、无返回。一个 listener 抛错不该带崩其它 listener。 */
  emit(event: string, ...args: any[]): void {
    for (const fn of this.of(event)) {
      try { fn(...args) } catch (e) { console.warn(`listener of ${event} failed:`, e) }
    }
  }

  /** 扇出：全部并行，等到都结束。 */
  async parallel(event: string, ...args: any[]): Promise<void> {
    await Promise.all(this.of(event).map(fn => fn(...args)))
  }

  /** 依次表态：按注册序等每一个，返回最后一个非 undefined 的结果。 */
  async serial<T>(event: string, ...args: any[]): Promise<T | undefined> {
    let out: T | undefined
    for (const fn of this.of(event)) {
      const r = await fn(...args)
      if (r !== undefined) out = r
    }
    return out
  }

  /** around 中间件：见第 5 步。 */
  async waterfall<T>(event: string, arg: unknown, seed: () => Promise<T>): Promise<T> {
    const chain = this.of(event)
    const run = async (i: number): Promise<T> => {
      if (i >= chain.length) return seed()
      return chain[i](arg, () => run(i + 1))
    }
    return run(0)
  }
}
```

### 怎么验收

四种模式的行为差异必须能被测出来，否则你迟早会混用：

```ts
const bus = new EventBus()
const order: string[] = []
bus.on('x', () => { order.push('a') })
bus.on('x', () => { order.push('b') })
bus.emit('x')
console.assert(order.join('') === 'ab', 'emit 按注册序')

// waterfall 能改写结果
bus.on('cfg', (_arg: unknown, next: () => Promise<any>) => next().then(c => ({ ...c, temp: 0 })))
const cfg = await bus.waterfall('cfg', null, async () => ({ model: 'deepseek-chat' }))
console.assert(cfg.temp === 0 && cfg.model === 'deepseek-chat')
```

### 真实 dsh 在哪儿

事件用 TypeScript declaration merging 声明，每个都带 `@mode` 标签。看工具注册表的四个事件（`packages/core/tools/src/index.ts:150,161,173,195`）：

```ts
/** @mode waterfall */  'tools/pre-execute'(exec, next): Promise<PreToolDecision>
/** @mode waterfall */  'tools/execute'(exec, next): Promise<...>
/** @mode waterfall */  'tools/post-execute'(exec, result, next): Promise<PostToolDecision>
/** @mode emit     */  'tools/result'(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): undefined
```

**注意最后一个的模式差异**：前三个是 waterfall（能改写），`tools/result` 是 `emit`（只能观察），而且两个参数都是 `Readonly<>`。

这是一个极有品味的设计：**"能改的"和"只能看的"用不同 dispatch 模式在类型层面分开**。结果一旦定稿就冻结，任何观察者都改不动。dsh 甚至给它加了运行时不变量（`packages/core/tools/src/invariant.ts:23-25`）：

```ts
if (!Object.isFrozen(exec)) fail('tools/result execution must be frozen before publication')
```

> 💡 **抄走这一条**：在你自己的框架里，给每个事件明确回答"它能不能改结果"。能改的走 waterfall，不能改的走 emit + `Readonly` + 冻结。这一条能挡掉一整类"某个插件偷偷改了已定稿结果"的诡异 bug。

---

<h2 id="s5">第 5 步 · waterfall 是 around 中间件：`next()` 的契约</h2>

### 为什么

第 4 步的 `waterfall` 实现只有 8 行，但它的**语义**需要单独一步来讲，因为这是整个架构里最容易写错的地方。

`waterfall` 不是"依次通知"，而是 **around 中间件**（`docs/cordis-primer.md:30`）：

> `ctx.waterfall` is around-middleware. A listener receives `(...args, next)`. Call `next()` to delegate the possibly wrapped result to the next service; return without `next()` to short-circuit. Values propagate through `next()`'s return value.

配合宪法里那条**大写强调**的规则（`AGENTS.md`）：

> **Waterfall listeners MUST call `next()`** to delegate; returning without it short-circuits the chain.

### 三种正确写法

```ts
// ① 协作型：改一改，然后委托（最常见）
ctx.on('agent/request', async (arg, next) => {
  const config = await next()          // 先让下游都表态
  return { ...config, maxTokens: 4096 } // 再包一层
})

// ② 前置型：先改输入，再委托
ctx.on('tools/pre-execute', async (exec, next) => {
  exec.args.path = normalize(exec.args.path)  // 改共享的可变对象
  return next()
})

// ③ 决策型：我拥有这个决定，故意不调 next（短路）
ctx.on('tools/pre-execute', async (exec, next) => {
  if (isDangerous(exec)) return { kind: 'deny', reason: '危险命令' }  // 短路
  return next()                                                      // 不管的就委托
})
```

第三种是**故意的**，不是 bug。primer 说得很清楚：

> For single-decision events, short-circuiting is the design. A policy listener can return without `next()` when it owns the decision, while a listener that only annotates or observes must delegate.

### 一个必踩的坑

```ts
// ❌ 错：忘了 next()，整条链后面的插件全被静默跳过
ctx.on('agent/request', async (arg, next) => {
  logRequest(arg)                       // 我只想记个日志
  return { model: 'deepseek-chat' }     // ← 灾难：短路了所有下游策略
})

// ✅ 对：观察者必须委托
ctx.on('agent/request', async (arg, next) => {
  logRequest(arg)
  return next()
})
```

这个 bug 的可怕之处在于**它不报错**。你的权限插件、压缩插件、遥测插件全都静静地不生效了，而日志一切正常。

所以 dsh 把这条规则放进了 `AGENTS.md` 并且**用大写 MUST**。

### 收尾：五个观念到手

```mermaid
mindmap
  root((Cordis 五观念))
    插件是实现 Service 的对象
      函数 + inject + apply
      或 Service 子类
    context 是服务仓库
      稳定的 ctx.key
      按名字找，不 import 实现
    inject 声明依赖
      服务需求代替启动顺序
      不动点迭代自动排序
    Typed Events 通信
      emit 观察
      waterfall 改写
      parallel 扇出
      serial 排队
    注册是可逆 effect
      每个注册返回 disposer
      逆序拆卸
      HMR 与逐会话装配的前提
```

到这里你手上的 `minidsh` 约 150 行，已经具备真实 dsh 的全部地基原语。**后面 15 步都是在这个地基上挂插件**，不再改地基——这正是 harness 型架构要证明的事。

### 真实 dsh 在哪儿

`docs/cordis-primer.md`（44 行，读完只要 5 分钟，强烈建议原文过一遍）；`vendor/` 是 pinned 的 Cordis 源码副本，manifest 与同步流程在 `vendor/README.md`。

Cordis 本身有一篇论文《A Programming Paradigm for Spatiotemporal Composability》（`README.md:7` 链接）。"时空可组合性"听起来很玄，落到工程上就是这五条：**空间上**服务可按键替换，**时间上**注册可逆序撤销。

---

<div class="part-band"><span class="band-k">Part 2 · 第 6–7 步</span>造真相：会话日志</div>

<h2 id="s6">第 6 步 · append-only 日志 + `deriveMessages()`</h2>

### 为什么

回到第 0 步那个 `main()`：`const messages = [...]`。这个数组就是它的全部真相，而它活在内存里。

于是这些需求全都做不到：断线重连、会话分叉、审计"模型到底看到了什么"、UI 重放流式过程、把某一步的输入喂给评测。

dsh 的选择（`docs/architecture.md:94`）：

> The session log is the source of the context the model sees. `deriveMessages()` projects model history from it, and raw `assistant/chunk` events preserve replay and UI fidelity. Fork, resume, transcripts, telemetry, and persistence all derive from this stream.

关键在**方向**：不是"消息数组顺便记个日志"，而是"**日志是真相，消息数组是从日志投影出来的**"。这是 event sourcing 用在 agent 上的正确姿势。

### 最小实现

```ts
// minidsh/session.ts
export interface SessionEvent {
  seq: number
  type: string
  data: any
}

export class Session {
  readonly events: SessionEvent[] = []
  private seq = 0
  constructor(private bus: EventBus, readonly id: string) {}

  /** 唯一的写入口。返回事件（带 seq），供调用方交叉引用。 */
  append(type: string, data: any): SessionEvent {
    const event: SessionEvent = { seq: ++this.seq, type, data }
    this.events.push(event)
    this.bus.emit('session/event', event)   // 持久化 / UI / 遥测都挂这里
    return event
  }

  /**
   * 从日志投影出模型历史。这是「模型看到什么」的唯一计算路径，
   * 所以任何想影响模型输入的东西都必须先成为一个事件。
   */
  deriveMessages(): Message[] {
    const out: Message[] = []
    for (const e of this.events) {
      if (e.type === 'user/message') out.push({ role: 'user', content: e.data.content })
      else if (e.type === 'assistant/message') out.push(e.data.message)
      else if (e.type === 'tool/result') {
        out.push({ role: 'tool', toolCallId: e.data.callId, content: e.data.content })
      }
      // 其它事件（turn/start、assistant/chunk、todo/write…）不进模型历史，
      // 但它们仍在日志里——UI 与重放需要它们。
    }
    return out
  }
}
```

注意 `deriveMessages()` 里**被跳过**的那些事件。这正是"日志是超集"的意思：

- `assistant/chunk`（每个流式分片）→ 不进历史，但 UI 重放需要
- `turn/start` / `step/end` → 不进历史，但结构分析和遥测需要
- `todo/write` → 不进历史，但 UI 要渲染 checklist

### 一个值得抄的细节：消息与其来源分片双向可追溯

真实 dsh 在 append `assistant/message` 时带上了它由哪些 chunk 事件拼成（`packages/core/agent-loop/src/agent.ts:349,381-390`）：

```ts
const chunkSeqs: number[] = []
for await (const chunk of stream) {
  chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)
  assembler.push(chunk)
}
// …
this.session.append('assistant/message', { turn, step, message, usage },
  { surfaceOp: 'append', sourceEventSeqs: chunkSeqs })
```

**`sourceEventSeqs: chunkSeqs`** 这一笔，让你能从任何一条最终消息回溯到它的原始流式分片。调"为什么这条回答被截断了"这类问题时，这是救命的。

### 怎么验收

```ts
const s = new Session(bus, 'sess-1')
s.append('turn/start', { turn: 1 })
s.append('user/message', { content: '修 bug' })
s.append('assistant/chunk', { chunk: '好' })     // 不进历史
s.append('assistant/message', { message: { role: 'assistant', content: '好的' } })
console.assert(s.deriveMessages().length === 2, '只有 user + assistant 进历史')
console.assert(s.events.length === 4, '但日志里四条都在')
```

### 真实 dsh 在哪儿

`packages/core/session`（8 401 行）的 `@module` JSDoc：

> Event-sourced session service: append-only session log, in-memory store, and the derived LLM message history. **Persistence is a plugin concern** (subscribe to `session/event`, drain on `session/flush`).

**"持久化是插件关心的事"**——会话服务本身不碰磁盘。`session` 组一共 13 个包，把持久化（JSONL + zstd）、投影、标题生成、遥测、引用解析全部拆成了独立插件。这也是为什么 `dsh-session` 被 84 个包依赖，排全仓第三（仅次于 `dsh-invariants` 218 和 `schemastery` 110）。

---

<h2 id="s7">第 7 步 · 把「模型可见 ⟺ 已记录」变成运行时断言</h2>

### 为什么

第 6 步立了一条规矩：日志是真相。但规矩会被违反。

典型的违反方式非常隐蔽：某个插件想给模型加一段上下文，于是它直接往 `request.messages` 里 push 了一条。功能正常，但这条消息**不在日志里**——于是会话重放少了一段，审计对不上，评测复现不了。

dsh 把这条规矩升级成了**带运行时断言的宪法**（`docs/architecture.md:96`，`AGENTS.md`）：

> **Model-visible means logged.** Anything that reaches a model request must be reconstructable from the log, and a runtime invariant asserts it. This is why a new model-visible input requires a new session event: extend `SessionEventMap` and render from the log.

> **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event.

### 最小实现

思路：请求只能从 `deriveMessages()` 来。想加东西？先 append 一个事件。

```ts
// minidsh/invariant.ts
/**
 * 断言这次请求的消息全部来自日志投影。
 * 不这么做的后果：插件绕过日志偷偷加上下文，会话重放与审计静默失真。
 */
export function assertModelVisibleIsLogged(session: Session, request: { messages: Message[] }): void {
  const derived = JSON.stringify(session.deriveMessages())
  const sent = JSON.stringify(request.messages)
  if (derived !== sent) {
    throw new InvariantError(
      `model-visible input not reconstructable from log:\n  derived=${derived}\n  sent=${sent}`,
    )
  }
}
```

于是"给模型加上下文"的唯一正确姿势变成：

```ts
// ❌ 绕过日志
request.messages.push({ role: 'user', content: '当前分支是 main' })

// ✅ 先成为事实，再被投影
agent.inject({ role: 'user', content: '当前分支是 main' })  // append 一个 user/message 事件
```

### 真实 dsh 怎么做得更狠

dsh 没有把不变量做成一个全局函数，而是做成了**逐包所有权的注册服务**（`docs/subsystems/invariants.md`）：

- `ctx.invariants.register(packageName, installer)`——**每个 workspace 包**都发布一个 `./invariant` 伴生插件，用自己的 npm 包名注册检查。
- 失败抛 `InvariantError`，带稳定 `code: 'INVARIANT'` 和 `packageName`，消息前缀 `invariant violated by "<package>": …`——**违规可归属，而注册表不需要 import 任何产品包**。
- 检查在专属子 fiber 里跑，`installer.inject` 声明该 fiber 能访问哪些服务。
- 可用正则白/黑名单选择启用哪些包的检查，**黑名单胜过白名单**；配置非法（空串、带空格、重复、正则无效）在服务启动时**抛错而不是跳过**。

最讲究的是**它规定了检查可以断言什么**（`AGENTS.md`）：

> **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, **not** service or method presence, plugin metadata or effects, or fixed pure examples. Without a plausible relationship, an explained empty companion is correct.

翻译：不许写"检查 `ctx.shell` 是否存在"这种**假装在检查**的断言。没有可断言的真实关系时，**正确做法是导出一个空的 installer，并用注释解释为什么这个包没什么可检查的**——注释必须以 `No runtime invariant:` 开头。

而且这条也被机器守着：`pnpm run verify-package-invariants` 会拒绝「生成的占位标记」「没解释的空 installer」「非空 installer 却忽略了 reporter」「注册名写错」。

这就是为什么 `dsh-invariants` 被 **218 个包**依赖——241 个包里 90%。我在本系列 21 个项目里没见过第二个这么做的。

### 怎么验收

不变量的验收方式很特别：**你得先违反它，看它变红**。这正是 dsh 测试宪法里那条（`docs/testing.md:34`）：

> A guard only guards if the regression actually fails it. … introduce the regression, watch red, revert.

```ts
// 故意绕过日志
request.messages.push({ role: 'user', content: '偷偷加的' })
try {
  assertModelVisibleIsLogged(session, request)
  console.assert(false, '不变量没抓到违规——它是假的')
} catch (e) { /* 期望：抓到 */ }
```

---

<div class="part-band"><span class="band-k">Part 3 · 第 8–13 步</span>造循环：turn 与 step</div>

<h2 id="s8">第 8 步 · turn 与 step：一个循环，两级边界</h2>

### 为什么

第 0 步那个 `main()` 的 while 只有一级。一级不够，因为有两个不同的问题要回答：

1. "这次模型请求之后还要不要再请求？"（工具跑完了要把结果喂回去）
2. "这一整轮对话结束了吗？"（用户还有没有新输入、插件还欠不欠事情）

混成一级的后果是：你没法表达"这一轮结束了，但因为有插件塞了新东西，所以要继续"。

dsh 的定义（`docs/architecture.md:65`）：

> A **step** is one model request plus the tools it calls. A **turn** is zero or more steps: it opens before its first input is claimed and closes once nothing is owed.

**"a turn is zero or more steps"** —— 零个 step 的 turn 是合法的，这是个刻意的设计（下一步会讲为什么）。

### 完整流水线

```mermaid
sequenceDiagram
    participant IN as inbox
    participant D as driver（循环）
    participant SP as systemPrompt
    participant L as 会话日志
    participant M as ctx.llm
    participant T as ctx.tools

    D->>L: append turn/start
    loop 每个 step
        D->>IN: claim(target, turn)
        D->>SP: assemble()（提示段 + 工具 schema）
        D->>D: agent/pre-step（waterfall：reject | enter）
        Note over D: reject 或首次 enter 被改空<br/>→ 关掉这个「零 step」的 turn
        D->>L: append step/start
        D->>L: append user/message ×N
        D->>L: deriveMessages()
        D->>D: agent/request（waterfall：定 provider/model/参数）
        D->>M: stream(request)
        M-->>L: append assistant/chunk ×N（每片都记）
        D->>L: append assistant/message（带 sourceEventSeqs）
        alt 命中 max-tokens
            D->>D: 结束（sticky）
        else 没有 tool call
            D->>D: completed
        else 有 tool call
            D->>T: 执行（三道 waterfall，见第 11 步）
            T-->>L: append tool/call + tool/result
        end
        D->>L: append step/end
        D->>D: agent/turn-stopping（serial）—— 插件最后的机会
        Note over D,IN: 还欠事情？（inbox.nextStep 非空）→ 继续下一 step
    end
    D->>L: append turn/end（结构化 reason）
```

### 最小实现

```ts
// minidsh/loop.ts
type TurnEndReason =
  | { kind: 'completed' } | { kind: 'max-tokens' } | { kind: 'blocked' }
  | { kind: 'aborted' } | { kind: 'error'; error: unknown }

export class Loop {
  private turnNo = 0
  constructor(private ctx: Context, private session: Session, private inbox: Inbox) {}

  /** 外层：turn 返回 true 表示 inbox 还有东西，继续开新 turn。 */
  async run(): Promise<void> {
    while (await this.turn()) { /* 空 body 是故意的：条件即语义 */ }
  }

  private async turn(): Promise<boolean> {
    const turn = ++this.turnNo
    this.session.append('turn/start', { turn })
    let reason: TurnEndReason | null = null
    let target: InboxTarget = 'next-turn'
    let stepNo = 0
    try {
      while (true) {
        const step = stepNo + 1
        const decision = await this.preStep(target, turn, step)

        if (decision.kind === 'reject') { reason = { kind: 'blocked' }; return false }
        if (reason && decision.messages.length === 0) break
        // 被清掉的唤醒消息、或被改写成空的 enter，仍然拥有这个 turn 边界，
        // 但它不花模型调用——日志因此记下了「这次尝试」。
        if (stepNo === 0 && decision.messages.length === 0) {
          reason = { kind: 'completed' }; return false
        }

        this.session.append('step/start', { turn, step })
        stepNo = step
        try {
          for (const m of decision.messages) this.session.append('user/message', m)
          const stepEnd = await this.step(decision.assembly, turn, step)
          // max-tokens 是 sticky 的：后面正常完成的 step 不能把它降级。
          if (reason === null || reason.kind !== 'max-tokens') reason = stepEnd ?? reason
        } finally {
          this.session.append('step/end', { turn, step })
        }

        // 停之前给插件最后一次机会往 inbox 塞东西（goal / continuation 靠它）
        if (reason && this.inbox.nextStep.length === 0) {
          await this.ctx.bus.serial('agent/turn-stopping', { turn })
        }
        if (reason && this.inbox.nextStep.length === 0) break
        target = 'next-step'
      }
    } catch (error) {
      reason = { kind: 'error', error }
      throw error
    } finally {
      this.session.append('turn/end', { turn, reason: reason ?? { kind: 'completed' } })
    }
    return this.inbox.hasPending
  }
}
```

### 四个刻意的设计，逐个解释

**① 零 step 的 turn 也要记进日志。**

```ts
if (stepNo === 0 && decision.messages.length === 0) { reason = { kind: 'completed' }; return false }
```

用户按了发送，但某个插件把消息拦空了。这时**仍然开一个 turn 并正常关掉它**。为什么？因为「用户发起过一次尝试」本身是一个需要被记录的事实。architecture.md 明写：「*a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt*」。

如果不这么做，UI 上会出现"我明明发了消息，但什么都没发生"的鬼故事，而日志里查不到任何痕迹。

**② `max-tokens` 是 sticky 的。**

真实源码里这条注释出现了两遍（`agent.ts:285-289`），可见作者很在意：

> max-tokens is sticky: once any step hits the ceiling, later steps that complete normally must not downgrade the turn outcome.

一个 turn 里第 3 步撞了输出上限，第 4 步正常结束——这个 turn 的结论必须是"撞了上限"，不能是"完成"。因为下游（UI 提示、重试策略、评测）要据此判断结果是否可信。

**③ `agent/turn-stopping` 检查两次。**

```ts
if (reason && this.inbox.nextStep.length === 0) { await serial('agent/turn-stopping', …) }
if (reason && this.inbox.nextStep.length === 0) break   // ← 再查一次
```

第一次判断"要停了"，于是发 serial 事件；发完**再查一次** inbox。因为 `agent/turn-stopping` 的 listener 完全可以往 inbox 里塞新东西——这正是 goal 插件让循环继续的机制（第 18 步）。

这是"如何在不改循环的前提下让循环继续"的标准答案：**给循环一个"我要停了"的公告时刻，任何人都能在这一刻提出异议。**

**④ `turn/end` 在 `finally` 里，reason 是结构化的。**

无论正常结束、被取消、还是抛异常，`turn/end` 必发。而且失败是结构化的（`agent.ts:307-314`）：`LlmError` 保留它的事实，其它一切 flatten 成 `errorChain` 文本 + `code: 'UNKNOWN'`。

**永远不要让一个 turn 在日志里没有结尾。**否则重放逻辑要处理"半个 turn"，而那是无底洞。

### 真实 dsh 在哪儿

`packages/core/agent-loop/src/agent.ts`（496 行）。外层 `while (await this.turn()) {}` 在 `:212`，`turn()` 在 `:246`，`step()` 在 `:332`。

顺带一个彩蛋：源码里能看到 `/* v8 ignore next -- private callers establish the running phase before executing a step */` 这类注释（`agent.ts:333`）。这是 **100% 逐文件覆盖率门禁**留下的痕迹——不可达的防御分支必须显式标注**并说明理由**。见第 20 步。

---

<h2 id="s9">第 9 步 · inbox：两个投递口决定"欠不欠"</h2>

### 为什么

第 8 步的循环里反复出现 `inbox.nextStep.length === 0`。这个 inbox 是什么？

它解决一个很具体的问题：**输入不是只从用户来**。至少有四个来源：

1. 用户敲的消息 → 应该立刻唤醒循环
2. 工具执行产生的附加上下文（比如"文件已被外部修改"）→ 应该在下一个 step 就被看到
3. 后台作业完成通知 → 不该打断当前 step，但要尽快
4. 插件注入的上下文（`agent.inject()`）→ 应该等到有别的消息时一起进去

第 4 种最微妙。architecture.md 这句话点出了区别（`:86`）：

> Input reaches the driver through one inbox. Some messages wake it immediately; **injected context waits in the inbox until another message does**.

**注入的上下文不主动唤醒循环**——否则你每注入一句"当前分支是 main"就会触发一次模型调用，那会烧钱且莫名其妙。

### 最小实现

```ts
// minidsh/inbox.ts
export type InboxTarget = 'next-turn' | 'next-step'

export class Inbox {
  /** 下一个 turn 开始时才领取的（普通用户消息）。 */
  private turnQueue: Message[] = []
  /** 当前 turn 的下一个 step 就要领取的（工具上下文、后台通知）。 */
  readonly nextStep: Message[] = []

  /** 唤醒型投递：进 turn 队列，并请求唤醒 driver。 */
  send(message: Message): void {
    this.turnQueue.push(message)
    this.onWake?.()
  }

  /** 注入型投递：进 turn 队列但不唤醒——等别的消息把它带进去。 */
  inject(message: Message): void {
    this.turnQueue.push(message)
  }

  /** step 内投递：当前 turn 的下一个 step 立刻可见。 */
  spliceNextStep(message: Message): void {
    this.nextStep.push(message)
  }

  /** 领取。两个投递口对应两种 target。 */
  claim(target: InboxTarget, _turn: number): Message[] {
    if (target === 'next-step') return this.nextStep.splice(0, this.nextStep.length)
    return this.turnQueue.splice(0, this.turnQueue.length)
  }

  get hasPending(): boolean {
    return this.turnQueue.length > 0 || this.nextStep.length > 0
  }

  onWake?: () => void
}
```

于是第 8 步循环里 `target` 的变化就有了意义：

```ts
let target: InboxTarget = 'next-turn'   // turn 的第一个 step 领 turn 队列
// …一个 step 结束后…
target = 'next-step'                    // 后续 step 领 step 队列
```

### 工具怎么往里塞东西

真实 dsh 的 `executeToolCalls` 第六个参数就是这个投递回调（`agent.ts:395-398`）：

```ts
const { concluded } = await executeToolCalls(
  this.loopCtx, turn, step, toolCalls, signal,
  context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]),
)
return concluded ? { kind: 'completed' } : null
```

这就是工具流水线里 `tools/post-execute` 的 "add context" 决策落地的地方：工具跑完可以说"顺便告诉模型一件事"，这句话变成 inbox 的 next-step 项，**在记录的工具结果之后**注入。

`concluded ? completed : null` 也值得看：返回 `null` 表示"工具还欠一次请求"，于是外层 while 继续下一 step。**"欠不欠"是工具批次自己报告的**，不是循环猜的。

### 怎么验收

三种投递的行为差异必须能测出来：

```ts
const inbox = new Inbox()
let woke = 0; inbox.onWake = () => { woke++ }

inbox.inject({ role: 'user', content: '当前分支 main' })
console.assert(woke === 0, 'inject 不该唤醒')
inbox.send({ role: 'user', content: '修 bug' })
console.assert(woke === 1, 'send 应该唤醒')
console.assert(inbox.claim('next-turn', 1).length === 2, '一起被领走')

inbox.spliceNextStep({ role: 'user', content: '文件被外部改了' })
console.assert(inbox.claim('next-step', 1).length === 1, 'step 口独立')
```

### 真实 dsh 在哪儿

`Inbox` 在 `packages/core/agent/src/`（由 `dsh-agent` 导出，`agent.ts:18` 引入）。

真实实现还有一层我在 minidsh 里简化掉了的东西：**wake latch**（`agent.ts:164-193`）。当 driver 处于 maintenance 阶段或已被 abort 时，它无法投递唤醒，于是把唤醒**latch 住**，等收敛时重放；而 **disposal 从不 latch**——"so teardown waits on no model turn"（拆卸不等任何模型轮次）。

这类"拆卸不能被业务逻辑拖住"的细节，是长期跑在生产里的 harness 和 demo 的分水岭。

---

<h2 id="s10">第 10 步 · `agent/pre-step`：决定模型看什么</h2>

### 为什么

现在循环会领消息了。但"领到的消息"和"发给模型的消息"之间需要一道闸门，理由：

- 权限：这个 agent 在计划模式下，不许提交任何变更
- 改写：把用户的相对路径补成绝对路径
- 注入：把当前工作目录、git 分支、待办列表拼成一段上下文
- 拦截：这个会话被管理员暂停了，直接拒

architecture.md 的定位（`:88`）：

> `agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright.

### 最小实现

```ts
// minidsh/loop.ts（续）
type PreStepDecision =
  | { kind: 'reject' }
  | { kind: 'enter'; messages: Message[] }

private async preStep(target: InboxTarget, turn: number, step: number) {
  const claimed = this.inbox.claim(target, turn)
  // 每个 step 都重新装配：工具集与提示段可能已经变了（见第 13 步）
  const assembly = await this.ctx.get<SystemPrompt>('systemPrompt').assemble()
  const context = this.runtimeContext.project(assembly.sections)

  const decision = await this.ctx.bus.waterfall<PreStepDecision>(
    'agent/pre-step',
    { messages: claimed, turn, step },
    async () => ({
      kind: 'enter',
      messages: context === undefined ? claimed : [...claimed, context],
    }),
  )
  return decision.kind === 'reject' ? decision : { ...decision, assembly }
}
```

三个要点：

**① seed 函数才是默认行为。** waterfall 的第三个参数是"没有任何 listener 干预时会发生什么"。默认行为 = 领到的消息 + 一段运行时上下文。这个写法让"默认"变得**显式**，而不是藏在某个 `?? default` 里。

dsh 的宪法专门有一条讲这个（`AGENTS.md`）：

> **Explicit > implicit at package boundaries**: defaulting is an explicit `resolve(request): Spec` step in the owning implementation, never a hidden `?? default` inside `run()`.

**② 运行时上下文是"投影"出来的，不是拼字符串拼进 system prompt 的。** 它作为一条额外消息跟在 claimed 后面。这样它就是**日志可见**的（第 7 步的不变量要求）。

**③ 每个 step 重新 assemble。** 不是每个 turn 一次。因为工具集可能在 turn 中途变化（一个子 agent 起来了、计划模式退出了、动态插件挂载了新工具）。

### 怎么验收

```ts
// 一个"计划模式"插件：拒绝一切写操作请求
ctx.bus.on('agent/pre-step', async (arg, next) => {
  if (planMode.active && arg.messages.some(m => /提交|commit/.test(m.content))) {
    return { kind: 'reject' }
  }
  return next()
})
```
验收点：`reject` 之后日志里应该出现一个 `turn/start` + `turn/end{reason:'blocked'}`，中间**没有** `step/start`。

### 真实 dsh 在哪儿

`packages/core/agent-loop/src/agent.ts:225-243`。真实版本比我这个多两处：`signal.throwIfAborted()` 在 assemble 前后各一次（取消要尽早生效），以及 `assembly` 会被一路带到 `step()` 里去（避免重复装配）。

---

<h2 id="s11">第 11 步 · 工具注册表与三道 waterfall</h2>

### 为什么

工具执行是 agent 里最需要插拔的地方。要挂在这条路上的东西太多了：权限、审批、沙箱、超时、重试、指标、结果改写、UI 渲染、文件读写策略、hook 桥接……

如果全塞进 `runTool()`，那个函数会变成一千行的泥球。

dsh 的做法是把工具执行拆成**三道 waterfall + 一道单调守卫 + 一次冻结通知**。生成的流水线图（`docs/tool-execution-pipeline.md`）开头一句话点明设计目标：

> This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering run **without changing the loop**.

### 完整流水线

```mermaid
flowchart TD
    A["assistant 消息里有 tool-call"] --> B["append tool/call<br/>（执行前就记日志）"]
    B --> C["UI：presentCall(args) 待处理卡片"]
    B --> D["tools/pre-execute（waterfall）<br/>hooks · 权限 · 沙箱"]
    D -->|allow| E["注册的单调守卫<br/>只能 deny 或弃权"]
    D -->|deny| X["跳过工具体"]
    D -->|ask| F["ctx.approval 一次性询问"]
    F -->|allowed-once| E
    F -->|拒绝/取消/不可用| X
    E -->|allow| G["tools/execute（waterfall · around）<br/>超时 · 重试 · 指标"]
    E -->|deny| X
    G --> H["工具 execute() 本体"]
    H --> I["fs/write-intent · fs/edit-intent 门"]
    H --> J["工具自有事件<br/>todo/write · fs/observed · hook/*"]
    H --> G
    G --> K["tools/post-execute（waterfall）<br/>accept · block · replace · add context"]
    X --> K
    K --> L["注册表外层规范化<br/>抛出 → isError"]
    L --> M["finalizeContent<br/>最后的 content-only 不变量"]
    M --> N["tools/result（emit · 冻结）<br/>只能观察，不能改"]
    N --> O["append tool/result<br/>单一模型可见结果"]
    O --> P["additionalContexts FIFO<br/>→ inbox.next-step"]
```

### 最小实现

```ts
// minidsh/tools.ts
type PreToolDecision = { kind: 'allow' } | { kind: 'deny'; reason: string } | { kind: 'ask' }
type PostToolDecision =
  | { kind: 'accept' } | { kind: 'block'; reason: string }
  | { kind: 'replace'; content: string } | { kind: 'context'; message: Message }

export class ToolRegistry {
  private tools = new Map<string, ToolDefinition>()
  /** 单调守卫：只能拒，不能放。见第 12 步。 */
  private guards: ((exec: ToolExecution) => 'deny' | undefined)[] = []

  register(def: ToolDefinition): Disposer {
    if (this.tools.has(def.name)) throw new Error(`tool "${def.name}" already registered`)
    this.tools.set(def.name, def)
    return () => this.tools.delete(def.name)
  }

  registerGuard(guard: (exec: ToolExecution) => 'deny' | undefined): Disposer {
    this.guards.push(guard)
    return () => { const i = this.guards.indexOf(guard); if (i >= 0) this.guards.splice(i, 1) }
  }

  /** 模型能看到的 schema 列表——直接进系统提示装配。 */
  schemas(): ToolSchema[] {
    return [...this.tools.values()].map(t => ({ name: t.name, description: t.description, parameters: t.parameters }))
  }

  async execute(exec: ToolExecution, session: Session, bus: EventBus): Promise<ToolResult> {
    session.append('tool/call', { callId: exec.callId, name: exec.name, args: exec.args })

    // ① 可扩展的前置决策
    let pre = await bus.waterfall<PreToolDecision>('tools/pre-execute', exec, async () => ({ kind: 'allow' }))
    if (pre.kind === 'ask') {
      pre = (await this.askApproval(exec)) ? { kind: 'allow' } : { kind: 'deny', reason: 'user rejected' }
    }

    // ② 单调守卫：在可扩展前置之后，谁也放宽不了
    if (pre.kind === 'allow') {
      for (const guard of this.guards) {
        if (guard(exec) === 'deny') { pre = { kind: 'deny', reason: 'guard denied' }; break }
      }
    }

    // ③ around：超时 / 重试 / 指标都是包在本体外面的
    let result: ToolResult
    if (pre.kind === 'deny') {
      result = { isError: true, content: `denied: ${pre.reason}` }
    } else {
      try {
        result = await bus.waterfall<ToolResult>('tools/execute', exec,
          () => this.tools.get(exec.name)!.execute(exec.args))
      } catch (error) {
        // 规范化：流水线里的任何抛出都变成模型可读的错误结果，而不是崩掉循环
        result = { isError: true, content: errorChain(error) }
      }
    }

    // ④ 后置：接受 / 拦 / 换内容 / 追加上下文
    const post = await bus.waterfall<PostToolDecision>('tools/post-execute',
      { exec, result }, async () => ({ kind: 'accept' }))
    if (post.kind === 'replace') result = { ...result, content: post.content }
    if (post.kind === 'block') result = { isError: true, content: post.reason }
    if (post.kind === 'context') this.inbox.spliceNextStep(post.message)

    // ⑤ 定稿：冻结后只广播，不再接受修改
    const frozen = Object.freeze({ ...result })
    bus.emit('tools/result', Object.freeze(exec), frozen)
    session.append('tool/result', { callId: exec.callId, content: frozen.content, isError: frozen.isError })
    return frozen
  }
}
```

### 五个值得抄的细节

**① `tool/call` 在执行之前就记日志。** 万一工具执行时进程崩了，日志里仍然有"我曾经要调这个工具"。事后能查。

**② 抛出被规范化成 `isError` 结果，而不是崩循环。** 工具作者写错代码不该让整个 agent 死掉；模型应该看到一条错误并自己决定怎么办。

**③ `tools/result` 是 `emit` + 冻结。** 结果定稿后只能被观察。前面已经讲过，这里再强调一次：**dsh 甚至用运行时不变量守着"发布前必须冻结"**（`packages/core/tools/src/invariant.ts:23`）。

**④ 事件顺序也被不变量守着。** `packages/core/tools/src/invariant.ts:94-115` 里能看到这些断言：

```ts
if (stages.has(exec)) fail('tools/pre-execute repeated for one execution')
if (stages.get(exec) !== 'pre') fail('tools/execute must follow tools/pre-execute')
if (…) fail('tools/post-execute must follow tools/pre-execute or tools/execute')
```

**"流水线阶段的顺序"本身是一条运行时契约**，而不是靠代码结构隐含保证。

**⑤ UI 渲染意图是工具设计的一部分。** dsh 的宪法（`AGENTS.md`）：

> **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args`.

工具要在定义时就说清"我该被渲染成普通卡片、终端输出、还是 diff"，而且渲染函数必须是 `args` 的**纯函数**（所以待处理卡片能在执行前就画出来）。

### 真实 dsh 在哪儿

`packages/core/tools`（13 743 行，全仓单包第二大）。`@module` 一句话：

> Tool registry, model presentation modes, and pre/guard/around/post/result execution pipeline.

47 个模型可见工具全部走这条流水线，完整 schema 目录在 `docs/tool-catalog.md`（1 873 行，**由脚本 boot 每个工具插件后读 `ctx.tools.schemas()` 生成**——因为工具 schema 不是静态可知的：有运行时展开的枚举、拼接的描述、配置驱动的名字、原始 JSON-Schema 的 MCP 工具）。

---

<h2 id="s12">第 12 步 · 单调守卫：审批为什么不能被后面的插件放宽</h2>

### 为什么

第 11 步的流水线里有一处看起来多余的设计：既然有了 `tools/pre-execute` waterfall，为什么还要额外一层"单调守卫"？

因为 waterfall 有一个致命的灵活性：**后注册的 listener 可以覆盖前面的决定**。

```ts
// 一个善意的插件
ctx.on('tools/pre-execute', async (exec, next) => {
  const decision = await next()
  if (decision.kind === 'deny' && isProbablySafe(exec)) {
    return { kind: 'allow' }     // ← 它"帮"你放宽了权限
  }
  return decision
})
```

这段代码能通过 code review（它看起来在做合理的事），但它把整个权限系统变成了摆设。**任何能注册 listener 的插件都能提权。**

dsh 的解法：在可扩展的 waterfall **之后**再跑一层**只能拒绝、不能批准**的守卫。

流水线文档的描述（`docs/tool-execution-pipeline.md`）：

> Registered monotonic guards<br/>**deny or abstain; identity protected**

「deny or abstain」——只有两个选项：拒，或者不表态。**没有"允许"这个返回值**，所以它在数学上不可能放宽任何东西。这就是"单调"的意思：安全性只能单向增强。

### 最小实现

关键在**类型**上就不给放宽的可能：

```ts
// ✅ 守卫的返回类型里没有 'allow'
type GuardVerdict = 'deny' | undefined      // deny 或弃权，就这两种

registerGuard(guard: (exec: ToolExecution) => GuardVerdict): Disposer

// 执行时：任何一个 deny 就终局
for (const guard of this.guards) {
  if (guard(exec) === 'deny') { pre = { kind: 'deny', reason: 'guard denied' }; break }
}
```

对比一下如果用 waterfall 会怎样：

| | waterfall | 单调守卫 |
|---|---|---|
| 能改写别人的决定 | 能 | 不能 |
| 顺序敏感 | 是（后者赢） | 否（任一 deny 即终局） |
| 新插件能不小心提权 | **能** | **不可能** |
| 适合 | 策略协作、上下文补充 | 安全边界 |

### 一个真实案例：读前必写策略

dsh 用这个机制实现"读之前不许写"（`docs/tool-catalog.md` 的 `dsh-tool-fs` 行）：

> The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, **no schema change**); a deployment that loads these tools is expected to also load it.

注意 **"no schema change"**：策略是挂在事件上的，模型看到的工具 schema 完全不变。这意味着你可以在不同部署里换掉这条策略，而模型的行为契约保持稳定——**提示词缓存不会失效**。

### 怎么验收

按第 7 步那条"先违反再看红"的规矩：

```ts
// 写一个想提权的恶意/善意插件
ctx.bus.on('tools/pre-execute', async (exec, next) => {
  const d = await next()
  return d.kind === 'deny' ? { kind: 'allow' } : d      // 试图放宽
})
registry.registerGuard(exec => exec.name === 'bash' && /rm -rf/.test(exec.args.command) ? 'deny' : undefined)

const r = await registry.execute({ name: 'bash', args: { command: 'rm -rf /' }, callId: '1' }, session, bus)
console.assert(r.isError, '守卫必须挡住，waterfall 的放宽不该生效')
```

### 真实 dsh 在哪儿

`packages/core/tools/src/index.ts:704` 与 `:1101` 的 JSDoc：

> A monotonic execution guard evaluated **after** every `tools/pre-execute` …  
> Register a monotonic guard **after the extensible** `tools/pre-execute` …

"after the extensible" 这个措辞很精确：**可扩展的部分先跑，不可放宽的部分后跑**。顺序本身就是安全设计。

---

<h2 id="s13">第 13 步 · 系统提示：每一步重新装配</h2>

### 为什么

系统提示在多数项目里是一个模板字符串加几个插值。在 harness 里它必须是**注册表**，因为贡献者太多了：

- 核心身份与规则
- 工具 schema（第 11 步的 `schemas()`）
- 当前工作目录、git 状态
- 技能清单（一技能一行）
- 待办列表
- 计划模式的额外约束
- 子 agent 的专属段
- 用户自定义的家规

而且这些贡献者**会来会走**：子 agent 起来了就多一段，计划模式退出了就少一段。

### 最小实现

```ts
// minidsh/system-prompt.ts
interface Section { key: string; order: number; render: () => string | undefined }

export class SystemPrompt {
  private sections: Section[] = []
  private variables = new Map<string, () => string>()

  /** 注册一段。返回 disposer——所以段落可以随插件来去。 */
  section(key: string, order: number, render: () => string | undefined): Disposer {
    this.sections.push({ key, order, render })
    return () => { const i = this.sections.findIndex(s => s.key === key); if (i >= 0) this.sections.splice(i, 1) }
  }

  /** 每个 step 调一次：顺序固定，内容动态。 */
  async assemble(): Promise<PromptAssembly> {
    const rendered = this.sections
      .slice().sort((a, b) => a.order - b.order)   // 顺序由 order 决定，不由注册时机决定
      .map(s => ({ key: s.key, text: s.render() }))
      .filter((s): s is { key: string; text: string } => Boolean(s.text))
    return { sections: rendered, tools: this.ctx.get<ToolRegistry>('tools').schemas() }
  }
}
```

**`order` 而不是注册顺序**是关键。注册顺序取决于插件加载顺序，而插件加载顺序取决于 `inject` 依赖图——那是不该泄漏到提示词布局里的实现细节。用显式 `order` 把两者解耦。

### 一个容易忽略的收益：缓存友好

提示段有稳定顺序 + 稳定内容 = 前缀可缓存。本系列里 Reasonix 花了大力气做「稳定前缀 + turn tail」；dsh 靠"注册表 + order"天然拿到一半。

### 真实 dsh 在哪儿

`packages/core/system-prompt`（1 582 行）的 `@module`：

> Registry for ordered system sections, dynamic context, tool schemas, and prompt variables.

四类贡献：**有序系统段 / 动态上下文 / 工具 schema / 提示变量**。被 30 个包依赖，`inject` 入度第二（20 次）——半个仓库都要往提示词里塞东西，这正是它必须是注册表的原因。

真实装配在循环里的调用点是 `agent.ts:230`：

```ts
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
```

`assembleContextFor(this, signal)` 把"哪个 agent 在问"和取消信号一起传进去——**装配是 per-agent 的**（第 14 步的 scope 会解释为什么）。

---

<div class="part-band"><span class="band-k">Part 4 · 第 14–17 步</span>造能力：seam 三角色</div>

<h2 id="s14">第 14 步 · capability seam：Definition / Provider / Consumer</h2>

### 为什么

到这里，你的 harness 已经能跑了。现在要加"执行 bash 命令"的能力。

最直接的写法是写一个 `tool-bash`，里面 `child_process.spawn`。能用。但三个月后你会遇到：

- 想在远程沙箱里跑（E2B / 容器）→ 得改 `tool-bash`
- Windows 用户要 PowerShell → 再写一个 `tool-pwsh`，两份逻辑
- 想给命令加超时/审批 → 又改 `tool-bash`
- LSP 和文件读写也要在同一个远程环境里 → 每个工具都得改一遍

dsh 的答案是把每个能力做成一个**三角色的缝**（`docs/architecture.md:100`）：

> A **seam** is a swappable capability with three roles: a **Service Definition** declaring the interface, a **Service Provider** implementing it, and a **Consumer** using it, commonly a model-facing tool. A package may combine roles, but **one role alone is not a seam**; adding a capability means designing all three.

glossary 里的定义更严格（`docs/glossary.md:9`）：Service Definition 必须是 Cordis 的 `Service`（抽象类或具体注册表），**never a TypeScript `interface`**。

### 三角色长什么样

以 shell 为例，这是 dsh 自己指定的范本：

```mermaid
flowchart LR
    subgraph def["① Service Definition：dsh-shell（476 行）"]
        D["abstract class ShellExecutor<br/>declare ctx.shell<br/>+ 词汇类型 ShellExecRequest/Spec/Process"]
    end
    subgraph prov["② Service Provider（四个，同一接口）"]
        P1["dsh-bash-local<br/>本机 bash"]
        P2["dsh-bash-sandbox<br/>沙箱 bash"]
        P3["dsh-pwsh-local<br/>本机 PowerShell"]
        P4["dsh-pwsh-sandbox"]
    end
    subgraph cons["③ Consumer（模型可见工具）"]
        C1["dsh-tool-bash → bash"]
        C2["dsh-tool-pwsh → pwsh"]
        C3["dsh-tool-bash-persistent → bash（PTY）"]
    end
    D -.实现.- prov
    D -.注入.- cons
    cons --> M["模型只看到工具名与 schema"]
```

三个角色的分工非常清楚：

| 角色 | 回答什么问题 | 它**不**回答什么 |
|---|---|---|
| Definition | 这个能力**是什么**（接口 + 词汇） | 怎么实现、谁来用 |
| Provider | **怎么实现**（本机 / 沙箱 / 远程） | 模型怎么看到它 |
| Consumer | **模型怎么用**（工具名、schema、渲染） | 底下是本机还是远程 |

### 最小实现

```ts
// ① Definition：只声明接口与词汇，不含任何实现
// minidsh/shell/definition.ts
export interface ShellExecRequest { command: string; cwd?: string; timeoutMs?: number }
export interface ShellExecSpec extends ShellExecRequest { cwd: string; timeoutMs: number }  // 默认值已解析
export interface ShellRunResult { stdout: string; stderr: string; exitCode: number }

export abstract class ShellExecutor {
  /** 显式的默认值解析步骤——不是藏在 run() 里的 ?? 默认。 */
  abstract resolve(request: ShellExecRequest): ShellExecSpec
  abstract run(spec: ShellExecSpec): Promise<ShellRunResult>
}

export const definition: Plugin = (ctx) => {
  // Definition 包本身只贡献词汇与设置命名空间，不 provide 实现
  ctx.provide('shellSettingsNamespace', 'shell')
}
```

```ts
// ② Provider：实现它
// minidsh/shell/bash-local.ts
class BashLocal extends ShellExecutor {
  resolve(request: ShellExecRequest): ShellExecSpec {
    return { ...request, cwd: request.cwd ?? process.cwd(), timeoutMs: request.timeoutMs ?? 120_000 }
  }
  async run(spec: ShellExecSpec): Promise<ShellRunResult> {
    const { execFile } = await import('node:child_process')
    return new Promise(resolve => {
      execFile('bash', ['-lc', spec.command], { cwd: spec.cwd, timeout: spec.timeoutMs },
        (err, stdout, stderr) => resolve({ stdout, stderr, exitCode: err ? 1 : 0 }))
    })
  }
}
export const bashLocal: Plugin = (ctx) => ctx.effect(() => ctx.provide('shell', new BashLocal()))
```

```ts
// ③ Consumer：把它变成模型可见的工具
// minidsh/shell/tool-bash.ts
export const toolBash: Plugin = (ctx) => {
  const shell = ctx.get<ShellExecutor>('shell')          // 只知道键，不知道实现
  return ctx.get<ToolRegistry>('tools').register({
    name: 'bash',
    description: 'Run a bash command.',
    parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] },
    render: 'terminal',                                   // UI 渲染意图（第 11 步）
    execute: async (args) => {
      const spec = shell.resolve({ command: args.command })   // 先显式解析
      const r = await shell.run(spec)                          // 再执行
      return { content: r.stdout + r.stderr, isError: r.exitCode !== 0 }
    },
  })
}
toolBash.inject = ['tools', 'shell']
```

### 那个 `resolve(request): Spec` 分裂为什么重要

dsh 把 shell 这个 request/spec 分裂当作宪法级模板（`AGENTS.md`）：

> **Explicit > implicit at package boundaries**: defaulting is an explicit `resolve(request): Spec` step in the owning implementation, never a hidden `?? default` inside `run()`（the `dsh-shell` request/spec split is the template）.

为什么这么讲究？考虑一个具体场景：**超时默认值是多少？**

- 藏在 `run()` 里的 `?? 120_000`：调用方无法知道实际用了什么；日志里没有；换 provider 后默默变了；测试断言不了。
- 显式 `resolve()`：`Spec` 是完整的、无 optional 的对象。**它可以被记录、被断言、被展示给用户**。

这就是 `ShellExecRequest`（字段可选）和 `ShellExecSpec`（字段齐全）分成两个类型的意义——**"未解析"和"已解析"在类型层面不同**，你不可能把一个没解析完的东西传进 `run()`。

### 真实 dsh 在哪儿

我做的普查里，shell 组共 9 个包 / 10 405 行：

| 包 | 角色 | 说明 |
|---|---|---|
| `shell` | Definition | `ctx.shell`，476 行，只有接口与词汇 |
| `bash-local` / `bash-sandbox` | Provider | 本机 / 沙箱 |
| `pwsh-local` / `pwsh-sandbox` | Provider | Windows 组合 |
| `shell-env` | 支撑 | `ctx.shellEnv`，托管 `DSH_*` 环境 |
| `tool-bash` / `tool-pwsh` | Consumer | 模型可见 `bash` / `pwsh` |
| `tool-bash-persistent` | Consumer | 走 `ctx.terminals` 的 PTY 版 |

Definition 的 JSDoc 里还有一句很能说明"边界感"的话（`packages/shell/shell/src/index.ts:1-5`）：

> Service Definition for the `ctx.shell` capability seam, covering foreground commands and background process handles. **Job ids, ownership, polling, and notices belong to `@deepseek-ai/dsh-jobs`, keeping executors independent of sessions.**

**执行器不该知道会话的存在**。后台作业的 id、归属、轮询、通知全部归 `ctx.jobs`。所以同一个执行器既能给聊天用，也能给自动化协议用，也能给子 agent 用。

---

<h2 id="s15">第 15 步 · 换一个 provider，搬走整个执行世界</h2>

### 为什么

第 14 步做完 seam，收益还只是"bash 能换实现"。真正的杠杆在下一层：**多个 seam 共享同一个执行世界**。

architecture.md 的这句话是整个架构最强的卖点（`:102`）：

> Seams are why one provider swap changes the whole product. **Filesystem and subprocess providers share one execution world, so pointing them at a remote sandbox moves Bash, PTY, and LSP with them, with no provider forks.**

翻译：把 `ctx.fs` 和 `ctx.subprocess` 指向远程沙箱，那么 **bash、持久终端、语言服务全都自动搬到了远程**——因为它们都建在这两个原语之上，而不是各自 spawn 自己的进程。

### 依赖方向决定了这件事成不成

```mermaid
flowchart TB
    subgraph prim["两个原语 seam"]
        FS["ctx.fs<br/>读写文件"]
        SP["ctx.subprocess<br/>起进程"]
    end
    subgraph built["建在原语之上的能力"]
        SH["ctx.shell → bash / pwsh"]
        TM["ctx.terminals → 持久 PTY"]
        LSP["ctx.lsp → 语言服务"]
        SEARCH["glob / grep（打包的 ripgrep）"]
        CR["ctx.codeRuntime → 代码模式"]
    end
    FS --> built
    SP --> built
    subgraph swap["换 provider = 换整个世界"]
        L["本机：node:fs + child_process"]
        E["远程：dsh-e2b（沙箱 FS/subprocess 适配器）"]
    end
    L -.任选其一.-> prim
    E -.任选其一.-> prim
```

这张图的关键是**箭头方向**：没有任何一个上层能力直接 import `node:child_process`。它们全都注入 `ctx.subprocess`。

我在普查里验证了这一点——`subprocess` 被 8 个包 inject（入度第 7），而 `glob`/`grep` 这两个搜索工具的目录描述明确写着（`docs/tool-catalog.md`）：

> glob and grep are unconditional discovery tools that **spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess** as ordinary foreground calls (never background jobs) — **no host `rg` install and no shell layer**.

注意 "no shell layer"：搜索不经过 shell，直接走 subprocess。所以搜索能力不依赖"机器上有没有 bash"，也就能在远程沙箱、Windows、精简容器里一致工作。

### 最小实现：一个远程 provider

```ts
// minidsh/e2b/remote-subprocess.ts
class RemoteSubprocess extends SubprocessRunner {
  constructor(private client: SandboxClient) { super() }
  async spawn(spec: SpawnSpec): Promise<ProcessHandle> {
    return this.client.exec(spec.argv, { cwd: spec.cwd, env: spec.env })   // 走 HTTP 到沙箱
  }
}
class RemoteFs extends FileSystem { /* 同理：把读写代理到沙箱 */ }

export const remoteWorld: Plugin = (ctx) => {
  const client = new SandboxClient(ctx.get<Settings>('settings').get('e2b.apiKey'))
  const d1 = ctx.provide('subprocess', new RemoteSubprocess(client))
  const d2 = ctx.provide('fs', new RemoteFs(client))
  return () => { d1(); d2() }
}
remoteWorld.inject = ['settings']
```

挂上这一个插件（替掉本机那两个），你的 bash / PTY / LSP / 搜索**全部**跑在远程沙箱里，而这四个包**一行代码都没改**。

### 怎么验收

这个论断值得写一个真正的测试来钉住：

```ts
// 同一个工具调用，在两个世界里跑，断言它落在正确的世界
for (const world of [localWorld, remoteWorld]) {
  const ctx = await bootHarness([world, toolBash, toolFsSearch, lspStdio])
  const r = await callTool(ctx, 'bash', { command: 'hostname' })
  console.assert(r.content.trim() === expectedHostname[world.name], `${world.name} 应落在对应执行世界`)
}
```

### 真实 dsh 在哪儿

`packages/e2b`（3 包 / 6 630 行）就是这个论断的实证。`AGENTS.md` 里它的定位是：

> `e2b/` E2B POC: sandbox + FS/subprocess adapters

一个 POC 用 6 630 行证明了架构承诺——**换两个 provider，五种能力跟着搬家**。

相关的执行世界包体量（我的普查）：`fs` 7 包 13 044 行、`shell` 9 包 10 405 行、`terminal` 3 包 5 721 行、`lsp` 3 包 5 496 行、`subprocess` 2 包 4 256 行、`code-runtime` 2 包 3 963 行。**这六组共 26 包、42 885 行，全部建在两个原语 seam 上。**

---

<h2 id="s16">第 16 步 · 沙箱：把 argv 包起来</h2>

### 为什么

工具能跑任意命令了，现在要限制它。

多数项目的做法是在工具里做检查：正则黑名单、路径白名单。问题是**这类检查在同一个进程里，模型只要绕过工具（比如用 `bash` 跑一个脚本去做被禁的事）就失效了**。

真正的隔离必须由操作系统提供。dsh 的做法是把沙箱做成一个 seam，让**消费者在 spawn 之前把 argv 包一层**（`docs/architecture.md` 扩展点表）：

> Confine spawned processes | use a `ctx.sandbox` backend; **consumers wrap argv before spawning**

### 最小实现

沙箱不执行命令，它只**改写 argv**：

```ts
// minidsh/sandbox/definition.ts
export abstract class Sandbox {
  /** 把原始 argv 包成"受限执行"的 argv。 */
  abstract wrap(argv: string[], policy: SandboxPolicy): string[]
  /** 如实上报自己真正提供的隔离强度——不许谎报。 */
  abstract readonly level: 'system' | 'application' | 'off'
}

// Linux Landlock 后端
class LandlockSandbox extends Sandbox {
  readonly level = 'system' as const
  wrap(argv: string[], policy: SandboxPolicy): string[] {
    return [
      'landlock-run',
      ...policy.readPaths.flatMap(p => ['--ro', p]),
      ...policy.writePaths.flatMap(p => ['--rw', p]),
      ...policy.allowNetwork ? [] : ['--no-net'],
      '--', ...argv,
    ]
  }
}
```

消费者侧只多一行：

```ts
// bash-sandbox provider
async run(spec: ShellExecSpec): Promise<ShellRunResult> {
  const raw = ['bash', '-lc', spec.command]
  const argv = this.sandbox.wrap(raw, this.policy.for(spec))   // ← 就这一行
  return this.subprocess.spawn({ argv, cwd: spec.cwd })
}
```

### 三个设计要点

**① 沙箱不执行，只包装。** 这让它能和任何 provider 组合：本机 bash、远程 subprocess、LSP 子进程，谁都能在 spawn 前包一层。如果沙箱自己负责执行，就会和执行器争夺同一个职责。

**② 隔离强度是一个可查询的值，不是一个假设。** `level` 字段让上层能据此**分级授权**。本系列里 DeepTutor 也用了同一招（SYSTEM 级对所有人开放、APPLICATION 级仅管理员）。这比"假装所有部署一样安全"诚实得多。

**③ 部分失败必须分类正确。** dsh 有一篇专门的事后复盘讲这个：`docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md`——**Landlock 的"部分生效"通知曾把子进程的失败错误分类**。

这个 postmortem 值得所有写沙箱的人读：当你的沙箱只能部分生效（老内核不支持某些 rule），你发出的"部分生效"提示不能把**子进程自己的失败**也算进去，否则用户会以为是沙箱问题而去关掉沙箱。

### 真实 dsh 在哪儿

- `packages/sandbox`（4 包 / 9 040 行）：Definition + 策略 + 后端
- `native/landlock-run`：`@deepseek-ai/node-addon-landlock-run` 的源码所在地，按平台分包（`-linux-x64` / `-linux-arm64`）
- `pnpm-workspace.yaml` 里对它有一句注释：*"The Landlock launcher is developed with its harness consumers but keeps its native build and publication scripts under native/landlock-run."*

`ctx.sandboxPolicy` 是独立的服务键（我的普查里 `sandbox` 与 `sandboxPolicy` 是两个键）——**"能力"和"策略"分开**，所以策略可以按部署替换而不换沙箱实现。

---

<h2 id="s17">第 17 步 · 上下文经济：compaction 与 spill</h2>

### 为什么

两个不同的溢出问题，需要两个不同的机制。

**问题 A：对话太长了。** 20 轮之后历史超过窗口。要压缩。

**问题 B：单次工具输出太大了。** `grep` 匹配了 3 万行，`cat` 了一个 5MB 的文件。这一条就能撑爆窗口。

很多项目只做 A（压缩历史），于是 B 发生时整个会话直接崩。或者只做 B（截断输出），于是模型永远看不到被截掉的部分。

dsh 把两者做成两个 seam：`ctx.compaction` 和 `ctx.spillStore`。

### spill：把超量输出落盘，给模型一个可取回的定位符

`packages/spill` 的 Definition JSDoc 说得很清楚：

> Service Definition for the spill storage capability seam (`ctx.spillStore`): an abstract service defining WHAT a spill backend does — **persist a tool's oversized text and return a model-facing locator plus retrieval guidance**.

三个词是关键：**persist（落盘）+ locator（定位符）+ retrieval guidance（怎么取回的说明）**。

不是简单截断，而是：完整内容存起来，给模型一条"我把完整结果存在这里了，你可以这样读它"的消息。

```ts
// minidsh/spill.ts
export abstract class SpillStore {
  abstract save(text: string, meta: { tool: string; callId: string }): Promise<SpillLocator>
}

/** 一个 post-execute 插件：超过阈值就溢出 */
export const spillPolicy: Plugin = (ctx) => {
  const store = ctx.get<SpillStore>('spillStore')
  return ctx.bus.on('tools/post-execute', async ({ exec, result }, next) => {
    if (result.content.length <= 20_000) return next()
    const locator = await store.save(result.content, { tool: exec.name, callId: exec.callId })
    return {
      kind: 'replace',
      content:
        `${result.content.slice(0, 2_000)}\n\n` +
        `[输出过长，完整 ${result.content.length} 字符已保存]\n` +
        `完整内容：${locator.path}（可用 read 工具按行区间读取）`,
    }
  })
}
spillPolicy.inject = ['spillStore']
```

真实 dsh 的 `glob`/`grep` 就是这么用的（`docs/tool-catalog.md`）：

> Capped results save the complete formatted list through the optional `ctx.spillStore` backend; **returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments**.

**"可后续读取/搜索的定位符"**——溢出的内容不是坟墓，它还能被 `read` 和 `grep` 继续处理。

### compaction：压缩历史，但结果必须回到日志

压缩的难点不是"怎么摘要"，而是**"压缩结果算不算模型可见"**。

按第 7 步的宪法：算。所以压缩产物必须成为一个 session event，而不是内存里的一个变量。

```ts
// 压缩后 append 一个事件，deriveMessages 据此改变投影
session.append('compaction/applied', {
  replacedRange: [firstSeq, lastSeq],
  summary: summaryText,
})

// deriveMessages 遇到它就跳过被替换的区间，改用摘要
deriveMessages() {
  const compactions = this.events.filter(e => e.type === 'compaction/applied')
  // …被覆盖区间的原始消息不再投影，改投影摘要…
}
```

这样一来：压缩是**可审计**的（能看到压了哪一段、摘要是什么）、**可重放**的（重放日志能得到同样的模型输入）、**可撤销的**（删掉那个事件就恢复原状）。

### 真实 dsh 在哪儿

- `packages/compaction`（4 包 / 8 032 行）：Definition + basic provider
- `packages/spill`（3 包 / 1 473 行）：Definition + backend + policy
- `docs/subsystems/compaction.md` / `spill.md` 是各自的子系统文档
- 还有一个 `ctx.toolResultPruner` 和 `ctx.tokenMeter`（我的普查里都是独立服务键）——**结果修剪**和**token 计量**也各自成缝

四个独立的服务键（`compaction` / `spillStore` / `toolResultPruner` / `tokenMeter`）管同一件事的四个侧面。这是本系列里对"上下文经济学"拆得最细的一家。

---

<div class="part-band"><span class="band-k">Part 5 · 第 18–20 步</span>造产品：装配与纪律</div>

<h2 id="s18">第 18 步 · 让循环继续：`agent/turn-stopping` 与 goal</h2>

### 为什么

第 8 步埋了一个钩子：turn 要停之前发一个 serial 事件，然后**再查一次 inbox**。现在用它。

场景：用户说"把测试跑到全绿"。模型改了一版，跑测试，还有 3 个失败，然后它说"我改好了一部分"就停了。

你希望它继续。怎么办？

- ❌ 改循环：加一个"如果目标没达成就继续"的判断 → 违反"Plugins, not loop changes"
- ❌ 在提示词里说"不要中途停" → 模型不听话，而且没有客观判据
- ✅ 挂一个 `agent/turn-stopping` 插件：检查目标状态，没达成就往 inbox 塞一条消息

### 最小实现

```ts
// minidsh/goal.ts
export const goalContinuation: Plugin = (ctx) => {
  const goals = ctx.get<GoalService>('goals')
  return ctx.bus.on('agent/turn-stopping', async ({ turn }) => {
    const goal = goals.active()
    if (!goal || goal.status !== 'open') return
    if (goal.round >= goal.maxRounds) return          // 有界：不能无限续
    goals.bumpRound()
    ctx.inbox.spliceNextStep({
      role: 'user',
      content: `目标未完成：${goal.objective}\n当前第 ${goal.round}/${goal.maxRounds} 轮，请继续。`,
    })
  })
}
goalContinuation.inject = ['goals']
```

循环那边**一行都不用改**——它在第 8 步就已经写好了"发公告 → 再查 inbox"的逻辑。

### dsh 的 goal 做得比这狠：权限分级 + 有界

看 `docs/tool-catalog.md` 里 `dsh-tool-goal` 的部署说明：

> `create_goal`, `get_goal`, `update_goal` … **create, edit, pause, and resume require direct-human root authority**; complete and blocked also accept the exact current goal round. **The default blocked lower bound is three admitted rounds.**

三个设计值得抄：

**① 创建/编辑/暂停/恢复目标需要"直接人类根权限"。** 模型不能给自己派目标，也不能悄悄改目标。否则"把测试跑绿"会被模型改成"把测试注释掉"。

**② `complete` 和 `blocked` 需要带上"确切的当前轮次"。** 这是一个 compare-and-set：模型说"我完成了第 5 轮的目标"，如果实际已经是第 6 轮，这个声明就失效。防的是过期声明。

**③ `blocked` 有下界：至少 3 个已准入轮次。** 模型不能第一轮就说"我被卡住了"。必须真的试过三轮。

`packages/goal` 的 `@module` 一句话：

> Same-session goal domain: **event-sourced state, compare-and-set mutations, and process-local continuation activation**.

**event-sourced + compare-and-set** —— 目标状态也是从日志派生的，变更是乐观并发控制的。

### 与本系列其它项目对照

| 项目 | 防"模型自称完成"的机制 |
|---|---|
| MiMo Code | Goal 独立裁判 + 四道死循环闸门 |
| grok-build | Goal Mode 五件套 |
| Reasonix | Delivery Profile 证据签收 + readiness 门禁 |
| DeepTutor | 掌握度门禁 / 计划 done 门禁沉进工具 |
| **DeepSeek Harness** | **`agent/turn-stopping` + event-sourced goal + CAS + 人类根权限 + 有界轮次** |

共同点是那句话：**凡是能被算准的，就别让模型自由心证。** dsh 的特点是把这件事做成了**一个事件 + 一个插件**，循环本身对"目标"一无所知。

### 真实 dsh 在哪儿

`packages/goal`（4 包 / 5 786 行，`ctx.goals`）。扩展点表里的一行（`docs/architecture.md`）：

> Manage a same-session objective | use `ctx.goals`; continue through `agent/*`

---

<h2 id="s19">第 19 步 · bundle 与 profile：把装配做成可打补丁的层</h2>

### 为什么

你现在有 40 个插件。用户要跑 Web 界面，需要其中 35 个；跑一次性无头任务，需要 22 个；跑自动化协议服务，需要 25 个。

怎么表达"哪些插件 + 什么配置"？

- ❌ 三个 `main.ts` → 行为漂移，改一处忘两处
- ❌ 一个巨大的 if/else → 无法被第三方扩展
- ✅ **配置即装配**，而且分层可打补丁

### dsh 的两个概念

`docs/architecture.md:19-21`：

> A **profile** is a named composition stored in the Harness home. It lists the bundles it stacks, holds any out-of-tree plugins it installs, and keeps the user's own `cordis.patch.yml`.  
> A **bundle** is a distribution format for Cordis config rows and the code they mount, **so whatever it inserts stays patchable by the layers above it**.

两者都在自己的 `package.json` 里用 `dsh` 字段自述：`dsh.profile` 列出 profile 的 bundle，`dsh.bundle` 指向 bundle 的 patch 文件。

### 分层顺序

```mermaid
flowchart LR
    E["空 entry 列表"] --> B1["bundle 1（按 profile 声明顺序）"]
    B1 --> B2["bundle 2"]
    B2 --> B3["bundle N"]
    B3 --> PP["profile 的 cordis.patch.yml"]
    PP --> HP["home 级 cordis.patch.yml"]
    HP --> CLI["--patch 命令行覆盖层"]
    CLI --> FINAL["最终插件树"]
```

规则（`docs/architecture.md:27`）：

> Layers apply to an empty entry list in this order: each bundle in the profile's listed order, then the profile's `cordis.patch.yml`, then the home-level one, then any `--patch` overlay. **A patch targets a row by id and replaces its whole config, or inserts new rows.**

**"replaces its whole config"** 而不是深合并。这是个刻意选择：深合并的语义在嵌套配置下极难预测（数组怎么合？null 表示删除还是设空？），整体替换虽然啰嗦，但**你永远知道最终值是什么**。

### 最小实现

```ts
// minidsh/boot.ts
interface ConfigRow { id: string; plugin: string; config?: Record<string, unknown>; disabled?: boolean }

/** 分层：按序应用每一层的 patch，靠 id 替换整行 config 或插入新行。 */
export function composeTree(bundles: ConfigRow[][], patches: ConfigRow[][]): ConfigRow[] {
  let rows: ConfigRow[] = []
  for (const bundle of bundles) rows = rows.concat(bundle)
  for (const patch of patches) {
    for (const row of patch) {
      const i = rows.findIndex(r => r.id === row.id)
      if (i >= 0) rows[i] = { ...rows[i], config: row.config, disabled: row.disabled }  // 整体替换
      else rows.push(row)                                                                // 插入新行
    }
  }
  return rows.filter(r => !r.disabled)
}

/** 把行变成插件树。 */
export async function boot(rows: ConfigRow[]): Promise<Context> {
  const ctx = new Context()
  for (const row of rows) {
    const mod = await import(row.plugin)
    const plug: Plugin = mod.apply ?? mod.default
    if ('default' in mod && !mod.apply) {
      // 见第 2 步那个 postmortem：default 导出会丢掉 inject
      throw new Error(`plugin "${row.plugin}" must use named exports (apply/inject), not default`)
    }
    await ctx.plugin(withConfig(plug, row.config))
  }
  ctx.assertSettled()
  return ctx
}
```

### 一个必须有的调试出口

dsh 提供了 `dsh --profile web --dump-config`，并且文档明确承诺（`:29-35`）：

> To see the tree your machine actually boots … **Any row it prints can be replaced by a patch of your own.**

**"打印出来的每一行都能被你自己的 patch 替换"** ——这句话是可配置性的验收标准。如果你的框架做不到"打印实际装配 + 每一行都可覆盖"，那它的可配置性是假的。

给 minidsh 加上：

```ts
if (process.argv.includes('--dump-config')) {
  console.log(JSON.stringify(composeTree(bundles, patches), null, 2))
  process.exit(0)
}
```

### 真实 dsh 在哪儿

- `packages/bundle`（3 包）：`dsh-base`（每个 profile 的第一层：模型适配器、工具、持久化、沙箱与审批策略、设置、凭据、遥测）、`dsh-web-app`、`dsh-headless`
- `packages/boot`（2 包 / 4 081 行）：组合机制
- `packages/preset`（2 包 / 3 794 行）：**逐会话**装配——`ctx.agentPresets`，让一个会话拥有不同能力集，其中的 service 行需要 `isolate` realm
- `examples/` 是可运行的 `cordis.yml` 叶子

有趣的是 `dsh-base` 这个包的 `@module` JSDoc（我的普查抓到的）：

> package's substance is `cordis.patch.yml`, declared by the `dsh.bundle.patch` manifest field and resolved by the profile composer through that field; **this module carries no runtime API**.

**一个 117 行、没有运行时 API 的包**，它的全部实质是一个 YAML 文件。这就是"配置即架构"的字面意思。

---

<h2 id="s20">第 20 步 · 把纪律写成门禁：100% 覆盖 + `verify-*`</h2>

### 为什么

前 19 步立了很多规矩：waterfall 必须调 `next()`、注册必须可逆、模型可见必须已记录、seam 必须三角色齐全、默认值必须显式解析……

**规矩写在文档里 = 迟早被违反。** 尤其当协作者是 AI 时——它读了 20 页文档，但第 21 次改动就会忘掉第 3 页那条。

dsh 的态度是：**能机械检查的不变量，全部接到一个会被执行的顶层门禁上**。宪法原文（`AGENTS.md`）：

> **Wire mechanically checkable invariants into an executed top-level gate and prove each changed acceptance path rejects an invalid case.**

### 六层测试

`docs/testing.md`：

| 层 | 命令 | 关键点 |
|---|---|---|
| 单元 | `pnpm run test` | **每个注册表都要有 HMR 安全测试**（拆掉贡献 fiber，断言清理干净） |
| 覆盖率门禁 | `pnpm run test:coverage` | **`packages/*/*/src` 逐文件 100%** |
| 真实 API e2e | `pnpm run test:e2e` | 带 key 打真实模型；无 key 自跳过 |
| 快照 | `pnpm run test:snapshot` | **keyless**：重放录制的会话，diff 规范化后的 JSON-RPC + 重新持久化的日志 |
| 浏览器快照 | `pnpm run test:web` | Chromium 对比重放输出（Linux PR 必过） |
| 门禁集合 | `pnpm run doc-sync` / `hygiene` / `duplication` | 见下 |

**逐文件 100% 覆盖率**这条我要单独说，因为 dsh 对它的解读和大多数团队完全不同：

> An uncovered line is often **dead code the gate is correctly flagging for deletion**, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped.

**"未覆盖的行往往是死代码，门禁正确地在提示你删掉它"** ——这是把覆盖率当**删代码的工具**，而不是当写测试的 KPI。

这也解释了第 8 步看到的那些注释：

```ts
/* v8 ignore next -- private callers establish the running phase before executing a step */
if (this.phase.kind !== 'running') throw new Error(...)
```

不可达的防御分支不能默默放过，必须**显式豁免 + 写清理由**。而 `scripts/coverage-exempt.ts` + `coverage-exempt.spec.ts` 又在守着这些豁免本身。

### 三条我认为最该抄的测试规矩

**① 验证世界，不验证自述**（`docs/testing.md:29`）：

> An e2e assertion **re-runs the command or re-reads the file externally**; a keyword probe on the agent's own output lets a cheating agent pass. **Assert untouched files are byte-identical.**

"对 agent 自己的输出做关键词探测会让作弊的 agent 通过"——这句话应该刻在每个 agent 项目的测试文件顶部。

**② 守卫必须被证明有效**（`:34`）：

> A guard only guards if the regression actually fails it. … add an explicit assertion, **and prove it: introduce the regression, watch red, revert.**

**③ 测真实入口路径**（`:35`）：

> "Real entry path" means the published artifact: a package `bin` runs built `lib/bin.js` **under plain `node`**, exposing failures tsx masks (settle races, module resolution, swallowed load failures).

用 tsx 跑源码会掩盖三类真实故障。所以关键路径必须用**发布产物**测一遍。

### 28 个 `verify-*`：把文档也焊住

`scripts/` 下有 124 个 `.ts`，其中 28 个是 `verify-*`。我挑几个说明它们把什么变成了机器可检查的：

| 门禁 | 它守什么 |
|---|---|
| `verify-export-jsdoc` | 每个导出都有 JSDoc，函数类导出必须有 `@param`/`@returns` |
| `verify-cordis-config` | `cordis.yml` 里的裸插件必须出现在 resolver manifest 的 `dependencies` |
| `verify-package-invariants` | 每个包都有 `./invariant` 伴生；空 installer 必须以 `No runtime invariant:` 开头解释 |
| `verify-type-equiv` | 文档里的 `ts type-equiv` 代码块必须与源码类型**等价** |
| `verify-mermaid` | 图必须能渲染 |
| `verify-md-links` / `verify-doc-refs` | 链接与引用不许烂 |
| `verify-md-wrap` | markdown 换行纪律（一段一物理行） |
| `verify-doc-budgets` | **文档字数预算**（想超要改 manifest 里的上限） |
| `verify-package-readme-limitations` | **每个包 README 必须写清"局限"** |
| `verify-package-readme-model-experience` | **必须写清"模型体验"** |
| `verify-agent-note-classification` / `-format` | 设计文档的分类与格式 |
| `verify-translation-pairing` / `-prompt` | 双语文档逐段配对 |
| `verify-runtime-closure` | 运行时依赖闭包 |
| `verify-dsh-package-licenses` | 许可证 |

最后两行的 `verify-package-readme-limitations` / `-model-experience` 是我在本系列里没见过第二家做的：**它强制每个包的 README 都要有"局限"和"模型体验"两节**。前者防止过度宣传，后者要求作者站在模型的视角描述自己的包。

而且——**这些门禁脚本自己也有 `.spec.ts`**（`coverage-exempt.spec.ts`、`run-gates.spec.ts`、`gen-doc-graphs.spec.ts`…）。守卫也要被守卫。

### 生成而非手写的文档

`docs/` 里有 8 177 行是**生成**的：`config-catalog.md`（3 151）、`tool-catalog.md`（1 873）、`module-graph.md`（1 638）、`persistence-catalog.md`（944）、`capability-seams.md`（471）……全部由 `scripts/gen-*.ts` 产出，并由 `doc-sync` 验证新鲜度。

`tool-catalog.md` 的生成方式尤其讲究（文件头注释）：

> Unlike the cordis catalog (a pure source-AST pass), this generator **BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`**, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). **A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator's boot manifest, so a new tool cannot be silently undocumented.**

**"新工具不可能被静默地漏掉文档"** ——这是把"文档完整性"变成了编译期问题。

### 怎么在自己项目里落地

不需要 124 个脚本。先抄这四条，投入产出最高：

1. **每个注册表一个 HMR 测试**（拆掉再断言清理干净）——挡住整类内存泄漏和幽灵注册。
2. **e2e 断言必须从外部重读世界**——挡住"agent 自述完成"。
3. **新守卫必须先看它变红**——挡住假守卫。
4. **一条 `verify-` 脚本查你最在意的那条不变量**，接进 CI。

---

<div class="part-band"><span class="band-k">Part 6</span>收尾：回放 · 验收 · 翻车清单</div>

<h2 id="replay">🎬 完整回放：一句"帮我改个 bug"到底跑了什么</h2>

把 20 步串起来。用户说：

> 帮我把 `src/parser.ts` 里那个把空字符串当合法输入的 bug 修掉，改完跑测试。

```mermaid
sequenceDiagram
    participant U as 用户
    participant IN as inbox
    participant D as 循环（插件）
    participant SP as systemPrompt
    participant L as 日志
    participant M as ctx.llm
    participant T as ctx.tools
    participant G as 守卫/沙箱
    participant FS as ctx.fs / ctx.shell

    U->>IN: send(消息) → 唤醒
    D->>L: turn/start {turn:1}
    D->>IN: claim('next-turn')
    D->>SP: assemble()（段落按 order + 47 个工具 schema）
    D->>D: agent/pre-step waterfall → enter
    D->>L: step/start {1,1} + user/message
    D->>L: deriveMessages()
    D->>D: agent/request waterfall → {provider, model, maxTokens}
    D->>M: stream()
    M-->>L: assistant/chunk ×N
    D->>L: assistant/message（sourceEventSeqs 指回那些 chunk）
    Note over D: 有 tool_call: read(src/parser.ts)
    D->>T: execute
    T->>L: tool/call（执行前先记）
    T->>G: tools/pre-execute → allow
    T->>G: 单调守卫 → 弃权
    T->>FS: read
    T->>L: fs/observed（读过了，写策略据此放行）
    T->>L: tool/result（冻结）
    D->>L: step/end {1,1}
    Note over D,IN: 工具还欠一次请求 → target='next-step'
    D->>L: step/start {1,2}
    D->>M: stream()（历史里已有文件内容）
    Note over D: tool_call: edit(...) + bash(pnpm test)
    T->>G: pre-execute → ask（写操作要审批）
    G-->>U: 审批：允许一次
    T->>FS: edit → fs/edit-intent 门 → 写入
    T->>G: bash 走沙箱：argv 被 landlock-run 包装
    FS-->>T: 测试输出 12 万字符
    T->>T: tools/post-execute → 超阈值 → spill
    T->>L: tool/result（前 2000 字 + 定位符）
    D->>L: step/end {1,2}
    D->>M: stream()（第 3 步：看测试结果）
    Note over D: 没有 tool_call → completed
    D->>D: agent/turn-stopping（serial）
    Note over D: goal 插件检查：目标达成，不续
    D->>L: turn/end {turn:1, reason:{kind:'completed'}}
```

这一趟里，**循环代码没有一行是为"审批""沙箱""溢出""目标"写的**。它们分别是：一个 `tools/pre-execute` listener、一个 argv 包装器、一个 `tools/post-execute` listener、一个 `agent/turn-stopping` listener。

这就是 harness 型架构的全部意义：**新行为长在扩展点上，不长在循环里。**

---

<h2 id="a1">附录 A · 验收断言（每步怎么验）</h2>

| 步 | 做完之后，这个断言必须过 |
|---|---|
| 1 | 重复 `provide` 抛错；`get` 未注册的键抛错 |
| 2 | 乱序挂载后依赖自动排好；`inject` 拼错时 `assertSettled()` 报出该插件 |
| 3 | 两个 effect 的撤销顺序是注册顺序的逆序；disposer 调两次不炸 |
| 4 | `emit` 无返回、`waterfall` 能改写、`parallel` 等全部、`serial` 按序 |
| 5 | 观察型 listener 忘了 `next()` 时，下游策略确实失效（写一个测试钉住这个后果） |
| 6 | 日志 4 条、`deriveMessages()` 2 条；`assistant/message` 带得到它的 chunk seq |
| 7 | 手工往 request 里 push 一条消息 → 不变量抛 `InvariantError` |
| 8 | 空 enter：日志有 `turn/start` + `turn/end`，**没有** `step/start`；`max-tokens` 不被后续 step 降级 |
| 9 | `inject` 不唤醒、`send` 唤醒；两个投递口互不干扰 |
| 10 | `reject` 后 `turn/end.reason.kind === 'blocked'` |
| 11 | 工具抛错变成 `isError` 结果而非崩循环；`tools/result` 的参数被冻结 |
| 12 | 存在一个试图放宽的 `pre-execute` listener 时，守卫仍然挡住 |
| 13 | 两个乱序注册的段落，按 `order` 输出 |
| 14 | `resolve()` 产出的 `Spec` 无 optional 字段；`run()` 里没有 `??` 默认 |
| 15 | 同一个 `bash` 工具在 local/remote 两套 provider 下落在正确的执行世界 |
| 16 | 沙箱 `wrap()` 后 argv 首元素是启动器；`level` 如实反映隔离强度 |
| 17 | 超阈值输出被替换成"摘要 + 定位符"，且完整内容可从定位符读回 |
| 18 | 目标未达成时 turn 自动续；达成后不续；轮次到上限停 |
| 19 | `--dump-config` 打印的每一行都能被一个 patch 按 id 换掉 |
| 20 | 覆盖率门禁能指出你故意留的一段死代码；新守卫先红后绿 |

---

<h2 id="a2">附录 B · 十四个最容易翻的车</h2>

1. **waterfall listener 忘了 `next()`**。不报错，下游全静默失效。写一个"观察者必须委托"的 lint 或测试。
2. **`export default` 吞掉 `inject`**。dsh 的 postmortem 0001 就是这个。用命名导出，并在测试里断言 `'default' in mod === false`。
3. **disposer 不幂等**。拆卸路径调两次就炸，而那是最难复现的路径。
4. **拆卸顺序写成注册顺序**。C 依赖 B 的状态，却先撤 B。
5. **绕过日志给模型加上下文**。功能正常，重放和审计静默失真。上不变量。
6. **零 step 的 turn 不记日志**。UI 出现"我发了消息但什么都没发生"的鬼故事。
7. **`max-tokens` 被后续 step 降级**。下游据此判断结果可信度，降级=撒谎。
8. **`turn/end` 不在 `finally` 里**。异常路径留下半个 turn，重放逻辑变成无底洞。
9. **注入型消息也唤醒循环**。每注入一句就烧一次模型调用。
10. **用 waterfall 做安全边界**。任何插件都能提权。安全用单调守卫。
11. **默认值藏在 `run()` 的 `??` 里**。无法记录、无法断言、换 provider 后默默变了。
12. **上层能力直接 import `child_process`**。执行世界从此搬不走，远程沙箱变成幻想。
13. **超量输出直接截断**。模型永远看不到被截掉的部分，也无法要求重看。用 spill：落盘 + 定位符 + 取回说明。
14. **把覆盖率当 KPI 而不是删码工具**。为不可达分支硬写测试，代码越来越肿。未覆盖的行先问"这行是不是死的"。

---

<h2 id="a3">附录 C · 源码对照索引</h2>

| 你想理解… | 读真实 dsh 的这里 |
|---|---|
| 五个观念 | `docs/cordis-primer.md`（44 行，5 分钟） |
| 架构全景 | `docs/architecture.md`（129 行，必读） |
| 硬规则清单 | `AGENTS.md`（根，也是 `CLAUDE.md` 的符号链接目标） |
| turn/step 循环 | `packages/core/agent-loop/src/agent.ts:212`（外层）`:246`（turn）`:332`（step） |
| Agent 接口与 inbox | `packages/core/agent/` |
| 会话日志 | `packages/core/session/`（`deriveMessages`、`SessionEventMap`） |
| 工具流水线 | `packages/core/tools/src/index.ts`；图见 `docs/tool-execution-pipeline.md` |
| 流水线顺序不变量 | `packages/core/tools/src/invariant.ts:94-115` |
| 系统提示装配 | `packages/core/system-prompt/` |
| per-agent scope | `packages/core/scope/` |
| seam 范本（三角色） | `packages/shell/`（definition / 4 providers / 3 consumers） |
| 一个执行世界 | `packages/fs/` + `packages/subprocess/`，实证在 `packages/e2b/` |
| 沙箱 | `packages/sandbox/` + `native/landlock-run/` |
| 上下文经济 | `packages/compaction/` `packages/spill/` |
| 目标续跑 | `packages/goal/` |
| 装配分层 | `packages/boot/` `packages/bundle/` `packages/preset/` |
| 不变量制度 | `docs/subsystems/invariants.md` + `packages/runtime-diagnostics/invariants/` |
| 测试宪法 | `docs/testing.md`（49 行，全是干货） |
| 门禁脚本 | `scripts/verify-*.ts`（28 个）、`scripts/run-gates.ts` |
| 设计决策考古 | `.agents/notes/{proposed,implemented,archived,rejected}/`（686 篇） |
| 事故复盘 | `docs/postmortem/0001-0004` |
| 生成的目录 | `docs/config-catalog.md` `tool-catalog.md` `module-graph.md` `capability-seams.md` |

### 本地复现

```bash
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
git rev-parse --short HEAD        # 本教程基线 47f9438

pnpm install                      # node ^22.19 || >=24
pnpm run build
pnpm dsh web                      # http://127.0.0.1:3080

# 看你的机器真正启动了什么（第 19 步）
pnpm dsh --profile web --dump-config

# 一次性无头任务（需要 DEEPSEEK_API_KEY）
pnpm dsh --profile headless "列出仓库里最大的三个包"

# 让 agent 修改自己的运行时（第 19 步的极端形态）
pnpm run demo:cordis
```

---

<h2 id="end2">结语：这条路线适合谁</h2>

**适合你，如果：**

- 你在做一个要活很久的 agent 产品，已经被"每加一个功能都要改主循环"折磨过；
- 你需要同时支持多个前端（CLI / Web / 自动化协议 / SDK），并且受不了行为漂移；
- 你想让第三方写扩展，而不是让他们 fork；
- 你要把 agent 交给团队（或 AI）长期维护，需要把口头约定变成机器门禁。

**不适合你，如果：**

- 你要在一周内验证一个想法。20 步的地基对 demo 是纯负担，第 0 步那个 `main()` 更合适。
- 你的产品形态已经确定且不会长出插件生态。那么这套间接层只是税。
- 你的团队没有维护门禁的意愿。**门禁不跑就是死代码**，而死代码比没有门禁更糟——它给人虚假的安全感。

**一个中间路线**（我推荐大多数人从这里起步）：

只抄前 8 步 + 第 20 步的四条测试规矩。也就是：服务仓库 + inject + 可逆注册 + 四种 dispatch + append-only 日志 + turn/step 双层循环 + "验证世界不验证自述"。

这套组合大约 400 行，能挡住 agent 项目里最贵的那几类返工：上下文丢失、权限被绕过、拆卸泄漏、以及"绿测试坏产品"。剩下的 seam / 沙箱 / spill / bundle，等你真的撞到那个问题时再加——**而这套地基保证了你到时候不用改循环**。

---

> **配套阅读**：[DeepSeek Harness 源码分析](./项目分析/DeepSeek-Harness-源码分析.md)——241 个包的完整名册、协作图谱、73 个服务键、47 个工具、686 篇设计决策文档背后的工程制度。  
> **同系列对照**：[Open Design](./项目分析/open-design-源码分析.md)（不写主循环，把别人的 CLI 当引擎）· [Reasonix](./项目分析/DeepSeek-Reasonix-源码分析.md)（同一家公司的另一条路：单二进制 + 缓存优先）· [Claude Code](./Claude-Code-工作原理科普-深入版.md)（工具循环的经典形态）
