OpenSpec-practise

让 delta 评审聚焦真正的变化:OpenSpec v1.11.0 工作流实践

OpenSpec v1.10.0 与 v1.11.0 相继发布(2026-08),两个版本均为增量迭代、无 breaking change。v1.11.0 的核心变化是 openspec show --diff——它回应了 SDD 实践中最实际的痛点:MODIFIED 需求必须复述全部保留的 Scenario,导致评审 delta 时看到的几乎都是与主 spec 相同的文本。本文通过真实案例「购物车查询与商品移除」(cart-query-and-remove),演示升级到 v1.11.0 后的完整工作流(Explore → Propose → Apply → Archive),并记录两个版本中影响实践的变化。

一、背景:v1.9.0 时代遗留的三个摩擦点

升级前(v1.9.0),我们的工作流有三个不顺手的地方,恰好被 v1.10/v1.11 逐一回应:

摩擦 1:MODIFIED 评审冗长
  一个被修改的需求要复述全部保留场景 → review 时 >90% 是没变的文本
  变化行淹没在复述里

摩擦 2:explore 边界模糊
  "创建提案文件"是思考还是实施?explore 中写文件之前无明确确认点
  回答澄清问题时,可能被误读为同意写入

摩擦 3:状态检查只针对单 change
  多个 active changes 时需逐个跑 status,看板/CI 集成不便

二、v1.10.0 与 v1.11.0 的变化要点(实践视角)

v1.10.0:小迭代

v1.11.0:评审体验的升级

三、实践案例:cart-query-and-remove

3.1 Explore:spec 与代码的倒挂盘点

探索阶段先盘点双实现(Node.js + Python)的 spec-code 差距,发现购物车能力存在双向缺口:

能力 spec 声明 Node.js Python
添加商品 有(3 场景) ✅ ✅
移除商品 有(1 场景) ❌ 无代码 ❌ 无代码
查询购物车 无此需求 服务层有、无路由 服务层有、无路由

「移除」是 spec 说要做而代码没有(欠实现),「查询」是代码已有而 spec 与 HTTP 层都没有(倒挂)。选定该 change:补齐「加购 → 查看 → 移除 → 下单」闭环,且同一 change 内同时产生 ADDED 与 MODIFIED 两种 delta——正好完整演示 show --diff 的两种渲染。

3.2 Propose:四个 artifacts

openspec new change "cart-query-and-remove"

按 schema 驱动生成 4 个 artifacts:

validate 拦下一个编辑陷阱:MODIFIED 中把场景「移除购物车商品」改名成「移除购物车中的商品」,校验器直接报错——MODIFIED 会替换整个 Requirement 块,archive 拒绝静默丢弃主 spec 仍在的场景名。场景级没有 RENAMED 机制(RENAMED 只用于 Requirement 级),解法是保留场景名、只改内容:

✗ [ERROR] MODIFIED "购物车商品移除" omits scenario(s) the current spec still has:
  "移除购物车商品". Copy them into the MODIFIED block ...
→ 恢复原场景名后 validate 通过

3.3 评审:show –diff 的两种渲染

delta 写好后,评审只用一个命令:

openspec show cart-query-and-remove --diff

关键体验:ADDED「购物车查询」显示完整文本(新内容没有”基线”可比);MODIFIED「购物车商品移除」只渲染真正变化的行——旧的 15 行需求被整体替换为 30 行,diff 却只显示:描述与 Rationale 的措辞变化、场景 When/Then 的细化、2 个新增场景。评审面从”通读 45 行复述”收敛为”只看 15 行变化”。

3.4 Apply:双实现落地

服务层实现保持两语言同构:

// Node.js - cart.js
removeItem(userId, productId) {
  const cart = this.getCart(userId)
  const index = cart.items.findIndex(i => i.productId === productId)
  if (index === -1) throw new Error('CART_ITEM_NOT_FOUND')
  cart.items.splice(index, 1)
  this.cartRepo.save(cart)
  return cart
}
# Python - cart.py
def remove_item(self, user_id: str, product_id: str) -> Cart:
    cart = self.get_cart(user_id)
    item = next((i for i in cart.items if i.product_id == product_id), None)
    if not item:
        raise ValueError("CART_ITEM_NOT_FOUND")
    cart.items.remove(item)
    self.repo.save(user_id, cart)
    return cart

HTTP 层:Node dev server 沿用固定用户 user_dev 的 mock 模式;Python 端沿用请求携带 userId 的惯例(GET/DELETE 走 query 参数)。路由错误映射补 CART_ITEM_NOT_FOUND → 404。server.prod.js 是独立维护的精简路由表,沿用前两次实践的范围约定不扩展。

测试:Node 单测 +3(成功移除/不存在抛错/多条目隔离)、集成 E2E +1(空车查询 → 加购 → 查询 → 移除 → 重复移除 404);Python smoke +1(同序列 + 独立 userId 隔离)。结果 Node 14/14、Python 5/5 全绿。

3.5 Archive:一条命令完成 sync + 归档

v1.11.0 的 openspec archive 内建了 spec 合并(CLI 中已无独立 sync 命令):

$ openspec archive cart-query-and-remove --yes
Specs to update:
  cart-management: update
Applying changes to openspec/specs/cart-management/spec.md:
  + 1 added
  ~ 1 modified
Totals: + 1, ~ 1, - 0, → 0
Specs updated successfully.
Change 'cart-query-and-remove' archived as '2026-09-02-cart-query-and-remove'.

合并保真度经抽查确认:MODIFIED 只替换目标 Requirement 块、ADDED 追加、未提及的需求(如「购物车数量限制」)原样保留且位置不变。全量 openspec validate:7 个 specs 全部通过,无遗留 active changes。

四、实践总结

4.1 show –diff 改变了评审节奏

以前评审 delta = 打开文件通读复述文本;现在 = show --diff 一次调用看到全部真正变化。MODIFIED 场景越多、需求越大,收益越明显——这正是 MODIFIED 必须”复述全部保留场景”这一设计代价的补偿机制。

4.2 v1.11.0 对实践流程的净影响

  1. 评审前置化:show --diff 让”写 delta → 立即评审”成为零摩擦动作,propose 阶段即可发现语义偏差
  2. 写入边界显式化:explore 的”先点名、再确认、单独立场”把 AI 的写权限收敛到用户明确授权,澄清问答不再产生歧义
  3. 归档简化:sync 与 archive 合并为一条命令,合并统计(+1/~1)自动打印,减少了 agent-driven 手动合并的心智负担

4.3 与 v1.9.0 迁移的衔接

v1.9.0 的 ## Purpose 迁移后,v1.11.0 的 validate 会警告未填写的 Purpose 占位符(默认 warning),形成一个完整的”格式迁移 → 内容守护”闭环。


本文基于 OpenSpec Practise 仓库的 cart-query-and-remove 实践(2026-09-02),完整产物见 openspec/changes/archive/2026-09-02-cart-query-and-remove/。