# 从零构建一个"AI 设计 Agent"（Open Design 系）· 开发全流程教程

> **这份教程教你什么**：不是教你再写一个"会写代码的终端 agent"，而是教你造一个**把别人的 Agent 当引擎、专门产出真实设计文件（HTML / PDF / PPTX / MP4）的宿主**——Open Design（`nexu-io/open-design`，Apache-2.0，三个月 82k star）那种。
> **最反直觉的一点**：这条路线里，**你一行 Agent 主循环都不写**。你要写的是「插座 + 剧本 + 闸门」。
> **怎么教**：和本仓库《Manus 系》《OpenWorker 系》两份教程一样——**跟着一个真实场景，从白纸开始，每撞一堵墙就补一个零件**。
> **对照源码**：每个零件都给出 Open Design 的 `文件:行号`，基线 commit `c893b60`（2026-07-28）。配套阅读《[open-design 源码分析](./项目分析/open-design-源码分析.md)》。
> **前置**：知道「Agent 循环 = 模型说话 → 软件替它动手 → 结果喂回模型 → 直到它不再要工具」就够了。你**不需要**会做设计。

---

## 目录

- [先看终点：一个"设计引擎宿主"长什么样](#end)
- [第 0 步 · 场景登场：一句"给我们做个落地页"](#s0)
- [第 1 步 · 决定不写 Agent：把已装好的 CLI 当引擎](#s1)
- [第 2 步 · 适配器即数据：一个对象字面量描述一条 CLI](#s2)
- [第 3 步 · 探测：怎么知道机器上有什么（以及一个必踩的坑）](#s3)
- [第 4 步 · 起跑：spawn 子进程，提示词走 stdin 而不是 argv](#s4)
- [第 5 步 · 听懂它说话：四种流格式，一套统一事件](#s5)
- [第 6 步 · 接住产物：两档执行画像](#s6)
- [第 7 步 · 给它品牌：`DESIGN.md` 契约与 token 注入顺序](#s7)
- [第 8 步 · 给它工艺：第四根轴 `craft/`](#s8)
- [第 9 步 · 给它剧本：三条硬规则](#s9)
- [第 10 步 · 管住优先级：提示词是一场"谁压谁"的博弈](#s10)
- [第 11 步 · 让它便宜：按变化频率分带 + 缓存命中归因](#s11)
- [第 12 步 · 第一道闸门：把"一眼假"写成正则](#s12)
- [第 13 步 · 第二道闸门：五位陪审员的评审剧场](#s13)
- [第 14 步 · 让它记住你：三张卡的记忆双环](#s14)
- [第 15 步 · 让它可扩展：四平面 + 原子 + 封闭 `until`](#s15)
- [第 16 步 · 装边界：你放弃了权限闸门，就必须补齐外围](#s16)
- [第 17 步 · 装成产品：桌面壳 + 侧车 + MCP 服务端 + 导出](#s17)
- [🎬 完整回放：这一句话到底跑了什么](#replay)
- [附录 A · 验收断言（做完每步怎么验）](#a1)
- [附录 B · 十二个最容易翻的车](#a2)
- [附录 C · 源码对照索引](#a3)

---

<h2 id="end">先看终点：一个"设计引擎宿主"长什么样</h2>

在写第一行代码前，先把要拼的东西看清楚。**编程 Agent 和设计 Agent 宿主的分界，不在模型，而在这五件事**：

1. **它自己不推理**——推理是你机器上那个 `claude` / `codex` / `cursor-agent` 在干；
2. **它的产物是给人看的**，不是给编译器看的，所以**"对不对"之外还有"好不好看"**；
3. **好看这件事有相当大一部分可以程序化执法**（Tailwind indigo、两段式渐变、emoji 当图标、ALL CAPS 不加字距）；
4. **品牌是一份可以版本化的契约**，不是一堆散落的 CSS 变量；
5. **交付物是真实文件**，能预览、能导出、能扔进 Cursor 继续写代码。

把这五件事画进装配图：

```mermaid
flowchart TB
    subgraph UI["① 你能看到的"]
        CHAT["聊天 + 问卷卡片"]
        FW["文件工作区"]
        PREV["沙箱 iframe 预览"]
    end
    subgraph HOST["② 宿主（你要写的全部代码）"]
        REG["运行时注册表<br/>26 条适配器数据定义"]
        COMP["提示词组装器<br/>20+ 层，按变化频率分带"]
        LINT["反 AI 味 linter"]
        JURY["五陪审评审剧场"]
        SSE["统一事件流（SSE）"]
    end
    subgraph CONTENT["③ 内容四平面（文件系统，可版本化）"]
        SK["skills/ 功能技能"]
        TP["design-templates/ 渲染模板"]
        DS["design-systems/ 品牌契约"]
        CR["craft/ 通用工艺规则"]
    end
    subgraph ENGINE["④ 引擎（不是你的代码）"]
        CLI["claude / codex / cursor-agent /<br/>copilot / opencode / …"]
    end

    CHAT --> SSE
    SSE --> COMP
    COMP --> CONTENT
    COMP -->|组装好的系统提示词| REG
    REG -->|spawn，cwd = 项目工作区| CLI
    CLI -->|原生 Write/Edit 工具| FILES["项目文件（真实磁盘）"]
    CLI -->|stdout| SSE
    FILES --> FW --> PREV
    FILES --> LINT -->|artifact-lint 系统提醒| CLI
    FILES --> JURY -->|轮次摘要| CLI
```

记住这张图。下面每一步都是往里填一个方块。

---

<h2 id="s0">第 0 步 · 场景登场：一句"给我们做个落地页"</h2>

我们的设计 Agent 只需要会干一件事（先把一件事干透）：

> **"帮我们做一个 SaaS 产品落地页，用我们公司的品牌。"**

这一句话里藏着我们要撞的每一堵墙：

| 用户说的 | 撞上的墙 | 在哪一步补 |
|---|---|---|
| "帮我做" | 谁来推理？我要不要自己写主循环？ | 第 1–2 步 |
| （机器上装了什么？） | 有 `claude` 吗？版本对吗？登录了吗？ | 第 3 步 |
| （提示词有 40 KB） | argv 塞不下，Linux `E2BIG`、Windows `ENAMETOOLONG` | 第 4 步 |
| （不同 CLI 输出格式不同） | Claude 吐 JSONL、DeepSeek 吐裸文本 | 第 5 步 |
| （产物在哪？） | 有的 CLI 会写文件，有的只会吐文本 | 第 6 步 |
| "用我们公司的品牌" | 品牌怎么变成模型能吃的东西？ | 第 7 步 |
| （做出来一眼是 AI 拉的） | 通用排版常识不在任何一份品牌文档里 | 第 8 步 |
| （15 秒没反应，用户走了） | 首字节时间 | 第 9 步 |
| （规则互相打架） | 「turn 1 必须问方向」vs「已选了设计系统别再问」 | 第 10 步 |
| （每回合几万 token） | 前缀缓存 | 第 11 步 |
| （紫色渐变 + 🚀 图标） | 审美质量的程序化执法 | 第 12–13 步 |
| （用户第三次说"别用米色"） | 记忆 | 第 14 步 |
| （别人想加自己的模板） | 扩展机制 | 第 15 步 |
| （你把 `--yolo` 喂给了子进程） | 边界防御 | 第 16 步 |
| "导出成 PDF 给老板" | 产品化 | 第 17 步 |

---

<h2 id="s1">第 1 步 · 决定不写 Agent：把已装好的 CLI 当引擎</h2>

### 1.1 先做一次诚实的成本核算

你要做的是**设计**产品。用户的每一次交互都是「说一句话 → 看到一个页面」。要支撑这个，你需要一个能读文件、写文件、跑命令、多轮迭代的 Agent 循环。

**方案 A：自己写循环。** 你要处理：模型调用与重试、工具定义与解析、上下文压缩、权限确认、断点续跑、取消、成本统计、多 provider 兼容、prompt 缓存。这大概是 8 000–20 000 行代码（对照本系列其他九个项目的规模）。写完之后，你的循环大概率**不如** Claude Code 或 Codex——因为那是别人全职团队做了一年的东西。

**方案 B：调用用户已经装好的那个。** 你要处理：探测、启动、参数构建、流解析。大概 3 000 行。

Open Design 选了 B，而且把理由写进了文档（`docs/agent-adapters.md:9`）：

> **Thesis:** The code agent space has already converged on strong implementations… **Reimplementing another one is worse than talking to all of them.**

### 1.2 这个决定的连锁后果（必须提前想清楚）

选 B 不是省事，是**换了一组问题**：

| 你不用做的 | 你必须接受的 |
|---|---|
| 主循环、工具系统、上下文压缩 | **你控制不了它怎么想**——只能通过系统提示词影响 |
| 模型兼容、重试、成本统计 | **你的权限模型没了**——子进程的权限由它自己管 |
| 断点续跑 | **你要处理 25 种不同的 stdout 格式** |
| 多 provider 抽象 | **用户没装 CLI 你就跑不起来**（所以还得有 BYOK 兜底） |

**最重要的一条**：既然你控制不了推理，你**唯一的杠杆就是上下文工程 + 输出闸门**。所以这份教程后面 60% 的篇幅都在讲提示词和闸门——那才是你真正的产品。

> ✅ **本步验收**：你能用一句话回答「为什么我们不写主循环」，并且能列出这个决定带来的三个新问题。

---

<h2 id="s2">第 2 步 · 适配器即数据：一个对象字面量描述一条 CLI</h2>

### 2.1 先看错误的做法

你要支持 25 个 CLI。第一直觉是抽象基类：

```ts
// ❌ 别这么写
abstract class AgentAdapter {
  abstract detect(): Promise<boolean>;
  abstract run(prompt: string): AsyncIterable<Event>;
  abstract cancel(): void;
}
class ClaudeAdapter extends AgentAdapter { /* 200 行 */ }
class CodexAdapter  extends AgentAdapter { /* 200 行 */ }
// × 25 = 5000 行，且每个都要自己实现取消、超时、错误分类
```

问题不在代码量，在**行为漂移**：25 个 `run()` 会有 25 种取消语义、25 种超时处理、25 种错误分类。半年后你会发现 Cursor 的取消不干净、Copilot 的超时没生效——因为没人能同时维护 25 份生命周期代码。

### 2.2 正确的做法：把「怎么跟它说话」和「怎么调度它」彻底分开

```ts
// ✅ 一个纯数据对象 + 一个纯函数
type RuntimeAgentDef = {
  id: string;                            // 唯一键
  name: string;                          // 显示名
  bin: string;                           // PATH 上探测的可执行文件
  versionArgs: string[];                 // 版本探测参数
  fallbackModels: RuntimeModelOption[];  // 探测不到模型时的静态兜底
  buildArgs: (prompt, imagePaths, extraDirs?, options?, ctx?) => string[];  // 唯一的函数，且是纯的
  streamFormat: string;                  // 引擎据此分派解析器
  // …~38 个可选字段，全部是数据
};
```

**六个必填字段，一个纯函数。** 剩下的全部由共享引擎做：

```mermaid
flowchart LR
    DEF["RuntimeAgentDef<br/>（纯数据）"] --> E1["detection.ts<br/>探测"]
    DEF --> E2["launch.ts<br/>路径解析 + 环境"]
    DEF --> E3["invocation.ts<br/>argv 组装 + spawn"]
    DEF --> E4["run 生命周期<br/>取消 / 超时 / 错误分类"]
    DEF -->|streamFormat 分派| E5["4 个解析器之一"]
    style DEF fill:#e8f5e9
```

对照源码：契约在 `apps/daemon/src/runtimes/types.ts:101-253`，26 条定义在 `apps/daemon/src/runtimes/defs/`。

### 2.3 写你的第一条定义

用 Claude Code 做样本（`defs/claude.ts`，全文 98 行）：

```ts
export const claudeAgentDef = {
  id: 'claude',
  name: 'Claude Code',
  bin: 'claude',
  fallbackBins: ['openclaude'],          // ① argv 兼容的分叉，PATH 上没 claude 时依次尝试
  versionArgs: ['--version'],
  authProbe: { args: ['auth', 'status'], timeoutMs: 5000 },   // ② 声明式鉴权探针
  helpArgs: ['-p', '--help'],            // ③ 注意是 `claude -p --help` 不是 `claude --help`
  capabilityFlags: {                     // ④ 子串 → 能力键
    '--include-partial-messages': 'partialMessages',
    '--add-dir': 'addDir',
  },
  fallbackModels: CLAUDE_FALLBACK_MODELS,
  buildArgs: (_prompt, _imagePaths, extraAllowedDirs = [], options = {}, ctx = {}) => {
    const caps = agentCapabilities.get('claude') || {};
    const args = ['-p', '--input-format', 'stream-json',
                  '--output-format', 'stream-json', '--verbose'];
    if (caps.partialMessages) args.push('--include-partial-messages');   // ⑤ 能力门
    if (options.model && options.model !== 'default') args.push('--model', options.model);
    const dirs = extraAllowedDirs.filter(d => typeof d === 'string' && d.length > 0);
    if (dirs.length > 0 && caps.addDir !== false) args.push('--add-dir', ...dirs);
    if (ctx.resumeSessionId)   args.push('--resume', ctx.resumeSessionId);   // ⑥ 会话续跑
    else if (ctx.newSessionId) args.push('--session-id', ctx.newSessionId);
    args.push('--permission-mode', 'bypassPermissions');
    return args;
  },
  promptViaStdin: true,                  // ⑦ 提示词走 stdin（下一步详述）
  promptInputFormat: 'stream-json',
  streamFormat: 'claude-stream-json',
  externalMcpInjection: 'claude-mcp-json',
  resumesSessionViaCli: true,
} satisfies RuntimeAgentDef;
```

**逐条讲为什么**：

**① `fallbackBins`**：生态里会出现 argv 兼容的分叉（issue #235 里用户只装了 OpenClaude）。一行数组解决，不用让用户写 wrapper 脚本。

**③ `helpArgs: ['-p', '--help']`** 是一个真实 bug 的修复（`claude.ts:33-38` 的注释）：`--add-dir` 和 `--include-partial-messages` 只出现在 `claude -p` 子命令的帮助里，探 `claude --help` 永远探不到（issue #430）。

**④+⑤ 能力门控**：老版本的 CLI 不认识新 flag，传了会「unknown option」退出 1，直接把聊天打死。所以**每个可选 flag 都要先探测再用**。`capabilityFlags` 是「帮助输出里的子串 → 能力键」，探测后写进内存 map，`buildArgs` 读它。

**⑥ 会话续跑**：不要每回合把全部对话重拼给 CLI。让 CLI 保留自己的会话，它就保住了工作记忆（读过哪些文件、改过什么、工具历史）。三种风格要分清（`types.ts:189-209`）：

| 字段 | 谁生成 session id | 例子 |
|---|---|---|
| `resumesSessionViaCli` | **你生成**，告诉 CLI 用它 | `claude --session-id <uuid>` |
| `capturesSessionIdFromStream` | **CLI 生成**，从流里报出来，你抓下来存 | `codex` 的 `thread.started.thread_id` |
| `resumesSessionViaAcpLoad` | 从 ACP 会话拿 durable id | AMR/Vela 用 `session/load` |

> ⚠️ **一个必踩的坑**：如果某条 CLI **自己有会话记忆**（比如 `agy -c`），你又**同时**把渲染的 web transcript 拼进用户消息，它会收到**两份**上一回合——包括它第 1 回合发出的 `<question-form>` 原文。模型会模式匹配这段原文，**在第 2 回合再发一遍问卷**，看起来像发现循环卡死了。解法不是改提示词，是加一个 opt-out 标记跳过 transcript 注入（`types.ts:70-84` 记录了这次尸检）。

### 2.4 注册表：81 行 + 一条加载期不变式

```ts
// registry.ts:30-76
const BASE_AGENT_DEFS: RuntimeAgentDef[] = [ /* 26 条 */ ];

export const AGENT_DEFS = [...BASE_AGENT_DEFS, ...readLocalAgentProfileDefs(BASE_AGENT_DEFS)];

const ids = new Set();
for (const def of AGENT_DEFS) {
  if (ids.has(def.id)) throw new Error(`Duplicate agent definition id: ${def.id}`);
  ids.add(def.id);
}
```

那个 `throw` 在**模块加载期**执行。用户自定义的 profile 撞了内置 id → daemon 起不来，而不是安静地覆盖掉内置适配器。**让错误尽早、尽响。**

### 2.5 契约要克制：明确它不包含什么

这一点比「它包含什么」更重要。Open Design 的契约里**刻意没有**：

- ❌ `nativeSkillLoading` / `skillInjectionStrategy` —— 技能投递是**共享行为**，不是 per-adapter 策略
- ❌ `capabilities()` 方法 —— `capabilities.ts` 全文只有 **131 字节**，就是一个 flag map
- ❌ `surgicalEdit` / `streaming` / `resume` 特性门表 —— 那会变成一张永远对不齐的能力矩阵

**判断标准**：如果一个字段描述的是「这个 CLI 长什么样」，放进定义；如果描述的是「遇到这种情况该怎么办」，放进引擎。

> ✅ **本步验收**：新增一条使用已有 wire format 的 CLI，改动只应该是**一个新文件 + registry 里一行**，引擎代码零改动。Open Design 里最小的定义是 `defs/kilo.ts`，**629 字节**。

---

<h2 id="s3">第 3 步 · 探测：怎么知道机器上有什么（以及一个必踩的坑）</h2>

### 3.1 探测流水线

```mermaid
flowchart TB
    S["probe(def, configuredEnv)"] --> R["① resolveAgentLaunch(def, env)<br/>解析出「将来真正会被 spawn 的那个路径」"]
    R -->|解析不出| U1["不可用 + 可执行文件诊断"]
    R --> V["② 版本探测（在那个路径上）"]
    V -->|OS 级缺失/不可执行| U2["不可用 + 不可调用诊断"]
    V -->|能启动但拒绝 --version| OK1["✅ 可用，version = null"]
    V -->|成功| P
    OK1 --> P
    P["③ 三个后置探测并发 Promise.all"] --> C1["--help 能力表"]
    P --> C2["模型发现"]
    P --> C3["鉴权探针（仅当声明了）"]
    C1 & C2 & C3 --> OUT["DetectedAgent"]
```

### 3.2 那个必踩的坑：探测和执行必须走同一条路径解析

这是本步唯一真正重要的一条。`detection.ts:243-250` 的注释是一份完整的现场记录：

> Detection must probe **the exact path the runtime will spawn**, not just the PATH-visible shim. This is load-bearing for **Codex under nvm/fnm/mise**: the discovered `codex` entry is often a `#!/usr/bin/env node` wrapper that is **not invocable from a GUI-launched app's stripped PATH**, while the launch resolver can still upgrade it to the packaged native Codex binary. **If detection probes the shim but chat/run spawns the native binary, the UI incorrectly reports "not installed"** until the user pins `CODEX_BIN` by hand even though the real launch path is healthy.

翻译：
- **GUI 启动的 App 拿到的 PATH 是被系统精简过的**（macOS 的 `launchd` 不读你的 `.zshrc`）
- nvm/fnm/mise 装的 CLI 常常是 `#!/usr/bin/env node` 的 shim，在那个精简 PATH 下跑不起来
- 但你的启动解析器可能有能力升级到打包的原生二进制
- 于是：探测用 A 路径说"没装"，真跑用 B 路径其实能跑 → **用户看到红叉但功能是好的**

**规则**：探测入口的第一件事必须是调用**和 spawn 完全相同的那个路径解析函数**。

### 3.3 三个次要但会救命的细节

**① 版本探测的三态**：

| 结果 | 判定 | 为什么 |
|---|---|---|
| OS 级缺失 / 不可执行 | 不可用 | 真的没有 |
| **能启动，但拒绝 `--version`** | **可用**，version 留空 | 有些 CLI 改过版本 flag 名，不该判死 |
| 成功 | 可用 + version | — |

**② 并发但有序**：版本探测**必须先完成**（它决定可用性），后面三个探测**互相独立**，并发跑。`detection.ts:273-277`：「a single agent's detection wall is **max(help, models, auth) ≈ 5s** rather than the sum ≈ 15s」。

**③ 每条适配器故障隔离**：裸 `Promise.all` 会因一条拒绝而整体拒绝——**一个坏掉的可执行文件不能清空整个选择器**。

### 3.4 鉴权：不猜

老做法：看 `~/.foo/` 目录在不在，猜有没有登录。猜错的代价是把能用的 agent 标红。

新规则（`docs/agent-adapters.md:126-128`）：

- **只有声明了 `authProbe` 的适配器才主动探测鉴权**
- 没声明的 → `authStatus` 就是 **unknown**，不给合成的失败状态
- 真正的鉴权问题只从**真实运行失败的错误文本**里推断（`classifyAgentServiceFailure`）

探针必须是**便宜、无副作用**的 status/whoami 类命令。Claude 是 `['auth', 'status']` + 5 秒超时。

### 3.5 探测的 UX

- `GET /api/agents?stream=1`：**每探完一条发一个 SSE 事件**，最后 `done`。设置面板不用等最慢的 CLI 就能开始画卡片。
- **不做 24 小时缓存**。每次调用都重新并发探测。宁可多花几秒，也不要给用户看陈旧状态（用户刚 `npm i -g` 装完，刷新就该看见）。

> ✅ **本步验收**：把 `claude` 从 PATH 移走 → 卡片变灰但**其他 25 条不受影响**；用 nvm 装一个 shim 版 codex → 探测结果和实际能不能跑**一致**。

---

<h2 id="s4">第 4 步 · 起跑：spawn 子进程，提示词走 stdin 而不是 argv</h2>

### 4.1 为什么提示词不能进 argv

你组装好的系统提示词有多大？算一笔账：

| 片段 | 量级 |
|---|---|
| 设计师宪章 | ~4 KB |
| 发现层 + 设计哲学 | ~12 KB（约 3 000 token） |
| 方向库（没选设计系统时） | ~6.7 KB |
| 活跃设计系统（DESIGN.md + tokens.css + 组件清单） | 8–40 KB |
| craft 工艺规则（3 个 slug） | ~6 KB |
| 活跃技能正文 | 2–15 KB |
| **合计** | **30–80 KB 很常见** |

而操作系统的限制是：

| 平台 | 限制 |
|---|---|
| Linux | `MAX_ARG_STRLEN` 把**单个 argv 条目**限制在 ~128 KB → `spawn E2BIG` |
| Windows | `CreateProcess` 把**整条命令行**限制在 ~32 KB（**通过 `.cmd` shim 只有 ~8 KB**）→ `spawn ENAMETOOLONG` |

所以规则很简单：**只要 CLI 支持 stdin，就走 stdin**（`promptViaStdin: true`）。`buildArgs` 的第一个参数会是 `_prompt`——带下划线，因为根本不用。

### 4.2 三种投递方式

| 字段 | 投递方式 | 何时用 |
|---|---|---|
| `promptViaStdin: true` | 写进子进程 stdin | **默认首选** |
| `promptViaFile: true` | 写进临时文件，路径通过 `ctx.promptFilePath` 给 `buildArgs` | CLI 有显式的 prompt-file flag |
| （都不设） | 进 argv | CLI 硬要位置参数（如 DeepSeek 的 clap 声明 `prompt: String` 必填） |

第三种要额外上守卫，见第 16 步。

### 4.3 `stream-json` 输入格式：为什么要保持 stdin 打开

Claude 的定义里有一句 `promptInputFormat: 'stream-json'`。`types.ts:125-130` 解释：

> When set to `'stream-json'` the daemon writes **a single JSONL line** wrapping the prompt as an Anthropic user message (so **tool_result blocks can later be injected into the same stdin without re-spawning the child**).

配套的是 `docs/agent-adapters.md:191-194`：

> Stdin remains **open** so the daemon can forward additional user messages mid-turn, then is closed **after a clean terminal `turn_end`/`usage`** rather than at a mid-tool `tool_use` pause.

**这条规则会咬你**：如果你在看到 `stop_reason: tool_use` 时就关 stdin，Claude 会以为对话结束，回合中途断掉。`apps/daemon/AGENTS.md:113` 专门写了一条守则：「**do not close stdin on `tool_use` stop reasons**」。

### 4.4 cwd 就是项目工作区

```ts
spawn(resolvedBin, args, { cwd: projectWorkspaceDir, env: spawnEnv })
```

**cwd 是执行根，不是沙箱**（这句话第 16 步会再强调一次）。子进程的 Write/Edit 直接落在这里，你的文件工作区监听这个目录的变化。

### 4.5 一个卡死回合的看门狗

子进程可能长时间不吐字节（比如 Copilot 生成 deck 时的思考阶段）。所以要有**基于 stdout/stderr/SSE 活动**的不活跃超时，全局默认 10 分钟，适配器可以用 `inactivityTimeoutMs` 声明更长的上限，操作者可以用 `OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS` 覆盖（env 优先）。

`types.ts:219-226` 的注释点破了本质：「**The watchdog observes child stdout/stderr/SSE activity, not real CPU progress**」——你观测的是"有没有说话"，不是"有没有在干活"。

> ✅ **本步验收**：构造一个 60 KB 的提示词，在 macOS + Windows 上都能正常跑完；在工具调用中途不会断流。

---

<h2 id="s5">第 5 步 · 听懂它说话：四种流格式，一套统一事件</h2>

### 5.1 分类而不是穷举

25 个 CLI 有 25 种 stdout？不。按 wire format 分类之后只有 **7 种 `streamFormat`、4 类解析器**：

| `streamFormat` | 谁在用 | 解析器 |
|---|---|---|
| `claude-stream-json` | claude · amp · codebuddy | `claude-stream.ts` |
| `json-event-stream` | codex · cursor-agent · opencode · mimo · byok-opencode | `json-event-stream.ts`（按 `eventParser` 再分派） |
| `copilot-stream-json` | copilot | `copilot-stream.ts` |
| `qoder-stream-json` | qoder | `qoder-stream.ts` |
| `acp-json-rpc` | **9 条**：amr · devin · hermes · kimi · kiro · kilo · reasonix · trae-cli · vibe | `agent-protocol/acp/` |
| `pi-rpc` | pi | `agent-protocol/pi-rpc/` |
| `plain` | aider · antigravity · atomcode · deepseek · grok-build · qwen | `plain-stream.ts` |

**九条 ACP 适配器共用同一个传输层**——这就是「新增一条 ACP agent 只要一个 629 字节的对象」的原因。

### 5.2 统一事件集

所有解析器输出同一套事件：`thinking` / `tool-call` / `tool-result` / `text-delta` / `file-write` / `error` / `done`。UI 只认这套，不认底层格式。

**注意**：这套事件是由**解析器**定义的，不是由 def 定义的（`docs/agent-adapters.md:107`）。def 只说"我是哪一类"，怎么翻译是解析器的事。

### 5.3 `plain` 流：最弱的适配器要写最多的代码

裸文本流的 CLI 没有结构化的文件写入事件。约定是让它吐 Anthropic 风格的源码块：

```html
<artifact identifier="landing-page" type="text/html" title="Landing page">
<!doctype html>
<html>...</html>
</artifact>
```

然后 run 结束时扫 stdout 提取。听起来五分钟能写完，实际 `plain-stream.ts` 有 **473 行**，因为要绕开四个坑：

**坑 1 — Markdown 围栏里的假 `<artifact>`。** 模型解释「你应该这样写」时会把 `<artifact>` 放进 ```` ``` ```` 或反引号里。天真的 `indexOf` 会把教学示例当真产物写盘。

解法（`plain-stream.ts:252-306`）：先算跳过区间。围栏用 `/^```(\w[\w+-]*)?\s*$/` 和 `/^```\s*$/` 逐行判断；行内反引号要**匹配相同数量的连续反引号**。

> 🔑 **配套要求**：这份围栏逻辑必须和**浏览器侧的产物解析器**（`apps/web/src/artifacts/markdown-context.ts`）**保持一致**——否则无头运行落盘的和有浏览器时解析的结果会不一样。

**坑 2 — 前缀误判。** `<artifacts>` 不是 `<artifact>`。判定：开标签后面**必须是空白字符**。

```ts
function isRealArtifactOpenAt(text, idx) {
  return /\s/.test(text.charAt(idx + '<artifact'.length));
}
```

**坑 3 — 属性值里的 `>`。** `title="A > B"` 会被 `indexOf('>')` 截断。要用**带引号状态机**找开标签结尾（`plain-stream.ts:235-250`）。

**坑 4 — 嵌套 / 未闭合。** 每次找到开标签，先探测**下一个**开标签的位置：如果它出现在当前开标签结束之前、或闭标签之前，说明当前这个坏了，跳到下一个重来。**一段畸形输出不能吞掉后面所有合法产物。**

再加两个防炸弹：`MAX_ARTIFACTS_PER_RUN = 50`，文件名冲突加 `-2`/`-3`（上限 10 000）。

### 5.4 落盘时同时写「产物清单」

不要只写文件。每个产物按扩展名生成一份旁挂清单：

```ts
// .html → { kind:'html', renderer:'html', exports:['html','pdf','zip'], primary:true, metadata:{…} }
// .css  → { kind:'code-snippet', renderer:'code', exports:['txt','zip'] }
// .svg  → { kind:'svg', renderer:'svg', exports:['svg','zip'] }
// .md   → { kind:'markdown-document', renderer:'markdown', exports:['md','html','pdf','zip'] }
```

这份清单让下游知道**用哪个渲染器、能导出成什么、哪个是入口文件**。没有它，你的文件工作区只能靠扩展名猜。

> ✅ **本步验收**：给解析器喂一段包含「代码围栏里的假 artifact + 真 artifact + 一个未闭合的坏 artifact」的 stdout，只应该落盘那一个真的。

---

<h2 id="s6">第 6 步 · 接住产物：两档执行画像</h2>

### 6.1 一个 249 字节的文件解决的问题

不同 CLI 的能力差得很远：Claude Code 有完整的 Read/Write/Edit/Bash；DeepSeek TUI 在你选的调用模式下只会吐文本。你不能为每条适配器写一套交付逻辑。

抽象成**两档**：

```ts
export type ExecutionProfile = 'filesystem' | 'text_artifact';
export function executionProfileFromStreamFormat(streamFormat) {
  return streamFormat === 'plain' ? 'text_artifact' : 'filesystem';
}
```

### 6.2 两档的提示词契约是**相反的**

```mermaid
flowchart TB
    RUN["一次生成"] --> P{"执行画像"}

    P -->|filesystem| F1["CLI 用原生工具直接写项目文件"]
    F1 --> F2["文件事件 → 文件工作区 → 预览"]
    F2 --> F3["助手以普通摘要收尾<br/>❌ 禁止再输出 &lt;artifact&gt; 源码块"]

    P -->|text_artifact| T1["模型循环里没有任何文件工具"]
    T1 --> T2["唯一交付形态：一个完整 &lt;artifact&gt; 块"]
    T2 --> T3["run 结束后宿主扫 stdout 提取并落盘"]
    T3 --> F2
```

**filesystem 档的提示词**（`FILESYSTEM_HANDOFF_OVERRIDE`）：

> - Do **not** output generated source code in a `<artifact type="text/html">` block.
> - Do **not** duplicate file contents in assistant text after writing them to disk.
> - A filesystem run that emits a source-code `<artifact>` is treated as an **unexpected fallback** by the host.

**text_artifact 档的提示词**（`API_MODE_OVERRIDE`）：

> **No tools are wired through to you.** `TodoWrite`, `Read`, `Write`, `Edit`, `Bash`, and `WebFetch` are unavailable — calls to them will not execute and will not render in the UI.
> The override does **NOT** block `<artifact>` blocks — those are how the web UI receives finished HTML in API mode.

### 6.3 一个真实 bug：不加顶部覆盖会怎样

`contracts/src/prompts/system.ts:296-307` 记录了 issue #313：

> …the discovery layer + base prompt below still tell it to call TodoWrite/Read/Write/Edit/Bash/WebFetch. Without an explicit top-anchored override, **the model invents pseudo-tool markup (`<todo-list>`, `[读取 X]`) instead of producing real progress events.**

模型不会说"我没有这个工具"，它会**假装调用**——吐出 `<todo-list>...` 这样的伪标记。UI 什么也渲染不出来，用户看到一堆乱码。

**修法**：把 API 模式覆盖钉在**绝对顶部**——比发现层还靠前。因为发现层自己开头就写着「以下规则覆盖后文一切」，你必须压在它上面。

> ⚠️ **这是本教程第一次出现「提示词优先级博弈」，第 10 步会系统讲。**

### 6.4 一个值得学的架构回归

早期的 Open Design 在 daemon 里实现过一个「直连 Anthropic + 自己实现 Read/Write/Edit」的兜底循环。现在被**彻底删掉**了，换成 `byok-opencode` profile——把 BYOK 凭证翻译成 OpenCode 配置，**让装好的 opencode 进程继续拥有模型/工具循环**。

`docs/agent-adapters.md:215-218`：

> There is **no daemon-owned fallback loop and no daemon implementation of `Read`/`Write`/`Edit` tools**.

**为什么值得学**：那个兜底循环违背了「我们不实现 Agent 循环」这条根本主张。一旦你开了这个口子，它会慢慢长成第二个（更差的）Agent。删掉它是对的。

> ✅ **本步验收**：用一条 `plain` 适配器跑同一个简报，产物文件应该和 filesystem 档跑出来的**落在同一个位置、有同样的清单**。

---

<h2 id="s7">第 7 步 · 给它品牌：<code>DESIGN.md</code> 契约与 token 注入顺序</h2>

从这一步开始，我们离开「怎么调 CLI」，进入**真正的产品**。

### 7.1 品牌不是一堆 CSS 变量，是一份可版本化的契约

**最小包形状**（三个文件，缺一不可）：

```
design-systems/<slug>/
├── manifest.json    ← 发现元数据、来源出处、声明的包内路径
├── DESIGN.md        ← 给 agent 看的规范散文（canonical）
└── tokens.css       ← 编译好的语义 token 样式表（canonical）
```

`manifest.json` 的关键约束：

```json
{
  "schemaVersion": "od-design-system-project/v1",
  "id": "acme",                          // 必须等于文件夹 slug，规范化 ASCII
  "name": "Acme",
  "category": "Productivity & SaaS",
  "description": "A concise English catalog summary.",
  "source": { "type": "bundled", "origin": "…" },
  "files": { "design": "DESIGN.md", "tokens": "tokens.css" }   // 固定文件名
}
```

**每条声明的路径必须安全、相对、存在**——这是 guard 检查的。

### 7.2 富文件是缓存，不是竞争的真理源

包可以带更多文件，但要分清主从：

| 文件 | 性质 |
|---|---|
| `DESIGN.md` · `tokens.css` | **真理源** |
| `components.manifest.json` | 由 `components.html` + `tokens.css` **派生** |
| `design-tokens.json` | 由 token 契约报告**派生**，必须与 `tokens.css` 一致 |
| `tailwind-v4.css` | 由 `tokens.css` **派生** |
| `USAGE.md` · `assets/` · `fonts/` · `preview/` · `source/` | 可选富资源 |

**派生文件一致性由 guard 校验**。这条规则防的是「有人改了 tokens.css 但忘了重生成 tailwind-v4.css，模型读到两套冲突的值」。

### 7.3 注入顺序：八层，顺序有意义

```mermaid
flowchart TB
    L1["① 包专属 USAGE.md（或默认使用契约）"] --> L2["② 完整的 DESIGN.md 正文"]
    L2 --> L3["③ import-mode 指引（声明了才有）"]
    L3 --> L4["④ tokens.css"]
    L4 --> L5["⑤ 紧凑组件清单<br/>（无清单时用 components.html）"]
    L5 --> L6["⑥ 富文件按需拉取索引"]
    L6 --> L7["⑦ craft 工艺规则"]
    L7 --> L8["⑧ 活跃技能/模板正文"]
    style L2 fill:#e8f5e9
    style L4 fill:#e8f5e9
```

**为什么 USAGE 在最前？** 因为它是"读法说明"——告诉模型下面这堆东西怎么用。默认的使用契约值得原样抄：

> Read DESIGN.md for visual principles, **paste tokens.css verbatim into the first `<style>`** when it is provided, and match component shapes from the reference component manifest or fixture when available. Treat any pull-layer index as **optional** context for deeper inspection; **do not assume those files have already been loaded.**

最后半句在防一个具体的幻觉：**模型看到索引就以为文件已经读过了**，然后引用一个它没读过的组件。

**为什么 craft 在设计系统之后、技能之前？** 因为优先级是 **品牌 token 赢冲突 > craft 规则补空白 > 技能定义工作流**。

### 7.4 三件刻意不做的事

| 不做 | 为什么 |
|---|---|
| **不**把设计系统拷进 run 的 cwd | 它是只读参考，不是工作副本；拷进去 agent 就可能改它 |
| **不**支持 `od.design_system.sections` 按段裁剪 | 裁剪会产生「模型只看到色板没看到用色规则」这类断章取义 |
| **不**用 `{{ design_system }}` 变量替换 | 模板变量会诱导技能作者把设计系统摆在错误的位置 |

### 7.5 `DESIGN.md` 的质量门槛：七个 H2，但不规定标题

上游的 `awesome-design-md` 用九段固定模板。Open Design 演进成：

> The package-quality guard requires **at least seven substantive H2 headings** for migrated packages, **without prescribing their names, order, or numbering.** Use headings that fit the actual system and keep their decisions **synchronized with `tokens.css`.**

保留「必须足够充实」的门槛，去掉「必须叫这几个名字」的僵化。**这是内容契约设计的一个好范式**：约束密度，不约束形态。

### 7.6 用户没有品牌怎么办：品牌提取五步

我们的场景里用户说了"用我们公司的品牌"，但可能只给一个网址或一张截图。那就跑提取：

1. **定位源**：有附件就列出来；给了 URL 就 WebFetch `<brand>.com/brand`、`/press`、`/about`
2. **下载样式产物**：CSS、品牌指南 PDF、截图
3. **提取真值**：`grep -E '#[0-9a-fA-F]{3,8}'` 抓 CSS 里的 hex；截图靠视觉读排版。**绝不凭记忆猜颜色**
4. **编码成契约**：写 `brand-spec.md`——六个 OKLch 色 token（`--bg` `--surface` `--fg` `--muted` `--border` `--accent`）+ display/body/mono 字体栈 + 3–5 条观察到的版式姿态（圆角、边框粗细、accent 预算）
5. **口头复述**：一句话说清将用的系统（"深海军蓝产品画布，单一电光青 accent 在 oklch(68% 0.16 220)，几何 display + 系统 body"），让用户能**廉价纠偏**

**一条防幻觉硬规则**：用户选了"我有品牌规范"但**还没给源** → **要源并停下**。不许猜品牌域名，不许发明 token。

> ✅ **本步验收**：换一个设计系统，下一次生成的 `:root` token 应该整体换掉；把 `tokens.css` 改坏一个值，guard 应该报派生文件不一致。

---

<h2 id="s8">第 8 步 · 给它工艺：第四根轴 <code>craft/</code></h2>

### 8.1 为什么需要第四根轴

你现在有三根轴：技能（干什么）、模板（做成什么形状）、设计系统（用什么品牌）。做出来的东西还是一眼假。为什么？

因为有一类知识**不属于任何一个品牌，也不属于任何一个技能**：

- ALL CAPS 永远需要 ≥0.06em 字距
- `var(--accent)` 每屏最多出现 2 次
- `#6366f1` 永远是 AI 默认色的破绽
- display 字体和 body 字体不该是同一个家族

这些是**称职设计师的肌肉记忆**。把它们写进 151 份 `DESIGN.md`？那是 151 份重复，而且改一次要改 151 处。

所以拆出第四根轴：

| 轴 | 范围 | 例子 |
|---|---|---|
| `skills/` | 干活时调用的**能力** | `brand-extract` · `web-clone` |
| `design-templates/` | 打包好的**产物形状** | `saas-landing` · `dashboard` |
| `design-systems/` | **品牌包** | `linear-app` · `apple` |
| **`craft/`** | **通用工艺知识——与品牌无关** | 字距规则、accent 用量上限、反 AI 味 |

### 8.2 按需订阅，不是全量注入

技能在 frontmatter 里声明它需要哪些段：

```yaml
od:
  craft:
    requires: [typography, color, anti-ai-slop]
```

**只有列出的段落进提示词**。一个只排版的技能不用为色彩、动效内容付 token 成本。

11 个已发布 slug（`craft/*.md`）：

| 文件 | 什么时候要 |
|---|---|
| `typography` | 任何输出文字的技能（≈全部） |
| `typography-hierarchy` | 层次需要"像被设计过"而不是"堆出来"的界面 |
| `typography-hierarchy-editorial` | 长阅读界面：博客、文档、电子书 |
| `color` | 任何输出样式的技能（≈全部） |
| `anti-ai-slop` | 营销页、落地页、幻灯 |
| `state-coverage` | 有状态 UI：仪表盘、移动应用、表单、列表/表格 |
| `animation-discipline` | 带动效的：移动应用、多屏流程、游戏化 UI、微交互 |
| `accessibility-baseline` | 任何交互 UI：焦点、标签、键盘路径 |
| `rtl-and-bidi` | 可能渲染阿拉伯语/希伯来语/波斯语的 |
| `form-validation` | 主产物含交互表单的：留资、登录、注册、设置、多步收集 |
| `laws-of-ux` | 组合决策撞上命名的认知极限：定价页（Hick's / 选择过载 / Von Restorff）、仪表盘（帕累托 / 选择性注意 / 工作记忆）、引导（目标梯度 / 蔡格尼克 / 峰终）、模态（费茨 / 泰斯勒） |

> 💡 `laws-of-ux` 和其他文件是**兄弟轴**：其他文件管"怎么渲染"，它管"该组合什么"。

### 8.3 两级执法：诚实地标注哪些是真检查

这是本步最值得学的设计：

| 层级 | 含义 | 谁执行 |
|---|---|---|
| **Auto-checked** | 接进了 linter 的规则 | `lint-artifact.ts`（第 12 步） |
| **Guidance** | 其余部分 | agent 读、评审者用、linter 不查 |

而且**在文档里逐条标注**：`craft/anti-ai-slop.md` 的 P1/P2 小节，凡是没接进 linter 的规则后面都跟着「*(guidance, not auto-checked)*」。

**为什么这很重要**：如果你写一份「规则手册」但只有 30% 真的被检查，而文档假装 100% 都被检查，那么半年后没人相信这份手册。**标注清楚反而让被检查的那 30% 更有权威。**

`craft/README.md:70`：「A purely behavioral craft file is guidance **unless a specific rule is later promoted into `lint-artifact.ts`.**」——晋升路径是明确的。

### 8.4 运行时宽容 vs 仓库严格

一条重要的分界线：

| 场景 | 行为 |
|---|---|
| **运行时**遇到不存在的 craft slug | **跳过，不报错**——外部安装的旧 bundle 可能引用当前资源集里没有的段，「A missing optional paragraph must not make an otherwise usable runtime bundle fail」 |
| **仓库里**checked-in 内容引用不存在的 slug | `pnpm lint:craft` 和 `pnpm guard` **失败** |
| **故意的前向引用** | 必须登记在 `craft/FUTURE_SECTIONS.md` 里才算合法 |

那个 `FUTURE_SECTIONS.md` 很妙：它让「计划中但还没写的段落」变成**可见的、有登记的**，而不是靠一个 typo 悄悄漏掉一整段提示词。

> ✅ **本步验收**：在一个技能里写 `requires: [typograpy]`（故意打错）→ `pnpm lint:craft` 应该报错并指出 manifest 路径；同样的错误在运行时应该只是跳过那一段。

---

<h2 id="s9">第 9 步 · 给它剧本：三条硬规则</h2>

现在你有了引擎、有了品牌、有了工艺。用户输入"帮我们做个落地页"，模型开始想……然后 25 秒后吐出一整页 HTML，用米色背景、紫色渐变、三个 emoji 图标。用户关掉页面走了。

**问题不在模型，在你没给它剧本。**

### 9.1 三条规则的时序

```mermaid
sequenceDiagram
    participant U as 用户
    participant A as Agent
    participant H as 你的宿主

    Note over A: RULE 1 · 第 1 回合
    U->>A: "帮我们做一个 SaaS 落地页，用我们公司的品牌"
    A->>H: 一句短散文 + <question-form id="discovery"> + 停
    Note right of A: ❌ 不读文件 ❌ 不 Bash<br/>❌ 不 TodoWrite ❌ 不扩展思考
    H->>U: 渲染成问卷卡（每题都已预填推荐值）

    Note over A: RULE 2 · 第 2 回合
    U->>A: "[form answers — discovery] brand: brand_spec …"
    alt 分支 A：给了品牌/参考源
        A->>A: 品牌提取五步（Bash / Read / WebFetch）
        A->>H: 写 brand-spec.md
        A->>U: 一句话复述系统
    else 分支 B：没有品牌源
        A->>A: 用活跃设计系统 / 自己从方向库挑
        Note right of A: ❌ 绝不再弹第二个方向问卷
    end

    Note over A: RULE 3 · 第 3 回合起
    A->>H: TodoWrite 九步计划
    loop 每完成一步
        A->>H: 立刻标 completed，下一步标 in_progress
    end
    A->>A: 第 7 步 checklist.md（P0 必须全过）
    A->>A: 第 8 步 五维自评，任一 <3/5 就返工
    A->>H: 第 9 步 交付
```

### 9.2 RULE 1：第一回合只能发问卷

原文规则（`discovery.ts:40-44`）：

> your **very first output** is one short prose line + a `<question-form>` block. **Nothing else. No file reads. No Bash. No TodoWrite. No native tool calls. No extended thinking.** **The form is your time-to-first-byte.**

**"问卷就是你的首字节时间"**——这句话点破了整条规则的产品动机。用户容忍不了 15 秒的沉默，但完全能接受 2 秒内弹出一张能一路点完的表单。

**而且必须堵死模型的借口**（`discovery.ts:154`）：

> The form **applies** even when the user's brief looks complete. … **Do not justify skipping it ("the brief is rich enough"); ask anyway.** The user is fast at picking radios; they are slow at re-doing a wrong direction.

只有三种情况允许跳过：
1. 用户在**已有设计里**做微调（"标题大一点"）
2. 用户明说 "skip questions" / "just build" / "no questions, go"
3. 用户消息以 `[form answers — …]` 开头（答案已经有了）

### 9.3 表单编写规则里的六条工程细节

这些细节每一条都是踩出来的：

**① 硬上限 5 题。** 「Before emitting, count the questions in your draft; if there are more than 5, delete the least build-critical until exactly 5 or fewer remain. **A question earns its place only if its answer genuinely changes what you would build for THIS brief.**」

**② 每题必须预填 `default`，而且 `default` 键要写在 `options` 前面。** 理由是纯工程的：

> the host renders forms **token-by-token**, and a `default` that trails a long `options` array **reaches the user late**.

流式渲染导致的 JSON 键顺序要求——这类"实现细节泄漏进提示词"是完全合理的。

**③ 显示层全本地化，控制层全英文。**

> Localize every user-facing string … **write what a native speaker would naturally say, never a word-for-word translation** (the Chinese title is 快速确认 · 30秒, not the literal 快速简报). … `id`, `type`, option `value`, and the stable branch values (`pick_direction`, `brand_spec`, `reference_match`) **MUST stay in English because later branch rules match against them.**

后面 RULE 2 要按 `value` 做分支匹配，本地化了就匹配不上。

**④ 别自己写"其他"选项。** 宿主会自动给每个有限选项题渲染一个本地化的"Other"逃生舱（一个点开变输入框的 chip）。模型再写一个就重复了。只有下游系统真的需要一个精确机器 id 时才设 `allowCustom: false`。

**⑤ 元数据和插件输入同等权威。** 这条最长也最实用：

- 「Project metadata」= 用户创建项目时选的（kind、fidelity、speakerNotes、slideCount、animations、template、platform）
- 「Plugin inputs」= 从 Home 的插件 chip 进来时带的同类数据
- **任一来源提供了答案 → 删掉对应默认问题**；标了「(unknown — ask)」的字段 → **新增一个问题**
- 甚至列出同义字段映射：`platform` / `surface` / `platformTargets` / `target` 都答"目标平台"；`slideCount` / `slides` / `pageCount` 都答"页数"；`artifactKind` / `mode` / `taskKind` 已经说明了做什么，**别再问"我们在做什么"**

**⑥ 富控件优先。** 可用类型：`radio` `checkbox` `select` `text` `textarea` `number` `range` `date` `time` `datetime-local` `color` `url` `email` `tel` `file` `switch` `direction-cards`。规则是"用最有表现力的主流控件"：数值强度用滑块、品牌色用 color、截止日期用 date、要上传就用 `file`（**在同一张表里**，不要表单发完再用散文要文件）。

### 9.4 RULE 2：四步优先级的分支解析

```
1. 当前消息/附件/先前简报/URL 里已有真实品牌源  → 分支 A
2. 否则看提交的 brand 值（有 [value: ...] 用稳定值，不用可见标签）
3. brand 值是 "brand_spec" 或 "reference_match"   → 分支 A
4. 否则                                            → 分支 B
```

**分支 B 明确禁止二次问方向**：

> **Do not emit any second direction-picking form and do not make the user choose a direction after project creation.** … If no active design system is present, **pick the best-matching direction yourself** from the Direction library and bind it without asking.

这是一次产品决策的沉淀：早期版本会弹"五选一方向卡"，后来发现多一次点击就多一次流失，改成"自己选，用户不满意再说"。

### 9.5 RULE 3：九步计划 + 两道非协商闸门

```
1. 读活跃 DESIGN.md + 技能资源（template.html, layouts.md, checklist.md）
2. 绑定 token 到 :root（分支A 用 brand-spec.md；有设计系统用它；否则自选方向）
3. 规划章节/幻灯/屏幕清单，含平台变体与节奏（写之前先口头说一遍）
4. 把种子模板拷到项目根
5. 粘贴并填充规划好的版式
6. 用简报里的真实、具体文案替换 [REPLACE] 占位
7. 自检：跑 references/checklist.md（P0 必须全过）
8. 评审：五维雷达，任一 <3/5 就修
9. 交付
```

**第 7、8 步是非协商的。**

五个自评维度（这是第 13 步 Design Jury 的人类可读版）：

| 维度 | 拷问 |
|---|---|
| **哲学** | 视觉姿态和要求的匹配吗？还是漂回了你最爱的默认？ |
| **层次** | 每屏眼睛有一个明显落点吗？还是所有元素在互相竞争？ |
| **执行** | 排版、间距、对齐、对比——是对的，还是只是"差不多"？ |
| **具体性** | 每个词、数字、图片都是**这个**简报专属的吗？ |
| **克制** | 一个 accent 最多用两次、一个决定性亮点——还是三个亮点在打架？ |

「Any dimension under 3/5 is a regression. Go back, fix the weakest, re-score. **Two passes is normal.**」

**注意最后那句**：默认就该返工两轮。把"一次成型"这个不现实的期待从流程里拿掉，模型就不会为了一次交付而降低自评标准。

### 9.6 Deck 的"框架优先"铁律

如果你的产品支持幻灯，这条一定要有：

> **Decks especially — framework first, content second.** … copy the deck framework HTML **verbatim** before authoring any slide content. **Do NOT write your own scale-to-fit logic, keyboard handler, slide visibility toggle, counter, or print stylesheet — every freeform attempt at this re-introduces the same iframe positioning / scaling bugs we have already fixed in the framework.**

实现上有三个分支（每个都是一次事故的化石）：

```ts
const isDeckProject     = skillMode === 'deck' || metadata?.kind === 'deck';
const isFreeformProject = !skillMode && (!metadata || metadata.kind === 'other');
const hasSkillSeed      = !!skillBody && /assets\/template\.html/.test(skillBody);

if (isDeckProject && !hasSkillSeed)          注入通用骨架;
else if (isFreeformProject && !hasSkillSeed) 注入带条件前缀的骨架;
// 有技能种子时不注入——种子自己有更有主张的框架，重复会冲突
```

第二个分支的条件前缀值得抄：

> **If — and only if — the brief reads as slides, keynote, presentation, deck, PPT, or 讲解**, follow the framework below. **Otherwise ignore everything in this section** and continue with the freeform output you would have written anyway.

**给一段"可能不适用"的指令加上显式的适用条件**，模型就不会硬套。

> ✅ **本步验收**：新开一个项目发一句模糊简报 → 2 秒内出现问卷卡，且每题都有预填值；直接提交不改 → 应该能跑出一个合理的产物。

---

<h2 id="s10">第 10 步 · 管住优先级：提示词是一场"谁压谁"的博弈</h2>

### 10.1 你的提示词已经有 20 层了

到这一步，你要往系统提示词里塞：注入抵抗、模式覆盖、locale、发现层、方向库、设备边框、设计师宪章、澄清问题、记忆、用户级指令、项目级指令、设计系统、craft、技能、文件名规则、插件块、阶段块、元数据、deck 框架、媒体契约、设计系统方向覆盖……

**它们会互相打架。** 举三个真实的冲突：

| 冲突 | 谁该赢 | 怎么保证 |
|---|---|---|
| 发现层说"turn 1 必须问方向" vs 已选了设计系统 | 设计系统 | 在**最尾部**加 `ACTIVE_DESIGN_SYSTEM_VISUAL_DIRECTION_OVERRIDE` |
| 发现层说"调用 TodoWrite" vs API 模式没有工具 | API 模式 | 把 `API_MODE_OVERRIDE` 钉在**绝对顶部** |
| 记忆说"用户喜欢深色" vs 本次品牌是浅色 | 品牌 | 在记忆块前言里显式写"brand wins on conflict" |

### 10.2 三种压制手段

**手段一：位置。** 大多数模型对提示词的**开头**和**结尾**更敏感。所以：

- **钉在最顶**：适用于"这一整段后文都要被推翻"的覆盖（API 模式、注入抵抗）
- **钉在最尾**：适用于"要压过前面某个具体规则"的覆盖（设计系统方向、deck 框架）

`contracts/src/prompts/system.ts:498-512` 的 JSDoc 把这个理由完整写下来了：

> Why it sits ABOVE `DISCOVERY_AND_PHILOSOPHY`: **that layer starts with "these override anything later in this prompt"** and then mandates TodoWrite / Bash / Read / WebFetch on turns 2–3. In daemon mode those tools exist; in API mode they don't… **Pinning the override at the absolute top is the cleanest way to beat the discovery layer's precedence without restructuring its rules.**

**手段二：显式仲裁语句。** 不要指望模型自己推断谁赢。写出来：

> Treat them as preferences and context, **NOT hard rules**: when they collide with the active design system tokens, **the brand wins**; when they collide with the active skill's workflow, **the skill wins**.

**手段三：条件门控。** 与其写一段"如果 X 就忽略下面"，不如**在组装时就不放进去**。

Ask 模式是最好的例子：`sessionMode === 'chat'` 时，直接**不组装**发现层（~3000 token）、方向库、设备边框、设计师宪章、deck 框架、媒体契约、评审面板、设计系统方向覆盖。但**保留**记忆、自定义指令、活跃设计系统、附加技能、插件、MCP 工具、澄清问题面。

> **Ask mode is light, not amnesiac.** 省掉的是**工作流**，保留的是**上下文**。

### 10.3 一个正在进行的重构：slim vs classic

Open Design 现在有两套装配路径并存：

| 变体 | 做法 | 状态 |
|---|---|---|
| **classic** | 分层堆叠：发现层 + 宪章 + 尾部覆盖 | 现役 |
| **slim** | 把三块**塌缩成一份宪章文档** | A/B 验证中 |

注释写得很坦白：「the classic stack keeps the legacy layered composition **until the A/B comparison signs off**」。

**这个做法值得学**：提示词重构的风险极高（改一个词可能让通过率掉 20%），所以**不要一次性替换**，两套并存 + A/B。

slim 还带一个只在特定条件下成立的优化：把 6.7 KB 的方向库改成「id+label 索引 + `od tools directions --id <id>` 按需拉取」——**但只在 filesystem 执行画像下**，因为 text_artifact 档没有工具去解引用索引，「anything less tells them to bind palettes they cannot fetch」。

> ✅ **本步验收**：选一个设计系统后开新项目 → 问卷里**不应该**出现方向/主题色问题；切到 Ask 模式 → 系统提示词长度应该掉一个数量级。

---

<h2 id="s11">第 11 步 · 让它便宜：按变化频率分带 + 缓存命中归因</h2>

### 11.1 问题

你的系统提示词 30–80 KB。用户在一个项目里聊 20 轮。如果每轮都是全新的前缀，你烧掉的钱是可以按数量级优化的。

LLM 的前缀缓存是**前缀匹配**的：只要前 N 个 token 一样就能命中。所以**排序决定成本**。

### 11.2 按变化频率分四带

```mermaid
flowchart LR
    Z1["① 全局静态<br/>设计师宪章 · 注入抵抗<br/>【所有会话共享】"] --> Z2["② 会话稳定<br/>模式覆盖 · locale<br/>【一个会话内不变】"]
    Z2 --> Z3["③ 项目稳定<br/>设计系统 · 技能 · 元数据 · 平台<br/>【一个项目内不变】"]
    Z3 --> Z4["④ 回合可变<br/>deck/media/platform 信号触发块<br/>【每回合可能翻转】"]
    style Z1 fill:#e8f5e9
    style Z2 fill:#f1f8e9
    style Z3 fill:#fff9e6
    style Z4 fill:#ffebee
```

`daemon/src/prompts/system.ts:851-867` 的注释：

> slim (non-ask): **the STATIC charter opens the document**… so **every conversation shares the same cacheable prefix**; conversation-stable overrides (mode, locale) follow, project context after that, **turn-variable blocks last**.

### 11.3 最精妙的一条：触发信号的稳定性决定块的位置

同一个内容块，**根据它是被什么信号触发的，放在不同的带**：

```ts
// 元数据信号（项目创建时固定）→ 放在项目稳定带
if (isSlimCore && metadataPlatformSignal) {
  parts.push(PLATFORM_CONTRACTS_BLOCK, '\n\n---\n\n');
}
// 对话文本信号（中途可能翻转）→ 推到回合可变后缀
else if (isSlimCore && (platformHintSignal ?? false)) {
  slimTurnVariableParts.push(`\n\n---\n\n${PLATFORM_CONTRACTS_BLOCK}`);
}
```

注释解释得很清楚：

> The conversation-text signal is turn-variable (**a mid-session "make it an iOS app" flips it on**), so signal-only triggers defer the block to the turn-variable suffix… **an early insert would break the cached prefix for every section after this line.**

**同一个块，放错位置就毁掉后面所有段的缓存。**

### 11.4 缓存命中归因：出问题时你要知道是哪一段

只做优化不做观测，你不会知道自己的缓存命中率在掉。所以要有归因：

```ts
describeStablePromptCache({ isResuming, storedStablePromptHash, currentStableHash, … })
// →
if (!isResuming)                            → missReason: 'new-session'
if (storedHash === currentHash)             → hit: true
if (storedHash === null)                    → missReason: 'missing-stored-hash'
else                                        → missReason: 'stable-prompt-changed'
                                              + changedSections: 逐段 diff
```

**一个细节**：`changedSections` 只在 `'stable-prompt-changed'` 时计算。注释解释：

> `missing-stored-hash` is a legacy/again-seeded row with **no baseline to diff against**, so naming sections there would **report the whole map as "changed" and drown the signal we care about.**

无基线的情况报告"全部变了"会淹没真正的漂移信号——**做遥测时要小心这类"技术上正确但信息量为零"的输出。**

> ✅ **本步验收**：同一项目连聊 5 轮，第 2 轮起 `hit: true`；中途切换设计系统 → `missReason: 'stable-prompt-changed'` 且 `changedSections` 精确指出是设计系统那一段。

---

<h2 id="s12">第 12 步 · 第一道闸门：把"一眼假"写成正则</h2>

### 12.1 一个反直觉的主张

「这个页面一眼是 AI 做的」——听起来完全主观。但拆开看，其中相当大一部分是**可枚举的具体模式**：

| 模式 | 具体到什么程度 |
|---|---|
| 默认 Tailwind indigo 当 accent | 精确到 7 个 hex：`#6366f1` `#4f46e5` `#4338ca` `#3730a3` `#8b5cf6` `#7c3aed` `#a855f7` |
| 两段式"信任渐变" | 蓝色系 13 个 hex × 青色系 8 个 hex 的配对 |
| emoji 当功能图标 | 17 个：`✨ 🚀 🎯 ⚡ 🔥 💡 📈 🎨 🛡️ 🌟 💪 🎉 👋 🙌 ✅ ⭐ 🏆` |
| 圆角卡片 + 左侧彩色边框 | 经典"AI 仪表盘瓦片"形状 |
| display 用无衬线 | h1/h2/h3 的 `font-family` 落在 Inter/Roboto/Arial/`-apple-system`/`system-ui`/SF Pro |
| 发明的指标 | `10× faster` `99.9% uptime` `zero-downtime` `3× more productive` |
| 填充文案 | `lorem ipsum` `feature one\|two\|three` `placeholder text` `sample content` |

**能枚举 → 能写正则 → 能自动检查。**

### 12.2 十六条规则

| 级别 | id | 检查 |
|---|---|---|
| P0 | `purple-gradient` | 渐变里出现 20 个 violet/indigo hex 之一，或字面量 `purple`/`violet` |
| P0 | `trust-gradient` | 蓝→青配对 |
| P0 | `ai-default-indigo` | 7 个默认 accent hex 的**纯色**使用 |
| P0 | `emoji-icon` | slop emoji 出现在 `<h*>` / `<button>` / `<li>` / `class*="icon"` |
| P0 | `left-accent-card` | 圆角 + 左彩边 |
| P0 | `sans-display` | 标题用无衬线 |
| P0 | `invented-metric` | 发明的数据 |
| P0 | `filler-copy` | 填充文案 |
| P0 | `scroll-into-view` | 用了 `Element.scrollIntoView()`（**跨 iframe 边界会拽走宿主页面**） |
| P0 | `slide-theme-missing` | deck 里 `.slide` 缺 light/dark/hero 主题类 |
| P1 | `all-caps-no-tracking` | 大写但字距 <0.06em |
| P1 | `external-image` | unsplash / placehold.co / picsum 等外链占位图 |
| P1 | `raw-hex` | `:root{}` 之外裸 hex **>12 个** |
| P1 | `accent-overuse` | `var(--accent)` 在 body 里 **>6 次** |
| P1 | `slide-rhythm` | 连续 3 张同主题幻灯 |
| P2 | `missing-section-anchor` | `<section>` 缺 `data-od-id` / `data-screen-label` |

### 12.3 真正的工程含量在哪：ALL CAPS 字距那一条

天真实现：

```js
// ❌ 会漏一半
const m = /letter-spacing:\s*([\d.]+)em/.exec(body);
if (!m || parseFloat(m[1]) < 0.06) report();
```

真实实现要做四件事：

```mermaid
flowchart TB
    S1["① extractCssTokens(html)<br/>收集每个作用域的 --name: value"] --> S2
    S2["② buildResolvedThemes(scopes)<br/>把全局主题作用域（:root、[data-theme=…]）<br/>组合成多套主题"] --> S3
    S3["③ resolveCssVars(body, tokens)<br/>递归解析 var(--x)，最大深度 4"] --> S4
    S4["④ resolveFontSizePx(decls)<br/>同规则里的 font-size 折算成 px（root=16）"] --> J
    J["判定：letter-spacing 在<br/>每套主题下、按该字号<br/>是否 ≥0.06em 等效"]
```

**为什么要折算字号？** `letter-spacing: 1px` 在 12px 字上够（0.083em），在 48px 字上远远不够（0.021em）。

**为什么要解析变量？** `letter-spacing: var(--caps-tracking)` 不该被当成"没设置"。

**为什么要多套主题？** 亮色主题下 token 是 0.08em，暗色主题下可能被覆盖成 0.02em。

### 12.4 一个字符类差别导致的漏检

`lint-artifact.ts:328-341` 的注释是正则工程的经典教材：

> The body alternation is `[^{}]*` (**not** `[^}]*`) so the regex matches only **innermost** `selector { body }` rules. With `[^}]*`, an outer `@media (...) { .display { font-size: 48px; text-transform: uppercase; … } }` matches as a **single** rule whose selector is the `@media (...)` wrapper… **the same-rule font-size is lost, and the check falls back to the lenient inherited-size path that accepts 1px tracking on a 48px heading.**

`[^}]*` → `[^{}]*`，一个字符的差别，决定 `@media` 里的大写标题会不会被漏检。

### 12.5 三层假阳性防护

假阳性比漏检更致命——报错报烦了没人看。三道防护：

| 防护 | 防什么 |
|---|---|
| **剥 HTML 注释** | 注释里的教学示例（"paste a `<section class="slide">` here"）会被结构正则匹配 |
| **剥 CSS 注释** | `/* .eyebrow { text-transform: uppercase; } */` 浏览器不渲染，但规则形状的正则会匹配 |
| **区分 token 定义 vs 直接使用** | 把 indigo 定义成一个 token 是合法的，直接当 accent 用不是 |

### 12.6 阈值要有理由

```
// lint-artifact.ts:415-419
Allow up to ~12 raw hex values outside :root. Device chrome
(mobile-app frame: bezel gradient, side rails, status icons) has
legitimate hardware-specific values in the 8–10 range; raise the
threshold so seed templates pass without ceremony.
```

12 这个数是「手机边框种子模板合法用到 8–10 个」倒推的，不是拍脑袋的。**每个阈值都应该能说出它是从哪个真实场景倒推的**，否则半年后没人敢动它。

### 12.7 反馈回路：给 agent 的报错必须自带修复动作

```
<artifact-lint>
The artifact you just produced has the following anti-slop / design-token issues.
2 P0 (must fix), 1 P1 (should fix), 0 P2 (nice to have).
Re-emit a corrected `<artifact>` in your next turn — do not write a separate
explanation; the user has the previous version already.

**[P0] ai-default-indigo** — Found #6366f1 used as a solid accent.
  Fix: Use var(--accent) from the active design system.
  Snippet: `background: #6366f1`
</artifact-lint>
```

三个要点：
1. **按严重度排序**，P0 在前
2. **明确要求重发修正版**，不要写解释（"用户已经有上一版了"）
3. **每条都带 `fix`**——只给 `message` 模型会去猜，猜错就多一轮

### 12.8 一个诚实的边界：不硬阻断

`craft/README.md:68`：「**Artifact persistence is not currently hard-blocked on P0 hits.**」

P0 命中**不阻止落盘**。这是对的——硬阻断会让"模型死循环修不好"变成"用户什么都拿不到"。闸门的作用是**推动改进**，不是**阻止交付**。

> ✅ **本步验收**：手写一份带紫色渐变 + 🚀 图标 + `lorem ipsum` 的 HTML 喂进 linter → 应该报 3 条 P0；给它一份用 `letter-spacing: var(--tracking)` 且 `--tracking: 0.08em` 的大写标题 → **不应该**误报。

---

<h2 id="s13">第 13 步 · 第二道闸门：五位陪审员的评审剧场</h2>

### 13.1 linter 抓不到的那一半

正则能抓"用了 indigo"，抓不到"这个层次结构很混乱"。第二道闸门用模型评模型。

**五位陪审员**：

| 角色 | 评什么 | 权重 |
|---|---|---|
| **Designer** | 版式、构图、层次 | **0.0** |
| **Critic** | 是否真的满足简报；对比度、字重、可读性 | **0.4** |
| **Brand** | token 合规、语气、品牌色使用 | **0.2** |
| **Accessibility** | WCAG、焦点环、语义结构、alt 文本 | **0.2** |
| **Copy** | 语气、简洁度、错误文案质量 | **0.2** |

**Designer 权重为 0 是刻意的**：

> Designer is weighted at zero in v1 because **their dimensions are aesthetic preferences rather than ship gates.** The slot exists so the Designer's qualitative notes still travel into the transcript, and a future config release can bump the weight without changing the schema.

**保留席位、权重归零**——定性意见进记录，但不让主观审美卡住发布。这是很成熟的产品决策。

### 13.2 最关键的实现决定：一个会话，不是五个进程

```mermaid
flowchart TB
    subgraph BAD["❌ 直觉做法"]
        B1["为每位陪审员开一个进程/会话"]
        B1 --> B2["五份互不知晓的上下文"]
        B2 --> B3["评审结果自相矛盾"]
        B1 --> B4["鉴权/环境变量/日志要复制五份"]
    end
    subgraph GOOD["✅ 正确做法"]
        G1["五位陪审员 = 同一 CLI 会话的五个回合"]
        G1 --> G2["用 &lt;PANELIST role='…'&gt; 标签分隔"]
        G2 --> G3["解析成 panelist_* 事件"]
        G1 --> G4["运行契约和普通生成完全一致<br/>same auth, same env, same logs"]
    end
```

原文（`docs/critique-theater.md:32-35`）：

> All five panelists are turns in the same conversation, which keeps the model context coherent and **prevents the "panelist disagrees with itself across processes" failure mode.**

### 13.3 收敛循环

```
composite = designer×0.0 + critic×0.4 + brand×0.2 + a11y×0.2 + copy×0.2
threshold = 8.0 / 10
maxRounds = 3
perRoundTimeoutMs = 90_000 · totalTimeoutMs = 240_000
fallbackPolicy = 'ship_best'   // 或 'ship_last' / 'fail'
```

达标就发货；不达标发轮次摘要 → agent 修改 → 下一轮；三轮不收敛走 `fallbackPolicy`（默认取 composite 最高的那轮）。

**五种结算状态**：`Shipped` / `Below threshold` / `Timed out` / `Interrupted` / `Degraded`。

**`Interrupted` 的文案要单独写**：

> The `interrupted` chip uses a distinct copy ("Interrupted at round N, best composite X.X") **so the user is not told the run shipped when it did not.**

不要让"被中断"看起来像"发货了"。

### 13.4 四级开关：谁能开谁能关

| 优先级 | 层 | 说明 |
|---|---|---|
| 1（最高） | **技能级 `od.critique.policy`** | `required` / `opt-in` / `opt-out` |
| 2 | **项目级覆盖** | localStorage（会话内 UI）+ 对项目做**读-合并-写** |
| 3 | **环境变量** | 高级用户 / CI |
| 4（最低） | **灰度阶段默认** | M0/M1 关，M2 按技能，M3 全开 |

第 2 层有一处防数据丢失：

> GET the current project, **merge** into the existing metadata blob, PATCH the merged object so other metadata fields survive. **If the prefetch GET fails the setter skips the PATCH entirely instead of stomping the row.**

**读-合并-写的失败分支必须是"不写"，不是"写默认值"。**

**技能作者的经验法则**：产出确定性产物的技能（导出 PDF）→ `opt-out`；生成全新设计输出的（落地页、海报）→ `required`。

### 13.5 跨 25 条 CLI 的一致性纪律

你的评审协议要求 agent 吐 `<CRITIQUE>` 块。25 条 CLI 的模型不一样，遵守程度也不一样。所以要有**降级机制**和**一致性门槛**：

| 降级原因 | 起因 |
|---|---|
| `malformed_block` | 解析器不认这个块 |
| `oversize_block` | 超过 256 KB（模型跑飞） |
| `adapter_unsupported` | 该适配器被标 degraded，**24h TTL** |
| `protocol_version_mismatch` | 适配器协议版本旧 |
| `missing_artifact` | run 结束但没产物——「Almost always a prompt bug」 |

**一致性门槛**：

> The conformance harness runs every adapter prerelease against **10 brief templates**. If an adapter drops under the **90% shipped** or **95% clean-parse** thresholds for **two consecutive cycles**, it gets marked `critique:degraded` for 24h. The mark **auto-clears on the next clean cycle.**

**全量开关的条件**：

> globally during M3 **after ≥ 90% of production adapters maintain conformance for 14 consecutive days.**

一个跨 25 条第三方 CLI 的功能，用「连续 14 天 ≥90% 一致性」作为全量开关——**这是把"依赖不可控的第三方"这件事工程化的正确姿势。**

### 13.6 可回放

每次 run 写一份结构化 `.ndjson`（可选 gz）。Replay 按钮挂只读剧场，支持 `Instant` / `Live` / `{intervalMs: N}` / `Paused`（暂停后从光标继续，**不重新冲刷已发事件**），`J`/`K` 逐轮跳，`Esc` 退出。

**为什么要可回放**：评审是个多轮过程，用户看完了想再看一遍"它到底为什么给我 6.2 分"。没有回放，这个信息就丢了。

> ✅ **本步验收**：故意给一份低质量产物 → 应该跑满 3 轮且状态是 `Below threshold`；中途按 Esc → 徽章文案应该是 `Interrupted at round N, best composite X.X`。

---

<h2 id="s14">第 14 步 · 让它记住你：三张卡的记忆双环</h2>

### 14.1 记忆的三条铁律

**铁律一：记忆是偏好，不是硬规则。** 前言必须显式仲裁：

> Treat them as **preferences and context, NOT hard rules**: when they collide with the active design system tokens, **the brand wins**; when they collide with the active skill's workflow, **the skill wins**. They are still authoritative for **tone, voice, terminology** … **never re-ask the user about something already captured here.**

不这么写，模型会把"用户上次说喜欢深色"压过"本次品牌是浅色"。

**铁律二：记忆改变的是"你知道什么"，不是"你跳过什么"。**

> Expanding intent this way changes only **WHAT you know going in**; it never shortcuts the standard build flow — you still plan with TodoWrite and still run the anti-slop / brand self-check on every artifact-producing turn.

**铁律三：模型不许声称自己记住了，除非同一条回复里有那张卡。**

### 14.2 三张 `<od-card>`

```mermaid
flowchart TB
    IN["用户短请求"] --> MEM{"记忆够不够<br/>扩写成简报？"}
    MEM -->|够 & rewrite=ON| C1["① task-brief 卡<br/>替代 turn-1 问卷"]
    MEM -->|不够| FORM["走 RULE 1 问卷"]
    C1 --> BUILD["TodoWrite + 构建<br/>【不可跳过】"]
    FORM --> BUILD
    BUILD --> SLOP["反 AI 味 / 品牌自检<br/>【不可跳过】"]
    SLOP -->|verify=ON & 有已验证规则| C2["② verify-scorecard 卡<br/>宿主程序化检查其存在"]
    SLOP --> HAND["交付收尾"]
    C2 --> HAND
    HAND --> FB{"用户纠正里<br/>隐含可复用规则？"}
    FB -->|是| C3["③ rule-proposal 卡<br/>Keep / Edit / Discard"]
    C3 -->|点 Keep| STORE[("已验证规则库")]
    STORE -.->|下次注入| MEM
```

**① `task-brief`（PRE 环）**

短请求被记忆扩写成完整简报时，在回复**最开头**发一张折叠卡：

```
<od-card type="task-brief">
{ "summary": "<一行重述扩写后的意图>",
  "fields": [ {"label":"Audience","value":"…"},
              {"label":"Deliverable","value":"…"},
              {"label":"Done means","value":"…"} ] }
</od-card>
```

约束：**每回合最多一张**；请求已经明确或很琐碎（问好、是非、小修改）就跳过；**绝不以散文形式输出简报，只能是卡片**。

最关键的一条：

> The task-brief card **REPLACES the turn-1 discovery question-form** when memory already makes the intent clear — **it does NOT replace the rest of the build flow.** … **Skipping the discovery form when intent is already understood is correct; skipping TodoWrite or the anti-slop gate is not.**

**② `verify-scorecard`（POST 环）** —— 这里出现程序化执法

产出/编辑产物后，逐条核对"已验证规则"，修好所有失败，然后发记分卡：

```
<od-card type="verify-scorecard">
{ "status": "pass|partial|fail",
  "summary": "5/6 checks passed · 1 auto-fixed",
  "rows": [ {"rule":"<检查项>","status":"pass|fail|fixed","note":"<哪里错了/修了什么>"} ] }
</od-card>
```

> The daemon **programmatically checks this scorecard after your turn** — a missing scorecard or a rule left uncovered on an artifact turn is **recorded as an enforcement failure.**

并且规定收尾顺序：**(1)** 完成反 AI 味/品牌自检并就地修复 → **(2)** 发记分卡 → **(3)** 正常交付收尾。

还有一条行为倾向：「**Prefer fixing silently over asking.** Leave a row as `fail` only when fixing it needs a decision you genuinely cannot make.」

**③ `rule-proposal`** —— 最克制的一张

用户的纠正隐含一条可复用、可检查的规则时，**提案而非静默保存**：

```
<od-card type="rule-proposal">
{ "name":"<短名>", "description":"<一行>", "assertion":"<必须成立什么>",
  "check":"<怎么验证>", "rationale":"<为什么这样推断>" }
</od-card>
```

> Propose **at most one rule per turn**, and only when confident it generalizes beyond the current artifact. **Do not claim in prose that a rule was recorded, saved, noted, added to memory, or will be remembered unless this same response includes the rule-proposal card for that rule; the rule becomes saved only after the user clicks Keep.**

**最后半句在治一个具体的模型撒谎行为**：模型很爱说"好的，我记住了"，但实际上什么也没记。解法是把"记住了"这个断言**绑定到卡片的存在性**上——没卡就不许说。

### 14.3 为什么这套设计值得抄

对比本系列其他项目的记忆方案：

| 项目 | 记忆形态 | 用户可见性 |
|---|---|---|
| openworker | 显式 SQLite 事实 | 设置面板里能看能改 |
| Raven | EverOS 双轨（工作记忆 + 长期） | 部分可见 |
| nanobot | Dream 夜间反思 | 结果可见，过程不可见 |
| **Open Design** | **三张卡 + 已验证规则库** | **每一步都是可见、可改、可拒绝的界面元素** |

**核心差别**：Open Design 把"模型的内部状态"变成了"用户能看见、能改、能拒绝的 UI"。写入需要用户点确认；检查有程序化执法；每一条规则都能追溯到"是哪次纠正产生的"。

> ✅ **本步验收**：连续三次纠正同一件事 → 第三次应该出现 rule-proposal 卡；点 Keep 后下一次生成应该出现 verify-scorecard 且包含这条规则。

---

<h2 id="s15">第 15 步 · 让它可扩展：四平面 + 原子 + 封闭 <code>until</code></h2>

### 15.1 插件的最小形状

```
my-plugin/
├── open-design.json    ← 必需：市场元数据 + inputs + pipeline + capabilities
├── SKILL.md            ← agent-skill / scenario 类型必需，其他类型可省
├── README.md           ← 可选
├── preview/            ← 可选：index.html / poster.png（视觉类强烈建议）
└── examples/           ← 可选
```

核心字段：`specVersion` · `name`（稳定 ID）· `version`（semver）· `od.kind`（`skill`/`scenario`/`atom`/`bundle`）· `od.taskKind`（`new-generation`/`figma-migration`/`code-migration`/`tune-collab`）· `od.mode` · `od.capabilities[]` · `od.inputs[]`。

**一条重要的默认**：`od.capabilities[]` 要**声明最小集**——受限安装默认只给 `prompt:inject`。

### 15.2 原子：宿主暴露给插件的能力单元

**插件不拥有实现，只按 id 引用。** 宿主负责把每个原子解析成：系统提示词片段 + 工具门控 + GenUI 面声明。

13 个内置原子（挑几个说明意图）：

| id | 说明 |
|---|---|
| `discovery-question-form` | 第 1 回合问卷（把第 9 步的 RULE 1 变成可引用单元） |
| `todo-write` | TodoWrite 驱动的计划 |
| `research-search` | Tavily 支撑的浅层调研 |
| **`critique-theater`** | 五维评审，**发出驱动收敛的 `critique.score` 信号** |
| `design-extract` / `figma-extract` / `token-map` | 迁移场景三件套 |
| `build-test` | 跑 build/typecheck/tests，产出 `build.passing` / `tests.passing` |
| `handoff` | 把产物推给下游（cli / cloud / desktop） |

### 15.3 流水线：五步 + 一行审计

```mermaid
flowchart TB
    M["插件 manifest<br/>od.pipeline.stages[*].atoms[]"] -->|①解析| PS["PipelineStage[]"]
    PS -->|②run 前| B["解析内置原子指令体<br/>渲染成 ## Active stage 提示词块"]
    B --> R["③运行时逐阶段走"]
    R --> E1["发 pipeline_stage_started"]
    E1 --> W["④向 worker 注册表要<br/>宿主可观测的信号"]
    W --> A["⑤往 run_devloop_iterations 写一行审计"]
    A --> E2["发 pipeline_stage_completed + 信号"]
    E2 --> R
```

**一处必须诚实的说明**：

> Atoms whose work happens **inside the selected agent CLI** may use the registry's **permissive compatibility signals** because **the daemon has no independent observation for that tool action.**

`file-write` 这类实际发生在子进程内部的原子，你观察不到真实结果，只能给宽松信号。**不要假装你能观测一切**——文档里说清楚比编一个假信号强。

### 15.4 `until` 词汇表必须是封闭的

阶段收敛条件用一套**封闭的信号词汇**：

| 信号 | 谁发出 |
|---|---|
| `critique.score` | `critique-theater` |
| `iterations` | 内建计数器 |
| `user.confirmed` | `confirmation` GenUI 面解析时 |
| `preview.ok` | live-artifact 预览流水线 |
| `build.passing` / `tests.passing` | build-test 流程 |

> The evaluator is **deliberately closed and is not arbitrary JavaScript.** Unknown signals fail parsing and `od plugin doctor` reports them.

**为什么这是本步最重要的一条**：如果插件能写任意 JS 作为收敛条件，你就得沙箱它、审计它、担心无限循环、担心它读环境变量。封闭词汇表把"插件能表达什么"限制在宿主能保证的语义内——**代价是表达力受限，换来的是插件市场可以开放安装。**

任何面向第三方开放的扩展点，都要问一遍这个问题：**我在这里放的是数据还是代码？**

### 15.5 原子的晋升路径

不要一上来就把新能力做成内置原子。路径是：

```
① 先作为树外插件实现
   ↓ SKILL.md / MCP 工具 / pipeline 形状稳定后
② 加内置原子 + 往 FIRST_PARTY_ATOMS 追加一行
   + 有真实可观测信号时才注册 worker
   ↓ 同一个 PR
③ 更新文档和 spec 表格
   ↓
④ 通过 pipeline 引用 / GET /api/atoms / od atoms list / od plugin doctor 触达
```

> ✅ **本步验收**：写一个插件，`until: "critique.score >= 8"` 能跑；改成 `until: "myCustomThing == true"` → `od plugin doctor` 应该报未知信号。

---

<h2 id="s16">第 16 步 · 装边界：你放弃了权限闸门，就必须补齐外围</h2>

### 16.1 先诚实面对：你确实放弃了权限闸门

你的 `buildArgs` 里有这些：

| CLI | 你喂给它的 |
|---|---|
| Claude | `--permission-mode bypassPermissions` |
| Cursor | `--force` + 能力门控的 `--trust` |
| Devin | `--permission-mode dangerous --respect-workspace-trust false` |
| Qoder / Trae | `--yolo` |
| Copilot | `--allow-all-tools` |
| DeepSeek | `--auto` |
| Amp | `--dangerously-allow-all` |

**为什么必须这样？** 因为你在**没有 TTY** 的环境里跑它们。交互式批准提示会直接把 run 挂死——用户在浏览器里等着，子进程在等一个永远不会来的 `y`。

**所以要把这件事写进文档，不要藏**：

> The effective project cwd is **an execution root, not a uniform Open Design sandbox**, and external-directory flags can widen a CLI's reach. … users must treat these runs as **trusted agent execution with the authority shown by the selected definition.**

放弃了执行层的闸门，就必须把预算全花在**边界**上。下面五条。

### 16.2 边界一：默认 loopback

- daemon 默认绑 `127.0.0.1`
- LAN 暴露需要**同时**设 `OD_BIND_HOST` **和** `OD_ALLOWED_ORIGINS`（缺一不可）
- **连接器凭证和预览路由无论如何都保持 loopback-only**——即使公开部署也不放开

### 16.3 边界二：SSRF——默认封内网，opt-out 极其严格

你有一个 BYOK 代理，用户可以填任意 `baseUrl`。这是教科书级的 SSRF 面。

**默认**：封锁解析到私有/内部地址的 provider base URL——RFC1918、link-local、CGNAT、**云元数据 IP**（`169.254.169.254` 那类，能偷 IAM 凭证）。

**但真实用户确实有内网网关**（VPN 里的 LiteLLM、Ollama）。所以要有 opt-out，且必须严格：

| 性质 | 规则 |
|---|---|
| 严格 opt-in | 默认空 |
| **精确主机匹配** | **不做**子域名/子串匹配 |
| 格式宽容 | 接受 `host:port` 或完整 URL，归约到 hostname；IPv6 必须 `[fd00::1]` |
| 范围受限 | **只**作用于你自己配的 provider 端点 |
| **不放宽下游** | **刻意不**放宽上游响应里返回的下载 URL |
| 错误项丢弃 | 畸形条目、**CIDR 记法（不支持）** 被丢弃并告警，不静默信任 |

最后还要**说清残余风险**：

> Allowlisting a hostname **trusts whatever it resolves to**; allowlist the resolved IP instead if you want the DNS-resolved address re-checked.

放行主机名 = 信任 DNS 解析结果（DNS rebinding 风险）。**说清楚残余风险比说"我们很安全"有价值得多。**

### 16.4 边界三：桌面文件夹导入的 HMAC 单次令牌

用户要导入本机任意文件夹。这个能力很危险——渲染进程里的一段恶意脚本能不能构造一个请求，让 daemon 去读 `/etc/`？

```mermaid
sequenceDiagram
    participant U as 用户
    participant M as Electron main（可信）
    participant R as Renderer（沙箱）
    participant D as Daemon

    U->>M: 点「导入文件夹」
    M->>U: 原生文件夹选择器
    U->>M: 选中 /Users/me/work/site
    M->>M: 铸造短寿命、单次 HMAC 令牌
    M->>R: 令牌 + 路径
    R->>D: POST /api/import/folder（带令牌）
    D->>D: 校验 HMAC + 规范化路径<br/>拒绝落在自己托管存储内的导入
    D->>D: 打上服务端控制的「可信选择器」标记
    Note over D: 之后每次文件访问都对该外部根做安全路径解析
```

**关键一条**：那个「可信选择器」标记由**服务端控制**，普通的项目创建/更新请求**伪造不了**。渲染进程拿不到 HMAC 密钥。

### 16.5 边界四：预览必须是沙箱 iframe

- 沙箱 iframe，**无宿主同源访问**
- 每个面**只 opt-in 它需要的 sandbox 特性**（下载、弹窗）
- 切换 URL/srcDoc 渲染模式时**两个 frame 都保持挂载**避免重载闪烁
- 消息处理器**校验发送方 iframe**
- 需要来自活跃 frame 的信号会**再次核对活跃窗口**

最后两条防的是：页面里有多个 iframe 时，一个后台 frame 冒充活跃 frame 发消息。

### 16.6 边界五：技能暂存必须是拷贝，不是软链

这是最值得学的一条，因为它是**代码评审抓出来的漏洞**。

**错误版本（PR #435 round 1）**：把 `.od-skills/` 做成指向仓库 `skills/` 树的目录软链。

**评审意见**：

> **write-amplification vulnerability: agents have write access to their cwd**, and a `Write`/`Edit`/`Bash` call against `.od-skills/<id>/SKILL.md` **resolves through the symlink and mutates the shipped resource itself.**

一次 `Edit` 就能改坏所有项目共用的技能源文件。

**正确版本**：每个项目一份**真实拷贝**。

```ts
export const SKILLS_CWD_ALIAS = '.od-skills';

export function skillCwdAliasSegment(dir: string): string {
  const folder = path.basename(dir) || 'skill';
  const normalizedDir = path.resolve(dir).replaceAll('\\', '/');
  const digest = createHash('sha256').update(normalizedDir).digest('hex').slice(0, 10);
  return `${folder}-${digest}`;   // saas-landing-3f2a91b0c4
}
```

四个配套细节：

| 细节 | 为什么 |
|---|---|
| **只暂存活跃技能**，不是整个 `skills/` | 单个技能 1–3 MB；APFS/btrfs/ReFS 上 `fs.cp` 走 CoW，稳态成本只是几个 syscall |
| **`dereference: true`** | 拷贝完全自包含，**里面任何东西都写不回项目外的真实文件** |
| **`stat()` 而不是 `lstat()`** 源根 | 有些环境把 `skills/` 本身放在软链后面（内容寻址挂载），要跟过去 |
| **路径哈希做后缀** | 用户根和内置根可能有同名技能（遮蔽关系），都被选中时目录名会撞 |
| **跨文件系统流式拷贝兜底** | `copy_file_range(2)` 跨文件系统被拒（`EXDEV`；容器镜像层拷到 ZFS/overlay 上是 `EPERM`），Node 不自动降级 |
| **提示词里给两条路径** | cwd 相对的暂存路径（主）+ 绝对源路径（兜底），暂存失败时 agent 仍能工作 |

### 16.7 边界六：Windows 命令行长度的三重守卫

如果你必须支持一条只吃 argv 的 CLI（比如 clap 声明 `prompt: String` 必填、没有 `-` stdin 哨兵），你需要三道守卫：

| 守卫 | 时机 | 检查 |
|---|---|---|
| `checkPromptArgvBudget` | **bin 解析前**（快） | 原始提示词字节数 vs `maxPromptArgBytes`（如 30 000） |
| `checkWindowsCmdShimCommandLineBudget` | `buildArgs` 后 | 解析出 `.cmd`/`.bat` shim 时，用**平台层相同的逐参数引号翻倍规则**重算 `cmd.exe /d /s /c "<inner>"` |
| `checkWindowsDirectExeCommandLineBudget` | `buildArgs` 后 | 解析出非 shim 的 `.exe` 时，用 **libuv `quote_cmd_arg` 规则**（每个 `"` 变 `\"`，紧邻引号的反斜杠翻倍）重算 |

两个 Windows 守卫在给定解析上**互斥**。三者一起抓的是：**原始字节数没超，但引号密集的提示词（代码块、JSON 形状的技能种子）展开后超过 CreateProcess 的 32 767 字符上限。**

三者发同一个可行动的错误：告诉用户「减少技能/设计系统上下文、缩短对话、或换一个支持 stdin 的适配器」。**并且三者都有单测**（超长 + 短提示词分支、两条 Windows 路径的引号密集回归、互斥性检查），「so the guards can't silently regress」。

> ✅ **本步验收**：把 `.od-skills/xxx/SKILL.md` 改坏 → 源 `skills/` 应该毫发无损；在 BYOK 里填一个 `http://169.254.169.254/` → 应该被拒并给出明确原因；在 Windows 上用一个含大量引号的 40 KB 提示词跑 argv-only 适配器 → 应该在 spawn 前就给出 `AGENT_PROMPT_TOO_LARGE`，而不是一个看不懂的 `ENAMETOOLONG`。

---

<h2 id="s17">第 17 步 · 装成产品：桌面壳 + 侧车 + MCP 服务端 + 导出</h2>

### 17.1 三种运行形态，一套代码

| 形态 | 入口 | 特征 |
|---|---|---|
| **源码开发** | `pnpm tools-dev run web` | 动态分配端口，daemon + web 侧车 |
| **打包桌面 / 无头** | Electron / headless 启动器 | 解析 channel/namespace 作用域的运行时与数据身份后再拉 daemon |
| **容器 / daemon 直服** | `docker compose up -d` | **同一个 daemon 直接服静态导出 + `/api/*`** |

**一条重要的架构纪律**：

> **Ports are transport details; they do not define process identity, namespaces, or daemon data roots.**

打包桌面模式下 Electron **不假设端口**——它通过 sidecar IPC 去问 web 的真实 URL。

### 17.2 数据根契约：一处文档纪律

这是全项目最值得抄的一条**元规则**：

> This document intentionally gives **no concrete daemon data path**. The root `AGENTS.md` section **Daemon data directory contract** is the **only** path authority.

README 里也重复了禁令（「This README MUST NOT restate it」）。

**为什么值得单独立规矩**：多处文档各自写死一个路径，是所有本地优先应用的经典腐烂源。改了实现，八个 md 里有六个还写着旧路径，用户按文档找不到数据。把路径**降级成单点权威**，其他文档只允许引用不允许复述。

实现上：启动时把 `OD_DATA_DIR` 解析一次成 `RUNTIME_DATA_DIR`，之后 SQLite、项目工作区、artifacts、用户注册表、凭证、自动化状态**全部由这个根派生**。唯一例外是文件夹导入（用用户选的外部根，校验+限界，不拷贝）。

### 17.3 dual-track 规则：能力必须同时在 UI 和 CLI 出现

> User-facing capabilities must be reachable through **both** Web/API routes **and** `od` CLI subcommands. When adding a user-facing capability, **close the loop in one change**: contract type, daemon route, web surface if applicable, and CLI command with `--json` plus `--prompt-file <path|->` for long prompts where relevant.

**为什么强制？** 因为 CLI 是外部 agent 消费你的产品的方式。如果一个能力只有 UI 有，那么通过 MCP 接进来的 Claude Code 就用不了它——你的"可被任意 agent 消费"这个卖点就漏了一个洞。

而且注意 `--json`：**每个命令都要支持**，这样才能 `| jq | xargs` 进自动化。

### 17.4 把自己做成 MCP 服务端

这是"agent 原生"这个定位的闭环：你不仅**调用**别人的 agent，还要**被**别人的 agent 调用。

```bash
od mcp install <agent>     # 一行装进 16+ 个 CLI 的配置
# 然后在那个 agent 里：
od project list --json
od files list <project-id> --json
od files read <project-id> <relative-path>
od plugin list --json
od skills list --json
```

**为什么 MCP 而不是导出 zip**：「Exporting and re-attaching a zip every iteration breaks flow. MCP exposes the design source directly — **the agent always sees the live file, not a stale export.**」

**安全模型**：默认只读，绑 127.0.0.1，SSRF 在代理边缘拦。

**一个真实的坑**：macOS / WSL2 上 `/usr/bin/od` 是系统的八进制转储工具，会在 PATH 上盖过你的 `od`。三种应对：
1. 桌面 App 的**设置 → MCP server** 给一段用**绝对路径**的片段
2. `install.sh` 是 `od mcp install` 的薄封装，存在的理由是「hosted URL 返回 shell 而不是落地页 HTML 兜底，**并且在 shell 解析到非 Open-Design 的 `od` 时快速失败**」
3. 文档里三处提醒

**教训**：选命令名前先 `which <name>` 一遍常见系统。

### 17.5 导出矩阵

| 格式 | 实现 |
|---|---|
| HTML | 单文件、内联所有资源 |
| PDF | 浏览器打印，deck 感知 |
| PPTX | **agent 驱动的技能**（还配了一个 `pptx-html-fidelity-audit` 技能做保真审计） |
| ZIP | 归档 |
| Markdown | — |
| MP4 | HyperFrames（HTML+CSS+GSAP → headless Chrome + FFmpeg） |

注意 PPTX 走的是**技能**而不是库——因为 HTML→PPTX 的保真度是一个判断问题，不是转换问题。

### 17.6 不做跨 agent 自动兜底

一个反直觉但正确的决定：

> Open Design does **not** implement an ordered cross-agent fallback chain. A chat request explicitly names its agent, and a crash, auth failure, timeout, or invalid invocation **remains a failure for that run.** … the daemon **does not silently — or through a dedicated one-click fallback action — move the request** to another detected CLI.

**为什么？** 自动切换 agent 会让**计费、鉴权、输出风格全部悄悄改变**，用户根本不知道刚才那份产物是谁做的。

只有两个窄规则：
- **运行前默认**（`agentId` 省略时）：用配置的 agent 如果可用，否则用第一个可用的。「This chooses an agent **before** a run starts; it is **not** failure recovery.」
- **过期会话恢复**：清掉**同一个 agent** 的陈旧会话，用完整 transcript 重新播种。「That recovery **never changes agent families**.」

### 17.7 mock agent：不烧额度的回归测试

维护 25 条适配器，每次改解析器都真跑 25 个 CLI 是不可能的。所以要有：

- `mocks/mock-agent.mjs` —— 假 CLI
- `mocks/recordings/` —— 录下来的真实流
- `mocks/golden/` —— 期望输出

守则：「For agent-stream/parser changes, **replay a mock CLI trace from `mocks/` when practical instead of burning provider budget.**」

> ✅ **本步验收**：`od project list --json | jq` 能跑；把 daemon 停掉，桌面 App 应该给出明确错误而不是白屏；改一个解析器 → 用 mock 回放能在 5 秒内跑完回归。

---

<h2 id="replay">🎬 完整回放：这一句话到底跑了什么</h2>

用户在 Home 打字：**"帮我们做一个 SaaS 产品落地页，用我们公司的品牌。"** 然后选了设计系统 `linear-app`，点 Run。

```mermaid
sequenceDiagram
    autonumber
    participant U as 用户
    participant W as Web
    participant D as 宿主 Daemon
    participant C as claude 子进程
    participant F as 项目文件

    U->>W: 输入简报 + 选 linear-app + Run
    W->>D: POST /api/projects（kind=prototype, designSystemId=linear-app）
    D->>D: 分配托管项目工作区（RUNTIME_DATA_DIR 派生）
    W->>D: POST /api/chat（SSE）

    Note over D: 【组装阶段】
    D->>D: 探测 agent（并发，已 warm）→ claude 可用
    D->>D: 暂存活跃技能到 .od-skills/saas-landing-3f2a91b0c4/（真实拷贝）
    D->>D: 组装系统提示词（20+ 层，按变化频率分带）
    Note right of D: ① 注入抵抗<br/>② 设计师宪章<br/>③ 发现层（3k token）<br/>④ linear-app 的 USAGE→DESIGN.md→tokens.css→组件清单<br/>⑤ craft: typography+color+anti-ai-slop<br/>⑥ saas-landing SKILL.md<br/>⑦ 元数据块<br/>⑧ 设计系统方向覆盖（压掉③里的方向问题）
    D->>D: 算 stablePromptHash → 新会话，miss

    Note over D: 【启动阶段】
    D->>D: resolveAgentLaunch → 拿到真实可执行路径
    D->>D: buildArgs → ['-p','--input-format','stream-json',<br/>'--output-format','stream-json','--verbose',<br/>'--include-partial-messages','--session-id',<uuid>,<br/>'--permission-mode','bypassPermissions']
    D->>C: spawn(cwd=项目工作区)，提示词经 stdin（JSONL user 消息），stdin 保持打开

    Note over C: 【第 1 回合 · RULE 1】
    C-->>D: text-delta: "明白了 — SaaS 落地页，用 Linear 的设计语言。补几个信息："
    C-->>D: text-delta: <question-form id="discovery">…</question-form>
    D-->>W: SSE 事件流
    W->>U: 渲染问卷卡（4 题，全部预填；**没有**方向/主题色题）
    Note over C: 停。不读文件、不 TodoWrite。

    U->>W: 直接提交（不改）
    W->>D: POST /api/chat（"[form answers — discovery] …"）
    D->>C: 同一 stdin 写入新的 user 消息（不重启进程）

    Note over C: 【第 2 回合 · RULE 2 分支 B】
    C-->>D: 有活跃设计系统 → 不再问方向，直接进 RULE 3

    Note over C: 【第 3 回合 · RULE 3】
    C-->>D: tool-call TodoWrite（9 步计划）
    D-->>W: 渲染 Todos 卡
    C->>F: Read .od-skills/saas-landing-…/assets/template.html
    C->>F: Read references/layouts.md, checklist.md
    C-->>D: 标记 step1 completed, step2 in_progress
    C->>F: Write saas-landing.html（绑 linear-app 的 :root token）
    D-->>W: file-write 事件 → 文件工作区出现 → 沙箱 iframe 预览
    C-->>D: step4–6 逐个 completed

    Note over D: 【闸门一】
    D->>D: lintArtifact(saas-landing.html)
    D->>D: 命中 1 条 P1: accent-overuse（var(--accent) 用了 9 次）
    D->>C: <artifact-lint> 系统提醒（含 fix + snippet）
    C->>F: Edit saas-landing.html（把 7 处降级成 var(--fg)）

    Note over C: 【自检】
    C-->>D: step7 checklist.md，P0 全过
    C-->>D: step8 五维自评：具体性 2/5（填充文案）→ 返工
    C->>F: Edit（替换 [REPLACE] 为简报里的真实文案）
    C-->>D: 重评：全部 ≥3/5

    Note over D: 【闸门二 · 若开启】
    D->>C: Design Jury round 1（同一会话的 5 个回合）
    C-->>D: <PANELIST role="critic">8.5</PANELIST> …
    D->>D: composite = 8.3 ≥ 8.0 → Shipped at round 1

    Note over C: 【交付 · filesystem 档】
    C-->>D: 普通摘要："写了 saas-landing.html —— hero / 三个能力块 /<br/>社会证明 / 定价 / CTA，全部绑 Linear token。"
    Note right of C: ❌ 不发 <artifact> 源码块（filesystem 档禁止）
    D-->>W: done 事件
    W->>U: 预览 + 「导出 PDF / HTML / ZIP」按钮

    U->>W: 导出 PDF
    W->>D: 浏览器打印路径 → PDF
```

**这一条链路上，你写的代码在哪？**

| 环节 | 谁的代码 |
|---|---|
| 探测、启动、argv 构建 | **你的** |
| 提示词组装（20+ 层） | **你的**（且这是你最重要的产品） |
| 技能暂存 | **你的** |
| 理解简报、决定问什么、写 HTML、修改 | **claude 的** |
| 流解析 → 统一事件 | **你的** |
| 反 AI 味 linter | **你的** |
| 评审剧场编排 | **你的**（评分是 claude 做的） |
| 预览、导出 | **你的** |

**推理全是别人的，产品全是你的。**

---

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

| 步 | 断言 |
|---|---|
| 1 | 能用一句话说清"为什么不写主循环"，并列出这个决定带来的三个新问题 |
| 2 | 新增一条已知 wire format 的 CLI = 一个新文件 + registry 一行，引擎零改动 |
| 2 | 用户 profile 撞了内置 id → **daemon 起不来**（加载期 throw），不是安静覆盖 |
| 3 | 把 `claude` 移出 PATH → 只有它变灰，其他 25 条不受影响 |
| 3 | nvm 装的 shim 版 CLI：探测结果和实际能不能跑**一致** |
| 4 | 60 KB 提示词在 macOS + Windows 都能跑完 |
| 4 | 工具调用中途不断流（`tool_use` 时不关 stdin） |
| 5 | 喂一段"围栏里的假 artifact + 真 artifact + 未闭合的坏 artifact" → 只落盘那一个真的 |
| 6 | `plain` 档和 `filesystem` 档跑同一简报，产物**落在同一位置、有同样的清单** |
| 7 | 换设计系统 → 下次生成的 `:root` 整体换掉 |
| 7 | 改坏 `tokens.css` 一个值 → guard 报派生文件不一致 |
| 8 | 技能里写 `requires: [typograpy]` → `lint:craft` 报错并指出 manifest 路径；运行时只跳过 |
| 9 | 新项目发模糊简报 → **2 秒内**出问卷卡，每题有预填；直接提交能跑出合理产物 |
| 10 | 选了设计系统 → 问卷里**不出现**方向/主题色题 |
| 10 | 切 Ask 模式 → 系统提示词长度掉一个数量级 |
| 11 | 同项目连聊 5 轮，第 2 轮起 `hit: true` |
| 11 | 中途换设计系统 → `missReason: 'stable-prompt-changed'` 且 `changedSections` 精确指段 |
| 12 | 紫渐变 + 🚀 + lorem ipsum → 报 3 条 P0 |
| 12 | `letter-spacing: var(--tracking)` 且 `--tracking: 0.08em` → **不误报** |
| 13 | 低质量产物 → 跑满 3 轮且状态 `Below threshold` |
| 13 | 中途 Esc → 徽章是 `Interrupted at round N, best composite X.X`，**不是** Shipped |
| 14 | 连续三次纠正同一件事 → 出现 rule-proposal 卡 |
| 14 | 点 Keep 后下次生成出现 verify-scorecard 且含这条规则 |
| 15 | `until: "critique.score >= 8"` 能跑；未知信号 → `od plugin doctor` 报错 |
| 16 | 改坏 `.od-skills/xxx/SKILL.md` → 源 `skills/` 毫发无损 |
| 16 | BYOK 填 `http://169.254.169.254/` → 拒绝并说明原因 |
| 16 | Windows + 引号密集的 40 KB 提示词 + argv-only 适配器 → spawn 前就报 `AGENT_PROMPT_TOO_LARGE` |
| 17 | `od project list --json \| jq` 能跑 |
| 17 | 改解析器 → mock 回放 5 秒内跑完回归 |

---

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

**① 探测和执行走了不同的路径解析。**
症状：UI 说"没装"，但手动跑得好好的（尤其 nvm/fnm/mise + GUI 启动的 App）。
修法：探测入口第一件事就是调用和 spawn 完全相同的路径解析函数。

**② 提示词进了 argv。**
症状：Linux `spawn E2BIG`、Windows `spawn ENAMETOOLONG`，而且是**间歇性的**（提示词长度取决于选了哪个设计系统）。
修法：能走 stdin 就走 stdin；必须走 argv 的上三重守卫（含 Windows 引号展开重算）。

**③ 在 `tool_use` 时关了 stdin。**
症状：回合在工具调用中途莫名结束。
修法：只在干净的 `turn_end` / `usage` 之后关。

**④ 双份上下文导致问卷循环。**
症状：第 2 回合又弹一次发现问卷，看起来像循环卡死。
根因：CLI 自己有会话记忆，你又把渲染的 transcript 拼进用户消息，它看到自己上回合发的 `<question-form>` 原文就模式匹配复读。
修法：为这类适配器加 opt-out，跳过 transcript 注入。

**⑤ `<artifact>` 提取吃了代码围栏里的教学示例。**
症状：项目里多出一堆奇怪的 `.html`。
修法：先算 Markdown 围栏 + 行内反引号的跳过区间；并且**和浏览器侧解析器保持一致**。

**⑥ 覆盖块放错位置。**
症状：API 模式下模型吐 `<todo-list>` 伪标记；或选了设计系统还在问主题色。
修法：搞清楚每个覆盖要压的是**哪一段**——要压全局的钉最顶，要压具体规则的钉最尾。

**⑦ 把回合可变的块插进了缓存前缀。**
症状：缓存命中率忽高忽低，账单不稳。
根因：一个由对话文本触发的块被插在了项目稳定带，用户中途说一句话就把后面所有段的缓存作废了。
修法：**触发信号的稳定性决定块的位置**，不是内容的重要性。

**⑧ linter 假阳性淹没真问题。**
症状：agent 每轮都在修 linter 报的问题，但用户看不出区别。
修法：剥 HTML 注释、剥 CSS 注释、区分 token 定义 vs 直接使用；每个阈值都要能说出它是从哪个真实场景倒推的。

**⑨ 五位陪审员开了五个进程。**
症状：评审结果自相矛盾；鉴权/环境/日志要维护五份。
修法：五位陪审员 = **同一会话的五个回合**，用标签分隔。

**⑩ 技能暂存用了软链。**
症状：一个项目里的 agent 改坏了 `SKILL.md`，所有项目一起坏。
修法：per-project 真实拷贝 + `dereference: true` + 路径哈希后缀 + 跨文件系统流式兜底。

**⑪ 让插件写任意 JS 作为收敛条件。**
症状：市场一开放就出现无限循环、读环境变量、超时不退的插件。
修法：`until` 用**封闭词汇表**，未知信号解析失败并由 doctor 报出。

**⑫ 记忆写入没有用户确认。**
症状：模型说"我记住了"但什么也没记；或者记了一堆用户根本不同意的"规则"。
修法：三张卡——提案卡要用户点 Keep 才落库；**并且禁止模型在没有卡的情况下声称记住了**。

---

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

| 教程步骤 | Open Design 源码 |
|---|---|
| 第 1 步 立论 | `docs/agent-adapters.md:5-11` |
| 第 2 步 契约 | `apps/daemon/src/runtimes/types.ts:101-253` |
| 第 2 步 样本定义 | `apps/daemon/src/runtimes/defs/claude.ts`（98 行） |
| 第 2 步 注册表 + 不变式 | `apps/daemon/src/runtimes/registry.ts:30-76` |
| 第 2 步 三种会话续跑 | `apps/daemon/src/runtimes/types.ts:189-209` |
| 第 3 步 探测流水线 | `apps/daemon/src/runtimes/detection.ts:238-318` |
| 第 3 步 路径解析必须一致 | `apps/daemon/src/runtimes/detection.ts:243-250`（注释） |
| 第 3 步 鉴权探针 | `apps/daemon/src/runtimes/types.ts:228-245` · `auth.ts` |
| 第 4 步 stdin 与命令行上限 | `apps/daemon/src/runtimes/defs/claude.ts:45-51`（注释） |
| 第 4 步 stdin 生命周期 | `docs/agent-adapters.md:191-194` · `apps/daemon/AGENTS.md:113` |
| 第 5 步 流格式分组 | `docs/agent-adapters.md:139-147` |
| 第 5 步 `<artifact>` 提取 | `apps/daemon/src/runtimes/plain-stream.ts`（473 行） |
| 第 5 步 产物清单 | `apps/daemon/src/runtimes/plain-stream.ts:421-472` |
| 第 6 步 执行画像 | `packages/contracts/src/execution-profile.ts` |
| 第 6 步 filesystem 交付契约 | `apps/daemon/src/prompts/system.ts:549-572` |
| 第 6 步 API 模式覆盖 | `packages/contracts/src/prompts/system.ts:498-540` |
| 第 7 步 设计系统包契约 | `design-systems/README.md` |
| 第 7 步 注入八层顺序 | `docs/skills-protocol.md:200-218` |
| 第 7 步 品牌提取五步 | `packages/contracts/src/prompts/discovery.ts:176-187` |
| 第 8 步 craft 四轴 | `craft/README.md` |
| 第 8 步 两级执法 | `craft/README.md:63-70` · `craft/anti-ai-slop.md` |
| 第 9 步 三条硬规则 | `packages/contracts/src/prompts/discovery.ts:25-354` |
| 第 9 步 表单编写规则 | `packages/contracts/src/prompts/discovery.ts:135-152` |
| 第 9 步 deck 框架优先 | `packages/contracts/src/prompts/system.ts:450-485` |
| 第 10 步 组装器 | `apps/daemon/src/prompts/system.ts:791-1370` |
| 第 10 步 优先级注释 | `packages/contracts/src/prompts/system.ts:498-512` |
| 第 11 步 缓存分带 | `apps/daemon/src/prompts/system.ts:851-867`（注释） |
| 第 11 步 命中归因 | `apps/daemon/src/runtimes/chat-prompt-inputs.ts:394-442` |
| 第 12 步 反 AI 味 linter | `apps/daemon/src/lint-artifact.ts`（1 000 行） |
| 第 12 步 字距 token 求值 | `apps/daemon/src/lint-artifact.ts:636-720` · `805-892` |
| 第 12 步 反馈回路 | `apps/daemon/src/lint-artifact.ts:519-537` |
| 第 13 步 评审配置与 schema | `packages/contracts/src/critique.ts` |
| 第 13 步 评审文档 | `docs/critique-theater.md` |
| 第 14 步 记忆与三张卡 | `packages/contracts/src/prompts/system.ts:376-404` |
| 第 15 步 原子目录 | `docs/atoms.md` · `apps/daemon/src/plugins/atoms.ts` |
| 第 15 步 流水线 | `apps/daemon/src/plugins/pipeline.ts` · `pipeline-runner.ts` |
| 第 16 步 授权边界 | `docs/agent-adapters.md:442-465` |
| 第 16 步 SSRF opt-out | `README.md`（Internally-hosted model endpoints 段） |
| 第 16 步 桌面 HMAC | `apps/daemon/src/desktop-auth.ts` · `docs/architecture.md:228-243` |
| 第 16 步 技能暂存尸检 | `apps/daemon/src/cwd-aliases.ts:1-30`（注释） |
| 第 16 步 Windows 三重守卫 | `docs/agent-adapters.md:345` · `runtimes/prompt-budget.ts` |
| 第 17 步 三种运行形态 | `docs/architecture.md:18-53` |
| 第 17 步 数据根契约 | `docs/architecture.md:150-162` · 根 `AGENTS.md` |
| 第 17 步 dual-track | `apps/daemon/AGENTS.md:99-106` |
| 第 17 步 不做跨 agent 兜底 | `docs/agent-adapters.md:411-424` |

---

## 结语：这条路线适合谁

**适合你，如果**：
- 你的产品价值在**内容和体验**，不在推理能力（设计、写作、数据分析、报表）
- 你的用户已经有 CLI Agent（开发者、设计师、技术型 PM）
- 你想把精力花在**领域知识的编码**上（品牌契约、工艺规则、质量闸门），而不是重造循环

**不适合你，如果**：
- 你的用户是完全不懂技术的普通人（他们不会装 CLI，你得全靠 BYOK 兜底，那条路的体验会差一档）
- 你需要**细粒度的权限控制**（这条路线里你控制不了子进程的行为，只能控制边界）
- 你的核心竞争力就是推理本身（那你就该自己写循环）

**最后一句**：Open Design 三个月拿到 82k star，不是因为它的适配器写得好——适配器只有 3 000 行。是因为它**把"什么是好设计"这件事，编码成了 151 个品牌包、11 份工艺规则、330 行行为脚本和 1 000 行审美 linter**。

**引擎是借的，产品是自己的。**

---

*本教程基于 `nexu-io/open-design` commit `c893b60`（2026-07-28）写成。所有 `文件:行号` 引用均可回源码验证。配套阅读：[open-design 源码分析](./项目分析/open-design-源码分析.md)。*
