上一篇把「契约归谁」说清楚了,跨仓需求照样翻车。常见场面是:Agent 在 web 里自作主张「合理推断」一套接口,api 按另一套口头约定实现;或者平台 SDK 的 major 已经发出去了,消费方一周后才补 Spec。
多半不是不懂归属,而是缺两样东西:一条人能照着做的变更顺序,以及开发过程中把上下文钉住、别让它在仓与仓、会话与会话之间溜走的办法。
这篇的主张很简单:跨仓仍然成立的行为,用 OpenSpec 记在可版本化的契约里;单仓、单次会话里干活时,用 User Harness 逼 Agent 先读这份契约、改完再对照检查。两者配合,比指望模型「自己记得上周在另一个仓说过什么」靠谱得多。
主要写给开发和测试工程师(下文称 TE)。读完你应该能自己走完一次「只改本仓」和一次「前端 + 后端一起改」的流程。Lead 可以看拓扑和门禁,但不是第一读者。OpenSpec 就是现成的 SDD 工具,这里不另起名字。
你们更像哪种产品
很多公司两套形态同时存在,翻车点不一样。
一种是业务拆成多个仓:结账、商品、营销各管一摊,跨模块需求不少,共享库不多。麻烦通常出在平级团队之间——谁先写契约、谁先合代码、Agent 到底读哪份 Spec。
另一种是平台或公共组件很重:一个 auth SDK、一套基础 API,被很多业务仓引用,不兼容变更经常有。麻烦变成中心带动四周——影响哪些仓、旧接口留多久、谁先升级谁后升级。
两套形态不必各搞一套方法论。同一条流水线就够,差别只是:要不要单独的平台契约仓,以及要不要「影响面清单 + 分批升级」。
整条线可以粗记成:
先分清需求类型(只动本仓 / 跨业务仓 / 动公共依赖)
→ 契约写在哪个仓、开哪次变更
→ 用 git 的 tag 或提交号钉住这一版契约
→ 各代码仓只写本仓怎么落地,并写明引用哪一版契约
→ 联调、发版(动公共依赖时尤其要谈清楚)
→ TE 按钉住的契约跑产品级场景
→ 归档,并把这次踩坑写回团队规则
这也呼应 SDD 真相源:代码大量由 Agent 生成时,人真正要稳住的是规格。仓一多,规格还得别人能引用、能钉版本。OpenSpec 在站内交付栈里仍是规格这一层,见 三层交付栈。
三种需求,一分钟分清走哪条
接到活,先问上一篇同一句:这份行为或约束,是不是要求别的仓的人也遵守? 然后再看冲击是「几个业务仓一起改」,还是「平台一动,很多人被迫跟着动」。
| 需求类型 | 怎么判断 | 建议怎么放 OpenSpec(不用 beta 也能做) | 谁先动手 |
|---|---|---|---|
| 只动本仓 | 影响出不了当前这个代码仓 | 本仓 openspec/changes,改本地 specs | 本仓团队 |
| 跨几个业务仓 | 至少两个业务/模块仓要共同遵守新行为 | 先在产品契约仓写清边界行为;各代码仓再开实现变更,并写明引用哪一版契约 | 契约负责人先合契约,再轮到各实现仓 |
| 动公共依赖 | 改的是很多仓在用的库或服务,可能逼消费方改代码或改行为 | 在平台契约仓写兼容约定;消费方分批改 | 平台侧提案 → 评审影响面 → 消费方分批落地 |
「跨业务仓」和「动公共依赖」都能跨仓库,协作方式不同:前者多半是前端和后端一起改结账;后者是 SDK 一变,一堆应用要跟。
大面积写代码之前,变更提案里建议写死四行(对应 Harness 里「先对齐再动手」):
- 需求属于上面哪一类
- 契约正文在哪个仓(本仓,还是产品契约仓 / 平台契约仓)
- 哪些代码仓会改(只动本仓就写「仅本仓」)
- 明确不做什么,防止聊着聊着 scope 偷偷变大
少一行,就别让 Agent 铺开改。
仓库怎么摆:普通 git 仓够用,不必先上 Stores
product-contracts/ # 产品契约:边界行为、跨端用户路径
platform-contracts/ # 平台契约:兼容说明、迁移窗口(平台压力大再单独拆)
web/ api/ mobile/ … # 各业务仓:本地 openspec/ 只写本仓怎么落地
团队不大、很少遇到「公共 SDK 一升,全员被迫改」时,也可以先共用一个契约仓,用目录和 CODEOWNERS 分开产品和平台。做法不变,只是少几个远程仓。
OpenSpec Stores 和 references / worksets 还在 beta。可以当成同一套做法的可选加速,不是开工前提。分清两件事:
- 工具能管:某个
openspec/下的 specs 和 changes;Stores 还能把规划放到独立仓并按名字引用。 - 工具不会替你做:自动
git pull、替你锁死「必须用某个提交号」、替你跑 TE 的产品套件。
官方也写了 Stores 默认不同步。你本机契约仓没拉新,读到的就是旧真相。所以「钉版本」靠的是 git 的 tag/提交号,再加上 CI 或团队规则检查,不是在配置里写一行 references: 就完事。
| 你想解决的问题 | 现在就能用的做法 | 若开了 Stores beta,大致对应 |
|---|---|---|
| 契约有独立的家 | 普通契约 git 仓 | 登记成一个 store |
| 钉住某一版契约 | 提案和 CI 写明 contracts@某 tag 或提交号 | 还是你们自己钉;Stores 不会自动锁版本 |
| 让 Agent 知道上游契约在哪 | 各代码仓 openspec/config.yaml 的 context: / rules: 写清楚「先读钉住的契约」 | references: 只在说明里加只读索引,不会改命令默认改哪个仓 |
| 一次打开多个仓 | 可提交的多根 .code-workspace | workset open(多半是本机名单,不是团队共享真相) |
| 做体检 | CI 检查版本写没写、能不能解析 | openspec doctor / context |
代码仓里可以先加这样一段稳定配置:
schema: spec-driven
context: |
跨仓行为写在 product-contracts。
写代码前:先读本 change 里钉住的 contracts@提交号。
不要把契约正文复制进本仓。
rules:
proposal:
- 写明需求类型、契约版本、实现仓、不做什么
- 跨仓或平台变更时,本仓提案只保留引用,不抄正文
不这样写,Agent 默认只看得见当前仓,「正文只在契约仓、代码仓只留引用」就很难落地。
变更怎么排队:评过可以并行,合主干必须对齐
默认建议就两步:
- 契约 PR 在 GitHub/GitLab 上被负责人点过 Approve 之后,各业务仓可以并行开实现 PR,开始写代码。
- 实现 PR 合进主干之前,契约 PR 必须已经 merge,并且实现 PR 里写的契约版本,已经换成 merge 之后的那一版。
比「契约不 merge 谁都不许动手」快,比「大家口头说好一起发」稳。
版本怎么钉(不然并行等于对着移动靶打)
OpenSpec 不管锁版本,git 管。
- 契约 PR 一被 Approve,马上记下不可变版本,比如
product-contracts@9f3c1a2(批准那一刻的提交),或钉在这个提交上的预发 tag。别钉「分支最新」,分支尖还会继续动。 - 所有实现 PR 的提案都要写上这个版本。若有人又往还没合的契约 PR 里推了改语义的提交,就重新评审,换新的冻结版本,各实现 PR 一起改引用。
- 契约 merge 之后(包括 squash),再钉一次:merge 出来的提交,或
main上的正式 tag,并更新还没合的实现 PR。 - CI 拦住这种货:契约已经进主干了,实现 PR 还挂着旧的「评审时提交号」——不许合。
一句话:评过时冻一版;合进主干后再对齐到最终版。
跨几个业务仓怎么走:结账加优惠券
虚构一家 Acme 商城:结账页要支持优惠券,web 和 api 都要改。
- 判成「跨几个业务仓」,把上面四行写进提案,先对齐。
- 在产品契约仓开变更:只写边界行为、错误含义、验收时要看的用户路径。别把前端任务清单和后端任务清单塞进契约仓。
- 契约评过之后,冻结
product-contracts@某一提交号。 web、api各自开本地变更:只写本仓设计和任务;提案开头只留引用(见后面模拟)。- 多根窗口打开契约仓和正在改的代码仓;让 Agent 先读钉住的那一版,再动代码。
- 联调一般先 api 后 web。字段含义要变,先改契约并换版本,再改代码。
- TE 按验收要点,对照 merge 后的契约版本跑产品场景。
- 实现都进主干后再归档:先归档各代码仓,再归档契约仓;把踩坑写回团队规则。
代码仓提案里的引用可以长这样:
- 需求类型: 跨几个业务仓
- 契约版本: product-contracts@<sha-or-tag>
- 契约变更: <契约仓内路径或 id>
- 实现仓: [web, api]
- 不做什么: <链到契约变更里对应小节>
- 相关 PR: [契约 PR, …]
- 产品场景: [<编号> → e2e 用例路径]
跨仓还成立的行为,正文只放契约仓一份;代码仓只引用,别抄摘要。摘要一抄,过两天就对不上。
动公共依赖怎么走:auth-sdk 不兼容升级
和「跨业务仓」比,平台变更通常只多三样:影响谁、旧行为留多久、谁先改谁后改。不是另一套 SDD。
- 标明这是公共依赖变更,附一份手写的影响仓列表(先能用;以后再收到平台契约仓的消费者文件里)。
- 在平台契约仓写清:旧行为变成什么、什么时候弃用、要不要双跑或开关。
- 评过后冻结版本;发出去的包版本要能追到这份契约。别先发 SDK、再补 Spec。
- 消费方按「先试点、再批量」开本地变更,都引用同一契约版本。
- 关迁移窗口的条件写死:关键产品场景全过,或者有人签字的例外清单。
平台压力大时,窗口期内至少跑通试点仓的场景和平台冒烟;真要关窗,再看全量关键场景。
模拟一下:Acme 微服务地图里走几遍
原则说过了。下面把目录、变更、PR 顺序写具体。Acme 按常见电商微服务拆仓——不是两个仓凑合演示,而是一张能对照真实拆分的地图。先摆工作区,再走「只动本仓」「跨多个微服务」「动公共 SDK」三条活。
工作区:契约仓 + 一堆业务微服务
普通 git 仓即可。每个业务服务一个仓(也可用 monorepo 子目录,协议一样:跨边界行为仍进契约仓)。
~/acme/
# —— 契约与验收 ——
product-contracts/ # 产品契约:跨服务仍成立的行为、用户路径
platform-contracts/ # 平台契约:auth-sdk 等公共依赖的兼容说明
shop-e2e/ # TE 维护的产品级用例(跨服务黑盒)
# —— 客户端 ——
shop-web/ # 网页店面
shop-mobile/ # App(例子二可不动,对照表会用到)
# —— 业务微服务(一仓一服务)——
svc-gateway/ # BFF / API 网关:聚合结账读写
svc-catalog/ # 商品目录
svc-inventory/ # 库存
svc-pricing/ # 计价、税、运费
svc-promotion/ # 优惠券、促销规则
svc-order/ # 订单与结账状态机
svc-notification/ # 短信 / 邮件 / App 推送
# —— 平台公共依赖 ——
auth-sdk/ # 登录态、错误码(被 gateway / web / mobile 等引用)
acme-shop.code-workspace # 多根窗口:按本次变更勾需要的仓
谁大概归谁(方便你对照自家分工):
| 仓 | 大致职责 | 典型归属 |
|---|---|---|
svc-catalog | SKU、类目、详情 | 商品团队 |
svc-inventory | 可售库存、预留 | 履约 / 库存团队 |
svc-pricing | 原价、折扣后应付、税运费 | 交易定价团队 |
svc-promotion | 券码校验、规则引擎 | 营销团队 |
svc-order | 下单、支付状态、订单查询 | 交易订单团队 |
svc-gateway | 给 web/App 的聚合接口 | 体验 / BFF 小组 |
shop-web / shop-mobile | 用户界面 | 前端 / 客户端团队 |
auth-sdk | 公共鉴权 | 平台团队 |
product-contracts | 跨仓真相 | 产品 + 各域 Lead 共审 |
shop-e2e | 产品场景自动化 | TE |
多根窗口不要一次勾完全部仓。结账加券这类活,勾契约仓 + 本次会改的服务即可,例如:
{
"folders": [
{ "path": "product-contracts", "name": "product-contracts" },
{ "path": "svc-promotion", "name": "svc-promotion" },
{ "path": "svc-pricing", "name": "svc-pricing" },
{ "path": "svc-order", "name": "svc-order" },
{ "path": "svc-gateway", "name": "svc-gateway" },
{ "path": "shop-web", "name": "shop-web" }
]
}
例子一:只动本仓——优惠券输入框报错样式
需求:结账页优惠券框报错时颜色和间距难看,要改样式。校验规则、接口字段、错误码都不动。周围那些微服务一个都别碰。
| 问题 | 答案 |
|---|---|
| 别的仓也得遵守吗? | 不用 → 只动本仓 |
| 契约写哪 | 没有新的跨仓行为,留在 shop-web |
| 谁改代码 | 只有 shop-web |
| 不做什么 | 不改 gateway 字段、不改 svc-promotion 规则、不动产品场景断言 |
落地步骤:
- 在
shop-web开本地变更,例如openspec/changes/coupon-input-error-styles/。 - 提案写明「只动本仓」和那四行;不用开产品契约仓的 PR,也不用钉跨仓版本。
- 设计和任务只谈本仓组件和样式;单仓工作区就够,别把
svc-order之类勾进窗口徒增噪音。 - 本仓检查过了就合
shop-web。TE 一般不必为样式重跑整条结账场景(除非你们把视觉回归也塞进 e2e)。 - 归档本仓变更。
这类活怎样算做完:本仓 Spec/变更验收过就行。别为了「看起来正规」硬开一张空契约 PR,更别顺手改微服务。
例子二:跨多个微服务——结账输入优惠券码
需求:用户在结账页输入优惠券码。链路大致是:
shop-web
→ svc-gateway(聚合结账页数据与「应用优惠券」动作)
→ svc-promotion(校验券码、算优惠明细)
→ svc-pricing(把优惠落到应付金额)
→ svc-order(把折扣写进进行中的结账/订单)
shop-mobile、svc-catalog、svc-inventory、svc-notification 这次明确不改(写进非目标)。成功和失败的含义,从 gateway 到 web 必须一致;promotion / pricing / order 之间的字段也不能各说各话。这是典型的 跨几个业务仓。
1. 契约正文只写在产品契约仓
product-contracts/
openspec/
changes/
add-checkout-coupon/
proposal.md
specs/ # 或 delta:跨服务边界行为 + 错误含义
提案可以大致这样写:
## 需求类型
跨几个业务仓——下列仓都必须遵守同一套边界行为
## 实现仓
- shop-web
- svc-gateway
- svc-promotion
- svc-pricing
- svc-order
## 不做什么
- 不改 shop-mobile(下个迭代再跟)
- 不改 svc-catalog / svc-inventory / svc-notification
- 不改 auth-sdk,不动 platform-contracts
- 本次不做礼品卡、不做「推荐券」列表
## 产品场景
- 编号: checkout-apply-coupon
- 用例: shop-e2e/tests/checkout/apply-coupon.spec.ts
正文只写跨服务仍必须当真相的内容,例如:
- 用户侧动作:结账页提交券码
code - gateway 对外:
POST /checkout/coupon的请求/响应形状 - 服务间约定(可写事件或同步调用,二选一写清):promotion 返回的优惠明细如何被 pricing 消费;order 如何持久化
discount - 失败:
COUPON_NOT_FOUND/COUPON_EXPIRED/COUPON_NOT_APPLICABLE对用户意味着什么(web 与 gateway 一致) - 库存与目录:本次不因用券而改预留逻辑
然后开契约 PR → 各相关域 Lead Approve → 冻结例如 product-contracts@9f3c1a2。
2. 每个会改的仓只开「带引用」的实现变更
评过之后,五个实现仓可以并行开 PR。这里有一条必须写准的规矩:
- 契约正文只在
product-contracts那一份 change 里。 - 五个实现仓各自也有
proposal.md,但里面是引用(指针):钉住同一契约版本、指向同一契约 change、列出全部实现仓、写明本仓职责。 - 不要把接口字段、错误码含义、跨服务约定再抄进这五个
proposal.md。本仓怎么落地,写在各自的design.md/tasks.md。
目录不要省,每个仓各自一套;五个 proposal.md 旁注统一写成「只有引用」:
shop-web/
openspec/
config.yaml
changes/
add-checkout-coupon/
proposal.md # 只有引用,没有契约副本
design.md # 结账页券码输入、调用 gateway、错误展示
tasks.md
svc-gateway/
openspec/
config.yaml
changes/
add-checkout-coupon/
proposal.md # 只有引用,没有契约副本
design.md # 对外 POST /checkout/coupon;编排下游调用
tasks.md
svc-promotion/
openspec/
config.yaml
changes/
add-checkout-coupon/
proposal.md # 只有引用,没有契约副本
design.md # 校验券码,返回优惠明细
tasks.md
svc-pricing/
openspec/
config.yaml
changes/
add-checkout-coupon/
proposal.md # 只有引用,没有契约副本
design.md # 把优惠落到应付金额 / tax / 运费口径
tasks.md
svc-order/
openspec/
config.yaml
changes/
add-checkout-coupon/
proposal.md # 只有引用,没有契约副本
design.md # 把 discount 写进进行中的结账/订单
tasks.md
五个 proposal.md 字段对齐如下。相同的是契约版本、契约 change、实现仓列表、非目标/场景引用;不同的只有「本仓职责」。
shop-web/openspec/changes/add-checkout-coupon/proposal.md:
- 需求类型: 跨几个业务仓
- 契约版本: product-contracts@9f3c1a2
- 契约变更: openspec/changes/add-checkout-coupon
- 实现仓: [shop-web, svc-gateway, svc-promotion, svc-pricing, svc-order]
- 本仓职责: shop-web = 结账页券码输入、调用 gateway、展示错误
- 不做什么: (见契约仓该变更的非目标,此处不抄正文)
- 相关 PR: [acme/product-contracts#84]
- 产品场景: [checkout-apply-coupon → shop-e2e/tests/checkout/apply-coupon.spec.ts]
svc-gateway/.../proposal.md:同上,仅本仓职责改为
svc-gateway = 对外 POST /checkout/coupon,编排 promotion/pricing/order。
svc-promotion/.../proposal.md:同上,仅本仓职责改为
svc-promotion = 校验券码并返回优惠明细。
svc-pricing/.../proposal.md:同上,仅本仓职责改为
svc-pricing = 把优惠落到应付金额 / tax / 运费口径。
svc-order/.../proposal.md:同上,仅本仓职责改为
svc-order = 把 discount 写进进行中的结账/订单。
多根窗口只打开契约仓 + 你正在改的那一两个服务。规则写死:先拉取并核对提交号 9f3c1a2,再改代码;别凭本仓旧 DTO「猜」邻居字段。
3. 合入顺序、换成最终版本、测试、归档
微服务多了,合入顺序按依赖方向,而不是谁先写完谁先合:
| 顺序 | 做什么 | 过不去的情况 |
|---|---|---|
| 1 | 合并契约 PR(可以 squash) | 得到最终版本,例如 tag contracts-checkout-coupon-2026-10-27 → 提交 c0ffee01 |
| 2 | 五个实现 PR 的引用都从 @9f3c1a2 改成 @c0ffee01 | CI:必须等于合并后的版本 |
| 3 | 先合 svc-promotion、svc-pricing(先提供优惠与计价能力) | 本仓对照钉住的 Spec |
| 4 | 再合 svc-order(消费上两步的结果写进结账) | 同上 |
| 5 | 再合 svc-gateway(对外暴露稳定聚合接口) | 同上 |
| 6 | 最后合 shop-web(对接 gateway) | 同上 |
| 7 | TE 在 shop-e2e 跑 apply-coupon(可编排起一整条调用链);用例写明 contracts@c0ffee01 | 和契约验收要点能对上 |
| 8 | 先归档各实现仓,再归档契约;补规则(例如「优惠券错误含义禁止只改 web」) | 归档时回写 |
svc-catalog、svc-inventory、shop-mobile 这次没有实现 PR——不是忘了,是非目标里写死了。下次若 App 要跟,另开变更,继续引用同一份(或更新后的)契约版本即可。
若 Approve 之后、merge 之前又有人改了契约含义:重新评审,换冻结提交号,五个实现 PR 一起改。仓越多,靶子偷着挪的代价越大。
例子三(对照):动公共依赖——auth-sdk 错误码不兼容
需求:auth-sdk 把一批登录错误码改成新枚举,旧码废弃。消费方不只是 shop-web,还包括 svc-gateway、shop-mobile,以及任何直接引用 SDK 的服务。
| 问题 | 答案 |
|---|---|
| 类型 | 动公共依赖 |
| 契约写哪 | platform-contracts(兼容说明、迁移窗口、旧码对照表) |
| 影响面(手写清单起步) | auth-sdk、svc-gateway、shop-web、shop-mobile;若 svc-order 也直连 SDK 就写上 |
| 这次仍可不碰 | svc-catalog、svc-inventory、svc-promotion…(以实际引用为准) |
| 怎么推进 | 平台契约评过并冻结 → 发带版本号的 SDK → 先试点 gateway+web → 再批量 mobile 等 → TE 跑登录相关产品场景 → 关窗 |
细节仍按上文「动公共依赖怎么走」:制品版本要能追到平台契约;窗口期内试点场景要过。
同一张地图上,五条活怎么选路
| 需求 | 类型 | 大概动哪些仓 | 要不要契约 PR 和钉版本 | 怎样算做完 |
|---|---|---|---|---|
| 优惠券输入框样式 | 只动本仓 | 仅 shop-web | 不要 | 本仓变更验收过 |
| 商品详情页多一个展示字段(仅 catalog 内部模型) | 只动本仓 | 仅 svc-catalog | 不要(若对外 API 不变) | 本仓验收过 |
| 结账优惠券码 | 跨几个业务仓 | 契约仓 + promotion + pricing + order + gateway + web(+ e2e) | 要;评过冻一版,合完对齐最终版 | 产品场景跑通整条链 |
| App 结账页跟优惠券(web 已上) | 跨几个业务仓 | 契约仓(可增补客户端差异)+ gateway(若需)+ shop-mobile | 要;引用已有或增补后的契约版本 | App 场景对照同一契约 |
| auth-sdk 错误码不兼容 | 动公共依赖 | 平台契约 + auth-sdk + gateway/web/mobile 等消费方分批 | 要;外加影响面和迁移窗口 | 试点场景过;关窗看关键登录场景或签字例外 |
注意中间两行:「只动 svc-catalog 内部」和「结账加券跨五六个仓」都可能发生在同一套微服务里——差别不在仓多不多,而在后果有没有溢出到别的仓。
做事顺序还是:先分类型 → 只动本仓就留在本仓;跨业务仓或动公共依赖,先把契约正文和冻结版本写进对应契约仓 → 各仓带着引用实现 → TE 对照同一版本验收 → 最后归档。以后若开 Stores,不过是多一种登记契约仓、生成索引的方式,钉版本和 PR 顺序不用换。
跨仓需求:OpenSpec 命令和提示语怎么跟做
前面讲清了仓和目录。这一节只盯跨几个业务仓(以结账加券为例),把你在终端和 AI 对话框里实际敲什么写清楚。
默认按稳定轨:每个仓自己有 openspec/,契约仓是普通 git 仓 product-contracts,不用 Stores beta。命令以当前主流 OPSX 工作流为准;在 Cursor 里斜杠命令常写成 /opsx-propose(带连字符),在 Claude Code 一类工具里常写成 /opsx:propose。意思一样,以你项目里 openspec init / openspec update 生成的技能名为准。下文稳定轨用到的 init / new change / status / instructions / validate / archive 在常见安装上都能用;若你本机还没有 store / workset / doctor 一类命令,多半是版本偏旧或未开 Multi-repo(beta)——稳定轨协议不依赖它们,需要时再升级 @fission-ai/openspec。
0. 每个仓先初始化一次
在契约仓和每个会改代码的仓里各做一次(已做过可跳过):
cd ~/acme/product-contracts && openspec init --tools cursor
cd ~/acme/svc-promotion && openspec init --tools cursor
# gateway / pricing / order / shop-web 同理
实现仓的 openspec/config.yaml 务必带上「先读钉住的契约、提案只留引用」——前文已有示例。没有这几行,Agent 很容易在本仓 proposal.md 里又写一份契约正文,那就偏了。
建议每人本机用多根窗口打开:product-contracts + 当前正在改的一两个服务仓。
1. 先在产品契约仓开变更(正文只在这里)
cd ~/acme/product-contracts
git checkout -b change/add-checkout-coupon
方式 A(推荐,对话里一条龙出规划产物):
在 Cursor 里对 product-contracts 工作区说:
/opsx-propose add-checkout-coupon
这是跨几个业务仓的变更。请按下面约束写规划产物:
需求:结账页支持输入优惠券码。
调用链:shop-web → svc-gateway → svc-promotion → svc-pricing → svc-order。
实现仓必须包含:shop-web, svc-gateway, svc-promotion, svc-pricing, svc-order。
明确不改:shop-mobile, svc-catalog, svc-inventory, svc-notification, auth-sdk。
proposal.md 要写清:需求类型=跨几个业务仓、实现仓列表、非目标、产品场景编号 checkout-apply-coupon。
specs 只写跨服务仍成立的边界行为、错误码含义、gateway 对外接口形状、promotion/pricing/order 之间如何交接。
不要写各仓内部类怎么拆、表怎么建——那些属于各实现仓的 design/tasks。
design.md 可以写跨服务时序,但不要变成五个仓的任务合订本。
tasks.md 若在契约仓出现,只保留「契约评审 / 版本冻结 / 通知各仓开实现变更」这类协调任务,不要写具体改哪个服务的代码。
方式 B(CLI 只建骨架,再让 AI 按状态补产物):
cd ~/acme/product-contracts
openspec new change add-checkout-coupon
openspec status --change add-checkout-coupon
openspec instructions proposal --change add-checkout-coupon
然后把 instructions 的输出连同上面那段约束贴给 Agent,让它先写 proposal.md,再按依赖补 specs / design / tasks。这是 core 配置下最稳的 Path B。
默认 openspec init 装的是 core 工作流(propose / explore / apply / archive)。/opsx-continue 和 /opsx-ff 都属于 expanded 命令:要先 openspec config profile 勾选后再在该仓跑 openspec update,Cursor 里才会出现对应斜杠命令。启用之后才可以用:
/opsx-continue add-checkout-coupon # 一次只出一个规划产物
/opsx-ff add-checkout-coupon # 一次出齐规划产物(想清楚了再用)
自检:
openspec status --change add-checkout-coupon
openspec validate add-checkout-coupon
人审过契约 PR 并 Approve 后,马上记下冻结版本(评审那一刻的提交号),写进各实现仓提案:
git rev-parse HEAD # 假设得到 9f3c1a2
契约 PR merge(可 squash)后,再取最终提交号(文中示例 c0ffee01),各实现仓要把引用改成最终版再合主干。
2. 在每个实现仓开「带引用」的变更(不要抄契约正文)
五个仓流程相同,只换目录和「本仓职责」。以 svc-promotion 为例:
cd ~/acme/svc-promotion
git checkout -b change/add-checkout-coupon
对话提示语(务必强调指针):
/opsx-propose add-checkout-coupon
本仓是实现仓,不是契约宿主。
请先阅读并遵守产品契约:
仓:~/acme/product-contracts
变更目录:openspec/changes/add-checkout-coupon
冻结版本:product-contracts@9f3c1a2
(可用 git -C ~/acme/product-contracts show 9f3c1a2:openspec/changes/add-checkout-coupon/proposal.md 核对)
本仓 proposal.md 只能写引用字段,禁止复制接口字段表、错误码含义、跨服务约定正文。
必须包含:
- 需求类型: 跨几个业务仓
- 契约版本: product-contracts@9f3c1a2
- 契约变更: openspec/changes/add-checkout-coupon
- 实现仓: [shop-web, svc-gateway, svc-promotion, svc-pricing, svc-order]
- 本仓职责: svc-promotion = 校验券码并返回优惠明细
- 不做什么: 见契约仓该变更(此处只引用)
- 相关 PR / 产品场景: 与契约仓一致
design.md / tasks.md 只写 svc-promotion 怎么落地与怎么测。
不要改 shop-web、gateway、pricing、order 的代码。
其余四仓把「本仓职责」换成:
| 仓 | 提示语里的本仓职责 |
|---|---|
shop-web | 结账页券码输入、调用 gateway、展示错误 |
svc-gateway | 对外 POST /checkout/coupon,编排 promotion/pricing/order |
svc-pricing | 把优惠落到应付金额 / tax / 运费口径 |
svc-order | 把 discount 写进进行中的结账/订单 |
也可用 CLI:
cd ~/acme/svc-promotion
openspec new change add-checkout-coupon
openspec status --change add-checkout-coupon
再让 Agent 按 openspec instructions proposal --change add-checkout-coupon 的模板写,但你必须在提示语里重复「只引用、不抄正文」。
五个仓的目录都应各自出现:
<repo>/openspec/changes/add-checkout-coupon/
proposal.md # 引用
design.md # 本仓设计
tasks.md # 本仓任务
(以及 schema 需要的 specs 等)
3. 按依赖方向实现代码
规划产物齐了、契约已冻结后,在各实现仓跑实现(仍在该仓目录 / 对应工作区根下):
/opsx-apply add-checkout-coupon
建议顺序与合入顺序一致,避免下游对着不存在的上游行为空写:
svc-promotion、svc-pricingsvc-ordersvc-gatewayshop-web
某个仓中途发现契约写错了:先改契约仓(必要时 /opsx-update add-checkout-coupon),重新评审并更换冻结版本,五个实现仓的 proposal.md 里的 契约版本 一起改,再继续 /opsx-apply。不要只在某一个服务仓里「本地纠正」跨服务语义。
实现过程中可随时:
openspec status --change add-checkout-coupon
openspec show add-checkout-coupon
4. 契约 merge 后换最终版本,再合实现 PR
# 在 product-contracts,merge 之后
git rev-parse HEAD # 得到最终版,如 c0ffee01
每个未合的实现仓提案里把:
契约版本: product-contracts@9f3c1a2
改成:
契约版本: product-contracts@c0ffee01
CI 应拦截「契约已进主干、实现 PR 仍挂旧 SHA」。
5. TE 验收与归档
TE 在 shop-e2e 跑 checkout-apply-coupon,用例元数据写明 contracts@c0ffee01。
归档顺序:先各实现仓,再契约仓。
cd ~/acme/svc-promotion && openspec archive add-checkout-coupon -y
# pricing / order / gateway / shop-web 同理
cd ~/acme/product-contracts && openspec archive add-checkout-coupon -y
对话里也可用 /opsx-archive add-checkout-coupon(工具拼写以本地技能为准)。实现仓若本次没有要合并进主 specs 的 delta,可按需要加 --skip-specs;契约仓通常要合并 delta。以 openspec archive --help 为准。
6. 一张对照表:跨仓时「在哪个仓敲什么」
| 步骤 | 在哪个仓 | 你做什么 |
|---|---|---|
| 写跨服务行为 | product-contracts | /opsx-propose + 跨仓约束提示语;人审 PR;冻结 SHA |
| 开实现变更 | 五个实现仓各一次 | /opsx-propose,proposal 只引用;design/tasks 只谈本仓 |
| 写代码 | 五个实现仓按依赖顺序 | /opsx-apply |
| 契约进主干后 | 五个实现仓 | 把 契约版本 改成最终 SHA,再合 PR |
| 产品验收 | shop-e2e | 跑场景,标注同一契约版本 |
| 收尾 | 先实现仓、后契约仓 | openspec archive … -y 或 /opsx-archive |
容易做错的两件事:
1)在 shop-web 或某个 svc-* 里把 API 字段表写进 proposal.md(应写在契约仓 specs);
2)五个仓各自 /opsx-propose 出五份互相矛盾的「跨服务真相」。跨服务真相只能有一份,在 product-contracts。
Harness:开了好几个文件夹,不等于上下文钉住了
跨仓丢上下文,常被怪成「模型记性差」。更有用的分法是三层:
| 层 | 是什么 | 跨仓时谁管 |
|---|---|---|
| 落在仓里的 | 契约仓的 specs/changes、钉住的版本、各仓自己的变更 | OpenSpec + git |
| 编辑器里看得见的 | 一次打开了哪些目录 | 多根 .code-workspace(或可选 workset) |
| 聊天里临时想的 | 当前对话里的理解 | 不能当跨仓真相 |
多根窗口只解决「看得见」。防漂移靠的是:冻结的契约版本、写代码前强制先读仓里的材料、再加上 CI 或规则检查。这和 Harness Engineering 的 Guides/Sensors,以及 ai4se-harness 里「先对齐 → 对照 Spec 验证 → 归档回写」是一回事。第一天不必上全套框架,几条写清楚的规则加一两个 CI 检查就够起步。
| 阶段 | 事先约定 | 事后检查 |
|---|---|---|
| 开干前 | 拉到钉住的契约,先读「不做什么」 | 找不到契约仓或提交号对不上 → 拦住 |
| 实现中 | 对外接口或事件有变,必须先改契约 | 动了对外面却没更新版本引用 → 失败 |
| 合入前 | 契约已进主干,引用是合并后的版本 | CI 校验 |
| 归档后 | 把教训写回规则,删掉过时规则 | 先用人肉清单也行 |
换仓继续干时:跨业务仓的活,两边都重读同一份产品契约版本;动公共依赖时,还要带上「现在是试点还是批量」,以及迁移窗口关没关。
怎样算产品做完:场景说明和可跑用例
两个仓单测都绿了,不等于产品做完。验收建议拆开:
| 层 | 写什么 | 放哪 |
|---|---|---|
| 场景说明 | 人和 Agent 都能读的跨模块「给定 / 当 / 则」 | 产品契约仓(写在当前变更里,或归档进 openspec/specs/)。普通 OpenSpec 材料就行,别发明新文件类型。 |
| 可跑用例 | 自动化脚本、数据、环境 | TE 维护的 e2e 仓 |
两边要能对上:场景说明写用例路径或编号;用例写明对照的 contracts@版本 和场景编号。缺一边,发布就不该过。OpenSpec 不负责跑用例,那是 CI 和测试工程的事。
少抄两份旅程正文:
- 面向用户的产品场景,只放在产品契约仓;
- 平台契约仓只放兼容、迁移类场景;
- 需要时产品侧变更引用平台契约的某个版本,而不是再抄一遍。
业务多仓产品通常一个产品 e2e 仓就够;平台很重时,再按影响面加消费方冒烟、分批跑。更广的 E2E 讨论见 AI4SE E2E 最佳实践。
尽量别这样干
- 把跨仓行为的 Spec 塞进某个「主业务仓」,别的仓变成二等公民
- 每个仓复制同一份契约,指望评审能对齐
- 把实现任务清单写进契约仓,规划层变成合订本
- 以为开了多根窗口或 workset,契约就有主、版本就钉住了
- 契约和代码靠口头约定同一波发布,没有冻结版本
- 用「各仓单测都绿」冒充产品做完
- 平台先发包,再补兼容说明
- 以为必须先学会 Stores beta 才能按这套做
动手前勾一遍
- 这条是「只动本仓」「跨几个业务仓」,还是「动公共依赖」?四行写进提案了吗?
- 契约正文在哪个仓?评过时冻过版本、合完后换成最终版了吗?
- 各代码仓的变更是否只有引用、没有抄契约正文?
- 配置或规则里有没有写「写代码前先读钉住的契约」?
- TE 的场景说明和可跑用例,是不是指向同一契约版本?
- 若是公共依赖:影响面、迁移窗口、分批和关窗条件写清了吗?
目录以后再磨也行。先把变更顺序跑通,再用规则把上下文钉在仓里的材料上。Stores 可有可无——普通契约仓,今天就能开工。
参考
- OpenSpec Stores(beta):https://openspec.dev/docs/stores
- OpenSpec Worksets(beta):https://openspec.dev/docs/worksets
- 站内:多仓契约归属、SDD 真相源、Harness、ai4se-harness、三层交付栈