Skip to content

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 的 RequirementADR 记"决策了什么",spec 记"当前系统必须满足什么",随代码演进
ESLint 规则docs/coding-standards.md + scripts/check_project.dart规则写在文档里,由自研脚本用 analyzer 强制,不是靠 review 自觉
组件库 Storybook 文档openspec/specs/he-* / component-* spec + he_components组件行为也有 spec,改组件前先读 spec
Cursor Rules / .cursorrulesAGENTS.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 行)是标准范式:

markdown
# 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 和人都照此实现
ScenarioWHEN … THEN … 逐条列场景可直接翻译成测试用例

3.2 为什么要写成 SHALL + WHEN/THEN ​

不是为了好看,是为了能被机器和 AI 消费:

  • SHALL 是硬约束——AI 读到 "UI SHALL NOT access auth storage directly",才不会图省事在 Widget 里直接读 TokenStorage
  • WHEN/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 和代码不一致时听谁的 ​

判断顺序:

  1. 代码跑了很久且线上无问题,spec 没更新 → 大概率是 spec 落后,改 spec,并在 change 里说明
  2. 代码刚改完还没合 → 以 change 的 design.md 为准,合入时同步 spec
  3. 两边都说不清 → 去看 uni-app 老实现(见 33-uni-app对照查证),老项目的行为是最终裁判

四、change:怎么提一个跨模块改动 ​

AGENTS.md:54 一条硬性要求:

跨模块任务先在 openspec/changes/ 建 change,包含 proposal / design / tasks,并写清字段、样式与交互决策。

4.1 四件套 ​

文件写什么谁看
proposal.mdWhy / What Changes / Capabilities / Impactreviewer 判断该不该做、影响面
design.md技术决策:数据结构、状态机、分层落点、取舍实现者(人和 AI)
tasks.md打勾清单,颗粒度到文件与字段执行与验收
specs/delta:对哪些 capability 做增删改合入后回写 openspec/specs/

4.2 proposal 的五段式 ​

以 openspec/changes/add-device-list/proposal.md(42 行)为例:

markdown
## 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 从占位页替换为设备页

值得学的三点:

  1. Why 里写清"边界"——"只做 B 端,C 端拆到另一个 change"。这是防止 scope creep 最有效的一句。
  2. Capabilities 区分 New / Modified——改动是扩展既有能力还是引入新能力,直接决定要不要新建 spec 目录。
  3. Impact 列到文件级——reviewer 不用读代码就知道会动哪些地方。

4.3 tasks 的颗粒度 ​

add-device-list/tasks.md(73 行)长这样:

markdown
## 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:

yaml
---
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-usageFigma 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 索引。这个设计有三个好处:

  1. 上下文省钱——接接口时不会把"Figma 组件落地"那套规则一起塞进上下文
  2. 规则可独立演进——改主题规则只动 theme-usage,不用碰全局文件
  3. 触发条件显式——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 站) = 教学与索引,把规则背后的"为什么"讲清楚,并标注 文件:行号 方便跳转核对。

七、一次完整改动的标准流程 ​

bash
# 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 要列到文件
改完不回写 specopenspec/specs/ 与代码分叉,AI 按旧契约写新代码把"回写 spec"作为 tasks.md 的最后一条
在 AGENTS.md 里堆规则入口文件膨胀、和 docs/ 分叉入口只放命令、红线、索引
单模块小改动也硬套 change为了改一行文案写了 4 个 md判断标准见 4.4:能不能被一句 SHALL 描述
Scenario 写得像实现步骤WHEN 用户点击 THEN 调用 xxxApi()Scenario 描述可观察的行为,不描述内部调用
不看 spec 直接问 AIAI 按通用 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 该怎么跟着拆

相关文档 ​