源码解析 第十份 commit c893b60

不写 Agent 的 Agent 产品

Open Design 一行主循环都没写。它把你机器上已经装好的 claudecodexcursor-agent 当成设计引擎,喂给它们一套由技能 + 品牌契约 + 工艺规则 + 插件组成的文件系统, 再用两道审美闸门把产出拦一遍。三个月,82 000 颗星。

11 745
仓库文件(321.9 MB)
26
运行时定义 / 25 个本地 CLI
82.0k
star(创建于 2026-04-28)
0
自己实现的 Agent 主循环
Part I

它是什么

先搞清楚一件事:这不是第十个编程 Agent,这是那些 Agent 的宿主

Chapter 01

一个不写 Agent 循环的 Agent 产品

2026 年 4 月,Anthropic 发布 Claude Design——第一次让大模型不再吐散文,而是直接交付设计成品。它火了,但它闭源、付费、纯云、锁死在 Anthropic 的模型和技能上:不能自托管,不能换 Agent,不能 Vercel 部署。

Open Design(下称 OD)是它的开源替身。README 的定位句拆成三条可验证的工程主张:

主张在代码里长什么样
Agent 无关apps/daemon/src/runtimes/defs/26 个对象字面量,每个描述一条 CLI 怎么调;引擎里零个 per-agent 分支
品牌可契约化design-systems/151 个包,每包 manifest.json + DESIGN.md + tokens.css,被整段塞进系统提示词
交付真实文件Agent 在项目 cwd 里写文件,沙箱 iframe 渲染,导出 HTML / PDF / PPTX / ZIP / MP4

与本系列前九个项目的根本差别

前面九份分析(opencode / hermes-agent / Raven / CodeWhale / goose / nanobot / grok-build / OpenManus / Suna / openworker)里,每一个都在实现自己的主循环:调模型、解析工具调用、执行、拼上下文、再调模型。它们争的是「谁的循环更好」。

OD 一行主循环都没有。docs/agent-adapters.md:5 说得非常直白:

We delegate the entire agent loop — model calls, tool use, context management, permission handling, resume, cancel — to the user's existing code agent CLI. OD's job is to detect it, feed it a skill + prompt + working directory, and stream its output back to the web UI.

以及那句立论 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.

一句话 别的项目在造引擎;OD 在造引擎的插座,然后把全部精力砸在「引擎跑起来之后,怎么让它产出的东西像人做的」——也就是提示词工程 + 内容库 + 质量闸门这三件事。

数字化的项目形状

daemon
465 个 TS 文件 / 6.9 MB
产品权威:/api/*、SQLite、spawn、提示词组装。32 个路由模块。
web
472 个 TS/TSX / 16.5 MB
Next.js 16 + React 18,i18n 覆盖 20+ 语言。FileViewer.tsx 单文件 660 KB
contracts
95 个源文件
唯一的 web↔daemon↔CLI 共享 DTO + 提示词镜像。
内容
164 技能 · 115 模板 · 153 品牌包 · 11 工艺规则
另有 277 个官方插件 + 183 个可重混示例 + 107 个媒体提示模板。
值得先记住的三个反差提示词的代码量比引擎还大apps/daemon/src/prompts/system.ts 2 075 行 / 125 KB,而 runtimes/registry.ts 只有 81 行
26 条定义对应 25 个二进制byok-opencode 复用 OpenCode 的可执行文件。
最小的适配器定义只有 629 字节defs/kilo.ts)。
Chapter 02

全景架构:四进程 · 四类内容 · 一条 SSE

flowchart TB
    subgraph P1["① 渲染进程 · 浏览器 / Electron renderer"]
        UI["Next.js 16 + React 18
聊天 · 文件工作区 · 沙箱 iframe 预览 · 设置"] end subgraph P2["② Web 侧车"] WEB["静态 UI + 预览状态
/api/* · /artifacts/* · /frames/* 重写"] end subgraph P3["③ Daemon · Node 24 + Express + better-sqlite3"] API["/api/* 32 个路由模块"] DB[("SQLite: projects · conversations
messages · runs · memory")] COMP["提示词组装器 2075 行"] REG["运行时注册表 26 条"] LINT["lint-artifact"] JURY["Critique Theater"] end subgraph P4["④ 被 spawn 的 CLI(不是 OD 的代码)"] CLI["claude / codex / cursor-agent / copilot /
opencode / devin / hermes / kimi / pi / …"] end subgraph P5["⑤ Electron main(可选)"] SHELL["原生窗口 · 自动更新 · 文件夹选择器
sidecar IPC"] end UI -->|同源 HTTP + SSE| WEB --> API SHELL --> WEB SHELL -.->|HMAC 单次令牌| API API --> DB API --> COMP --> REG REG -->|spawn, cwd = 项目工作区| CLI CLI -->|原生工具| FILES["项目文件(真实磁盘)"] CLI -->|stdout| API API -->|SSE 规范化事件| UI API --> LINT API --> JURY FILES -->|文件事件| UI
图 1 进程拓扑。注意 ④ 不是 OD 的代码——当你在界面上看到「思考中 / 调用工具 / 写文件」,那是 Claude Code 或 Codex 在跑它们自己的循环,OD 只是把 stdout 翻译成统一事件画出来。

四条必须记牢的边界

  1. Web UI 和 od CLI 调同一套 daemon HTTP API。docs/architecture.md:75「The CLI is not a second business-logic implementation; it is the machine-readable surface for the same capabilities.」这就是仓库反复强调的 dual-track rule:加一个用户能感知的能力,必须同一次改动里补齐 contract 类型 + daemon 路由 + web 界面 + od 子命令。
  2. 协议是 HTTP + SSE,不是 WebSocket。架构文档开头有一段罕见的「历史否定说明」docs/architecture.md:11-16:最早的草案画过 Vercel 隧道模式、浏览器直连模式、WebSocket session.generate、内存 session bus、history.jsonl、三根监听式技能注册表——全部被实现推翻。这段「否定式文档」是判断一份 spec 是否可信的好信号。
  3. Daemon 是唯一特权进程。它绑 loopback,拥有 SQLite、凭证、项目文件、spawn 权。web 只是壳。
  4. 子进程不是 OD 的代码。最容易搞混的一点,值得说第二遍。

内容四平面

OD 把「让模型做出好设计」拆成四条互相正交的轴。craft/README.md:9-16 的表格是最清楚的官方表述:

目录API管什么例子
功能技能skills/(164)GET /api/skillsAgent 干活时调用的能力brand-extract · web-clone
渲染模板design-templates/(115)GET /api/design-templates可渲染的成品形状saas-landing · html-ppt-*
设计系统design-systems/(153)GET /api/design-systems品牌契约apple · stripe · linear-app
工艺规则craft/(11 个 .md由 daemon 按需组装与品牌无关的普适规则ALL CAPS 必须 ≥0.06em 字距

第四轴是 OD 相对 Claude Design 最聪明的一处拆分。craft/README.md:18-21

DESIGN.md tells the agent which colors and fonts a brand uses. craft/ tells the agent the universal rules a competent designer applies on top — e.g. ALL CAPS always needs ≥0.06em tracking, regardless of the brand.

技能通过 frontmatter 按需订阅工艺规则,只有被列出的段落会进提示词——一个只排版的技能不用为色彩、动效内容付 token 成本:

od:
  craft:
    requires: [typography, color, anti-ai-slop]
一句话 把「品牌怎么长」(DESIGN.md,会变)和「专业设计师的肌肉记忆」(craft,不会变)拆开,是 OD 让 151 个品牌包不互相抄袭同一份排版常识的关键。

注册表的「用户根优先」扫描

四类内容都遵循同一条发现规则:每次请求都重扫(没有 watcher、没有 SIGHUP、不用重启),同名用户条目遮蔽内置条目(不删除,删掉用户版内置版自动回来)。聊天期解析跨两个注册表——因为持久化的项目里 skillId 既可能是功能技能也可能是设计模板。这是历史包袱换来的兼容性。

Chapter 03

三种运行形态与「数据根」这条唯一真理

形态入口谁起 daemon谁起 web桌面壳
源码开发pnpm tools-dev run webtools-dev 侧车tools-dev 侧车不启
打包桌面 / 无头apps/packagedpackaged 启动器packaged 启动器Electron(无头版跳过)
容器 / daemon 直服docker compose up -d单进程:同一个 daemon 直接服 apps/web/out 静态导出 + /api/*

When ports are not supplied, tools-dev chooses available daemon and web ports… Ports are transport details; they do not define process identity, namespaces, or daemon data roots. docs/architecture.md:22-31

端口是临时的,身份不是端口决定的。打包桌面模式下 Electron 甚至不假设端口——它通过 sidecar IPC 去问 web 的真实 URL。这跟 openworker 用固定 8765 端口 + token 文件的做法形成对照:OD 把端口彻底降级成传输细节。

「数据根契约」:一处刻意的文档纪律

这是全仓库最有意思的一条元规则docs/architecture.md:160-162

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」)。apps/daemon/AGENTS.md:97 再补一刀:「Route all daemon-owned data through RUNTIME_DATA_DIR or constants derived from it.」

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

flowchart TB
    ENV["OD_DATA_DIR 环境变量"] -->|启动时解析一次| ROOT["RUNTIME_DATA_DIR"]
    ROOT --> D1["SQLite 库"]
    ROOT --> D2["托管项目工作区"]
    ROOT --> D3["artifacts"]
    ROOT --> D4["用户 skills / templates / design-systems"]
    ROOT --> D5["凭证 · 自动化状态 · 插件状态"]
    IMP["POST /api/import/folder"] -->|唯一例外| EXT["用户选中的外部 baseDir
(校验 + 边界限制,不拷贝)"]
图 2 数据根派生。唯一的例外是文件夹导入——导入的项目用用户选中的外部 metadata.baseDir,daemon 对那个工作区做校验和边界限制,而不是把它拷进托管根。
Part II

适配器层

整个项目最值得学的一层。它把「支持 N 个 CLI」的成本从 O(N) 压到了 O(1)。

Chapter 04

适配器契约:RuntimeAgentDef 是数据规格,不是类

面对「要支持 25 个 CLI」这个需求,99% 的团队会写一个抽象基类。OD 没有。docs/agent-adapters.md:17

An adapter is not a class that implements the agent loop. It is a plain data object — one RuntimeAgentDef object literal per CLI — that declares how to talk to that CLI… There is no per-agent subclass and no run() / cancel() method to implement.

契约本体在 apps/daemon/src/runtimes/types.ts:101-253,共 ~45 个字段,其中只有 6 个必填

export type RuntimeAgentDef = {
  id: string;                          // 唯一键,注册表用它去重
  name: string;                        // 显示名
  bin: string;                         // 要在 PATH 上探测的可执行文件
  versionArgs: string[];              // 版本探测参数
  fallbackModels: RuntimeModelOption[];// 探测不到模型时的静态兜底
  buildArgs: (prompt, imagePaths, extraAllowedDirs?, options?, runtimeContext?) => string[];
  streamFormat: string;               // 引擎据此分派到解析器
  // …剩下 ~38 个可选字段,全部是数据或纯 arg-builder
};

关键性质buildArgs 是唯一的函数字段,而且它是纯的——输入提示词和选项,输出 argv 数组。它不 spawn、不读文件、不管生命周期。所以探测、启动、调用、取消、解析全部由共享引擎做。

Adding a CLI is a one-file change. Drop a new runtimes/defs/<cli>.tsno engine edits, no new class, no method overrides. docs/agent-adapters.md:27

LAB 01 适配器解剖台 选一条 CLI,拨动开关,看同一份契约怎么长出完全不同的 argv。灰色斜体是被门控掉的部分——这正是「数据规格 + 共享引擎」的价值所在。
运行时上下文开关
能力探测命中--help 里发现了可选 flag
用户选了非默认模型options.model
有额外允许目录extraAllowedDirs
续跑已有会话ctx.resumeSessionId
这条定义声明了什么
引擎最终 spawn 的 argv
这条定义的特别之处

注册表:81 行 + 一条启动期不变式

// registry.ts:30-57 —— 26 条基础定义
const BASE_AGENT_DEFS: RuntimeAgentDef[] = [
  amrAgentDef, claudeAgentDef, codexAgentDef, devinAgentDef,
  opencodeAgentDef, byokOpenCodeAgentDef, hermesAgentDef, traeCliAgentDef,
  grokBuildAgentDef, kimiAgentDef, cursorAgentDef, qwenAgentDef,
  qoderAgentDef, copilotAgentDef, ampAgentDef, piAgentDef,
  kiroAgentDef, kiloAgentDef, vibeAgentDef, deepseekAgentDef,
  aiderAgentDef, antigravityAgentDef, reasonixAgentDef, codebuddyAgentDef,
  mimoAgentDef, atomcodeAgentDef,
];

// registry.ts:65-68 —— 用户本地 profile 合并进来
export const AGENT_DEFS = [...BASE_AGENT_DEFS, ...readLocalAgentProfileDefs(BASE_AGENT_DEFS)];

// registry.ts:70-76 —— 启动期不变式:id 不许重复,重复直接 throw
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 起不来,而不是安静地覆盖掉内置适配器。这是「让错误尽早、尽响地暴露」的教科书写法。

三种「会话续跑」风格

types.ts:189-209 用三个布尔字段区分了三种截然不同的续跑机制,这是我在其他项目里没见过的精细分类:

字段风格谁生成 session id代表
resumesSessionViaCli指定式daemon 生成 newSessionId,告诉 CLI 用它claude --session-id <uuid>
capturesSessionIdFromStream捕获式CLI 自己生成,从流里报出来codexthread.started.thread_id
resumesSessionViaAcpLoadACP 式从 ACP 会话拿 getDurableSessionId()AMR / Vela,用 session/load

混淆前两者会导致「以为在续跑,实际每次都新开」——捕获式的 newSessionId 根本不传给 CLI

一份真实的 bug 尸检:双份上下文 types.ts:70-84 记录了 agy -c 的事故:
「Without this opt-out, agy with -c receives the same prior turn twice — once from its own conversation memory, once embedded in the composed user request — and the embedded copy includes the literal <question-form> markup it emitted on turn 1. The model then pattern-matches that and re-emits the form on turn 2, looking like the discovery loop never breaks.

解法不是改提示词,是加一个 opt-out 标记,让 daemon 在这类适配器上跳过渲染的 web transcript,只发最新一条用户消息。

契约的边界:它刻意不包含什么

docs/agent-adapters.md:176-181 有一段「反向说明」,价值不亚于正向说明:

没有
nativeSkillLoading
技能投递是共享的 daemon 行为,不是 per-adapter 策略。daemon 也不创建 .cursorrules
没有
agents.capabilities()
capabilities.ts 全文只有 131 字节——它只是一个「从 --help 输出里发现的 flag」的内存 map。
没有
特性门表
没有 surgicalEdit / streaming / resume 能力矩阵——那会变成一张永远对不齐的表。
判断标准 如果一个字段描述的是「这个 CLI 长什么样」,放进定义;如果描述的是「遇到这种情况该怎么办」,放进引擎。

这条设计的可验证后果:接第 26 个 CLI 要改几处?

「适配器是数据不是类」这句话本身是个主张。它的后果才是证据——下面这张图来自 架构图库,把「加一个新引擎」这件事的改动半径摊开:

E · 扩展点与改动半径 扩展点图(Extension Surface)
flowchart LR
    NEED["需求:接入第 26 个 CLI 运行引擎"] --> ASK{"要改什么?"}
    ASK -->|"❶ 数据扩展 (数据即适配器)"| D1["runtimes/registry.ts
往数组加一条 RuntimeAgentDef 字面量
改动半径 = 1 个文件 · 0 行新逻辑代码"] ASK -.->|"❷ 接口扩展 (本项目不需要)"| D2["编写 JavaScript 类 / 实现 Adapter 接口"] ASK -.->|"❸ 核心改造 (本项目不需要)"| D3["修改 Daemon 主循环调度器"] D1 --> OK["✅ 接入完成"] classDef good fill:#e8f5e9,stroke:#2e7d32,stroke-width:2.5px classDef na fill:#fafafa,stroke:#bdbdbd,stroke-dasharray:4 3,color:#9e9e9e class D1,OK good class D2,D3 na
图 · 灰掉的那两条才是重点 绿色 = 实际要做的;灰色虚线 = 因为架构选对了而完全不需要做的
反向对照:如果适配器是「类」,接第 26 个 CLI 需要新建继承 Adapter 的类文件 + 在工厂注册 + 补单测 + 处理生命周期钩子 ≈ 4 处改动; 现在是 1 处,且是纯数据。这个差值就是本章那条设计的全部收益。
这张图为什么用「没发生的事」论证 它的说服力不来自绿色那条,而来自被灰掉的两条——用不需要做的工作去证明设计的价值, 比任何形容词都有力。这是「扩展点图」这种体裁的典型用法。

更多同类图见 七大 Agent 架构图库
Chapter 05

探测与能力协商:为什么必须探测「将来真正会被 spawn 的那个路径」

flowchart TB
    START["probe(def, configuredEnv)"] --> RESOLVE["① resolveAgentLaunch(def, env)
解析出「将来真正会 spawn 的路径」"] RESOLVE -->|无路径| UNAVAIL1["不可用 + ExecutableDiagnostic"] RESOLVE --> ENV["② 组装 spawn 环境"] ENV --> VER["③ 版本探测"] VER -->|OS 级缺失/不可执行| UNAVAIL2["不可用 + NotInvocableDiagnostic"] VER -->|能启动但拒绝 --version| OKNOVER["✅ 可用,version = null"] VER -->|成功| PAR OKNOVER --> PAR PAR["④ 三个后置探测并发 Promise.all"] --> C1["--help 能力表"] PAR --> C2["模型发现"] PAR --> C3["鉴权探针(仅当声明了)"] C1 --> CACHE["agentCapabilities.set(def.id, caps)"] C1 & C2 & C3 --> OUT["DetectedAgent"]
图 3 探测流水线 detection.ts:238-318。版本探测必须先完成(它决定可用性),后面三个探测互相独立、并发跑。
全章最重要的一条:探测和执行必须走同一条路径解析 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 装的 shim 在里面跑不起来;但启动解析器有能力升级到打包的原生二进制。如果探测用 A 路径、真跑用 B 路径,用户就会看到「没装」但其实能跑。

三个次要但会救命的细节

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

并发的动机被写进注释detection.ts:273-277:「run them concurrently so a single agent's detection wall is max(help, models, auth) ≈ 5s rather than the sum ≈ 15s.」

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

鉴权:只对声明了探针的适配器断言

这是一处刻意的减法。老做法是看 ~/.foo/ 目录在不在来猜有没有登录——猜错的代价是把能用的 agent 标成红叉。

Definitions without authProbe are not assigned a synthetic auth failure from a config-directory guess. docs/agent-adapters.md:126-128

没声明探针就是 unknown,真正的鉴权失败只从真实运行失败的错误文本里推断(classifyAgentServiceFailure)。Claude 的探针就是 ['auth','status'],5 秒超时。

探测的 UX

GET /api/agents?stream=1 每探完一条就发一个 agent SSE 事件,最后 done。设置面板因此不用等最慢的那条 CLI 就能开始画卡片。

没有 24 小时探测缓存——每次调用都重新并发探测。这是一个「宁可多花几秒也不要给用户看陈旧状态」的取舍(用户刚 npm i -g 装完,刷新就该看见)。

Chapter 06

四种流格式:从 claude-stream-json 到 ACP 再到裸文本

streamFormat运行时 id解析器
claude-stream-jsonclaude · amp · codebuddyruntimes/claude-stream.ts(25.9 KB)
json-event-streamcodex · cursor-agent · opencode · mimo · byok-opencoderuntimes/json-event-stream.ts(30.2 KB,按 eventParser 再分派)
copilot-stream-jsoncopilotsrc/copilot-stream.ts(唯一平铺在 src/ 的解析器)
qoder-stream-jsonqoderruntimes/qoder-stream.ts
acp-json-rpc9 条:amr · devin · hermes · kimi · kiro · kilo · reasonix · trae-cli · vibeagent-protocol/acp/(session.ts 40.7 KB)
pi-rpcpiagent-protocol/pi-rpc/
plainaider · antigravity · atomcode · deepseek · grok-build · qwenruntimes/plain-stream.ts

九条 ACP 适配器共用同一个传输层——这是「新增一条 ACP agent 只需一个 def 文件」的直接原因。defs/kiro.ts 637 字节、defs/vibe.ts 635 字节、defs/kilo.ts 629 字节。

所有解析器输出同一套事件thinking / tool-call / tool-result / text-delta / file-write / error / done。这套事件是由解析器定义的,不是由 def 定义的docs/agent-adapters.md:107

plain 流的 <artifact> 提取:最弱的适配器要写最多的代码

裸文本流的 CLI 没有结构化的文件写入工具调用。约定是 Anthropic 风格的源码块,run 结束时扫 stdout 提取。听起来五分钟能写完,实际 plain-stream.ts473 行,因为要绕开四个坑:

坑 1
Markdown 围栏里的假 artifact
模型解释「你应该这样写」时会把 <artifact> 放进代码围栏。解法:先算跳过区间——围栏逐行判断,行内反引号要匹配相同数量的连续反引号。plain-stream.ts:252-306
坑 2
前缀误判
<artifacts> 不是 <artifact>。判定:开标签后面必须是空白字符plain-stream.ts:230-233
坑 3
属性值里的 >
title="A > B" 会被 indexOf('>') 截断。要用带引号状态机找开标签结尾。plain-stream.ts:235-250
坑 4
嵌套 / 未闭合
先探测下一个开标签的位置:如果它出现在当前开标签结束之前或闭标签之前,说明当前这个坏了,跳过重来。一段畸形输出不能吞掉后面所有合法产物。plain-stream.ts:85-103
一个容易忽略的一致性要求 plain-stream.ts:48-49 的注释标明这份围栏实现镜像 apps/web/src/artifacts/markdown-context.ts——headless 落盘和浏览器解析必须看到同一套围栏边界,否则无头运行和有浏览器时的结果会不一样。

再加两个防炸弹:MAX_ARTIFACTS_PER_RUN = 50,文件名冲突加 -2/-3(上限 10 000)。

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

// plain-stream.ts:421-472 —— 按扩展名生成 artifactManifest
// .html → { kind:'html', renderer:'html', exports:['html','pdf','zip'], primary:true }
// .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'] }

这份清单就是 CONTEXT.md:23-25 定义的 Artifact Manifest——「把一个项目文件标记成正式产物,并记录它的种类、渲染器、导出方式、入口文件」的旁挂元数据。没有它,文件工作区只能靠扩展名猜。

嵌套 agent:一个诚实的开放问题

Claude Task frames have a non-null parent_tool_use_id; the parser surfaces their content but prevents the child's turn_end from completing the parent run. The UI still does not expose an independent nested-run tree. docs/agent-adapters.md:518-522

内容能显示,但 UI 里没有嵌套树。文档把它明确列在「Open questions」里,而不是假装已解决。

Chapter 07

两档执行画像:filesystem vs text_artifact

这是 OD 处理「适配器能力参差」的核心抽象,只有一个 249 字节的文件:

// packages/contracts/src/execution-profile.ts
export type ExecutionProfile = 'filesystem' | 'text_artifact';
export function executionProfileFromStreamFormat(streamFormat) { … }
flowchart TB
    RUN["一次生成"] --> PROF{"executionProfileFromStreamFormat
(def.streamFormat)"} PROF -->|filesystem| F1["CLI 用自己的 Write/Edit 原生工具
直接在项目 cwd 写文件"] F1 --> F2["文件事件推给文件工作区"] F2 --> F3["可预览文件 = 交付物"] F3 --> F4["助手以普通摘要收尾
❌ 禁止再输出 artifact 源码块"] PROF -->|text_artifact| T1["模型循环里没有文件系统工具"] T1 --> T2["唯一交付形态:一个完整的
artifact 源码块"] T2 --> T3["run 结束后 daemon 扫 stdout
persistPlainStreamArtifacts()"] T3 --> T4["落成项目文件 + Artifact Manifest"] T4 --> F3
图 4 两档交付路径。注意两档的提示词契约是相反的:filesystem 档明令禁止输出 <artifact> 源码块,text_artifact 档则明确说 artifact 块是唯一交付形态。
filesystem 档
FILESYSTEM_HANDOFF_OVERRIDE
daemon/prompts/system.ts:549-572
「Do not output generated source code in a <artifact> 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
contracts/prompts/system.ts:513-517
No tools are wired through to you. TodoWrite, Read, Write, Edit, Bash, WebFetch are unavailable.」「The override does NOT block <artifact> blocks — those are how the web UI receives finished HTML in API mode.」
issue #313:不加顶部覆盖会怎样 contracts/prompts/system.ts:296-307:「…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.」

模型不会说「我没有这个工具」,它会假装调用。UI 什么也渲染不出来,用户看到一堆乱码。

修法的难点在于:发现层自己开头就写着「以下规则覆盖后文一切」。要压过它,唯一干净的办法是把 API 模式覆盖钉在绝对顶部contracts/prompts/system.ts:498-512 的 JSDoc 把这个理由完整记录了下来——这是我见过对「提示词优先级」最认真的一处工程注释。
这个架构的一处成本 daemon 和 contracts 两份提示词组装器必须逐字节同步。仓库里至少 6 处注释在提醒这件事(「Keep this whole block BYTE-IDENTICAL to the daemon-side composer」)。这是「同一套语义要同时服务 CLI 执行和 BYOK 执行」付出的税。
Part III

上下文工程

真正的产品护城河。既然你控制不了推理,唯一的杠杆就是这一层。

Chapter 08

输入分流:六个创建面 × 七种 skill mode

They are intentionally not one-to-one. UI tabs describe the workflow a user starts; skill modes describe how the daemon indexes and routes an instruction bundle. docs/modes.md:5-11

创建标签项目元数据技能路由主要区别
Prototypekind: prototype默认 prototype 技能,被选中的设计模板替换响应式 web / 移动 / 平板 / 桌面界面
Live Artifactkind: prototype, intent: live-artifact具备 live-artifact 能力的 prototype 技能高保真、带数据 / 连接器
Deckkind: deck默认 deck 技能,被设计模板替换幻灯导航 + 演示导出
Templatekind: template用户通过 Share 保存的项目模板从用户自己存的模板起步,回落到内置目录
Mediakind: image / video / audio匹配 mode 的媒体技能provider / 模型 / 画幅 / 时长 / 音色
Otherkind: other不要求技能自由形态

Daemon 侧七种 od.modeprototype · deck · template · design-system · image · video · audio

两个必须记住的错位design-system 是合法的 skill mode,但已不是创建标签——设计系统被提升成独立的产品页面了。
Live Artifact 只是 prototype + intent:「Code that branches on project kind alone must therefore also check the intent.」这是一条会被踩的隐坑。

「模板替换默认技能」而不是「模板叠加默认技能」

A selected Start from template replaces the tab's default skill as the project's primary skillIdit does not automatically compose the default prototype skill with the template. docs/modes.md:36-41

选了 saas-landing 模板,就没有默认 prototype 技能了。列表分离(两个 API)不等于自动组合——两份都注入会产生互相打架的工作流指令。

HyperFrames:一个不新增后端 kind 的新入口

CONTEXT.md:39-41 记录了一个很聪明的产品/工程折中:HyperFrames 在 Home 有独立入口(用户感知上是一等公民),提交时却是 kind: "video" + videoModel: "hyperframes-html"——不动 ProjectKind 联合类型、不动 SQLite schema、不动已有项目。

值得抄的实践:带 _Avoid_ 的领域词典 CONTEXT.md 里每个术语都带一行 _Avoid_——列出不许用的同义词。例如 Normal Artifact 后面写着「_Avoid_: live artifact, generic file upload」。它甚至附了一段示例对话和一条已解决的歧义记录:「"artifact creation" was used to mean both Normal Artifact creation and Live Artifact creation; resolved: this capability creates Normal Artifacts only.
Chapter 09

心脏地带:composeSystemPrompt 的二十层与缓存分区

如果说别的 Agent 项目的心脏是主循环,OD 的心脏就是这个函数:daemon 版 apps/daemon/src/prompts/system.ts:791-1370(约 580 行函数体,文件 2 075 行),BYOK 镜像版 packages/contracts/src/prompts/system.ts:256-496ComposeInput30+ 个字段

LAB 02 提示词分层装配器 拨动条件,看 21 个片段怎么装配成一份系统提示词。左侧竖条的颜色 = 变化频率带;灰掉的层表示被门控掉了。注意底部的「可缓存前缀」比例怎么随开关变化。
选了活跃设计系统省掉 6.7 KB 方向库
技能订阅了 craftod.craft.requires
绑定了技能/模板skillBody
有个人记忆memoryBody
有自定义指令用户级 + 项目级
走插件进来的pluginBlock + stage 块
多平台目标设备边框目录
plain 流(BYOK/API)顶部钉 API 模式覆盖
🔴 deck 信号(对话中途)回合可变带
🔴 media 信号(对话中途)回合可变带
① 全局静态(所有会话共享) ② 会话稳定 ③ 项目稳定 ④ 回合可变

装配顺序(classic 变体)

注意 ⑬ 和 ㉑ 是同一件事说两遍:设计系统的内容在第 13 层注入,第 21 层再补一个「别再问用户要视觉方向了」的覆盖。为什么要重复?因为第 5 层的发现层里有一条硬规则是「turn 1 必须发问卷」,而问卷里通常有品牌/方向问题。

// daemon/prompts/system.ts:603-616 —— ACTIVE_DESIGN_SYSTEM_VISUAL_DIRECTION_OVERRIDE
- Do not ask the user to pick a separate theme color, visual direction, palette,
  typography mood, or direction card.
- Do not emit a direction question-form, a `direction-cards` picker, or any
  visual-direction card while an active design system is present.
- If an earlier discovery answer asks to "Pick a direction for me", treat that as
  already satisfied by the active design system and continue with the plan.
这就是提示词工程的真实形态 不是写一段漂亮的指令,而是在几千行互相冲突的规则之间管理优先级

slim vs classic:一次带 A/B 的提示词重构

Slim core collapses the discovery layer + designer charter + their tail overrides into one charter document; the classic stack keeps the legacy layered composition until the A/B comparison signs off.

提示词重构的风险极高(改一个词可能让通过率掉 20%),所以不一次性替换,两套并存 + A/B。slim 还带两个 classic 没有的优化:

  1. 方向库改成索引 + 按需拉取system.ts:996-1005:「Slim carries only the id+label index and the agent pulls the chosen direction's full spec via od tools directions --id <id>but ONLY on filesystem runs. text_artifact runs have no tools to dereference the index… anything less tells them to bind palettes they cannot fetch.
  2. 平台契约块按信号稳定性分区——见下一节。

缓存友好的分区排序:全仓库最精妙的一处工程

Head ordering differs by variant, following prompt-caching prefix rules (stable content first)… the STATIC charter opens the document… so every conversation shares the same cacheable prefix; conversation-stable overrides follow, project context after that, turn-variable blocks last. daemon/prompts/system.ts:851-867

LLM 的前缀缓存是前缀匹配的:只要前 N 个 token 一样就能命中。把最稳定的放最前,一个回合中途翻转的信号(用户说「改成 iOS 版」)只会作废缓存后缀,而不是整份提示词。

最精妙的一条:触发信号的稳定性决定块的位置 同一个内容块,根据它是被什么信号触发的,放在不同的带

Trigger stability decides position. Metadata is fixed at project creation → the block can sit in the project-stable zone. 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.system.ts:1023-1037

缓存命中归因

// runtimes/chat-prompt-inputs.ts:394-442
if (!isResuming)                            → missReason: 'new-session'
if (storedStablePromptHash === currentHash) → hit: true
if (storedStablePromptHash === null)       → missReason: 'missing-stored-hash'
else                                        → missReason: 'stable-prompt-changed'
                                              + changedSections: 逐段 diff

changedSections 只在真正漂移时计算。注释解释:missing-stored-hash 是没有基线的老行,在那里报告「所有段都变了」会淹没真正关心的信号。做遥测时要小心这类「技术上正确但信息量为零」的输出。

一句话 hermes-agent 的绝活是「前缀缓存神圣」,OD 把同一个理念推进了一步——不只是不破坏前缀,还给每一段打上「变化频率」标签,按频率排序,并且监控每次未命中是哪一段造成的。这是这十个项目里最成熟的提示词缓存工程。

Ask 模式:轻,但不失忆

every artifact-oriented block (the ~3k-token discovery layer, direction library, device frames, the full designer charter, deck framework, media contracts, codex imagegen override, critique panel, DS visual-direction override) is gated off so the turn stays cheap. Memory, custom instructions, the active design system, attached skills, plugins, MCP tools, and the clarifying-questions surface are still composed in — Ask mode is light, not amnesiac. system.ts:939-947

省掉的是工作流,保留的是上下文。用户问「这个配色为什么这么选」时,不需要设计师宪章,但需要知道当前设计系统是什么。

Chapter 10

三条硬规则:question-form → 品牌分支 → TodoWrite

DISCOVERY_AND_PHILOSOPHYpackages/contracts/src/prompts/discovery.ts:25-354,330 行纯提示词)是整个产品的行为脚本。它蒸馏自两个上游项目:alchaincyf/huashu-design(初级设计师模式、变体而非答案、反 AI 味、化身专家)和 op7418/guizang-ppt-skill(预飞资源读取、P0 自检、主题节奏)。

sequenceDiagram
    participant U as 用户
    participant A as Agent
    participant H as OD 宿主
    Note over A: RULE 1 · 第 1 回合
    U->>A: "帮我做一个 SaaS 落地页"
    A->>H: 一句短散文 + question-form + 停
    Note right of A: ❌ 不读文件 ❌ 不 Bash
❌ 不 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 end A->>A: 第7步 checklist.md(P0 必须全过) A->>A: 第8步 五维自评,<3/5 就返工 A->>H: 第9步 交付
图 5 三条硬规则的时序。整条链路的产品动机只有一句:「问卷就是你的首字节时间」

RULE 1 的偏执程度

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. discovery.ts:40-44

而且它明确堵死了模型最爱找的借口:

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. discovery.ts:154

只有三种情况允许跳过:在已有设计里做微调、用户明说「skip questions / just build」、消息以 [form answers — …] 开头。

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

硬上限
每张表最多 5 题
「Before emitting, count the questions… 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 必须写在 options 前面
「the host renders forms token-by-token, and a default that trails a long options array reaches the user late.」——纯工程细节泄漏进提示词,但完全合理。
i18n
显示层全本地化,控制层全英文
「write what a native speaker would naturally say, never a word-for-word translation(中文标题是 快速确认 · 30秒,不是直译的 快速简报)」。但 id/type/valuepick_direction 等分支值必须保持英文,因为 RULE 2 要按 value 匹配。
去重
别自己写「其他」选项
宿主会自动给每个有限选项题渲染一个本地化的「Other」逃生舱(点开变输入框的 chip)。模型再写一个就重复了。
同等权威
元数据 = 插件输入
任一来源提供了答案 → 删掉对应默认问题;标了「(unknown — ask)」的字段 → 新增问题。甚至列出同义映射:platform/surface/platformTargets/target 都答「目标平台」。
控件
富控件优先
17 种类型可用。数值强度用 range、品牌色用 color、截止日期用 date、要上传就用 file——在同一张表里,不要表单发完再用散文要文件。

RULE 2 的四步优先级 + 品牌提取五步

// discovery.ts:167-172 —— 分支解析顺序
1. 当前消息/附件/先前简报/URL 里已有真实品牌源  → 分支 A
2. 否则看提交的 brand 值(有 [value: ...] 用稳定值,不用可见标签)
3. brand 值是 "brand_spec""reference_match"   → 分支 A
4. 否则                                            → 分支 B
  1. 定位源:附件就列出来;URL 就 WebFetch <brand>.com/brand/press/about
  2. 下载样式产物:CSS、品牌指南 PDF、截图
  3. 提取真值grep -E '#[0-9a-fA-F]{3,8}' 抓 CSS 里的 hex;截图靠肉眼看排版。「Never guess colors from memory.」
  4. 编码成契约:写 brand-spec.md——六个 OKLch 色 token + display/body/mono 字体栈 + 3–5 条观察到的版式姿态
  5. 口头复述:一句话说清将用的系统,让用户能廉价纠偏
防幻觉硬规则 选了 brand_spec / reference_match还没给源要源并停下。「Do not guess a brand domain or invent tokens.」
分支 B 则明确禁止二次问方向:「Do not emit any second direction-picking formpick the best-matching direction yourself and bind it without asking.」——早期版本大概会弹「五选一方向卡」,后来发现多一次点击就多一次流失。

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.  交付
维度拷问
Philosophy 哲学视觉姿态和要求的匹配吗(editorial vs minimal vs brutalist)?还是漂回了你最爱的默认?
Hierarchy 层次每屏眼睛有一个明显落点吗?还是所有元素在互相竞争?
Execution 执行排版、间距、对齐、对比——是对的,还是只是「差不多」?
Specificity 具体性每个词、数字、图片都是这个简报专属的吗?还是混进了填充和通用数据味?
Restraint 克制一个 accent 最多用两次、一个决定性亮点——还是三个亮点在打架?
「Two passes is normal.」 默认就该返工两轮。把「一次成型」这个不现实的期待从流程里拿掉,模型就不会为了一次交付而降低自评标准。

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. discovery.ts:223

// contracts/prompts/system.ts:466-485 —— 三个分支,每个都是一次事故的化石
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) 注入带条件前缀的骨架;
// 有技能种子时不注入——种子自己有更有主张的框架(simple-deck 的 scroll-snap、
// guizang-ppt 的杂志版式),重复会冲突

第二个分支的条件前缀值得抄:「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.」——给一段「可能不适用」的指令加上显式的适用条件,模型就不会硬套。

Chapter 11

记忆与双环:task-brief / verify-scorecard / rule-proposal

记忆不是硬规则

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, terminologynever re-ask the user about something already captured here. contracts/prompts/system.ts:378

注释说明了这段措辞的用意:「what stops the model from treating remembered preferences as harder than the active design system」——防止「用户上次说喜欢深色」压过「本次品牌是浅色」。

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.

flowchart TB
    IN["用户短请求"] --> MEM{"记忆够不够
扩写成简报?"} MEM -->|够 & rewrite=ON| CARD1["① od-card type=task-brief
替代 turn-1 问卷"] MEM -->|不够| FORM["走 RULE 1 问卷"] CARD1 --> BUILD["TodoWrite + 构建
【不可跳过】"] FORM --> BUILD BUILD --> SLOP["反 AI 味 / 品牌自检
【不可跳过】"] SLOP -->|verify=ON & 有已验证规则| CARD2["② od-card type=verify-scorecard
daemon 程序化检查其存在"] SLOP --> HANDOFF["交付收尾"] CARD2 --> HANDOFF HANDOFF --> FB{"用户纠正里
隐含可复用规则?"} FB -->|是| CARD3["③ od-card type=rule-proposal
Keep / Edit / Discard"] CARD3 -->|用户点 Keep| STORE[("已验证规则库")] STORE -.->|下次注入| MEM
图 6 记忆双环。PRE 环(task-brief)扩写意图,POST 环(verify-scorecard)核对规则,中间那条「构建 + 自检不可跳过」是硬约束。
PRE 环
task-brief
「Emit at most one per turn.」「Never dump the brief as prose — only as the card.

最关键的一句:它替代 turn-1 发现问卷,但不替代其余构建流程——「Skipping the discovery form when intent is already understood is correct; skipping TodoWrite or the anti-slop gate is not.
POST 环
verify-scorecard
这里出现程序化执法:「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.
写回环
rule-proposal
「Propose at most one rule per turnDo 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; the rule becomes saved only after the user clicks Keep.」
最后半句在治一个具体的模型撒谎行为 模型很爱说「好的,我记住了」,但实际上什么也没记。OD 的解法是把「记住了」这个断言绑定到卡片的存在性上——没卡就不许说。
项目记忆形态用户可见性
openworker显式 SQLite 事实设置面板里能看能改
RavenEverOS 双轨(工作记忆 + 长期)部分可见
nanobotDream 夜间反思结果可见,过程不可见
Open Design三张卡 + 已验证规则库每一步都是可见、可改、可拒绝的界面元素
一句话 OD 把「模型的内部状态」变成了「用户能看见、能改、能拒绝的 UI」。写入需要用户点确认;检查有程序化执法;每一条规则都能追溯到「是哪次纠正产生的」。这是这十个项目里最产品化的记忆设计。
Part IV

内容平面与质量闸门

这一部分是 OD 独一份的东西:把审美质量变成可执行的检查。

Chapter 12

内容四平面:skills / design-templates / design-systems / craft

技能格式:原样吃下 Claude Code 的 SKILL.md

基础格式完全不改 Claude Code 的约定,OD 只在 frontmatter 里加一个可选的 od: 命名空间。目标写得很明确:「The goal: zero-config compatibility for existing Claude Code skills.」docs/skills-protocol.md:121

字段什么都不写时的缺省推断
mode从 description 和正文推断,兜底 prototype
surfaceimage/video/audio 条目取对应媒体,否则 web
preview.typehtml
design_system.requirestrue(要显式写 false 才不给品牌上下文)
example_promptdescription 的第一句,截断到选择器长度
scenario从 description/正文推断,兜底 general

.od-skills 暂存:从符号链接到真实拷贝的一次安全修复

代码评审抓出来的写放大漏洞 apps/daemon/src/cwd-aliases.ts:1-30 的文件头注释是一份完整的尸检报告:

「An earlier draft of this fix (PR #435 round 1) created a directory link pointing at the repository's live skills/ tree. Reviewers flagged that as a 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 就能改坏所有项目共用的技能源文件。
flowchart LR
    SRC1["内置 skills/saas-landing/"] -->|fs.cp dereference:true| STAGE
    SRC2["用户根 skills/saas-landing/"] -->|同名遮蔽| SRC1
    STAGE[".od-skills/saas-landing-3f2a91b0c4/
(项目私有真实拷贝)"] STAGE --> AGENT["Agent 读 SKILL.md / assets/ / references/"] AGENT -.->|即使 Edit 写坏了| STAGE STAGE -.->|❌ 不会影响| SRC1 FAIL["暂存失败"] -.->|提示词里的绝对路径兜底| SRC1
图 7 技能暂存。别名带 源路径 sha256 前 10 位作后缀——因为用户根和内置根可能有同名技能(遮蔽关系),两者都被选中时目录名会撞。
成本论证
只暂存活跃技能
不是整个 SKILLS_DIR。单个技能 1–3 MB;APFS / btrfs / ReFS 上 fs.cp 走 CoW,稳态成本只是几个 syscall。
自包含
dereference: true
「the staged copy is fully self-contained — nothing inside it can write back to a real file outside the project.」并且用 stat() 而不是 lstat() 看源根,这样把 skills/ 本身放在软链后面的环境(内容寻址挂载)也能跟过去。
跨文件系统
流式拷贝兜底
copy_file_range(2) 跨文件系统被拒(常见 EXDEV;容器镜像层拷到 ZFS/overlay bind mount 上是 EPERM)。Node 不自动降级,所以 OD 自己实现了递归流式拷贝。
兜底路径
提示词里给两条路径
cwd 相对的暂存路径(主)+ 绝对源路径(兜底),这样暂存失败时 agent 仍能工作

设计系统包:三文件最小契约 + 八层注入顺序

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

约束:文件夹 slug 必须等于 manifest.id 且用规范化 ASCII;files.design 固定 DESIGN.mdfiles.tokens 固定 tokens.css每条声明的路径必须安全、相对、存在

富文件是缓存不是真理源components.manifest.jsoncomponents.html+tokens.css 派生;design-tokens.json 由 token 契约报告派生且必须与 tokens.css 一致;tailwind-v4.csstokens.css 派生。派生文件一致性由 guard 校验——防的是「有人改了 tokens.css 但忘了重生成,模型读到两套冲突的值」。

flowchart TB
    L1["① 包专属 USAGE.md(或默认使用契约)"] --> L2["② 完整的 DESIGN.md 正文"]
    L2 --> L3["③ import-mode 指引(声明了才有)"]
    L3 --> L4["④ tokens.css"]
    L4 --> L5["⑤ 紧凑组件清单
(无清单时用 components.html)"] L5 --> L6["⑥ 富文件按需拉取索引"] L6 --> L7["⑦ craft 工艺规则"] L7 --> L8["⑧ 活跃技能/模板正文"]
图 8 设计系统注入顺序 docs/skills-protocol.md:200-213。优先级是 品牌 token 赢冲突 > craft 规则补空白 > 技能定义工作流

默认使用契约值得原样引用daemon/prompts/system.ts:617

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.

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

刻意不做
不拷进 run 的 cwd
它是只读参考,不是工作副本;拷进去 agent 就可能改它。
刻意不做
不按段裁剪
没有 od.design_system.sections——裁剪会产生「模型只看到色板没看到用色规则」这类断章取义。
刻意不做
不做 {{ design_system }} 变量替换
模板变量会诱导技能作者把设计系统摆在错误的位置。
内容契约设计的一个好范式:约束密度,不约束形态 上游的 awesome-design-md 用九段固定模板。OD 演进成:「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.」

craft 的两级执法与两级严格度

层级含义谁执行
Auto-checked接进了 linter 的规则lint-artifact.ts(下一章)
Guidance其余部分:agent 读、评审者用、linter 不查

而且 craft 文件里逐条标注了哪些是「(guidance, not auto-checked)」。为什么这很重要:如果你写一份「规则手册」但只有 30% 真的被检查,而文档假装 100% 都被检查,那么半年后没人相信这份手册。标注清楚反而让被检查的那 30% 更有权威。

场景行为
运行时遇到不存在的 craft slug跳过,不报错——「A missing optional paragraph must not make an otherwise usable runtime bundle fail.」
仓库里 checked-in 内容引用不存在的 slugpnpm lint:craftpnpm guard 失败
故意的前向引用必须登记在 craft/FUTURE_SECTIONS.md 里才算合法

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

Chapter 13

质量闸门一:lint-artifact 程序化反 AI 味

apps/daemon/src/lint-artifact.ts1 000 行 / 46 KB。这是本仓库最有创造性的一个模块:把「一眼假」这种主观判断,落成可执行的正则 + CSS 求值

LAB 03 反 AI 味 linter 实验台 这是 lint-artifact.ts 十六条规则的可运行复刻(含 CSS 变量递归求值、字号折算、注释剥离)。选一个样本或直接改 HTML,实时看发现。
产物 HTML
回传给 agent 的系统提醒
试试这三个 ① 切到「假阳性陷阱」,它同时演示该放过的该抓住的:HTML 注释里的 <section class="slide dark"> 示例、CSS 注释里的坏规则、经三层 var() 解析出 .08em.eyebrow——全部放过;而 @media 里那条 font-size:48px + letter-spacing:1px(折算只有 0.021em被准确抓住——这正是把正则从 [^}]* 改成 [^{}]*(只匹配最内层规则)换来的。
② 在「合规产物」里把 --caps-tracking:.09em 改成 .02emall-caps-no-tracking 会立刻亮起——因为求值是穿透 token 的。
③ 在「幻灯节奏」里把第 4 张的 light 改成 darkslide-rhythm(连续 3 张同主题)会亮起。

十六条规则

级别id检查什么行号
P0purple-gradient渐变里出现 20 个 Tailwind violet/indigo hex 之一,或字面量 purple/violet131-160
P0trust-gradient蓝→青「信任渐变」:蓝色系 13 个 hex × 青色系 8 个 hex 配对177, 559-635
P0ai-default-indigo7 个「默认 LLM accent」hex 的纯色使用(不只渐变)205
P0emoji-icon17 个 slop emoji 出现在 <h*>/<button>/<li>/class*="icon"228
P0left-accent-card圆角卡片 + 左侧彩色边框(经典「AI 仪表盘瓦片」)245
P0sans-displayh1/h2/h3 的 font-family 落在 Inter/Roboto/Arial/system-ui/SF Pro259
P0invented-metric「10× faster」「99.9% uptime」等 5 个句式272
P0filler-copylorem ipsum / feature one|two|three / placeholder text287
P0scroll-into-view用了 Element.scrollIntoView()(跨 iframe 边界会拽走宿主页面)296-304
P0slide-theme-missingdeck 里 .slide 缺 light/dark/hero 主题类465-479
P1all-caps-no-trackingtext-transform: uppercase 但字距 <0.06em(含内联 style)306-387
P1external-imageunsplash / placehold.co / picsum 等外链占位图389-403
P1raw-hex:root{} 之外裸 hex >12 个405-429
P1accent-overusevar(--accent) 在 body 里 >6 次431-446
P1slide-rhythm连续 3 张同主题幻灯(视觉疲劳)480-506
P2missing-section-anchor<section>data-od-id / data-screen-label448-463

真正有工程含量的那一条:ALL CAPS 字距

天真实现是 /letter-spacing:\s*([\d.]+)em/ 抓个数字。OD 做的事复杂得多:

flowchart TB
    S1["① extractCssTokens(html)
收集每个作用域的 --name: value"] --> S2 S2["② buildResolvedThemes(scopes)
把全局主题作用域(:root、[data-theme=…])
组合成多套主题"] --> S3 S3["③ resolveCssVars(body, tokens)
递归解析 var(--x),最大深度 4"] --> S4 S4["④ resolveFontSizePx(decls)
同规则里的 font-size 折算成 px(root=16)"] --> J J["判定:letter-spacing 在每套主题下、
按该字号是否 ≥0.06em 等效"]
图 9 字距判定 lint-artifact.ts:636-720 · 805-892为什么要折算字号letter-spacing: 1px 在 12px 字上够(0.083em),在 48px 字上远远不够(0.021em)。为什么要多套主题:亮色下 token 是 0.08em,暗色下可能被覆盖成 0.02em。
一个字符类差别导致的漏检 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.

三层假阳性防护 + 有理由的阈值

防护 1
剥 HTML 注释
注释里常有教学示例(「paste a <section class="slide"> here」),不剥会误报。
防护 2
剥 CSS 注释
/* .eyebrow { text-transform: uppercase; } */ 浏览器不渲染,但规则形状的正则会匹配。
防护 3
区分 token 定义 vs 直接使用
declarationLaundersIndigo / isTokenShapedDeclaration:把 indigo 定义成 token 是合法的,直接当 accent 用不是。

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. lint-artifact.ts:415-419

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

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

renderFindingsForAgent()lint-artifact.ts:519-537 有三个要点:按严重度排序(P0 在前)、明确要求重发修正版而不是写解释(「the user has the previous version already」)、每条都带 fix——只给 message 模型会去猜,猜错就多一轮。

一个诚实的边界:不硬阻断 craft/README.md:68:「Artifact persistence is not currently hard-blocked on P0 hits.
P0 命中不阻止落盘。这是对的——硬阻断会让「模型死循环修不好」变成「用户什么都拿不到」。闸门的作用是推动改进,不是阻止交付
Chapter 14

质量闸门二:Design Jury 五陪审员评审剧场

产品名 Design Jury,内部代号 Critique Theater。代码路径 apps/daemon/src/critique/、web 侧 components/Theater/、SSE 频道 critique.*、环境变量 OD_CRITIQUE_*——用户可见名来自单个 i18n key critiqueTheater.userFacingName,所以改产品名不用动代码

LAB 04 五陪审评分器 拖动五位陪审员的分数,看加权合成分和 8.0 阈值的关系。特别注意 Designer 的权重是 0——把它拉到 10 分,composite 纹丝不动。
角色评什么v1 权重
Designer版式、构图、层次——「这东西好看且平衡吗」0.0
Critic是否真的满足简报;对比度、字重、可读性0.4
Brandtoken 合规、语气、品牌色使用0.2
AccessibilityWCAG、焦点环、语义结构、alt 文本0.2
Copy语气、简洁度、错误文案质量0.2

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. docs/critique-theater.md:65-68

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

// packages/contracts/src/critique.ts:58-73 —— 默认配置
weights: { designer: 0, critic: 0.4, brand: 0.2, a11y: 0.2, copy: 0.2 },
maxRounds: 3, scoreScale: 10, scoreThreshold: 8.0,
perRoundTimeoutMs: 90_000, totalTimeoutMs: 240_000,
parserMaxBlockBytes: 262_144, fallbackPolicy: 'ship_best',
maxConcurrentRuns: 4, enabled: false,

// critique.ts:48-54 —— 浮点比较的教科书处理
.refine((cfg) => cfg.scoreThreshold <= cfg.scoreScale + 1e-9, …)

最关键的实现决定:一个会话,不是五个进程

The orchestrator does not spawn extra processes per panelist. Each panelist is a turn in the same agent session, separated by <PANELIST role="..."> tags… This keeps the operational contract identical to a normal generation: same auth, same env, same logs. docs/critique-theater.md:76-82

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.

flowchart TB
    ART["Agent 发出 artifact"] --> R1["Round 1:五位陪审员各自打分
(同一会话的五个回合)"] R1 --> COMP["composite = designer×0 + critic×0.4
+ brand×0.2 + a11y×0.2 + copy×0.2"] COMP --> GATE{"composite ≥ 8.0 ?"} GATE -->|是| SHIP["Shipped at round N, composite X.X"] GATE -->|否| CNT{"round < maxRounds(3) ?"} CNT -->|是| REV["发出轮次摘要 → Agent 修改 → 下一轮"] REV --> R1 CNT -->|否| FB["fallbackPolicy"] FB --> SB["ship_best(默认):取 composite 最高的那轮"] FB --> SL["ship_last"] FB --> FL["fail"]
图 10 收敛循环。五种结算状态:Shipped / Below threshold / Timed out / Interrupted / DegradedInterrupted 的文案是单独写的——「so the user is not told the run shipped when it did not」。

四级开关解析器

优先级说明
1(最高)技能级 od.critique.policyrequired 强制开、opt-out 强制关、opt-in 只在 M2+ 开
2项目级覆盖localStorage(会话内 UI)+ 对项目做读-合并-写
3OD_CRITIQUE_ENABLED高级用户 / CI fixture
4(最低)灰度阶段默认M0/M1 = false,M2 = opt-in 技能为 true,M3 = 全开
读-合并-写的失败分支必须是「不写」 「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.

技能作者的经验法则:产出确定性产物的技能(如 od-export-pdf)通常 opt-out;生成全新设计输出的(magazine-postersaas-landing)设 required

跨 25 条第三方 CLI 的一致性纪律

降级原因起因处置
malformed_block适配器发的 <CRITIQUE> 块解析器不认跑一致性测试 tests/critique-conformance.test.ts
oversize_block超过 parserMaxBlockBytes(256 KB)通常是模型跑飞,重试或加预算
adapter_unsupported该适配器被标 critique:degraded24h TTL等 TTL 过期,或调 clearDegraded(adapterId)
protocol_version_mismatch适配器协议版本旧升级适配器或钉住协议协商
missing_artifactrun 结束但没有 artifact 主体Almost always a prompt bug; check the skill template.」

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% 一致性」作为全量开关,而不是靠拍脑袋决定哪天上线。

可回放:每次 run 写一份结构化 .ndjson(可选 gz)。Replay 支持 Instant / Live / {intervalMs: N} / Paused(暂停后从光标继续,不重新冲刷已发事件),J/K 逐轮跳,Esc 退出。

Chapter 15

插件与原子:atoms + pipeline + 封闭的 until 词汇表

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

核心字段:specVersion(当前 1.0.0)· name(稳定 ID)· version(semver)· od.kindskill/scenario/atom/bundle)· od.taskKindnew-generation/figma-migration/code-migration/tune-collab)· od.mode · od.capabilities[] · od.inputs[]

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

官方插件层级数量内容
scenarios/13od-default · od-design-refine · od-figma-migration · od-code-migration · od-react-export · od-nextjs-export · od-vue-export · od-media-generation · od-new-generation · od-tune-collab · od-plugin-authoring · od-share-to-community · od-web-effect-extractor
image-templates/45一次性图像提示
video-templates/64HyperFrames / Seedance / Veo 动效模板
design-systems/143品牌 DESIGN.md 包成插件
atoms/13可复用原子能力体
examples/183可重混的参考产出

原子:daemon 暴露给插件的能力单元

A plugin assembles atoms into ordered stages… The daemon is responsible for resolving each atom into a system-prompt fragment, tool gating, and (when applicable) GenUI surface declarations. Plugins never own the atom implementations; they only reference them by id. docs/atoms.md:1-8

id干什么适用 task kind
discovery-question-form第 1 回合问卷new-generation, tune-collab
direction-picker定稿前 3–5 个方向选择new-generation, tune-collab
todo-writeTodoWrite 驱动的计划全部
file-read / file-write / file-edit项目 cwd 的文件操作全部
research-searchTavily 支撑的浅层调研new-generation
media-image / media-video / media-audio通过配置的 provider 生成媒体new-generation, tune-collab
live-artifact创建/刷新 live artifactnew-generation, tune-collab
connectorComposio 连接器工具调用new-generation, tune-collab
critique-theater五维面板评审,发出驱动收敛的 critique.score 信号全部
code-import克隆/读取已有仓库code-migration
design-extract从源码 / Figma / 截图提取设计 tokencode-migration, figma-migration
figma-extract提取 Figma 节点树 + token + 资源figma-migration
token-map把提取的 token 映射到活跃设计系统code-migration, figma-migration
rewrite-plan / patch-edit / diff-review多文件重写 / 小步补丁 / 可评审 diffcode-migration, tune-collab
build-test跑 build/typecheck/tests,产出收敛信号code-migration
handoff把产物推给下游(cli / cloud / desktop)tune-collab
flowchart TB
    M["插件 manifest
od.pipeline.stages[*].atoms[]"] -->|①解析| PS["PipelineStage[]
plugins/pipeline.ts"] PS -->|②run 开始前| BODIES["解析内置原子指令体
plugins/atom-bodies.ts"] BODIES --> BLOCK["渲染成 ## Active stage 提示词块"] BLOCK --> RUN["③ pipeline-runner.ts 逐阶段走"] RUN --> E1["发 pipeline_stage_started SSE"] E1 --> W["④ 向 atoms/registry.ts 的 worker
要 daemon 可观测的信号"] W --> AUDIT["⑤ 往 run_devloop_iterations 写一行审计"] AUDIT --> E2["发 pipeline_stage_completed + 信号"] E2 --> RUN
图 11 流水线执行。一处诚实的说明:发生在 CLI 内部的原子(如 file-write)只能用宽松兼容信号,因为「the daemon has no independent observation for that tool action」——不假装能观测一切。

封闭的 until 词汇表

信号由谁发出
critique.scorecritique-theater 原子
iterations内建的每阶段计数器
user.confirmedconfirmation GenUI 面解析时
preview.oklive-artifact 预览流水线
build.passing / tests.passingbuild-test 流程
任何面向第三方开放的扩展点,都要问一遍:我在这里放的是数据还是代码? 「The evaluator is deliberately closed and is not arbitrary JavaScript. Unknown signals fail parsing and od plugin doctor reports them.」docs/atoms.md:86-87

如果插件能写任意 JS 作为收敛条件,你就得沙箱它、审计它、担心无限循环、担心它读环境变量。封闭词汇表把「插件能表达什么」限制在 daemon 能保证的语义内——代价是表达力受限,换来的是插件市场可以开放安装。

原子的晋升路径

不要一上来就把新能力做成内置原子。docs/atoms.md:89-100 定义了一条路:① 先作为树外插件实现 → ② 形状稳定后加内置原子 + 往 FIRST_PARTY_ATOMS 追加一行 + 有真实可观测信号时才注册 worker → ③ 同一个 PR 更新文档和 spec 表格 → ④ 通过 pipeline 引用 / GET /api/atoms / od atoms list / od plugin doctor 触达。

Part V

边界与总结

你放弃了权限闸门,就必须补齐外围。以及:三个最独特的设计,三处必须知道的取舍。

Chapter 16

安全边界:loopback / SSRF / 桌面 HMAC / .od-skills 拷贝

先诚实面对:OD 主动放弃了权限闸门
CLIheadless 权限姿态
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
OpenCode--dangerously-skip-permissions且仅当 help 探测确认该 flag 存在时
CodexmacOS/Linux 默认 workspace-write + 网络;Windows/WSL 或显式 OD_CODEX_SANDBOX=danger-full-accessdanger-full-access

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. docs/agent-adapters.md:442-465

为什么必须这样?「the daemon runs agent CLIs without a TTY, so it must not rely on an interactive tool-approval prompt to make progress.」没有 TTY 就没法交互批准,交互批准会直接把 run 挂死。

放弃了执行层的闸门,安全预算就全花在边界上:

边界一:默认 loopback

daemon 默认绑 127.0.0.1;LAN 暴露需要同时OD_BIND_HOST OD_ALLOWED_ORIGINS连接器凭证和 live-artifact 预览路由无论如何都保持 loopback-only——即使公开部署也不放开。

边界二:SSRF——默认封内网,opt-out 极其严格

默认封锁解析到私有/内部地址的 provider base URL——RFC1918、link-local、CGNAT、云元数据 IP169.254.169.254 那类,能偷 IAM 凭证)。但真实用户确实有内网网关(VPN 里的 LiteLLM、Ollama),所以有 OD_ALLOWED_INTERNAL_HOSTS

性质规则
严格 opt-in默认空
精确主机匹配不做子域名/子串匹配
格式宽容接受 host:port 或完整 URL,归约到 hostname;IPv6 必须 [fd00::1]
范围受限作用于你自己配置的 provider 端点(连接测试、模型发现、BYOK 聊天)
不放宽下游刻意不放宽上游响应里返回的下载 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 风险),并给出了更严格的替代方案。

边界三:桌面文件夹导入的短寿命单次 HMAC 令牌

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 + 规范化路径
拒绝落在自己托管存储内的导入 D->>D: 打上服务端控制的「可信选择器」标记 D-->>R: 项目创建成功 Note over D: 之后每次文件访问都对该外部根做安全路径解析
图 12 桌面信任链。关键一句:那个「可信选择器」标记由服务端控制,普通的项目创建/更新请求伪造不了——渲染进程拿不到 HMAC 密钥。实现在 desktop-auth.tsimport-export-routes.ts

边界四:沙箱 iframe 按需开启最小特性

沙箱 iframe,无宿主同源访问;每个面只 opt-in 它需要的 sandbox 特性(下载、弹窗)。切换 URL/srcDoc 渲染模式时两个 frame 都保持挂载避免重载闪烁;消息处理器校验发送方 iframe;需要来自活跃 frame 的信号会再次核对活跃窗口——最后两条防的是页面里有多个 iframe 时,一个后台 frame 冒充活跃 frame 发消息。渲染模式决策单独放在 apps/web/src/components/file-viewer-render-mode.ts

边界五:Windows 命令行长度的三重守卫

守卫时机检查什么
checkPromptArgvBudgetbin 解析前(快速)原始组装提示词的字节数 vs maxPromptArgBytes(DeepSeek 声明 30 000)
checkWindowsCmdShimCommandLineBudgetbuildArgs 之后解析出 .cmd/.bat shim 时,用平台层相同的逐参数引号翻倍规则重算 cmd.exe /d /s /c "<inner>"
checkWindowsDirectExeCommandLineBudgetbuildArgs 之后解析出非 shim 的 .exe 时,用 libuv quote_cmd_arg 规则(每个 "\",紧邻引号的反斜杠翻倍)重算

两个 Windows 守卫在给定解析上互斥。三者一起抓的是:原始字节数没超,但引号密集的提示词(代码块、JSON 形状的技能种子)展开后超过 CreateProcess 的 32 767 字符 lpCommandLine 上限。三者发同一个可行动的 AGENT_PROMPT_TOO_LARGE 错误,而且三者都有单测(超长 + 短提示词分支、两条 Windows 路径的引号密集回归、互斥性检查),「so the guards can't silently regress」。

这是 OD 与本系列所有其他项目最大的分野 openworker 有五档权限模式 + 收件箱挂起,nanobot 有 bwrap 沙箱,CodeWhale 有 safe-by-construction,Claude Code 有权限闸门——OD 一个都没有。它的安全预算全花在边界上(loopback、SSRF、HMAC、沙箱 iframe、.od-skills 拷贝),而不是执行上。
对个人本地使用,这是合理的;对团队共享部署,这需要非常认真的评估。
Chapter 17

功能特性拾遗

od CLI 与 MCP 服务端:把 OD 变成别人的工具

OD 出货三种形态:skills、CLI、MCP serverod mcp install <agent> 一行装进 16+ 个 CLI 的配置,然后:

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

Why MCP? 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.

一个真实的命名冲突 macOS / WSL2 上 /usr/bin/od 是系统的八进制转储工具,会在 PATH 上盖过 Open Design 的 od。三种应对:桌面 App 的设置 → MCP server 给一段用绝对路径的片段;install.sh 存在的理由之一就是「fails fast if your shell resolves a non-Open-Design od binary」;文档里三处提醒。

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

一次值得尊敬的架构回归

The former direct-Anthropic fallback was replaced by the byok-opencode profile… There is no daemon-owned fallback loop and no daemon implementation of Read/Write/Edit tools, and this profile is not an automatic recovery target for failed local agents. docs/agent-adapters.md:206-218

早期版本大概真的在 daemon 里实现过一个「直连 Anthropic + 自己实现 Read/Write/Edit」的兜底循环。现在被彻底删掉了——因为那违背了「我们不实现 Agent 循环」这条根本主张。一旦你开了这个口子,它会慢慢长成第二个(更差的)Agent。

没有跨 agent 兜底链

A chat request explicitly names its agent, and a crash, auth failure, timeout, or invalid invocation remains a failure for that runthe daemon does not silently — or through a dedicated one-click fallback action — move the request to Claude, another detected CLI, or BYOK OpenCode.

只有两个窄规则:运行前默认agentId 省略时用配置的 agent,不可用就用第一个可用的——「This chooses an agent before a run starts; it is not failure recovery」),和过期会话恢复(清掉同一个 agent 的陈旧会话,用完整 transcript 重新播种——「That recovery never changes agent families」)。

为什么反直觉但正确:自动切换 agent 会让计费、鉴权、输出风格全部悄悄改变,用户根本不知道刚才那份产物是谁做的。

Normal Artifact vs Live Artifact

概念定义_Avoid_
Normal Artifact由一个产物入口文件 + 一份产物清单表示的项目设计产出live artifact、通用文件上传
Live Artifact可刷新的项目设计产出,存成 live-artifact 记录,带源数据和预览状态normal artifact、静态产物
Artifact Entry File打开/渲染一个 normal artifact 的主项目文件支持文件、资源、旁挂文件
Artifact Manifest把项目文件标记为 normal artifact 的旁挂元数据,记录 kind / renderer / exports / entrylive-artifact 文档、项目元数据
Active Project用户最近交互的项目,MCP 工具在未指定项目时可用它latest project、default project

HyperFrames、导出矩阵与 mock agent

HyperFrames
HTML → MP4
HeyGen 开源的 agent 原生视频框架。agent 写 HTML + CSS + GSAP,用 headless Chrome + FFmpeg 渲染成确定性 MP4。仓库带 11 个模板 + 39 个 Seedance 提示。
导出
六种格式
HTML(单文件内联)· PDF(浏览器打印,deck 感知)· PPTX(agent 驱动的技能,不是库)· ZIP · Markdown · MP4。PPTX 走技能是因为 HTML→PPTX 的保真度是判断问题,不是转换问题。
测试
mock agent
mocks/mock-agent.mjs + recordings/ + golden/。守则:「replay a mock CLI trace from mocks/ instead of burning provider budget」——维护 25 条适配器的必需品。
Chapter 18

总结:三个最独特的设计与三处取舍

三个最独特的设计

DESIGN 01
适配器是数据规格,不是类——把「支持 N 个 CLI」的成本从 O(N) 压到 O(1)

RuntimeAgentDef45 个字段,只有 buildArgs 一个函数,而且是纯的。探测、启动、调用、取消、解析全在共享引擎里。结果:新增一条已知 wire format 的 CLI = 一个对象字面量 + registry 一行,最小的定义只有 629 字节;九条 ACP 适配器共用一个传输层;引擎里零个 per-agent 分支

它的克制同样关键:契约里没有 nativeSkillLoading没有 skillInjectionStrategy没有 capabilities() 方法、没有 特性门表。所有想往定义里塞行为的诱惑都被文档明确拒绝了。

DESIGN 02
把「审美质量」变成程序化闸门——lint-artifact + Design Jury 双层

这是本系列十个项目里独一份的能力。别的项目做「工具调用是否正确」的校验,OD 做的是「这东西看起来像不像 AI 拉的」的校验:

  • 第一层 lint-artifact.ts(1 000 行):16 条规则,带 CSS 变量递归求值(深度 4)、主题作用域解析、字号折算的字距判定、三层假阳性防护。规则表和 craft/anti-ai-slop.md 用注释里的「keep in sync」绑成一对。
  • 第二层 Design Jury:五位陪审员在同一个 CLI 会话里以五个回合评分,加权合成,≥8.0/10 才发货,最多 3 轮。跨 25 条第三方 CLI 用「连续 14 天 ≥90% 一致性」作为全量开关。

而且两层都不硬阻断:P0 命中不阻止落盘、评审不达标走 ship_best。这是「有闸门但不锁死用户」的成熟取舍。

DESIGN 03
提示词的缓存分区排序——把 hermes 的「前缀神圣」推进了一步

composeSystemPrompt 把 20+ 个片段按变化频率分成四带(全局静态 → 会话稳定 → 项目稳定 → 回合可变),并且:

  • 触发信号的稳定性决定块的位置:项目创建时固定的元数据信号可以插在项目稳定带;对话中途可能翻转的文本信号必须推到回合可变后缀,因为「an early insert would break the cached prefix for every section after this line」。
  • 配套 describeStablePromptCache()命中归因,未命中时逐段 diff 出是哪一段漂移了,并且刻意不在「无基线」的情况下报告全量变化以免淹没信号。
  • slim 变体把方向库改成「索引 + 按需拉」——但只在 filesystem 执行画像下,因为 text_artifact 档没有工具去解引用索引。

三处必须知道的取舍

取舍 01
主动放弃权限闸门
给每个 CLI 喂最危险的非交互 flag。理由自洽(无 TTY + 策略应由 CLI 自己执行),文档也坦率,但事实是:本系列里只有 OD 一个没有执行层闸门。安全预算全在边界上。个人本地使用合理;团队共享部署需要认真评估。
取舍 02
两份提示词组装器必须逐字节同步
daemon 侧 2 075 行 vs contracts 侧 1 064 行,是两份独立实现的同一套语义。至少六处注释在提醒「keep both in sync」。这是「既要服务本地 CLI 执行、又要服务 BYOK 直连」付出的税。漂移风险已知且被显式管理,但它是真实的技术债。
取舍 03
内容库的体量本身就是维护负担
151 个设计系统包 × 每包至少 3 个文件、164 个技能、115 个模板、277 个官方插件、183 个示例——2 246 个 Markdown、2 493 个 HTML、322 MB。质量守卫已经写了很多,但内容腐烂的速度和内容增长的速度是同一个量级。三个月 82k star 意味着社区贡献会持续涌入——这套守卫能不能扛住,是未来一年最大的不确定性。

它在这十个项目里的位置

维度其余九个编程/任务 AgentOpen Design
主循环各自实现不实现,委托给 25 个 CLI
工具系统自己定义 40–74 个工具零个,用被托管 CLI 的原生工具
权限模型从 safe-by-construction 到五档模式,全部委托 + 边界防御
上下文工程压缩、裁剪、双轨记忆分层组装 + 缓存分区(20+ 层)
质量保证测试通过 / 编译通过审美 linter + 五陪审评审
扩展机制插件 / MCP / 技能四平面:skills + templates + design-systems + craft
交付物代码变更HTML/PDF/PPTX/MP4 真实文件
产品形态CLI / TUI / IDE 插件本地优先桌面 App + MCP 服务端 + Docker
最后一句 如果说 openworker 回答的是「怎么让模型像同事一样干完跨应用的活」,那 Open Design 回答的是另一个问题——「已经有一堆很强的编程 Agent 了,怎么让它们一起变成一个设计师?」

它给出的答案不是再造一个 Agent,而是造一整套它们都能读懂的文件系统(技能 + 模板 + 品牌 + 工艺),加上一层它们都能被同一套数据规格描述的插座,再加上两道它们都必须通过的审美闸门

这个答案对不对,三个月 82k star 已经给了初步的市场判断。但它真正的工程价值在于:它证明了「适配器即数据」这条路能撑住 25 个异构 CLI,而且证明了「设计质量」这件看起来纯主观的事,有相当大一部分是可以被程序化执法的。

继续读