你在写一个状态机:订单可以从「待支付」走到「已支付」,但「超时取消」和「部分退款」同时发生时,
纸上怎么画都觉得对,心里就是没底。或者:设置页到底用侧边栏还是标签页,脑子里三张草图打了一下午的架。
这种问题在对话里争不出结果——它需要一个能跑起来的答案。prototype 干的就是这件事:
写一段一次性代码(throwaway code:从第一天起就打算扔掉的代码),
它的唯一职责是回答这一个设计问题。问题决定代码的形状:问「这个状态模型感觉对吗」,
就造一个可以在终端里用手推着走的小程序;问「这页面该长什么样」,就在同一条路由上
并排摆几个截然不同的界面版本,用浏览器底部的一条浮动栏来回切换。
学完这节课,你能说清楚:怎么选分支、两条分支各自的骨架长什么样、
收尾时原型本体和答案分别落到哪里、行为不对时该改哪个文件的哪一段。
0001 把 22 个已发布 skill 分成三层:配置层、编排层、纪律层。prototype 不在这三层的任何一条
主流程骨架上——ask-matt 把它放在 Standalone(随时伸手用的独立工具)一节:
「a small, throwaway program that answers one design question」,并且明说
「reach for it any time a design question is hard to settle on paper」
(任何时候,只要一个设计问题在纸上定不下来,都可以伸手用它)。
但它同时又是主流程里的一个正式岔路:主流程第 2 步问
「所有问题都能在对话里定下来吗」,如果某个问题需要一个能跑的答案
(状态、业务逻辑、必须亲眼看到的 UI),就从主流程岔出去做 prototype,
两头都用 handoff 摆渡——出去时 handoff 一次、开新会话做原型,
回来时把学到的东西 handoff 回原线程,并在原来的想法线程里引用它。
它是 model-invoked 的(人和 AI 都能启动):frontmatter 里没有
disable-model-invocation,agents/openai.yaml 里也没有
allow_implicit_invocation: false。docs 页的说法是
「Type /prototype, or the agent reaches for it automatically when a task fits」——
你可以显式打 /prototype,AI 在对话里闻到「这个问题纸上定不下来」的味道时也会自己伸手。
主流程(ask-matt 的 step 2 岔路):
/grill-with-docs 面试中冒出一个需要「能跑的答案」的问题
│
│ /handoff ──► 新会话
▼
┌─────────────┐ 答案 + 原型本体
│ /prototype │ ────────────────┐
└─────────────┘ │
▲ │ /handoff 回原线程
└──────────────────────────┘
然后继续主流程:to-spec / to-tickets / implement
Standalone 用法:不挂任何流程,任何时候单独 /prototype。
地图:0001 · 岔路原文:ask-matt/SKILL.md 的 main flow 第 2 步和 Standalone 一节 · 摆渡船:0006 handoff · 启动方式判定:prototype/agents/openai.yaml(没有禁止隐式调用)
SKILL.md 开篇第一句就是定义:「A prototype is throwaway code that answers a
question. The question decides the shape.」(原型是回答问题的一次性代码;问题决定它的形状。)
三个词都不能省:一次性——从第一天起就打算扔,不带测试、不带错误处理、不带抽象;
回答问题——它不是产品的雏形,也不是 MVP(最小可行产品),它的产出是一个答案,不是一套功能;
问题决定形状——先想清楚问的是什么,再决定造什么,顺序反了就白做。
frontmatter 的 description 给出触发条件:当你想 sanity-check(快速验证)
一个状态模型或一段逻辑感觉对不对,或者想探索一个 UI 该长什么样。展开成具体场景:
| 情境 | 该用 prototype 吗 |
更该去哪 |
|---|---|---|
| 「这个状态机处理 X 然后 Y 的边界情况,我说不清对不对」 | 该(LOGIC 分支:造一个能用手推着走的终端小程序) | — |
| 「这个 dashboard 提交之前我想看几种方案」 | 该(UI 分支:同一路由并排几个截然不同的版本) | — |
| 「已经造好的东西行为不对,我想知道为什么」 | 不该(那是诊断问题,不是设计问题) | diagnosing-bugs——docs 页原话:prototype 探索「该造什么」,不查「造出来的东西为什么坏了」 |
| 「想法还很模糊,需求和边界都没聊清」 | 不该(先磨想法,把问题聊出来) | grill-with-docs 主流程的面试 |
| 「某个第三方 API 的行为我查不到,需要读一手文档」 | 不该(那是阅读跑腿活,不需要写代码) | research(后台 agent 去读一手来源) |
| 「接口形状怎么设计,模块够不够深」 | 可以用原型验证手感,但词汇和原则在那边 | codebase-design(0005 讲过:语言不是流程) |
| 「不知道该走哪条流程」 | 不该 | ask-matt |
权威原文:skills/engineering/prototype/SKILL.md · 给人看的叙事版:docs/engineering/prototype.md (aihero.dev/skills-prototype)
SKILL.md 的「Pick a branch」一节只有两条路,由问题本身决定:
?variant=B)和一条浮在底部的工具栏切换。
怎么判断当前问题属于哪条?按 SKILL.md 的顺序:从你的 prompt 里读;
读不出来就看周围的代码(一个后端模块 → 多半走 LOGIC;一个页面或组件 → 多半走 UI);
还拿不准而你在场——直接问你。为什么这一步不能糊弄:
原文说「getting this wrong wastes the whole prototype」——选错分支,整个原型白做,
因为两条分支产出的东西完全不同(一个是终端程序,一个是路由上的几个界面)。
如果你真的联系不上、问题又真的模糊,默认规则是:看周围代码更像哪边就走哪边,
并且把这个假设写在原型顶部,让你回来第一眼就能看到。
不管走哪条分支,SKILL.md 的「Rules that apply to both」一节都适用。
这六条是 prototype 的骨架,逐条拆开看:
| # | 规则 | 具体要求 |
|---|---|---|
| 1 | Throwaway from day one, and clearly marked(从第一天起就是一次性的,而且要看得出来) | 代码放在它真正服务的位置旁边(给哪个模块或页面做原型,就放在它旁边),上下文一目了然;但命名要让随手翻到的人一眼看出这是原型、不是生产代码。UI 的一次性路由要服从项目已有的路由约定,不许发明新的顶层目录结构。 |
| 2 | One command to run(一条命令跑起来) | 用项目已有的任务运行器——pnpm <name>、python <path>、bun <path> 之类。你必须能不动脑子就启动它。项目没有任务运行器时,把命令写在原型 README 的最顶上。 |
| 3 | No persistence by default(默认不持久化) | 状态活在内存里。持久化往往正是原型要检验的东西,不该是原型依赖的东西。问题明确涉及数据库时,用一个名字写着「PROTOTYPE — wipe me」(用完就清掉)的临时库或本地文件。 |
| 4 | Skip the polish(不打磨) | 不写测试,错误处理只做到「能跑」为止,不做抽象。要点是快速学到东西。 |
| 5 | Surface the state(把状态亮出来) | LOGIC 分支:每次动作之后打印全部相关状态;UI 分支:每次切换变体时完整渲染。你随时能看见「刚才那一下改变了什么」。 |
| 6 | Capture it when done(做完要收档) | 把验证过的决策折进真实代码;原型本体作为一手材料提交到一次性分支(不并入 main);在实现用的 Issue 上留一个指向该分支的上下文指针;答案(结论 + 它解决的问题)也记进 Issue 或 commit。main 分支只保留验证过的决策。详见第 7 节。 |
LOGIC.md 的适用信号是:问题关于业务逻辑、状态迁移、数据形状—— 「在纸上看挺合理,但只有真的推几个 case 才知道感觉对不对」的那种东西。 它的原文例子:「我不确定这个状态机能不能处理 X 然后 Y 的边界情况」、 「这个数据模型到底能不能表达……这种情况」、「我想在动手写之前先摸摸这个 API 的手感」。 共同点:你想按按钮、看状态变。
| 步 | 做什么 | 要点 |
|---|---|---|
| 1. State the question | 写代码之前,先用一段话写下:原型要验的状态模型是什么、要回答的问题是什么 | 写在原型的 README 或文件顶部注释里。答错问题的逻辑原型是纯浪费;把问题写明,日后(你看着的时候,或者 AFK 之后回来)才能核对它答的是不是原来那道题 |
| 2. Pick the language | 用宿主项目已经在用的语言和工具链 | 项目没有明显运行时(比如纯文档仓库)就问你。不许为了原型引进新的包管理器或运行时 |
| 3. Isolate the logic | 把真正回答问题的那段逻辑收进一个可搬走的独立模块:小的、纯的接口,日后能整个拎起来放进真实代码库 | TUI(终端界面)那层壳是一次性的,逻辑模块不是。这条决定了原型的价值能不能活过原型本身——见 §5.2 |
| 4. Build the smallest TUI | 造一个轻量终端界面:每一拍清屏重绘整个画面,而不是不停往下滚 | 你永远看到一个稳定的画面,不是越来越长的滚动记录。结构见 §5.3 |
| 5. One command | 在项目已有的任务运行器里加一条脚本(package.json scripts、Makefile、justfile、pyproject.toml) | 你跑 pnpm run <prototype-name> 之类就能启动,永远不用记路径 |
| 6. Hand it over | 把运行命令交给你,由你自己开 | 最值钱的时刻是你说「等等,这不应该可能发生」或「咦,我原以为 X 会不一样」——那些是想法里的 bug,正是整个原型的目的。想加动作就加,原型是会演化的 |
| 7. Capture | 问题答完,先收答案,再按 SKILL.md 的方式收原型本体 | LOGIC 分支的具体映射:验证过的逻辑模块拎进真实代码(决策被吸收),TUI 壳跟着原型一起去一次性分支当一手材料 |
第 3 步「把逻辑隔离进可搬走的模块」是整个 LOGIC 分支最值钱的设计决策。 LOGIC.md 给了四种候选形状,选最贴合问题的那种,而不是最好接 TUI 的那种:
| 形状 | 长什么样 | 什么时候合用 |
|---|---|---|
| Pure reducer(纯归约器) | (state, action) => state:吃当前状态和一个动作,吐新状态 |
动作是一个个离散事件,状态是单一值 |
| State machine(状态机) | 显式列出有哪些状态、状态之间允许哪些迁移 | 「当前这个时刻哪些动作根本是合法的」本身就是问题的一部分时 |
| 一组纯函数 | 作用在一个普通数据类型上的若干纯函数 | 没有隐含的「当前状态」概念,只是数据变换 |
| 有清晰方法面的类或模块 | 逻辑自己持有持续的内部状态,对外只露一组方法 | 逻辑确实拥有进行中的内部状态时 |
不管选哪种,都保持纯:不做 I/O、不碰终端、不用 console.log 当控制流。
依赖方向是单向的:TUI import 逻辑模块并调用它,反方向什么都不流。
回报在第 7 步兑现:问题答完,验证过的 reducer / 状态机 / 函数集可以原样拎进真实模块——
原型死了,器官捐献给了产品。
每一拍(tick)清屏后重绘的整帧画面,按顺序只有两部分:
┌──────────────────────────────────────┐
│ 1. 当前状态 │
│ 友好打印、方便比对(一行一个字段, │
│ 或格式化 JSON) │
│ 字段名/小节标题用粗体,次要信息用暗色 │
│ (时间戳、ID、派生值) │
├──────────────────────────────────────┤
│ 2. 键盘快捷键(列在底部) │
│ [a] add user [d] delete user │
│ [t] tick clock [q] quit │
│ 键名加粗、说明暗色(或反过来,读着顺就行)│
└──────────────────────────────────────┘
样式直接用终端原生的 ANSI 转义码(\x1b[1m 粗体、\x1b[2m 暗色、
\x1b[0m 复位),项目里没有现成的样式库就不要为原型引一个。
行为循环四步:初始化一个内存里的状态对象并渲染第一帧 → 每次读一个按键(或一行输入)、
派发给处理函数改状态 → 每个动作之后整帧重绘(替换,不是追加)→ 循环直到退出。
整帧必须一屏放得下。
console.log、prompt、终端转义码,它就搬不走了。壳要薄,核要纯。UI.md 的适用信号是:问题是「该长什么样」。做法:在同一条路由上生成 几个截然不同(radically different)的 UI 变体,用一条浮在屏幕底部的工具栏切换, 你在浏览器里来回翻,挑一个(或者从每个里偷一点),剩下的扔掉。 原文对时机的判断很到位:任何时候,你如果不这么做、就得花一整天在脑子里比较三个模糊的假想稿—— 那就该做这个原型。
| 子形状 | 做法 | 什么时候用 |
|---|---|---|
| A — 挂在已有页面上(默认) | 路由已经存在;变体在同一条路由上渲染,用 ?variant= 这个 search param 开关。已有的数据获取、路由参数、登录态全部保留,只换渲染的那棵子树 |
只要有一个说得过去的已有页面能容纳变体,就用它。被原型的东西还没有页面、但天然会长在某个页面里(dashboard 的新板块、设置页的新卡片、现有流程的新步骤)——也算 A:把变体挂载进宿主页面 |
| B — 新开一条一次性路由(最后手段) | 按项目已有的路由约定造一条一次性路由,命名里带上 prototype 字样,同样用 ?variant= 切换 |
只在被原型的东西真的没有家时用:全新的顶层界面、嵌不进任何地方的流程。决定用 B 之前先自问:真的没有任何已有页面能装它吗? |
为什么强烈优先 A?UI.md 的理由:UI 原型要贴着真实的应用才好判断——真实的页头、侧边栏、 真实的数据、真实的密度。孤零零的一次性路由是一个真空环境:每个变体在真空里看着都挺好, 空路由会把设计问题藏起来,而有内容的路由会把它们暴露出来。
| 步 | 做什么 | 要点 |
|---|---|---|
| 1. State the question, pick N | 默认 3 个变体;超过 5 个就不再「截然不同」,开始变成噪音——封顶 5 个 | 用一句话写下计划,放在原型的位置或文件顶部注释里,例如「Three variants of the settings page, switchable via ?variant=, on the existing /settings route.」你在不在场都能被核对 |
| 2. Generate radically different variants | 逐个起草,每个都要服从三样约束:页面的用途和它能拿到的数据;项目已有的组件库 / 样式体系(TailwindCSS、shadcn、MUI、纯 CSS 都行);清晰的导出组件名(VariantA、VariantB、VariantC) |
变体必须结构上不同:布局不同、信息层级不同、主要操作入口不同——不是换颜色。三个微调过的卡片网格不是 UI 原型,是墙纸(原文:wallpaper)。两个草稿太像,就重做其中一个,明确指示「不许用卡片网格」之类 |
| 3. Wire them together | 在路由上写一个切换组件:读 search param,按值渲染对应变体,最后挂上 PrototypeSwitcher |
子形状 A:保留切换器之上全部已有的数据获取,只有被渲染的子树随变体换。子形状 B:一次性路由挂同一个切换器 |
| 4. Build the floating switcher | 底部居中的固定位置小条,三件东西:左箭头(循环到上一个,绕回)、变体标签(当前键 + 名字,如「B — Sidebar layout」)、右箭头(循环到下一个,绕回) | 行为细则见 §6.3。做成一个共享组件,两种子形状复用,放在项目里公共 UI 该在的地方 |
| 5. Hand it over | 把 URL 和 ?variant= 的键告诉你,你有空就翻 |
最值钱的反馈往往是「我要 B 的页头配 C 的侧边栏」——那才是你真正想要的设计 |
| 6. Capture and clean up | 变体决出胜负后:收答案(哪个变体、为什么),再按 SKILL.md 收原型 | 子形状 A:赢家折进已有页面,输家和切换器从 main 里删掉。子形状 B:赢家提升为正式路由,一次性路由和切换器从 main 里删掉。全套变体是一手材料,去一次性分支,不进回收站——留在 main 里的变体组件和切换器会快速腐烂、迷惑后来的读者 |
router.replace,React Router 用 navigate),这样变体可分享、刷新后不变。← 和 → 也能切换;但当焦点在 <input>、<textarea> 或 [contenteditable] 里时不要拦截方向键——用户正在打字。process.env.NODE_ENV !== 'production' 之类的开关拦住,防止某次误合并把这条栏发给真实用户。<Header> 可以;共享一个 <Layout> 就没意义了。每个变体要有推翻整个布局的自由。六条共用规则里的第 6 条(Capture it when done)值得单独一节,因为它是这个 skill 和其它「随便写写试试」之间最大的差别:原型做完不是删掉,是收档。 docs 页把收档的产出拆成两样东西:
| 收什么 | 收到哪 | 为什么 |
|---|---|---|
| 答案——结论(verdict)加上它解决的那个问题 | 耐久的地方:commit message、ADR(架构决策记录)、或 Issue | 答案是原型真正产出的东西,要能被以后的人(和 AI)查到 |
| 原型本体——那份能跑的代码 | 一条一次性分支:提交进去、离开 main、永不合并;然后在实现用的 Issue 上留一个指向这条分支的上下文指针 | 原型是答案的一手材料(primary source:答案所依据的原始证据)。它没有测试、没有错误处理、不值得维护,所以不属于 main;但这不是销毁它的理由——想复跑证据的人,点一下就能拿到 |
收档全景:
原型答完了问题
│
├─► 验证过的决策 ──折进──► 真实代码(main 只留这个)
│ · LOGIC:逻辑模块拎进真实模块(正经重写/移植)
│ · UI:赢家变体折进已有页面或提升为正式路由
│
├─► 答案(结论 + 问题)──写进──► Issue / commit / ADR
│
└─► 原型本体 ──提交到──► 一次性分支(off main,never merged)
实现用的 Issue 上留指针
出处:SKILL.md 规则 6 · docs/engineering/prototype.md 的「Keep the prototype as a primary source」一节 · 分支各自的映射:LOGIC.md 第 7 步、 UI.md 第 6 步
想法线程(主流程)
/grill-with-docs 面试中
冒出一个「纸上定不下来」的问题
│
/handoff ─┼─► 新会话 ─► /prototype
│ │
│ 答案 + 一次性分支
│ │
◄── /handoff ─────┘
│
▼
┌───────────────┼────────────────────┐
▼ ▼ ▼
/to-spec /domain-modeling 直接回主流程
把验证过的状态 若决策不可逆, (甚至 /implement)
模型或 UI 方向 记一条 ADR
当已确定的输入写进 spec
| Skill | 启动 | 和 prototype 的关系 |
|---|---|---|
| handoff | User | 双向摆渡船。ask-matt 主流程第 2 步的原话:岔出去做原型时 handoff 一次、开新会话对着那份文件干活;做完把学到的东西 handoff 回原线程,并在原想法线程里引用它。为什么不就地做?原型是个自成一体的探索,配一个干净的上下文窗口。 |
| to-spec | User | 最常见的下游。docs 页原话:验证过的状态模型或 UI 方向,成为 to-spec 落笔时「已确定的输入」。spec 里写的是结论,原型分支的指针是证据。 |
| domain-modeling | Model | 原型验出来的决策如果难以逆转(比如选定了某个状态机语义),值得按 0004 的规矩记成一条 ADR。 |
| codebase-design | Model | 提供词汇:LOGIC 分支「逻辑收进小的纯接口、壳可扔」就是深模块 / 接缝那套语言的一次性应用。想先想清楚接口形状再造原型,可以先踩这块词汇地板。 |
| diagnosing-bugs | Model | 分工对偶:prototype 探索「该造什么」,diagnosing-bugs 查「造好的东西为什么坏了」。docs 页互相点名,别混。 |
| research | User | 另一条 standalone 岔路,但产出不同:research 交出一份带引用的 Markdown(读出来的一手资料),prototype 交出一份能跑的代码(推出来的手感)。一个问题靠「读」解决就走 research,靠「跑」解决就走 prototype。 |
| ask-matt | User | 路由:在它的地图里,prototype 同时是主流程 step 2 的岔路和 Standalone 名单的一员——「reach for it any time」。 |
| 动作 | 会写/改什么 | 说明 |
|---|---|---|
| 造原型(任一分支) | 仓库里新增一次性代码:放在被原型对象的旁边,命名标明是原型;UI 分支可能改已有页面(子形状 A 的切换器挂载) | 规则 1:贴着使用位置放;规则 4:不带测试和错误处理 |
| 让它一条命令能跑 | 改项目已有的任务运行器配置(package.json scripts、Makefile、justfile、pyproject.toml),加一条脚本 | 项目没有任务运行器就只写 README |
| 问题涉及数据库时 | 新建一个名字写着「PROTOTYPE — wipe me」的临时库或本地文件 | 规则 3:默认内存;持久化是被检验对象,不是依赖 |
| 收尾(规则 6) | ① 真实代码:折入验证过的决策(移植或正经重写)② 一次性分支:提交原型本体,离开 main、永不合并 ③ Issue / commit / ADR:写答案(结论 + 问题)和指向分支的指针 ④ 从 main 删掉原型残留(UI 分支:输掉的变体和切换器) | main 只留验证过的决策;探索过程留在分支上,指针留在 Issue 上 |
| 不会动的 | Issue tracker 的结构(不改标签、不开新 Issue 类型)、CONTEXT.md(除非走 domain-modeling)、main 分支的产品行为(决策折入之前) | 它是 standalone:不写 CONTEXT.md、不动 triage 那套标签体系 |
/handoff 把答案带回原线程 → 继续主流程(多半进 /to-spec,把验证过的结论当已确定的输入)。domain-modeling 记一条 ADR,把「为什么是这个状态机 / 这个方向」钉下来。/grill-with-docs 重新磨——原型暴露了想法里的 bug,这是胜利,不是返工。| 症状 | 先查 | 不要误改 |
|---|---|---|
| AI 造出来的东西带着测试、抽象、错误处理全家桶 | SKILL.md 规则 4「Skip the polish」;docs 页「the moment you start hardening it…」 |
LOGIC.md 的 TUI 画面细则(那是形状,不是造价) |
| 选错分支——给状态问题造了 UI,或给长相问题造了终端程序 | SKILL.md 的「Pick a branch」一节(含模糊时的默认规则和「把假设写在顶部」) |
两个分支文件的正文(分支选错是上游问题) |
| 逻辑原型做完了,逻辑搬不走——和终端代码糊成一团 | LOGIC.md 第 3 步(四种形状、保持纯、单向依赖)和反面模式「Don't blur the logic and the TUI together」 |
TUI 的重绘细则 |
| UI 变体彼此雷同,只是换皮 | UI.md 第 2 步「structurally different」和「wallpaper」警告 |
切换器的键盘行为 |
| 原型孤零零开在真空路由里,看不出好坏 | UI.md 的「Two sub-shapes」一节:优先 A,B 是最后手段 |
变体数量默认值(3) |
| 浮动栏跟着生产构建发出去了 | UI.md 第 4 步的 NODE_ENV 开关要求 |
search param 的命名 |
| 原型做完直接进了 main,或者做完随手删了没留档 | SKILL.md 规则 6;docs 页「primary source」一节 |
分支各自的流程步骤 |
| AI 从不主动伸手用它 | SKILL.md frontmatter 的 description(「Use when…」触发条件) |
openai.yaml(那里只有展示名和简介,不管触发) |
先别往回翻,凭记忆答。选项长度刻意对齐,不会从版式泄题。规则编号和措辞以本课和 SKILL.md / LOGIC.md / UI.md 原文为准。
本课主一手材料(请打开原文读,不要只背本页摘要):
skills/engineering/prototype/SKILL.md
—— 一句话定义、选分支、六条共用规则、收档纪律。首选 primary source。
…/LOGIC.md
—— 逻辑分支:四种逻辑形状、TUI 画面结构、七步流程、反面模式。
…/UI.md
—— UI 分支:两种子形状、变体纪律、浮动切换器细则、收尾映射。
docs/engineering/prototype.md
—— 给人看的叙事版:throwaway 的哲学、「primary source」论证、和 diagnosing-bugs 的分工
(aihero.dev/skills-prototype)。
速查页(本课同步): reference/prototype.html
导航: 上一课 0007 research (另一条 standalone 岔路:一个交 Markdown,一个交能跑的代码)。 总览仍回 0001 系统地图; 摆渡船见 0006 handoff; 下游接 0009 to-spec。
建议下一课(0009 to-spec): 原型把「感觉对吗」变成了「验证过」——下一课看 to-spec 怎么把这种已确定的结论 (连同面试记录、研究文件)写成一份能拆成 Issue 的 spec。 交接点:spec 的 Implementation Decisions 一节写的就是这类已敲定的决策, 而原型分支的指针就是它们的证据附件。
SKILL.md / LOGIC.md / UI.md 的原文,不会临场编造。
做完检索练习后,回复「练习结果 / 哪里卡住 / 开 0009 或先补 0007」,我们安排下一课。