决定只留在当次对话里,下一次改动读不到它。所以先把改动写成规约,再动代码。
只看仓库里写下的规约、不看对话,助手能不能产出要的东西?验收的人能不能照着逐条判对错?
两个都答「能」,规约才算立住。答「不能」的部分,后面五层补不了——先补规约,再动代码。
助手指正在用的 AI 编程工具(Cursor / trae / Claude Code);它只按写下的文字执行,不猜意图。只写在对话里的内容,下一次改动读不到。下表从左到右:症状 → 代价 → 该写进哪个文件。
| 症状 | 代价 | 写进哪里 |
|---|---|---|
| 两人对同一字段用了不同名字 | 合并时接口对不上,返工 1 次 | 字段名写进本次改动的增量(delta),随合并请求评审 |
| 同一处逻辑两周内被改了三次 | 说不清原先的验收标准是什么 | 以基线 openspec/specs/ 为准;改它 = 改规约,留一次版本记录 |
| 助手产出「看起来对」但没人能验收 | 评审只能逐行读代码 | 每条 Requirement 配一个可执行的检查,放进质量门禁的 make check |
| 新成员接手要先问一圈 | 前两周产出接近零 | 验收标准写进 openspec/specs/,新成员读文件,不用挨个问 |
| 需求讨论只留在群里 | 三个月后答不出「为什么这么做」 | 决定写进 openspec/changes/<change-id>/design.md |
左列不是「不规范」,是没有验收标准。
| 氛围编程 | 规约驱动 | |
|---|---|---|
| 起点 | 边聊边改,需求停在对话里 | 先立项,需求进 openspec/changes/ |
| 助手拿到什么 | 当次对话的几句话 | 仓库里的规约原文,加上 AGENTS.md 等上下文文件 |
| 什么算做完 | 看起来对 | 规约里的场景逐条通过 |
| 复查看什么 | 逐行读助手的改动差异 | 先读规约,再看实现是否照规约 |
| 换人接手 | 要重新问一遍 | 读 openspec/specs/ |
| 出问题时 | 不知道原先想要什么 | 规约写着原先要什么,差在哪能定位到具体场景(Scenario) |
| 多轮改动 | 每轮验收标准都可能变,且没痕迹 | 验收标准变了要改规约,改动留在版本历史里 |
「一次改动」= 一个 CHANGE,就是 openspec/changes/ 下的一个目录。规约用 OpenSpec 写,不手搓目录:仓库跑一次 openspec init,之后每开一次改动执行 /opsx-propose,四件套由它生成。四件套是 openspec/changes/<change-id>/ 下的 proposal.md、增量 specs/<capability>/spec.md、design.md、tasks.md,各回答一个别人会问的问题,缺一件不算立项。
| 文件 | 回答什么问题 |
|---|---|
proposal.md | 这次解决什么问题;这次明确不做哪些 |
specs/<capability>/spec.md | 行为是什么:每条 Requirement 配若干 Scenario,写成 Given / When / Then |
design.md | 为什么选这个方案、舍弃了什么 |
tasks.md | 拆成哪些可勾选的步骤,一条一步 |
目录名是 change-id(例:class-isolation),不带日期。四件套之外只有一份验收记录 verify.md,它是本课要求补的,不属于 OpenSpec 的四件套。
openspec/
config.yaml # openspec init 生成:给 AI 看的上下文与规则
changes/ # 进行中,可能被否掉
class-isolation/ # change-id,不带日期
proposal.md
design.md
tasks.md
verify.md # 本课补充:验收后补,场景逐条结论
specs/
class-isolation/
spec.md # 增量:本次新增或修改的 Requirement
archive/ # 已完成,只作追溯
2026-09-26-login-lockout/ # 归档时由 OpenSpec 加上日期前缀
specs/ # 当前行为基线,归档后并入
auth/
spec.md
class-isolation/
spec.md
「这次不做」是提案里最有用的一节:评审先看它,才知道边界画在哪。
# 提案:班级资料隔离 - change-id:class-isolation - 提出人:张(后端) - 日期:2026-10-03 - 关联 issue:#42 ## 问题 学生 A 用检索接口能查到二班的材料。已在测试环境复现两次,见 issue #42。 ## 这次做 1. 检索接口按班级过滤,非本班材料不进结果集。 2. 越权尝试记一条 warning 日志,字段含用户、班级、查询词。 ## 这次不做 1. 不改管理员跨班查看——管理员本来就应当能看。 2. 不改已上传材料的可见性设置。 3. 不做全文检索的排序优化。 ## 边界 只动 /api/kb/search 与它的数据访问函数。前端不感知这次改动, 不改任何响应字段名。
增量只写本次新增或修改的部分,不重抄整份规约;正常路径与失败路径平级。完整写法见可执行规约怎么写。
## ADDED Requirements ### Requirement: 检索按班级隔离 学生只能检索到本班的材料。 #### Scenario: 学生检索他班资料 - Given 学生 A 属于一班,且二班已上传材料 - When 学生 A 用关键词检索二班的材料 - Then 返回结果为空,且响应中不含二班文档标题 - And 服务端记录一条 warning,含用户、班级、查询词 #### Scenario: 直接按 id 取他班文档 - Given 学生 A 属于一班,文档 42 属于二班 - When 学生 A 请求 /api/kb/doc/42 - Then 返回 404,且不返回任何正文 #### Scenario: 未登录访问检索接口 - Given 请求未携带会话 - When 调用 /api/kb/search - Then 返回 401,且不返回任何文档片段
只记「需要解释的决定」:写「为什么」和「舍弃了什么」。
# 设计:检索按班级隔离 - 状态:已定,2026-10-03 冻结 ## 决定 在数据访问层加班级过滤,不在业务层过滤。 ## 为什么 业务层过滤要先取全量结果再筛,代价随材料总数增长;数据访问层过滤走 索引,且将来加缓存时缓存键天然带班级。代价是这段查询与数据库绑定, 本项目只用 PostgreSQL,可以接受。 ## 舍弃的方案 1. 在 controller 里过滤:有三个调用点,漏一处就漏一个洞。 2. 按班级建视图分表:改动面太大,本次不做。
勾选项写不进一句话的,说明还没拆开;每条都要能指到文件或命令。
# 任务:检索按班级隔离 - [ ] 1. 在 app/kb/data.py 的检索查询里加 class_id 过滤条件 - [ ] 2. 越权命中时写 warning 日志,字段:user_id / class_id / query - [ ] 3. 补上面三个 Scenario 的测试,接进 make check - [ ] 4. 跑 openspec validate class-isolation --strict,无 error - [ ] 5. 在同一次合并请求里提交规约与代码 - [ ] 6. 评审通过后补 verify.md,归档:增量并入 openspec/specs/
## 这次不做 1. 不涉及其他模块的改动。 ← 讲了等于没讲 ### Scenario: 检索结果正确 - Given 用户发起检索 - When 调用 /api/kb/search - Then 返回正确的检索结果 ← 不可判 $ git log --oneline -2 7c2e4f3 实现按班级过滤检索 ← 实现在前 b41d90a 补规约 ← 规约在后
认它的办法:看规约的提交是不是早于实现的提交。上面这份四件套齐全、格式工整,但它是实现完了才补的——照着已经写好的代码倒写出来的规约,永远全绿,因为它描述的正是已经发生的事。倒写的规约挡不住任何东西:它和代码一致是构造出来的,不是判出来的。
这套做法只为会改变行为的改动付成本(多半天写规约、多一轮评审)。把所有改动都套一遍流程,结果是全组绕过流程,比不做流程更糟。
| 改动 | 要不要开 CHANGE | 为什么 |
|---|---|---|
| 改错别字、改代码格式 | 不用,直接提交 | 没有行为变化,规约不需要动 |
| 依赖版本升级且无接口变化 | 不用,直接提交 + 跑 make check | 验收标准没变,检查能覆盖 |
| 改一个超时时间、改一个阈值 | 要开 | 阈值写在基线 openspec/specs/ 里,改它就得留一次版本记录 |
| 加按钮、改文案 | 不用 | 无行为变化,除非文案本身是验收项 |
| 谁都说不清这次要改什么 | 先写 proposal.md,写不出就不动手 | 写不出「这次不做」,说明边界还没想清楚 |