先看终点:一个「设计引擎宿主」长什么样
在写第一行代码前,先把要拼的东西看清楚。编程 Agent 和设计 Agent 宿主的分界,不在模型,而在这五件事:
- 它自己不推理——推理是你机器上那个
claude/codex/cursor-agent在干; - 它的产物是给人看的,不是给编译器看的,所以「对不对」之外还有「好不好看」;
- 好看这件事有相当大一部分可以程序化执法(Tailwind indigo、两段式渐变、emoji 当图标、ALL CAPS 不加字距);
- 品牌是一份可以版本化的契约,不是一堆散落的 CSS 变量;
- 交付物是真实文件,能预览、能导出、能扔进 Cursor 继续写代码。
flowchart TB
subgraph UI["① 你能看到的"]
CHAT["聊天 + 问卷卡片"]
FW["文件工作区"]
PREV["沙箱 iframe 预览"]
end
subgraph HOST["② 宿主(你要写的全部代码)"]
REG["运行时注册表
26 条适配器数据定义"]
COMP["提示词组装器
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 /
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
装配进度追踪
读完一步、装好一个零件就点一下。进度存在浏览器本地,关掉页面也在。
buildArgsE2BIG / ENAMETOOLONG<artifact> 提取四道防坑until 词汇表场景登场:一句「给我们做个落地页」
我们的设计 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 |
决定不写 Agent
最重要的一个决定,也是最容易做错的一个。
先做一次诚实的成本核算
Thesis: The code agent space has already converged on strong implementations… Reimplementing another one is worse than talking to all of them. docs/agent-adapters.md:9
这个决定的连锁后果(必须提前想清楚)
选 B 不是省事,是换了一组问题:
| 你不用做的 | 你必须接受的 |
|---|---|
| 主循环、工具系统、上下文压缩 | 你控制不了它怎么想——只能通过系统提示词影响 |
| 模型兼容、重试、成本统计 | 你的权限模型没了——子进程的权限由它自己管 |
| 断点续跑 | 你要处理 25 种不同的 stdout 格式 |
| 多 provider 抽象 | 用户没装 CLI 你就跑不起来(所以还得有 BYOK 兜底) |
适配器即数据:一个对象字面量描述一条 CLI
先看错误的做法
// ❌ 别这么写
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 份生命周期代码。
正确的做法:把「怎么跟它说话」和「怎么调度它」彻底分开
// ✅ 一个纯数据对象 + 一个纯函数
type RuntimeAgentDef = {
id: string; // 唯一键
name: string; // 显示名
bin: string; // PATH 上探测的可执行文件
versionArgs: string[]; // 版本探测参数
fallbackModels: RuntimeModelOption[]; // 探测不到模型时的静态兜底
buildArgs: (prompt, imagePaths, extraDirs?, options?, ctx?) => string[]; // 唯一的函数,且是纯的
streamFormat: string; // 引擎据此分派解析器
// …~38 个可选字段,全部是数据
};
flowchart LR
DEF["RuntimeAgentDef
(纯数据)"] --> E1["detection.ts
探测"]
DEF --> E2["launch.ts
路径解析 + 环境"]
DEF --> E3["invocation.ts
argv 组装 + spawn"]
DEF --> E4["run 生命周期
取消 / 超时 / 错误分类"]
DEF -->|streamFormat 分派| E5["4 个解析器之一"]
逐条讲为什么:以 Claude Code 的定义为样本
fallbackBins: ['openclaude']——issue #235 里用户只装了 OpenClaude。一行数组解决,不用让用户写 wrapper 脚本。helpArgs: ['-p','--help'] 而不是 ['--help']——--add-dir 和 --include-partial-messages 只出现在 claude -p 的帮助里(issue #430 的修复)。capabilityFlags 是「帮助输出里的子串 → 能力键」,探测后写进内存 map,buildArgs 读它决定加不加。每个可选 flag 都要先探测再用。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;
},
注意 _prompt 前面那个下划线——提示词根本不进 argv。理由见第 4 步。
三种「会话续跑」风格要分清
| 字段 | 谁生成 session id | 例子 |
|---|---|---|
resumesSessionViaCli | 你生成,告诉 CLI 用它 | claude --session-id <uuid> |
capturesSessionIdFromStream | CLI 生成,从流里报出来,你抓下来存 | codex 的 thread.started.thread_id |
resumesSessionViaAcpLoad | 从 ACP 会话拿 durable id | AMR/Vela 用 session/load |
agy -c),你又同时把渲染的 web transcript 拼进用户消息,它会收到两份上一回合——包括它第 1 回合发出的 <question-form> 原文。模型会模式匹配这段原文,在第 2 回合再发一遍问卷,看起来像发现循环卡死了。解法不是改提示词,是加一个 opt-out 标记跳过 transcript 注入。types.ts:70-84 记录了这次尸检。
注册表:81 行 + 一条加载期不变式
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 起不来,而不是安静地覆盖掉内置适配器。让错误尽早、尽响。
契约要克制:明确它不包含什么
这一点比「它包含什么」更重要。刻意没有:nativeSkillLoading / skillInjectionStrategy(技能投递是共享行为)、capabilities() 方法(那个文件全文只有 131 字节)、surgicalEdit 特性门表(那会变成一张永远对不齐的能力矩阵)。
defs/kilo.ts,629 字节。想看不同定义怎么长出不同 argv?去解析站的 LAB 01 适配器解剖台拨开关。
接住引擎
探测它、启动它、听懂它、接住它的产物。这四步就是那 ~3 000 行。
探测:怎么知道机器上有什么(以及一个必踩的坑)
flowchart TB
S["probe(def, configuredEnv)"] --> R["① resolveAgentLaunch(def, env)
解析出「将来真正会被 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"]
「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… If detection probes the shim but chat/run spawns the native binary, the UI incorrectly reports "not installed".」detection.ts:243-250
翻译:GUI 启动的 App 拿到的 PATH 是被系统精简过的(macOS 的 launchd 不读你的 .zshrc);nvm/fnm/mise 装的 CLI 常常是 shim,在那个精简 PATH 下跑不起来;但你的启动解析器可能有能力升级到打包的原生二进制。于是探测用 A 路径说「没装」,真跑用 B 路径其实能跑——用户看到红叉但功能是好的。
规则:探测入口的第一件事必须是调用和 spawn 完全相同的那个路径解析函数。
三个次要但会救命的细节
| 版本探测结果 | 判定 | 为什么 |
|---|---|---|
| OS 级缺失 / 不可执行 | 不可用 | 真的没有 |
能启动,但拒绝 --version | 可用,version 留空 | 有些 CLI 改过版本 flag 名,不该判死 |
| 成功 | 可用 + version | — |
- 并发但有序:「a single agent's detection wall is max(help, models, auth) ≈ 5s rather than the sum ≈ 15s」
- 每条适配器故障隔离:裸
Promise.all会因一条拒绝而整体拒绝——一个坏掉的可执行文件不能清空整个选择器
鉴权:不猜
老做法是看 ~/.foo/ 目录在不在猜有没有登录,猜错的代价是把能用的 agent 标红。新规则:只有声明了 authProbe 的适配器才主动探测鉴权;没声明的 → authStatus 就是 unknown;真正的鉴权问题只从真实运行失败的错误文本里推断。探针必须是便宜、无副作用的 status/whoami 类命令。
探测的 UX
GET /api/agents?stream=1:每探完一条发一个 SSE 事件,设置面板不用等最慢的 CLI 就能开始画卡片。不做 24 小时缓存——用户刚 npm i -g 装完,刷新就该看见。
claude 从 PATH 移走 → 卡片变灰但其他 25 条不受影响;用 nvm 装一个 shim 版 codex → 探测结果和实际能不能跑一致。
起跑:spawn 子进程,提示词走 stdin 而不是 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 很常见 |
MAX_ARG_STRLEN 把单个 argv 条目限制在 ~128 KB → spawn E2BIGWindows:
CreateProcess 把整条命令行限制在 ~32 KB(通过 .cmd shim 只有 ~8 KB)→ spawn ENAMETOOLONGpromptViaStdin: true)。buildArgs 的第一个参数会是 _prompt——带下划线,因为根本不用。| 字段 | 投递方式 | 何时用 |
|---|---|---|
promptViaStdin: true | 写进子进程 stdin | 默认首选 |
promptViaFile: true | 写进临时文件,路径通过 ctx.promptFilePath 给 buildArgs | CLI 有显式的 prompt-file flag |
| (都不设) | 进 argv | CLI 硬要位置参数(如 DeepSeek 的 clap 声明 prompt: String 必填)——要额外上守卫,见第 16 步 |
stream-json 输入格式:为什么要保持 stdin 打开
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). types.ts:125-130
Stdin remains open so the daemon can forward additional user messages mid-turn, then is closed after a clean terminal
turn_end/usagerather than at a mid-tooltool_usepause.
stop_reason: tool_use 时就关 stdin,Claude 会以为对话结束,回合中途断掉。apps/daemon/AGENTS.md:113 专门写了一条守则:「do not close stdin on tool_use stop reasons」。
cwd 就是项目工作区 + 一个看门狗
spawn(resolvedBin, args, { cwd: projectWorkspaceDir, env: spawnEnv })
cwd 是执行根,不是沙箱(第 16 步会再强调一次)。子进程的 Write/Edit 直接落在这里,你的文件工作区监听这个目录的变化。
子进程可能长时间不吐字节(比如 Copilot 生成 deck 时的思考阶段)。所以要有基于 stdout/stderr/SSE 活动的不活跃超时,全局默认 10 分钟,适配器可以用 inactivityTimeoutMs 声明更长上限,操作者可以用环境变量覆盖(env 优先)。注释点破了本质:「The watchdog observes child stdout/stderr/SSE activity, not real CPU progress」——你观测的是「有没有说话」,不是「有没有在干活」。
听懂它说话:四种流格式,一套统一事件
25 个 CLI 有 25 种 stdout?不。按 wire format 分类之后只有 7 种 streamFormat、4 类解析器。所有解析器输出同一套事件:thinking / tool-call / tool-result / text-delta / file-write / error / done。UI 只认这套,不认底层格式。
plain 流:最弱的适配器要写最多的代码
裸文本流的 CLI 没有结构化的文件写入事件。约定是让它吐 Anthropic 风格的源码块,run 结束时扫 stdout 提取。听起来五分钟能写完,实际要 473 行,因为要绕开四个坑:
<artifact> 放进代码围栏。天真的 indexOf 会把教学示例当真产物写盘。解法:先算跳过区间。围栏逐行判断,行内反引号要匹配相同数量的连续反引号。
<artifacts> 不是 <artifact>。return /\s/.test(
text.charAt(idx + '<artifact'.length));>title="A > B" 会被 indexOf('>') 截断。要用带引号状态机找开标签结尾。落盘时同时写「产物清单」
不要只写文件。每个产物按扩展名生成一份旁挂清单,声明 kind / renderer / exports / entry。这份清单让下游知道用哪个渲染器、能导出成什么、哪个是入口文件。没有它,你的文件工作区只能靠扩展名猜。
接住产物:两档执行画像
不同 CLI 的能力差得很远:Claude Code 有完整的 Read/Write/Edit/Bash;DeepSeek TUI 在你选的调用模式下只会吐文本。你不能为每条适配器写一套交付逻辑。抽象成两档——一个 249 字节的文件:
export type ExecutionProfile = 'filesystem' | 'text_artifact';
export function executionProfileFromStreamFormat(streamFormat) {
return streamFormat === 'plain' ? 'text_artifact' : 'filesystem';
}
flowchart TB
RUN["一次生成"] --> P{"执行画像"}
P -->|filesystem| F1["CLI 用原生工具直接写项目文件"]
F1 --> F2["文件事件 → 文件工作区 → 预览"]
F2 --> F3["助手以普通摘要收尾
❌ 禁止再输出 artifact 源码块"]
P -->|text_artifact| T1["模型循环里没有任何文件工具"]
T1 --> T2["唯一交付形态:一个完整 artifact 块"]
T2 --> T3["run 结束后宿主扫 stdout 提取并落盘"]
T3 --> F2
<todo-list>、[读取 X])instead of producing real progress events.」模型不会说「我没有这个工具」,它会假装调用。UI 什么也渲染不出来,用户看到一堆乱码。
修法:把 API 模式覆盖钉在绝对顶部——比发现层还靠前。因为发现层自己开头就写着「以下规则覆盖后文一切」,你必须压在它上面。
一个值得学的架构回归
早期版本在 daemon 里实现过一个「直连 Anthropic + 自己实现 Read/Write/Edit」的兜底循环。现在被彻底删掉了,换成把 BYOK 凭证翻译成 OpenCode 配置,让装好的 opencode 进程继续拥有模型/工具循环。
There is no daemon-owned fallback loop and no daemon implementation of
Read/Write/Edittools.
为什么值得学:那个兜底循环违背了「我们不实现 Agent 循环」这条根本主张。一旦你开了这个口子,它会慢慢长成第二个(更差的)Agent。删掉它是对的。
plain 适配器跑同一个简报,产物文件应该和 filesystem 档跑出来的落在同一个位置、有同样的清单。
给它剧本
从这里开始,我们离开「怎么调 CLI」,进入真正的产品。
给它品牌:DESIGN.md 契约与 token 注入顺序
品牌不是一堆 CSS 变量,是一份可版本化的契约
design-systems/<slug>/
├── manifest.json ← 发现元数据、来源出处、声明的包内路径
├── DESIGN.md ← 给 agent 看的规范散文(canonical)
└── tokens.css ← 编译好的语义 token 样式表(canonical)
关键约束:manifest.id 必须等于文件夹 slug 且用规范化 ASCII;文件名固定;每条声明的路径必须安全、相对、存在——这是 guard 检查的。
富文件是缓存,不是竞争的真理源
| 文件 | 性质 |
|---|---|
DESIGN.md · tokens.css | 真理源 |
components.manifest.json | 由 components.html + tokens.css 派生 |
design-tokens.json | 由 token 契约报告派生,必须与 tokens.css 一致 |
tailwind-v4.css | 由 tokens.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["⑧ 活跃技能/模板正文"]
默认使用契约值得原样抄:
Read DESIGN.md for visual principles, paste tokens.css verbatim into the first
<style>when it is provided… Treat any pull-layer index as optional context for deeper inspection; do not assume those files have already been loaded.
最后半句在防一个具体的幻觉:模型看到索引就以为文件已经读过了,然后引用一个它没读过的组件。
三件刻意不做的事
{{ }} 变量替换质量门槛:约束密度,不约束形态
The package-quality guard requires at least seven substantive H2 headings, without prescribing their names, order, or numbering. Use headings that fit the actual system and keep their decisions synchronized with
tokens.css.
上游用九段固定模板,OD 演进成「保留充实度门槛,去掉标题名的僵化」。这是内容契约设计的一个好范式。
用户没有品牌怎么办:品牌提取五步
- 定位源:有附件就列出来;给了 URL 就 WebFetch
<brand>.com/brand、/press、/about - 下载样式产物:CSS、品牌指南 PDF、截图
- 提取真值:
grep -E '#[0-9a-fA-F]{3,8}'抓 CSS 里的 hex;截图靠视觉读排版。绝不凭记忆猜颜色 - 编码成契约:写
brand-spec.md——六个 OKLch 色 token(--bg--surface--fg--muted--border--accent)+ display/body/mono 字体栈 + 3–5 条观察到的版式姿态(圆角、边框粗细、accent 预算) - 口头复述:一句话说清将用的系统(「深海军蓝产品画布,单一电光青 accent 在
oklch(68% 0.16 220),几何 display + 系统 body」),让用户能廉价纠偏
:root token 应该整体换掉;把 tokens.css 改坏一个值,guard 应该报派生文件不一致。
给它工艺:第四根轴 craft/
你现在有三根轴:技能(干什么)、模板(做成什么形状)、设计系统(用什么品牌)。做出来的东西还是一眼假。为什么?
因为有一类知识不属于任何一个品牌,也不属于任何一个技能:ALL CAPS 永远需要 ≥0.06em 字距;var(--accent) 每屏最多出现 2 次;#6366f1 永远是 AI 默认色的破绽;display 字体和 body 字体不该是同一个家族。
这些是称职设计师的肌肉记忆。把它们写进 151 份 DESIGN.md?那是 151 份重复,而且改一次要改 151 处。所以拆出第四根轴。
按需订阅,不是全量注入
od:
craft:
requires: [typography, color, anti-ai-slop]
只有列出的段落进提示词。一个只排版的技能不用为色彩、动效内容付 token 成本。11 个已发布 slug:typography · typography-hierarchy · typography-hierarchy-editorial · color · anti-ai-slop · state-coverage · animation-discipline · accessibility-baseline · rtl-and-bidi · form-validation · laws-of-ux。
laws-of-ux 是一根兄弟轴
其他文件管「怎么渲染」,它管「该组合什么」:定价页(Hick's / 选择过载 / Von Restorff)、仪表盘(帕累托 / 选择性注意 / 工作记忆)、引导(目标梯度 / 蔡格尼克 / 峰终)、模态(费茨 / 泰斯勒)。
两级执法:诚实地标注哪些是真检查
anti-ai-slop.md 的 P0 列表)而且在文档里逐条标注:凡是没接进 linter 的规则后面都跟着「(guidance, not auto-checked)」。
lint-artifact.ts」。
运行时宽容 vs 仓库严格
| 场景 | 行为 |
|---|---|
| 运行时遇到不存在的 craft slug | 跳过,不报错——「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](故意打错)→ lint:craft 应该报错并指出 manifest 路径;同样的错误在运行时应该只是跳过。
给它剧本:三条硬规则
现在你有了引擎、有了品牌、有了工艺。用户输入「帮我们做个落地页」,模型开始想……然后 25 秒后吐出一整页 HTML,用米色背景、紫色渐变、三个 emoji 图标。用户关掉页面走了。
问题不在模型,在你没给它剧本。
sequenceDiagram
participant U as 用户
participant A as Agent
participant H as 你的宿主
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: 品牌提取五步
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(P0 全过)
A->>A: 第8步 五维自评(<3/5 就返工)
A->>H: 第9步 交付
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.
用户容忍不了 15 秒的沉默,但完全能接受 2 秒内弹出一张能一路点完的表单。而且必须堵死模型的借口:
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.
只有三种情况允许跳过:① 用户在已有设计里做微调;② 用户明说「skip questions / just build」;③ 用户消息以 [form answers — …] 开头。
表单编写规则里的六条工程细节
default 要写在 options 前面default that trails a long options array reaches the user late.」——流式渲染导致的 JSON 键顺序要求。id/type/value 和分支值必须保持英文——RULE 2 要按 value 匹配。platform/surface/target 都答「目标平台」。range、品牌色用 color、要上传就用 file——在同一张表里,不要表单发完再用散文要文件。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… pick the best-matching direction yourself and bind it without asking.」这是一次产品决策的沉淀:早期版本会弹「五选一方向卡」,后来发现多一次点击就多一次流失。
RULE 3:九步计划 + 两道非协商闸门
1. 读活跃 DESIGN.md + 技能资源(template.html, layouts.md, checklist.md)
2. 绑定 token 到 :root
3. 规划章节/幻灯/屏幕清单(写之前先口头说一遍)
4. 把种子模板拷到项目根
5. 粘贴并填充规划好的版式
6. 用简报里的真实、具体文案替换 [REPLACE] 占位
7. 自检:跑 references/checklist.md(P0 必须全过)
8. 评审:五维雷达(任一 <3/5 就修)
9. 交付
| 自评维度 | 拷问 |
|---|---|
| 哲学 | 视觉姿态和要求的匹配吗?还是漂回了你最爱的默认? |
| 层次 | 每屏眼睛有一个明显落点吗?还是所有元素在互相竞争? |
| 执行 | 排版、间距、对齐、对比——是对的,还是只是「差不多」? |
| 具体性 | 每个词、数字、图片都是这个简报专属的吗? |
| 克制 | 一个 accent 最多两次、一个决定性亮点——还是三个亮点在打架? |
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.
实现上有三个分支(每个都是一次事故的化石):deck 项目注入通用骨架;自由形态项目注入带条件前缀的骨架;有技能种子时不注入(种子自己有更有主张的框架,重复会冲突)。
第二个分支的条件前缀值得抄:「If — and only if — the brief reads as slides, keynote, presentation, deck, PPT, or 讲解, follow the framework below. Otherwise ignore everything in this section.」——给一段「可能不适用」的指令加上显式的适用条件,模型就不会硬套。
管住优先级:提示词是一场「谁压谁」的博弈
到这一步,你的系统提示词已经有 20 层了。它们会互相打架。举三个真实的冲突:
| 冲突 | 谁该赢 | 怎么保证 |
|---|---|---|
| 发现层说「turn 1 必须问方向」vs 已选了设计系统 | 设计系统 | 在最尾部加设计系统方向覆盖 |
| 发现层说「调用 TodoWrite」vs API 模式没有工具 | API 模式 | 把 API 模式覆盖钉在绝对顶部 |
| 记忆说「用户喜欢深色」vs 本次品牌是浅色 | 品牌 | 在记忆块前言里显式写「brand wins on conflict」 |
三种压制手段
钉在最顶:适用于「这一整段后文都要被推翻」的覆盖。
钉在最尾:适用于「要压过前面某个具体规则」的覆盖。
「when they collide with the active design system tokens, the brand wins; when they collide with the active skill's workflow, the skill wins」
Ask 模式是最好的例子:直接不组装发现层(~3000 token)、方向库、宪章、deck 框架、媒体契约……但保留记忆、指令、设计系统、技能。
Ask mode is light, not amnesiac.
省掉的是工作流,保留的是上下文。用户问「这个配色为什么这么选」时,不需要设计师宪章,但需要知道当前设计系统是什么。
一个正在进行的重构:slim vs classic
两套装配路径并存:classic 分层堆叠(现役),slim 把三块塌缩成一份宪章文档(A/B 验证中)。注释写得很坦白:「the classic stack keeps the legacy layered composition until the A/B comparison signs off」。
想手动拨条件看层怎么装配?去解析站的 LAB 02 提示词分层装配器。
让它便宜:按变化频率分带 + 缓存命中归因
你的系统提示词 30–80 KB。用户在一个项目里聊 20 轮。如果每轮都是全新的前缀,你烧掉的钱可以按数量级优化。LLM 的前缀缓存是前缀匹配的:只要前 N 个 token 一样就能命中。所以排序决定成本。
flowchart LR
Z1["① 全局静态
设计师宪章 · 注入抵抗
【所有会话共享】"] --> Z2["② 会话稳定
模式覆盖 · locale
【一个会话内不变】"]
Z2 --> Z3["③ 项目稳定
设计系统 · 技能 · 元数据
【一个项目内不变】"]
Z3 --> Z4["④ 回合可变
deck/media/platform 信号触发块
【每回合可能翻转】"]
最精妙的一条:触发信号的稳定性决定块的位置
同一个内容块,根据它是被什么信号触发的,放在不同的带:
// 元数据信号(项目创建时固定)→ 放在项目稳定带
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.
同一个块,放错位置就毁掉后面所有段的缓存。
缓存命中归因:出问题时你要知道是哪一段
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 只在真正漂移时计算。missing-stored-hash 是没有基线的老行,在那里报告「所有段都变了」会淹没真正关心的信号。做遥测时要小心这类「技术上正确但信息量为零」的输出。
hit: true;中途切换设计系统 → missReason: 'stable-prompt-changed' 且 changedSections 精确指出是设计系统那一段。
装闸门
你控制不了推理,但你可以控制什么样的产出算合格。
第一道闸门:把「一眼假」写成正则
「这个页面一眼是 AI 做的」——听起来完全主观。但拆开看,其中相当大一部分是可枚举的具体模式:
| 模式 | 具体到什么程度 |
|---|---|
| 默认 Tailwind indigo 当 accent | 精确到 7 个 hex:#6366f1 #4f46e5 #4338ca #3730a3 #8b5cf6 #7c3aed #a855f7 |
| 两段式「信任渐变」 | 蓝色系 13 个 hex × 青色系 8 个 hex 的配对 |
| emoji 当功能图标 | 17 个:✨ 🚀 🎯 ⚡ 🔥 💡 📈 🎨 🛡️ 🌟 💪 🎉 👋 🙌 ✅ ⭐ 🏆 |
| 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 |
真正的工程含量在哪:ALL CAPS 字距那一条
// ❌ 天真实现,会漏一半
const m = /letter-spacing:\s*([\d.]+)em/.exec(body);
if (!m || parseFloat(m[1]) < 0.06) report();
flowchart TB
S1["① extractCssTokens(html)
收集每个作用域的 --name: value"] --> S2
S2["② buildResolvedThemes(scopes)
把全局主题作用域组合成多套主题"] --> S3
S3["③ resolveCssVars(body, tokens)
递归解析 var(--x),最大深度 4"] --> S4
S4["④ resolveFontSizePx(decls)
同规则里的 font-size 折算成 px(root=16)"] --> J
J["判定:letter-spacing 在每套主题下、
按该字号是否 ≥0.06em 等效"]
letter-spacing: 1px 在 12px 字上够(0.083em),在 48px 字上远远不够(0.021em)。为什么要解析变量:letter-spacing: var(--caps-tracking) 不该被当成「没设置」。为什么要多套主题:亮色下 token 是 0.08em,暗色下可能被覆盖成 0.02em。[^{}]* (not [^}]*) so the regex matches only innermost 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 path that accepts 1px tracking on a 48px heading.」[^}]* → [^{}]*,一个字符的差别,决定 @media 里的大写标题会不会被漏检。
三层假阳性防护 + 有理由的阈值
假阳性比漏检更致命——报错报烦了没人看。三道防护:剥 HTML 注释(注释里常有教学示例)、剥 CSS 注释(/* ... */ 浏览器不渲染但正则会匹配)、区分 token 定义 vs 直接使用(把 indigo 定义成 token 是合法的)。
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.
每个阈值都应该能说出它是从哪个真实场景倒推的,否则半年后没人敢动它。
反馈回路:给 agent 的报错必须自带修复动作
<artifact-lint>
2 P0(必须修), 1 P1(应该修), 0 P2(可以修)。
下一回合重发修正版 <artifact> —— 不要另写解释,用户手上已经有上一版了。
**[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>
三个要点:按严重度排序(P0 在前)、明确要求重发修正版(不要写解释)、每条都带 fix——只给 message 模型会去猜,猜错就多一轮。
lorem ipsum 的 HTML 喂进 linter → 应该报 3 条 P0;给它一份用 letter-spacing: var(--tracking) 且 --tracking: 0.08em 的大写标题 → 不应该误报。想直接改 HTML 看实时结果?去解析站的 LAB 03 反 AI 味 linter 实验台。
第二道闸门:五位陪审员的评审剧场
正则能抓「用了 indigo」,抓不到「这个层次结构很混乱」。第二道闸门用模型评模型。
| 角色 | 评什么 | 权重 |
|---|---|---|
| Designer | 版式、构图、层次 | 0.0 |
| Critic | 是否真的满足简报;对比度、字重、可读性 | 0.4 |
| Brand | token 合规、语气、品牌色使用 | 0.2 |
| Accessibility | WCAG、焦点环、语义结构、alt 文本 | 0.2 |
| Copy | 语气、简洁度、错误文案质量 | 0.2 |
保留席位、权重归零——定性意见进记录,但不让主观审美卡住发布。
最关键的实现决定:一个会话,不是五个进程
<PANELIST role="…"> 标签分隔,解析成 panelist_* 事件。运行契约和普通生成完全一致:same auth, same env, same logsAll 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.
收敛循环与五种结算状态
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'
五种结算状态: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.」不要让「被中断」看起来像「发货了」。
跨 25 条 CLI 的一致性纪律
你的评审协议要求 agent 吐 <CRITIQUE> 块。25 条 CLI 的模型不一样,遵守程度也不一样。所以要有降级机制(5 种原因,含 adapter_unsupported 的 24h TTL)和一致性门槛:
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 degraded for 24h.
…globally after ≥ 90% of production adapters maintain conformance for 14 consecutive days.
还要可回放:每次 run 写一份结构化 .ndjson。评审是个多轮过程,用户看完了想再看一遍「它到底为什么给我 6.2 分」——没有回放,这个信息就丢了。
Below threshold;中途按 Esc → 徽章文案应该是 Interrupted at round N, best composite X.X。想拖动分数看合成分怎么变?去解析站的 LAB 04 五陪审评分器。
让它记住你:三张卡的记忆双环
记忆的三条铁律
flowchart TB
IN["用户短请求"] --> MEM{"记忆够不够
扩写成简报?"}
MEM -->|够 & rewrite=ON| C1["① task-brief 卡
替代 turn-1 问卷"]
MEM -->|不够| FORM["走 RULE 1 问卷"]
C1 --> BUILD["TodoWrite + 构建
【不可跳过】"]
FORM --> BUILD
BUILD --> SLOP["反 AI 味 / 品牌自检
【不可跳过】"]
SLOP -->|verify=ON & 有已验证规则| C2["② verify-scorecard 卡
宿主程序化检查其存在"]
SLOP --> HAND["交付收尾"]
C2 --> HAND
HAND --> FB{"用户纠正里
隐含可复用规则?"}
FB -->|是| C3["③ rule-proposal 卡
Keep / Edit / Discard"]
C3 -->|点 Keep| STORE[("已验证规则库")]
STORE -.->|下次注入| MEM
task-brief约束:每回合最多一张;请求已明确或很琐碎就跳过;绝不以散文形式输出简报,只能是卡片。
verify-scorecard收尾顺序被规定死:(1) 自检并就地修 → (2) 发记分卡 → (3) 正常交付。「Prefer fixing silently over asking.」
rule-proposal「the rule becomes saved only after the user clicks Keep.」
模型很爱说「好的,我记住了」,但实际上什么也没记。解法是把「记住了」这个断言绑定到卡片的存在性上——没卡就不许说。
核心差别:这套设计把「模型的内部状态」变成了「用户能看见、能改、能拒绝的 UI」。写入需要用户点确认;检查有程序化执法;每一条规则都能追溯到「是哪次纠正产生的」。
rule-proposal 卡;点 Keep 后下一次生成应该出现 verify-scorecard 且包含这条规则。
让它可扩展:四平面 + 原子 + 封闭 until
my-plugin/
├── open-design.json ← 必需:市场元数据 + inputs + pipeline + capabilities
├── SKILL.md ← agent-skill / scenario 类型必需
├── README.md ← 可选
├── preview/ ← 可选:index.html / poster.png(视觉类强烈建议)
└── examples/ ← 可选
一条重要的默认:od.capabilities[] 要声明最小集——受限安装默认只给 prompt:inject。
原子:宿主暴露给插件的能力单元
Plugins never own the atom implementations; they only reference them by id. 宿主负责把每个原子解析成:系统提示词片段 + 工具门控 + GenUI 面声明。
flowchart TB
M["插件 manifest
od.pipeline.stages[*].atoms[]"] -->|①解析| PS["PipelineStage[]"]
PS -->|②run 前| B["解析内置原子指令体
渲染成 ## Active stage 提示词块"]
B --> R["③运行时逐阶段走"]
R --> E1["发 pipeline_stage_started"]
E1 --> W["④向 worker 注册表要
宿主可观测的信号"]
W --> A["⑤往 run_devloop_iterations 写一行审计"]
A --> E2["发 pipeline_stage_completed + 信号"]
E2 --> R
file-write)只能给宽松信号,因为「the daemon has no independent observation for that tool action」——不要假装你能观测一切。until 词汇表必须是封闭的
| 信号 | 谁发出 |
|---|---|
critique.score | critique-theater |
iterations | 内建计数器 |
user.confirmed | confirmation GenUI 面解析时 |
preview.ok | live-artifact 预览流水线 |
build.passing / tests.passing | build-test 流程 |
od plugin doctor reports them.」如果插件能写任意 JS 作为收敛条件,你就得沙箱它、审计它、担心无限循环、担心它读环境变量。封闭词汇表把「插件能表达什么」限制在宿主能保证的语义内——代价是表达力受限,换来的是插件市场可以开放安装。
原子的晋升路径
不要一上来就把新能力做成内置原子:① 先作为树外插件实现 → ② 形状稳定后加内置原子、往目录追加一行、有真实可观测信号时才注册 worker → ③ 同一个 PR 更新文档和 spec 表格 → ④ 通过 pipeline 引用 / API / CLI / doctor 触达。
until: "critique.score >= 8" 能跑;改成 until: "myCustomThing == true" → od plugin doctor 应该报未知信号。
装成产品
边界、桌面壳、MCP 服务端、导出。最后回放整条链路。
装边界:你放弃了权限闸门,就必须补齐外围
--permission-mode bypassPermissions(Claude)· --force(Cursor)· --permission-mode dangerous --respect-workspace-trust false(Devin)· --yolo(Qoder / Trae)· --allow-all-tools(Copilot)· --auto(DeepSeek)· --dangerously-allow-all(Amp)为什么必须这样?因为你在没有 TTY 的环境里跑它们。交互式批准提示会直接把 run 挂死——用户在浏览器里等着,子进程在等一个永远不会来的
y。所以要把这件事写进文档,不要藏:「users must treat these runs as trusted agent execution」。
放弃了执行层的闸门,就必须把预算全花在边界上。六条:
边界一:默认 loopback
daemon 默认绑 127.0.0.1;LAN 暴露需要同时设两个环境变量(缺一不可);连接器凭证和预览路由无论如何都保持 loopback-only——即使公开部署也不放开。
边界二:SSRF——默认封内网,opt-out 极其严格
你有一个 BYOK 代理,用户可以填任意 baseUrl。这是教科书级的 SSRF 面。默认封锁解析到私有/内部地址的 URL——RFC1918、link-local、CGNAT、云元数据 IP(169.254.169.254 那类,能偷 IAM 凭证)。
但真实用户确实有内网网关(VPN 里的 LiteLLM、Ollama),所以要有 opt-out,且必须严格:严格 opt-in(默认空)· 精确主机匹配(不做子域名/子串匹配)· 范围受限(只作用于你自己配的 provider 端点)· 不放宽下游(上游响应里返回的下载 URL 仍然被封)· 错误项丢弃(畸形条目和 CIDR 记法被丢弃并告警,不静默信任)。
放行主机名 = 信任 DNS 解析结果(DNS rebinding 风险)。说清楚残余风险比说「我们很安全」有价值得多。
边界三:桌面文件夹导入的 HMAC 单次令牌
用户要导入本机任意文件夹。这个能力很危险——渲染进程里的一段恶意脚本能不能构造一个请求,让 daemon 去读 /etc/?
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: 打上服务端控制的「可信选择器」标记
Note over D: 之后每次文件访问都对该外部根做安全路径解析
边界四:预览必须是沙箱 iframe
沙箱 iframe,无宿主同源访问;每个面只 opt-in 它需要的特性;切换渲染模式时两个 frame 都保持挂载避免闪烁;消息处理器校验发送方 iframe;需要来自活跃 frame 的信号会再次核对活跃窗口——最后两条防的是:页面里有多个 iframe 时,一个后台 frame 冒充活跃 frame 发消息。
边界五:技能暂存必须是拷贝,不是软链
.od-skills/ 做成指向仓库 skills/ 的目录软链。评审意见:「write-amplification vulnerability: agents have write access to their cwd, and a
Write/Edit/Bash call… resolves through the symlink and mutates the shipped resource itself.」一次
Edit 就能改坏所有项目共用的技能源文件。别名 =
<folder>-<源路径 sha256 前 10 位>,因为用户根和内置根可能有同名技能。配套:只暂存活跃技能(CoW 让稳态成本只是几个 syscall)·
dereference: true(拷贝完全自包含)· stat() 而非 lstat()(跟过内容寻址挂载的软链)· 跨文件系统流式拷贝兜底(EXDEV/EPERM)· 提示词里给两条路径(暂存失败仍能工作)。边界六:Windows 命令行长度的三重守卫
如果你必须支持一条只吃 argv 的 CLI,你需要三道守卫:
| 守卫 | 时机 | 检查 |
|---|---|---|
checkPromptArgvBudget | bin 解析前(快) | 原始提示词字节数 vs maxPromptArgBytes(如 30 000) |
checkWindowsCmdShimCommandLineBudget | buildArgs 后 | .cmd/.bat shim:用平台层相同的引号翻倍规则重算 cmd.exe /d /s /c "…" |
checkWindowsDirectExeCommandLineBudget | buildArgs 后 | 非 shim 的 .exe:用 libuv quote_cmd_arg 规则(每个 " 变 \",紧邻引号的反斜杠翻倍)重算 |
两个 Windows 守卫互斥。三者一起抓的是:原始字节数没超,但引号密集的提示词(代码块、JSON 形状的技能种子)展开后超过 CreateProcess 的 32 767 字符上限。三者发同一个可行动的错误:告诉用户「减少技能/设计系统上下文、缩短对话、或换一个支持 stdin 的适配器」。并且三者都有单测,「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。
装成产品:桌面壳 + 侧车 + MCP 服务端 + 导出
三种运行形态,一套代码
| 形态 | 入口 | 特征 |
|---|---|---|
| 源码开发 | 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。
数据根契约:一处值得抄的文档纪律
This document intentionally gives no concrete daemon data path. The root
AGENTS.mdsection Daemon data directory contract is the only path authority.
把路径降级成单点权威,其他文档只允许引用不允许复述。实现上:启动时解析一次成
RUNTIME_DATA_DIR,之后所有 daemon 拥有的数据全部由这个根派生。唯一例外是文件夹导入。
dual-track 规则:能力必须同时在 UI 和 CLI 出现
When adding a user-facing capability, close the loop in one change: contract type, daemon route, web surface if applicable, and CLI command with
--jsonplus--prompt-file <path|->for long prompts where relevant.
为什么强制?因为 CLI 是外部 agent 消费你的产品的方式。如果一个能力只有 UI 有,那么通过 MCP 接进来的 Claude Code 就用不了它——你的「可被任意 agent 消费」这个卖点就漏了一个洞。而且注意 --json:每个命令都要支持,这样才能 | jq | xargs 进自动化。
把自己做成 MCP 服务端
这是「agent 原生」这个定位的闭环:你不仅调用别人的 agent,还要被别人的 agent 调用。
od mcp install <agent> # 一行装进 16+ 个 CLI 的配置
# 然后在那个 agent 里:
od project list --json
od files read <project-id> <relative-path>
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.
which 一遍
macOS / WSL2 上 /usr/bin/od 是系统的八进制转储工具,会在 PATH 上盖过你的 od。三种应对:设置面板给一段用绝对路径的片段;install.sh 存在的理由之一就是「fails fast if your shell resolves a non-Open-Design od binary」;文档里三处提醒。
不做跨 agent 自动兜底
A crash, auth failure, timeout, or invalid invocation remains a failure for that run… the daemon does not silently move the request to another detected CLI.
为什么反直觉但正确:自动切换 agent 会让计费、鉴权、输出风格全部悄悄改变,用户根本不知道刚才那份产物是谁做的。
导出矩阵与 mock agent
PPTX 走技能是因为 HTML→PPTX 的保真度是判断问题,不是转换问题。
守则:「replay a mock CLI trace instead of burning provider budget」。
od project list --json | jq 能跑;把 daemon 停掉,桌面 App 应该给出明确错误而不是白屏;改一个解析器 → 用 mock 回放能在 5 秒内跑完回归。
这一句话到底跑了什么
用户在 Home 打字:「帮我们做一个 SaaS 产品落地页,用我们公司的品牌。」然后选了设计系统 linear-app,点 Run。
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
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+ 层,按变化频率分带)
D->>D: 算 stablePromptHash → 新会话,miss
Note over D: 【启动阶段】
D->>D: resolveAgentLaunch → 拿到真实可执行路径
D->>D: buildArgs → -p --input-format stream-json …
D->>C: spawn(cwd=项目工作区),提示词经 stdin,stdin 保持打开
Note over C: 【第 1 回合 · RULE 1】
C-->>D: "明白了 — SaaS 落地页,用 Linear 的设计语言。补几个信息:"
C-->>D: question-form(4 题,全部预填)
D-->>W: SSE 事件流
W->>U: 渲染问卷卡(没有方向/主题色题)
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 步计划)
C->>F: Read .od-skills/… 的 template.html / layouts.md / checklist.md
C->>F: Write saas-landing.html(绑 linear-app 的 :root token)
D-->>W: file-write 事件 → 文件工作区 → 沙箱 iframe 预览
Note over D: 【闸门一】
D->>D: lintArtifact() → 1 条 P1: accent-overuse(9 次)
D->>C: artifact-lint 系统提醒(含 fix + snippet)
C->>F: Edit(把 7 处降级成 var(--fg))
Note over C: 【自检】
C-->>D: step7 checklist P0 全过
C-->>D: step8 五维自评:具体性 2/5(填充文案)→ 返工
C->>F: Edit(替换 [REPLACE] 为真实文案)→ 重评全部 ≥3/5
Note over D: 【闸门二 · 若开启】
D->>C: Design Jury round 1(同一会话的 5 个回合)
D->>D: composite = 8.3 ≥ 8.0 → Shipped at round 1
Note over C: 【交付 · filesystem 档】
C-->>D: 普通摘要(❌ 不发 artifact 源码块)
D-->>W: done
W->>U: 预览 + 导出按钮
U->>W: 导出 PDF
这条链路上,你写的代码在哪?
| 环节 | 谁的代码 |
|---|---|
| 探测、启动、argv 构建 | 你的 |
| 提示词组装(20+ 层) | 你的(且这是你最重要的产品) |
| 技能暂存 | 你的 |
| 理解简报、决定问什么、写 HTML、修改 | claude 的 |
| 流解析 → 统一事件 | 你的 |
| 反 AI 味 linter | 你的 |
| 评审剧场编排 | 你的(评分是 claude 做的) |
| 预览、导出 | 你的 |
验收断言(做完每步怎么验)
点一下打勾,进度存在浏览器本地。全部通过 = 你有一个能跑的设计 Agent 宿主。
claude 移出 PATH → 只有它变灰,其他 25 条不受影响tool_use 时不关 stdin)plain 档和 filesystem 档跑同一简报,产物落在同一位置、有同样的清单:root 整体换掉tokens.css 一个值 → guard 报派生文件不一致requires: [typograpy] → lint:craft 报错并指出 manifest 路径;运行时只跳过hit: truemissReason: 'stable-prompt-changed' 且 changedSections 精确指段letter-spacing: var(--tracking) 且 --tracking: 0.08em → 不误报Below thresholdInterrupted at round N…,不是 Shippedrule-proposal 卡verify-scorecard 且含这条规则until: "critique.score >= 8" 能跑;未知信号 → od plugin doctor 报错.od-skills/xxx/SKILL.md → 源 skills/ 毫发无损http://169.254.169.254/ → 拒绝并说明原因AGENT_PROMPT_TOO_LARGEod project list --json | jq 能跑十二个最容易翻的车
点开看症状、根因、修法。每一条都是真实事故的化石。
spawn E2BIG、Windows spawn ENAMETOOLONG,而且是间歇性的(提示词长度取决于选了哪个设计系统)。
tool_use 时关了 stdin›turn_end / usage 之后关。
<question-form> 原文就模式匹配复读。
<artifact> 提取吃了代码围栏里的教学示例›.html。
<todo-list> 伪标记;或选了设计系统还在问主题色。
SKILL.md,所有项目一起坏。
Edit 会穿过软链改到源文件。
dereference: true + 路径哈希后缀 + 跨文件系统流式兜底。
until 用封闭词汇表,未知信号解析失败并由 doctor 报出。
源码对照索引
| 教程步骤 | 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 步 路径解析必须一致 | detection.ts:243-250(注释) |
| 第 4 步 stdin 与命令行上限 | 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 行) |
| 第 6 步 执行画像 | packages/contracts/src/execution-profile.ts |
| 第 6 步 两档交付契约 | daemon/prompts/system.ts:549-572 · contracts/prompts/system.ts:498-540 |
| 第 7 步 设计系统包契约 | design-systems/README.md |
| 第 7 步 注入八层顺序 | docs/skills-protocol.md:200-218 |
| 第 7 步 品牌提取五步 | contracts/src/prompts/discovery.ts:176-187 |
| 第 8 步 craft 四轴 + 两级执法 | craft/README.md · craft/anti-ai-slop.md |
| 第 9 步 三条硬规则 | contracts/src/prompts/discovery.ts:25-354 |
| 第 9 步 表单编写规则 | discovery.ts:135-152 |
| 第 9 步 deck 框架优先 | contracts/prompts/system.ts:450-485 |
| 第 10 步 组装器 | apps/daemon/src/prompts/system.ts:791-1370 |
| 第 10 步 优先级注释 | contracts/prompts/system.ts:498-512 |
| 第 11 步 缓存分带 | daemon/prompts/system.ts:851-867(注释) |
| 第 11 步 命中归因 | runtimes/chat-prompt-inputs.ts:394-442 |
| 第 12 步 反 AI 味 linter | apps/daemon/src/lint-artifact.ts(1 000 行) |
| 第 12 步 字距 token 求值 | lint-artifact.ts:636-720 · 805-892 |
| 第 13 步 评审配置与文档 | packages/contracts/src/critique.ts · docs/critique-theater.md |
| 第 14 步 记忆与三张卡 | contracts/prompts/system.ts:376-404 |
| 第 15 步 原子与流水线 | docs/atoms.md · plugins/atoms.ts · pipeline-runner.ts |
| 第 16 步 授权边界 | docs/agent-adapters.md:442-465 |
| 第 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 · :150-162 |
| 第 17 步 dual-track | apps/daemon/AGENTS.md:99-106 |
结语:这条路线适合谁
· 你的用户已经有 CLI Agent(开发者、设计师、技术型 PM)
· 你想把精力花在领域知识的编码上(品牌契约、工艺规则、质量闸门),而不是重造循环
· 你需要细粒度的权限控制(这条路线里你控制不了子进程的行为,只能控制边界)
· 你的核心竞争力就是推理本身(那你就该自己写循环)
引擎是借的,产品是自己的。