OpenSpec
一个 OpenSpec 改动就是四个文件组成的目录,在写任何代码之前完成,说清楚你要做什么、为什么做。代理写出来,你批准,然后照着它干活。它是工作流在 Propose 步骤产出的东西,也是 Retro 日后读回去的记录。
重点是那个中断点。已经写了 400 行代码的代理会为这些代码辩护;手里只有一页提案的代理会痛快接受修改。你会在它误解任务的时候就发现,代价是一句话,而不是一个下午。
目录
openspec/changes/2026-09-20-saved-searches/
├── .openspec.yaml
├── proposal.md
├── specs/
│ └── saved-searches/
│ └── spec.md
└── tasks.md
目录名是日期加一个 slug,因此按时间排序,两个改动也不会撞名。一次改动的所有东西都在一个地方,归档就是一次移动。
.openspec.yaml
四行元数据:name、created、status,以及在多项目仓库中它作用于哪个项目。
name: saved-searches
created: 2026-09-20
status: approved
project: app
status 就是门禁:在你表态之前是 proposed,表态之后才是 approved。代理看到 proposed 的改动,就意味着还没有获准写代码。
proposal.md
论证本身,分为几节。
| 小节 | 该写什么 |
|---|---|
| Why | 问题,带证据。来自代码库或线上的数字,不是形容词。 |
| Goals / Non-Goals | 这次改动做什么,以及明确不做什么,让范围蔓延有个撞墙的地方。 |
| Decisions | 每个真实的取舍、被放弃的方案,以及选它的理由。半年后你会回来读这一节。 |
| Impact | 新建和修改的文件,以及仓库之外的东西:规则、基础设施、其他服务。 |
| Risks | 可能出什么问题,以及什么能兜住。 |
值得较真的是 Why。「搜索很慢」不是证据。「每次查询都把五个集合的全部文档拉进浏览器,约 500 次读取、1 到 3 秒」才是,而且它同时告诉评审者什么算成功。
specs/<capability>/spec.md
需求,一条行为一条,用祈使句。
# Saved searches
## Requirement: save and manage
A logged-in user on a result page SHALL be able to save the current query and
filters (max 10 per user); a logged-out user SHALL be sent to login and back.
## Requirement: daily digest
A scheduled function at 08:00 Europe/Berlin SHALL, for every saved search,
fetch new ads since `lastNotifiedAt`, and when there is at least one, email the
owner a digest, then set `lastNotifiedAt`. A failed send SHALL leave
`lastNotifiedAt` untouched so the next run retries without duplicates.
SHALL 不是仪式感。它逼你写出可验证的句子:你可以指着跑起来的系统说「是」或「否」。「应当优雅地处理错误」没法检查,所以永远不会被检查。
一个能力一个目录。涉及三个能力的改动就有三个 spec 文件,而这些文件正是行为变化时后续改动要修改的对象。
tasks.md
任务组,不是文件清单。三到八组,每组可独立验证,最后一组永远是验证。
## 1. Engine [ ]
- Files: `src/lib/server/search.ts`.
- Index, query parsing, scoring, suggestions.
- Acceptance: spec § ranked results.
## 2. Verify [ ]
- Type-check, curl matrix, browser run with real clicks, review, prod check.
按单元而不是按文件分组,才让清单可评审。「改六个文件」什么也没说;「先引擎、再页面、最后验证」说清了这活的形状,而且每一组只有真正跑通了才能打勾。
门禁
任何涉及两个及以上文件、改动公开接口、改动 spec 或新增行为的改动,都需要先有一份获批的 OpenSpec 才能写代码。豁免只有四条,而且是字面意义上的:
- 单个文件里的错别字修正
- 只改注释或文档字符串
- 你直接指定的配置值(「把超时改成 30」)
- 已获批且仍在进行中的改动的后续修补
「微不足道」「显而易见」「很小」「就改一点点」不在这份清单上,而这四句话恰恰是代理重写你的鉴权之前会说的。拿不准就提案:多写一份用不上的提案,代价是一页纸;该写而没写,代价是重来一遍。
生命周期
- Propose. 代理写完四个文件就停下。不写代码。
- Approve. 你读、提修改、或者说继续。这个时候改最便宜。
- Apply. 代理按顺序做任务组,做完一组勾一组。
- Review.
/coograph-review拿结果对照 spec,而不是对照 diff,范围蔓延和被悄悄丢掉的需求就是这样抓出来的。 - Archive. 目录移到
openspec/changes/archive/,成为当初决定了什么的记录。
需求会中途变。变了就先改 spec,再继续照新版本干。代码和自己的 spec 对不上的改动,没人评审得了。
归档为什么重要
归档的改动不是档案柜,而是为什么这件事唯一留得住的记录,它比参与其中的每个人和每个代理都活得久。
它也是数据。Retro 会读归档,报告每次改动有几个任务、多少改动带着没勾完的任务就归档了、评审多久才发现一次值得写进修复小节的问题,以及哪些路径反复出现。一个项目里三分之一的归档改动都带着没勾完的任务,那是工作流出了问题,单看任何一次评审都看不出来。
一个真实的例子
某个线上分类信息站的搜索重写,就是一个改动目录。提案的 Why 写着:客户端搜索每次查询都要拉取五个集合的全部文档。Goals 把范围钉在排序、查询理解、建议和零结果处理上,Non-Goals 排除了外部搜索服务——否则那场对话必然往那边跑。Decisions 记下了 60 秒的索引缓存,以及结果为什么在服务端算。Spec 定了五条需求,其中一条是所有已有筛选必须照常工作,正是这条在评审时抓出了一个被丢掉的排序项。Tasks 依次是引擎、页面、建议、验证。
这次改动一天内上线。提案花了二十分钟,而它正是评审能用「是」或「否」结束、而不是变成一场争论的原因。
它住在哪里
写和执行这些东西的技能是 coograph-propose、coograph-apply、coograph-review 和 coograph-archive,在 Claude Code 里是 /coograph-* 命令,在其他受支持的工具里是对应的代理。格式就是你仓库里的纯 Markdown 和 YAML,没有任何一处绑定厂商。一个改动目录,在任何特定代理不再是写它的那一个之后,依然读得懂。