32 · 规格驱动开发与 AI 协作:openspec / skills / docs
读这篇的前提:你已经看完
07-项目架构总览,知道lib/分层。 本篇回答的是另一个问题——这个项目的"怎么改代码"是被谁管着的。 答:三件套openspec/(规格与变更)、.agents/skills/(固化给 AI 的流程)、docs/(强制规则的唯一真源)。
开篇:前端概念对照
| 你熟悉的前端概念 | 本项目对应物 | 差异点 |
|---|---|---|
| RFC / 技术方案文档 | openspec/changes/<name>/proposal.md + design.md | 不是 Word 长文,是结构化四件套,且与代码同仓库、同 PR |
| ADR(架构决策记录) | openspec/specs/<capability>/spec.md 的 Requirement | ADR 记"决策了什么",spec 记"当前系统必须满足什么",随代码演进 |
| ESLint 规则 | docs/coding-standards.md + scripts/check_project.dart | 规则写在文档里,由自研脚本用 analyzer 强制,不是靠 review 自觉 |
| 组件库 Storybook 文档 | openspec/specs/he-* / component-* spec + he_components | 组件行为也有 spec,改组件前先读 spec |
Cursor Rules / .cursorrules | AGENTS.md + .agents/skills/*/SKILL.md | 按场景加载 skill,不是一坨全局规则 |
| 需求拆 task(Jira 子任务) | openspec/changes/<name>/tasks.md | 打勾式清单,颗粒度到"改哪个文件、加哪个 i18n key" |
一句话差异:前端项目的规范大多"写在文档里、靠人遵守";这个项目把规范写成了机器可消费的规格 + 可执行脚本 + 可被 AI 加载的 skill,因为主力开发方式是 AI 结对 + uni-app 迁移,人和 AI 都需要一份不会走样的真源。
一、为什么这个项目需要规格驱动
三个现实约束叠加,才有了这套机制:
| 约束 | 具体表现 | 没有规格会怎样 |
|---|---|---|
| uni-app 迁移项目 | lib 内 1368 处 uni-app 注释、282 处 .vue 引用,行为要 1:1 对齐老项目 | "对齐"是口头约定,三个月后没人说得清当初对齐了什么 |
| AI 结对写代码 | 大量代码由 AI 生成,AI 不知道 uni-app 的历史包袱 | AI 每次都重新发明一套写法,或者"优化"掉刻意保留的历史行为 |
| 多端 + 多角色 | B 端(安装商)/ C 端(终端用户)共用页面槽位,靠 NahEmsRole 分流 | 改 B 端悄悄弄坏 C 端,因为契约只存在于写代码的人脑子里 |
所以 openspec/ 里有 87 个能力 spec、59 个变更单。这不是形式主义——它是这个项目唯一能说清"当前系统应该是什么样"的地方。
二、三件套:各自管什么
haierenergy/
├── AGENTS.md # 入口:命令、红线、必读文档索引(67 行,刻意保持短)
├── docs/ # 强制规则的唯一真源(9 篇)
│ ├── coding-standards.md
│ ├── architecture.md
│ ├── theme-guide.md # Figma token / Dark / 缩放 / 字重
│ ├── figma-component-mapping.md
│ ├── iconfont.md
│ ├── i18n.md
│ ├── project-overview.md # 项目定位、保留/删除边界、命名红线
│ ├── 750-baseline-adaptation-guide.md
│ └── uniapp-migration-workflow.md # 迁移完成后删除
├── openspec/
│ ├── config.yaml # schema: spec-driven
│ ├── specs/ # 现状契约:87 个能力,每个目录一个 spec.md
│ └── changes/ # 变更提案:59 个,proposal / design / tasks / specs
└── .agents/skills/ # 10 个 skill,按场景被 AI 加载AGENTS.md:1-3 把分工说得很清楚:
本文件只保留仓库入口、命令和开工流程。强制规则的唯一真源在
docs/,不要在 AGENTS.md 里复制第二份长文档。
这条约定值得抄进你自己的项目:入口文件一旦开始复制规则,就必然和真源分叉。
三、spec:系统"当前应该是什么样"
3.1 文件长什么样
openspec/specs/auth-state/spec.md(96 行)是标准范式:
# auth-state Specification
## Purpose
定义 HaierEnergy 两步登录、会话恢复、鉴权状态路由、权限码状态和登出的应用内状态契约。
## Requirements
### Requirement: AuthViewModel is the single auth state source
`AuthViewModel` SHALL be a keepAlive Riverpod Notifier and hold the only
HaierEnergy `AuthState`. ... UI SHALL NOT access auth storage directly.
#### Scenario: Initial state
- **WHEN** the provider is first built
- **THEN** state SHALL be `AuthState.initial()` and session restoration SHALL start asynchronously
#### Scenario: Logout
- **WHEN** `logout()` is called
- **THEN** `TokenStorage.clear()` SHALL remove ... and state SHALL become `AuthState.initial()`三个固定要素,缺一不可:
| 要素 | 写法 | 作用 |
|---|---|---|
Purpose | 一段话,说清这个能力覆盖什么 | 判断"我的改动属不属于这个能力" |
Requirement | 用 SHALL / SHALL NOT 陈述不变式 | 不是"建议",是契约;AI 和人都照此实现 |
Scenario | WHEN … THEN … 逐条列场景 | 可直接翻译成测试用例 |
3.2 为什么要写成 SHALL + WHEN/THEN
不是为了好看,是为了能被机器和 AI 消费:
SHALL是硬约束——AI 读到 "UI SHALL NOT access auth storage directly",才不会图省事在 Widget 里直接读TokenStorageWHEN/THEN天然是测试用例骨架——test/下的用例基本可以照抄- 场景里可以写"保留 uni-app 的历史顺序"这种反直觉但必须保持的行为(见
auth-state/spec.md的 Session acquisition storage order:先存角色、再存记住的登录信息、再 login、再存 token、再存历史账号、最后 getAgentInfo)
最后一条尤其关键:没有 spec,这类"看起来不合理但有原因"的行为会在某次重构中被当成 bug 修掉。
3.3 87 个 spec 怎么找
命名有规律,按前缀就能定位:
| 前缀/后缀 | 含义 | 例子 |
|---|---|---|
component-* | 通用图表/交互组件契约 | component-donut-chart、component-date-picker |
he-* | he_components 组件库单组件契约 | he-button、he-dialog、he-input、he_tab_bar |
*-page、*-ui | 具体页面/UI 契约 | about-us-page、register-start-page-ui |
*-system、*-architecture | 基础设施 | i18n-system、theme-architecture、permission-system |
| 业务能力名 | 领域能力 | device-list、auth-state、data-layer、result-type |
找 spec 的口诀:改哪个能力,先 ls openspec/specs | grep 关键词。
3.4 spec 和代码不一致时听谁的
判断顺序:
- 代码跑了很久且线上无问题,spec 没更新 → 大概率是 spec 落后,改 spec,并在 change 里说明
- 代码刚改完还没合 → 以 change 的
design.md为准,合入时同步 spec - 两边都说不清 → 去看 uni-app 老实现(见
33-uni-app对照查证),老项目的行为是最终裁判
四、change:怎么提一个跨模块改动
AGENTS.md:54 一条硬性要求:
跨模块任务先在
openspec/changes/建 change,包含 proposal / design / tasks,并写清字段、样式与交互决策。
4.1 四件套
| 文件 | 写什么 | 谁看 |
|---|---|---|
proposal.md | Why / What Changes / Capabilities / Impact | reviewer 判断该不该做、影响面 |
design.md | 技术决策:数据结构、状态机、分层落点、取舍 | 实现者(人和 AI) |
tasks.md | 打勾清单,颗粒度到文件与字段 | 执行与验收 |
specs/ | delta:对哪些 capability 做增删改 | 合入后回写 openspec/specs/ |
4.2 proposal 的五段式
以 openspec/changes/add-device-list/proposal.md(42 行)为例:
## Why
当前设备列表功能仅存在于 uniapp 旧项目(app_haier_energy_master/pages/agent/device/index.vue),
Flutter 分中心 App 尚未实现该核心设备管理入口。旧项目中 B 端与 C 端设备页混在同一 vue 文件,
本变更只承接 B 端安装商设备列表,C 端内容拆分至独立变更 add-user-device-list。
## What Changes
- 新增设备列表数据层:DeviceApiService(Retrofit)对接 POST /meta/device/app/getDevicePage ...
- 新增 B 端设备列表 UI:按 MVVM 新增 ui/device/ ...
- 多语言:补齐 i18n key 至 assets/i18n/zh-CN.json
## Capabilities
### New Capabilities
(无。本变更不引入新能力,字典服务由 add-sys-dict-service 提供。)
### Modified Capabilities
- device-list: 将既有 uniapp 业务规约落地为 Flutter B 端实现 ...
## Impact
- 依赖变更:先行依赖 add-sys-dict-service;与 add-user-device-list 并列
- 新增代码:lib/data/services/api/device/device_api_service.dart ...
- 路由/Tab:将 nahui-ems device Tab 从占位页替换为设备页值得学的三点:
- Why 里写清"边界"——"只做 B 端,C 端拆到另一个 change"。这是防止 scope creep 最有效的一句。
- Capabilities 区分 New / Modified——改动是扩展既有能力还是引入新能力,直接决定要不要新建 spec 目录。
- Impact 列到文件级——reviewer 不用读代码就知道会动哪些地方。
4.3 tasks 的颗粒度
add-device-list/tasks.md(73 行)长这样:
## 2. 设备列表数据层
- [x] 2.1 新增 lib/data/services/api/models/device/device_item_dto.dart
(freezed,对齐 apiMock/getDevicePage records 字段:id/hrSn/deviceSn/...)
- [x] 2.5 新增 device_api_service.dart(Retrofit):getDevicePage(@POST(devicePage))...
- [x] 2.8 新增 lib/config/providers/device_providers.dart:deviceRepositoryProvider
按 useMockApiProvider 注入
- [x] 2.9 运行 fvm dart run build_runner build --delete-conflicting-outputs
- [x] 2.10 编写 test/data/repositories/device/device_repository_test.dart颗粒度标准:一条 task = 一个文件级动作 + 一句验收说明。注意 2.9 和 2.10 是独立 task——"跑代码生成"和"补测试"在这个项目里不配省略,写成 task 就是给 AI 的显式提醒。
4.4 什么时候必须建 change
| 情况 | 要不要建 change |
|---|---|
跨模块(动到 data/ + ui/ + 路由 + i18n) | 必须 |
| 新增/修改一个 spec 能力的行为契约 | 必须 |
| 单页面内改样式、改文案、修 bug | 不用,直接改 |
| 只加一个接口 + 一个页面且已有 spec 覆盖 | 可省 proposal,但 tasks 仍建议写 |
| 重构(改分层、改 Provider 结构) | 必须,且 design.md 要写清迁移路径 |
判断口诀:改动能不能被一句 "SHALL" 描述? 能,就说明它动了契约,就该走 change。
五、skills:把流程固化给 AI
.agents/skills/ 下 10 个 skill,每个是一个带 frontmatter 的 SKILL.md:
---
name: api-integration-flow
description: >
接入新后端接口的完整流程(CBS BFF + retrofit + dio + riverpod + freezed)。
当用户提到:加接口、接入新 API、新增网络请求、对接后端、加 ApiService 方法、
加 Repository 方法、加 Service 方法、新增业务模块数据层、改请求体、改响应解析、
Mock 切换、加 CancelToken 等场景时使用此 Skill。
---| Skill | 什么时候会命中 |
|---|---|
api-integration-flow | 加接口、加 Repository/ApiService 方法、改响应解析、Mock 切换、401 排查 |
common-component-usage | 用 he_components 的通用组件拼页面 |
he-component-from-figma | 从 Figma 落地新组件,或对齐既有 He 组件 |
figma-iconfont-mapping | 从 Figma 构建页面时把图标名映射成 AppIcon,并标出未命中图标 |
theme-usage | Figma Variables 同步、新增 token、主题取值 |
flutter-gen-assets | 新增/导出图片、强类型引用、透明通道校验 |
system-permission | 定位、相机、相册、通知等系统权限 |
flutter-add-widget-test | 补组件渲染与交互测试 |
flutter-fix-layout-issues | 布局约束、溢出、TDesign 布局异常 |
git-commit-message | 写提交说明、amend、修乱码 |
为什么要单独做 skill,而不是全塞进 AGENTS.md
AGENTS.md 只有 67 行,它在 AGENTS.md:43-51 只做 skill 索引。这个设计有三个好处:
- 上下文省钱——接接口时不会把"Figma 组件落地"那套规则一起塞进上下文
- 规则可独立演进——改主题规则只动
theme-usage,不用碰全局文件 - 触发条件显式——
description里写清"当用户提到 XXX 时使用",AI 才知道何时加载
对你(人)的意义:这些 skill 的正文就是项目在各场景下的最佳实践。 即便你不写代码给 AI 跑,遇到"要接接口了"去读一遍
api-integration-flow/SKILL.md,比翻 07/09 两篇还快。
六、docs/:规则的唯一真源
AGENTS.md:32-41 给了一张按场景查文档的表格:
| 场景 | 必读 |
|---|---|
| 任何代码修改 | docs/coding-standards.md |
| 架构、数据层、鉴权、权限、分页、错误链路 | docs/architecture.md |
| Figma token、主题、Dark、缩放、字重 | docs/theme-guide.md |
| Figma 组件映射 | docs/figma-component-mapping.md |
| iconfont 动态图标管理 | docs/iconfont.md |
| 多语言(i18n)管理 | docs/i18n.md |
| 项目定位、保留/删除边界、命名红线 | docs/project-overview.md |
| uni-app 老项目迁移任务(迁移完成后删除) | docs/uniapp-migration-workflow.md |
和本系列文档的分工:
docs/(项目内) = 强制规则、红线、当前真值。冲突时以它为准。- 本系列(doc 站) = 教学与索引,把规则背后的"为什么"讲清楚,并标注
文件:行号方便跳转核对。
七、一次完整改动的标准流程
# 1. 先看契约:这个能力现在是什么样
ls openspec/specs | grep <关键词> # 找到 spec
cat openspec/specs/<capability>/spec.md
# 2. 看老实现(迁移场景下必做)
# 见 33-uni-app对照查证:按 routes.dart 的 vue 注释定位原页面
# 3. 跨模块改动:建 change
mkdir -p openspec/changes/<my-change>
# 写 proposal.md(Why / What Changes / Capabilities / Impact)
# 写 design.md(技术决策)
# 写 tasks.md(文件级打勾清单)
# 4. 实现:按场景加载 skill,按 docs/ 红线写
fvm dart run build_runner build --delete-conflicting-outputs # 改了模型/Provider/ApiService 后必跑
# 5. 自证清白
bash scripts/precheck.sh # 统一守护:check_project.dart + 格式 + 分析
# 6. 回写契约:把 delta 合回 openspec/specs/<capability>/spec.md第 6 步最容易被跳过。跳过它,spec 就从这个 commit 开始腐烂,下一个接手的人(或 AI)会读到错误契约,然后写出错误代码——这就是规格驱动最常见的死法。
八、常见坑
| 坑 | 表现 | 怎么避 |
|---|---|---|
| 把 change 写成流水账 | proposal.md 只有 What、没有 Why 和 Impact | 五段式缺一不可,尤其 Impact 要列到文件 |
| 改完不回写 spec | openspec/specs/ 与代码分叉,AI 按旧契约写新代码 | 把"回写 spec"作为 tasks.md 的最后一条 |
| 在 AGENTS.md 里堆规则 | 入口文件膨胀、和 docs/ 分叉 | 入口只放命令、红线、索引 |
| 单模块小改动也硬套 change | 为了改一行文案写了 4 个 md | 判断标准见 4.4:能不能被一句 SHALL 描述 |
| Scenario 写得像实现步骤 | WHEN 用户点击 THEN 调用 xxxApi() | Scenario 描述可观察的行为,不描述内部调用 |
| 不看 spec 直接问 AI | AI 按通用 Flutter 最佳实践写,违反项目约定 | 开工前先让 AI 读 spec + 对应 skill |
九、自检清单
- [ ] 能说出
openspec/specs/与openspec/changes/的区别(现状契约 vs 变更提案) - [ ] 能按前缀(
he-*/component-*/*-page)在 87 个 spec 里定位到目标能力 - [ ] 能把一个跨模块需求写成 proposal 五段式,写清 Capabilities 是 New 还是 Modified
- [ ] 知道 tasks.md 的颗粒度标准,并且会把"跑 build_runner""补测试"单独列为 task
- [ ] 能判断"这个改动要不要建 change"
- [ ] 知道改完代码要回写 spec
- [ ] 知道 10 个 skill 各自的触发场景,接接口时会先读
api-integration-flow - [ ] 知道冲突时以项目
docs/为准,本系列文档只负责讲清为什么
下一步
33-uni-app对照查证—— 迁移场景下最高频的动作:怎么找到老 vue 实现并核对行为16-工程化与调试——scripts/precheck.sh与check_project.dart守护了哪些红线30-包与插件开发—— 组件库抽离时,spec 该怎么跟着拆