Commit f659a376564dca2acdfae479a9999d74e41f7e57

Authored by yanghl
1 parent 18c17c4e

workflow: 增量需求识别(req-ledger 哈希台账 + /add-req 入口)

初始 Plan 完结后追加/修改需求时,只跑增量、不必整仓重跑。

- lib/req-ledger.mjs(+单测):对 docs/01 REQ 卡 + docs/08 §三 FE 行算 sha256 内容哈希,
  对比 .req-ledger.json,scan/commit 出 new/changed/removed/unchanged
- skills/plan/add-req:新增 user-invocable 增量入口。基线→检测→校验真实数据→
  A3-delta(写 V_n migration + 同步 docs/03,永不改 V1)→A5-delta(docs/05/02/08)→
  作废 changed 单元的 req-done/milestone tag→提交台账。Router 无需改动,缺 tag 即重跑
- 变更识别靠内容哈希(非仅 tag 缺失):改过的老 REQ 也能认出并作废其 req-done,
  否则 Router 会跳过它——OpenSpec delta 思路的零依赖落地
- 首跑无台账则先建基线,避免把存量误当新增重跑整仓
- plugin.json 注册第 9 个 skill + 版本 0.2.0→0.3.0;CLAUDE 模板 Schema 演化补增量 V_n 规约

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
.claude-plugin/plugin.json
1 1 {
2 2 "name": "erp-workflow",
3   - "description": "ERP 项目全流程框架:阶段 A 计划(plan-start 入口 + A0~A5 共 6 个 skill + B 阶段瘦入口 coding-start = 8 个 skill;plan-start 终结闸 4 项前移硬校验) + 阶段 B 编码(单个静默 Workflow 脚本 coding.mjs,子代理自动跑后端+前端功能循环、测试闸门、本地里程碑 tag)。",
4   - "version": "0.2.0",
  3 + "description": "ERP 项目全流程框架:阶段 A 计划(plan-start 入口 + A0~A5 共 6 个 skill + 增量需求入口 add-req + B 阶段瘦入口 coding-start = 9 个 skill;plan-start 终结闸 4 项前移硬校验;add-req 经 req-ledger 哈希台账识别后续新增/变更需求只跑增量) + 阶段 B 编码(单个静默 Workflow 脚本 coding.mjs,子代理自动跑后端+前端功能循环、测试闸门、本地里程碑 tag)。",
  4 + "version": "0.3.0",
5 5 "skills": [
6 6 "./skills/plan/plan-start",
7 7 "./skills/plan/project-init",
... ... @@ -10,6 +10,7 @@
10 10 "./skills/plan/db-design-gen",
11 11 "./skills/plan/db-init",
12 12 "./skills/plan/downstream-gen",
  13 + "./skills/plan/add-req",
13 14 "./skills/coding/coding-start"
14 15 ]
15 16 }
... ...
README.md
... ... @@ -95,7 +95,7 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。
95 95 ```
96 96 erp-workflow-plugin/
97 97 ├── .claude-plugin/
98   -│ └── plugin.json # 插件清单,显式列出 8 个 skill 路径
  98 +│ └── plugin.json # 插件清单,显式列出 9 个 skill 路径
99 99 ├── README.md # 本文档
100 100 ├── workflows/
101 101 │ └── coding.mjs # 阶段 B:整个编码阶段编排为单个静默 Workflow
... ... @@ -103,30 +103,33 @@ erp-workflow-plugin/
103 103 │ ├── validate-ddl.mjs # docs/03 ↔ DDL 4 维校验(替代 validate.sh)
104 104 │ ├── yaml-config.mjs # config-vars.yaml 极简 YAML 读取(2 层 map + 标量)
105 105 │ ├── apply-ddl.mjs # 解析 config-vars.yaml database: 段 + mysql2 apply
  106 +│ ├── req-ledger.mjs # 需求台账:docs/01 REQ + docs/08 §三 FE 内容哈希,scan/commit 识别新增/变更(add-req 用)
106 107 │ └── *.test.mjs # 各助手的 node:test 单测
107 108 ├── agents/
108 109 │ └── code-reviewer.md # 统一 reviewer(coding.mjs review stage 调用,phase 选维度集)
109 110 └── skills/ # 按阶段分组(slug 不变,由 SKILL.md frontmatter name 决定)
110   - ├── plan/ # 阶段 A:7 个 skill(入口 + A0~A5
  111 + ├── plan/ # 阶段 A:8 个 skill(入口 + A0~A5 + 增量入口
111 112 │ ├── plan-start/ # A 阶段入口 + Plan 终结硬闸
112 113 │ ├── project-init/ # A0
113 114 │ ├── scope-lock/ # A1
114 115 │ ├── skeleton-gen/ # A2
115 116 │ ├── db-design-gen/ # A3
116 117 │ ├── db-init/ # A4
117   - │ └── downstream-gen/ # A5(含前端 FE 清单推导,原 A6 已并入)
  118 + │ ├── downstream-gen/ # A5(含前端 FE 清单推导,原 A6 已并入)
  119 + │ └── add-req/ # 增量需求入口(Plan 完结后追加/修改需求,经 req-ledger 哈希台账只跑增量)
118 120 └── coding/ # 阶段 B:1 个 skill(瘦入口)
119 121 └── coding-start/ # 启动 workflows/coding.mjs
120 122 ```
121 123  
122   -## Skill 清单(8 个)
  124 +## Skill 清单(9 个)
123 125  
124   -### 入口(2 个)
  126 +### 入口(3 个)
125 127  
126 128 | Skill | 作用 | 谁调用 |
127 129 |---|---|---|
128 130 | `plan-start` | **A 阶段入口 + Plan 终结硬闸**。读 docs/08 § 一 找第一个未勾 A 子项 → 派发对应 A skill;A 全部完成时校验 4 项前移闸门(REQ 真实数据、`config-vars.yaml` 全部配置(含 DB 凭据 / 密钥)全锁、docs/04 § 零 命令齐、docs/05+02 已评审),全过才提示运行 `/erp-workflow:coding-start`,否则指出缺口不放行 | **用户手动** `/erp-workflow:plan-start` |
129 131 | `coding-start` | **B 阶段瘦入口**(`allowed-tools: Read Glob Workflow Bash(git ...)`)。校验 Plan 完成态(docs/08 § 一 全勾)+ 取得 projectRoot(git 就绪由 coding.mjs runBranchSetup 在运行时把守)→ 读 docs/08 § 二/§ 三 + `git tag -l 'milestone/*'` 概述阶段进度(Workflow Router 再用 `req-done/*` 判定功能级 resume)→ 调用 `Workflow({scriptPath:"${CLAUDE_PLUGIN_ROOT}/workflows/coding.mjs", args:{projectRoot}})` 启动整个编码阶段 → 告知"已在后台启动" | **用户手动** `/erp-workflow:coding-start` |
  132 +| `add-req` | **增量需求入口**(Plan 完结后追加 / 修改需求时用)。`node lib/req-ledger.mjs scan` 用内容哈希台账(`.req-ledger.json`)识别 docs/01 REQ 卡片 + docs/08 §三 FE 行的**新增 / 变更**;只对增量做 **A3-delta**(写新 `V_n` migration + 同步 docs/03,**绝不改 V1**)+ **A5-delta**(补 docs/05 端点 / docs/02 顺序 / docs/08 模块行 / FE 行),并 `git tag -d` 作废**变更**单元已有的 `req-done/*`(及所属 `milestone/*`,复位 docs/08 里程碑字段),使 coding.mjs Router 只重跑增量。首跑无台账则先建基线。 | **用户手动** `/erp-workflow:add-req` |
130 133  
131 134 ### Plan 阶段 A skill(A0~A5,共 6 个)
132 135  
... ...
lib/req-ledger.mjs 0 → 100644
  1 +// lib/req-ledger.mjs — 需求台账(增量识别)
  2 +// 跟踪 docs/01 REQ 卡片 + docs/08 §三 FE 行的内容哈希,识别后续新增 / 变更 / 删除的
  3 +// 需求单元,使 Coding 阶段只跑增量、不必整仓重跑(变更项由调用方作废其 req-done tag)。
  4 +//
  5 +// 用法(CLI):
  6 +// node lib/req-ledger.mjs scan <root> → 打印 JSON {new,changed,removed,unchanged}
  7 +// node lib/req-ledger.mjs commit <root> → 把当前哈希写入 <root>/.req-ledger.json
  8 +// node lib/req-ledger.mjs status <root> → 同 scan,但人类可读摘要打到 stderr
  9 +// 退出码:0 = 成功;2 = 用法 / 路径错误。
  10 +//
  11 +// 程序内:import { computeUnits, loadLedger, diffLedger, writeLedger } from './req-ledger.mjs'
  12 +//
  13 +// 台账文件 <root>/.req-ledger.json(随项目提交,机器状态,调用方勿手改):
  14 +// { "version": 1, "units": { "<id>": { "kind": "req"|"fe", "hash": "<sha256-12>" } } }
  15 +//
  16 +// 单元(unit)= Router 能据以 resume 的最小完成单位:
  17 +// - kind=req:后端 REQ 卡片,id = 卡片文件名(去 .md),== req_id
  18 +// - kind=fe :前端功能行,id = FE-NN(取自 docs/08 §三)
  19 +
  20 +import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs'
  21 +import { join, basename } from 'node:path'
  22 +import { createHash } from 'node:crypto'
  23 +
  24 +const LEDGER_BASENAME = '.req-ledger.json'
  25 +const REQ_DIR_REL = join('docs', '01-需求清单')
  26 +const DOCS08_REL = join('docs', '08-模块任务管理.md')
  27 +
  28 +export const ledgerPath = (root) => join(root, LEDGER_BASENAME)
  29 +
  30 +// ── 哈希:行尾归一(去 \r + 去行尾空白),整体 trim,sha256 取前 12 位十六进制 ──
  31 +export function hashContent(text) {
  32 + const norm = String(text)
  33 + .split('\n')
  34 + .map((l) => l.replace(/\r$/, '').replace(/[ \t]+$/, ''))
  35 + .join('\n')
  36 + .trim()
  37 + return createHash('sha256').update(norm, 'utf8').digest('hex').slice(0, 12)
  38 +}
  39 +
  40 +// ── 递归列出目录下所有 .md(用于 docs/01-需求清单)─────────────────
  41 +function walkMd(dir) {
  42 + const out = []
  43 + let entries
  44 + try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return out }
  45 + for (const e of entries) {
  46 + const full = join(dir, e.name)
  47 + if (e.isDirectory()) out.push(...walkMd(full))
  48 + else if (e.isFile() && e.name.endsWith('.md')) out.push(full)
  49 + }
  50 + return out
  51 +}
  52 +
  53 +// ── 提取 docs/01 REQ 卡片单元 ────────────────────────────────────
  54 +// 排除 _module.md(模块头)与 index.md(模块索引),其余 .md 文件名(去 .md)== req_id。
  55 +export function collectReqUnits(root) {
  56 + const dir = join(root, REQ_DIR_REL)
  57 + const units = new Map() // id -> { kind:'req', hash, path }
  58 + for (const file of walkMd(dir)) {
  59 + const name = basename(file)
  60 + if (name === '_module.md' || name === 'index.md') continue
  61 + const id = name.slice(0, -3) // 去 .md
  62 + units.set(id, { kind: 'req', hash: hashContent(readFileSync(file, 'utf8')), path: file })
  63 + }
  64 + return units
  65 +}
  66 +
  67 +// ── 提取 docs/08 §三 的 FE-NN 行 ─────────────────────────────────
  68 +// §三 从 `## 三、` 起,到下一个 `## ` 或文件末尾止。行形如:- [ ] FE-01 登录页
  69 +export function collectFeUnits(root) {
  70 + const docs08 = join(root, DOCS08_REL)
  71 + const units = new Map() // id -> { kind:'fe', hash, line }
  72 + if (!existsSync(docs08)) return units
  73 + const lines = readFileSync(docs08, 'utf8').split('\n')
  74 + let inSec3 = false
  75 + for (const raw of lines) {
  76 + const line = raw.replace(/\r$/, '')
  77 + if (/^##\s+三[、.]/.test(line)) { inSec3 = true; continue }
  78 + if (inSec3 && /^##\s/.test(line)) break // 进入下一 ## 章节,§三 结束
  79 + if (!inSec3) continue
  80 + const m = line.match(/^\s*-\s*\[.\]\s*(FE-\d+)\b(.*)$/)
  81 + if (!m) continue
  82 + const id = m[1]
  83 + const text = (id + ' ' + m[2].trim()).trim()
  84 + units.set(id, { kind: 'fe', hash: hashContent(text), line: text })
  85 + }
  86 + return units
  87 +}
  88 +
  89 +// ── 当前全部单元(req + fe):id -> { kind, hash } ─────────────────
  90 +export function computeUnits(root) {
  91 + const out = new Map()
  92 + for (const [id, u] of collectReqUnits(root)) out.set(id, { kind: u.kind, hash: u.hash })
  93 + for (const [id, u] of collectFeUnits(root)) out.set(id, { kind: u.kind, hash: u.hash })
  94 + return out
  95 +}
  96 +
  97 +// ── 台账读写 ─────────────────────────────────────────────────────
  98 +export function loadLedger(root) {
  99 + const p = ledgerPath(root)
  100 + if (!existsSync(p)) return null
  101 + try {
  102 + const j = JSON.parse(readFileSync(p, 'utf8'))
  103 + if (!j || typeof j !== 'object' || !j.units) return { version: 1, units: {} }
  104 + return j
  105 + } catch {
  106 + return { version: 1, units: {} }
  107 + }
  108 +}
  109 +
  110 +export function writeLedger(root, units) {
  111 + const obj = { version: 1, units: {} }
  112 + // 稳定排序,保证 git diff 可读
  113 + for (const id of [...units.keys()].sort()) {
  114 + const u = units.get(id)
  115 + obj.units[id] = { kind: u.kind, hash: u.hash }
  116 + }
  117 + writeFileSync(ledgerPath(root), JSON.stringify(obj, null, 2) + '\n', 'utf8')
  118 + return obj
  119 +}
  120 +
  121 +// ── 对比当前单元 vs 台账 → {new,changed,removed,unchanged} ─────────
  122 +// 每项为 { id, kind } 数组;台账缺失(null)时调用方应先 commit 建基线(此处一律算 new)。
  123 +export function diffLedger(ledger, units) {
  124 + const stored = (ledger && ledger.units) || {}
  125 + const res = { new: [], changed: [], removed: [], unchanged: [] }
  126 + for (const [id, u] of units) {
  127 + const prev = stored[id]
  128 + if (!prev) res.new.push({ id, kind: u.kind })
  129 + else if (prev.hash !== u.hash) res.changed.push({ id, kind: u.kind })
  130 + else res.unchanged.push({ id, kind: u.kind })
  131 + }
  132 + for (const id of Object.keys(stored)) {
  133 + if (!units.has(id)) res.removed.push({ id, kind: stored[id].kind })
  134 + }
  135 + for (const k of ['new', 'changed', 'removed', 'unchanged']) {
  136 + res[k].sort((a, b) => a.id.localeCompare(b.id))
  137 + }
  138 + return res
  139 +}
  140 +
  141 +// ── CLI ─────────────────────────────────────────────────────────
  142 +function isMain() {
  143 + try {
  144 + return process.argv[1] && import.meta.url === new URL(`file://${process.argv[1].replace(/\\/g, '/')}`).href
  145 + || (process.argv[1] && process.argv[1].endsWith('req-ledger.mjs'))
  146 + } catch { return false }
  147 +}
  148 +
  149 +if (isMain()) {
  150 + const [cmd, root] = process.argv.slice(2)
  151 + if (!cmd || !root) {
  152 + process.stderr.write('用法: node req-ledger.mjs <scan|commit|status> <projectRoot>\n')
  153 + process.exit(2)
  154 + }
  155 + if (!existsSync(root) || !statSync(root).isDirectory()) {
  156 + process.stderr.write(`错误: projectRoot 不是目录: ${root}\n`)
  157 + process.exit(2)
  158 + }
  159 + const units = computeUnits(root)
  160 + if (cmd === 'commit') {
  161 + const obj = writeLedger(root, units)
  162 + process.stdout.write(JSON.stringify({ committed: Object.keys(obj.units).length, path: ledgerPath(root) }) + '\n')
  163 + process.exit(0)
  164 + }
  165 + if (cmd === 'scan' || cmd === 'status') {
  166 + const ledger = loadLedger(root)
  167 + const diff = diffLedger(ledger, units)
  168 + if (cmd === 'status') {
  169 + const fmt = (arr) => arr.map((x) => `${x.id}(${x.kind})`).join(', ') || '无'
  170 + process.stderr.write(
  171 + `台账: ${ledger ? '已存在' : '缺失(需先 commit 建基线)'}\n` +
  172 + ` 新增: ${fmt(diff.new)}\n 变更: ${fmt(diff.changed)}\n` +
  173 + ` 删除: ${fmt(diff.removed)}\n 未变: ${diff.unchanged.length} 项\n`,
  174 + )
  175 + }
  176 + process.stdout.write(JSON.stringify({ ledgerExists: !!ledger, ...diff }) + '\n')
  177 + process.exit(0)
  178 + }
  179 + process.stderr.write(`未知命令: ${cmd}\n`)
  180 + process.exit(2)
  181 +}
... ...
lib/req-ledger.test.mjs 0 → 100644
  1 +// lib/req-ledger.test.mjs — 单测:需求台账 哈希 / 提取 / diff / 读写
  2 +import { test } from 'node:test'
  3 +import assert from 'node:assert/strict'
  4 +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
  5 +import { tmpdir } from 'node:os'
  6 +import { join } from 'node:path'
  7 +import {
  8 + hashContent, collectReqUnits, collectFeUnits, computeUnits,
  9 + loadLedger, writeLedger, diffLedger,
  10 +} from './req-ledger.mjs'
  11 +
  12 +// 搭一个最小项目目录:docs/01-需求清单/<mod>/{_module.md,index.md,<req>.md} + docs/08
  13 +function scaffold() {
  14 + const root = mkdtempSync(join(tmpdir(), 'req-ledger-'))
  15 + const reqDir = join(root, 'docs', '01-需求清单', 'USR-UserInfo')
  16 + mkdirSync(reqDir, { recursive: true })
  17 + writeFileSync(join(root, 'docs', '01-需求清单', 'index.md'), '# 索引\n| a | b |\n')
  18 + writeFileSync(join(reqDir, '_module.md'), '# 模块头\n')
  19 + writeFileSync(join(reqDir, 'USR-UserInfo-Login.md'), '# 登录\n目标: 用户登录\n')
  20 + writeFileSync(join(reqDir, 'USR-UserInfo-Logout.md'), '# 登出\n目标: 用户登出\n')
  21 + writeFileSync(join(root, 'docs', '08-模块任务管理.md'),
  22 + '## 一、Plan\n- [x] A0\n\n## 三、前端\n- 整体里程碑: —\n- 功能:\n - [ ] FE-01 登录页\n - [ ] FE-02 用户列表\n\n## 四、其它\n- [ ] FE-99 不该被收\n')
  23 + return root
  24 +}
  25 +
  26 +test('hashContent: CRLF 与行尾空白归一后哈希一致', () => {
  27 + assert.equal(hashContent('a\nb\n'), hashContent('a\r\nb \r\n'))
  28 + assert.equal(hashContent('a\nb'), hashContent(' a\nb \n\n'))
  29 + assert.notEqual(hashContent('a\nb'), hashContent('a\nc'))
  30 +})
  31 +
  32 +test('collectReqUnits: 排除 _module.md 与 index.md,其余 == req_id', () => {
  33 + const root = scaffold()
  34 + try {
  35 + const u = collectReqUnits(root)
  36 + assert.deepEqual([...u.keys()].sort(), ['USR-UserInfo-Login', 'USR-UserInfo-Logout'])
  37 + assert.equal(u.get('USR-UserInfo-Login').kind, 'req')
  38 + } finally { rmSync(root, { recursive: true, force: true }) }
  39 +})
  40 +
  41 +test('collectFeUnits: 只收 §三 的 FE 行,遇下一个 ## 即止', () => {
  42 + const root = scaffold()
  43 + try {
  44 + const u = collectFeUnits(root)
  45 + assert.deepEqual([...u.keys()].sort(), ['FE-01', 'FE-02']) // FE-99 在 §四,不收
  46 + assert.equal(u.get('FE-01').kind, 'fe')
  47 + } finally { rmSync(root, { recursive: true, force: true }) }
  48 +})
  49 +
  50 +test('computeUnits: req + fe 合并', () => {
  51 + const root = scaffold()
  52 + try {
  53 + const u = computeUnits(root)
  54 + assert.equal(u.size, 4) // 2 req + 2 fe
  55 + } finally { rmSync(root, { recursive: true, force: true }) }
  56 +})
  57 +
  58 +test('diffLedger: 台账缺失 → 全部算 new', () => {
  59 + const root = scaffold()
  60 + try {
  61 + const diff = diffLedger(null, computeUnits(root))
  62 + assert.equal(diff.new.length, 4)
  63 + assert.equal(diff.changed.length, 0)
  64 + } finally { rmSync(root, { recursive: true, force: true }) }
  65 +})
  66 +
  67 +test('writeLedger/loadLedger 往返 + diff 识别 新增/变更/删除/未变', () => {
  68 + const root = scaffold()
  69 + try {
  70 + // 建基线
  71 + writeLedger(root, computeUnits(root))
  72 + let diff = diffLedger(loadLedger(root), computeUnits(root))
  73 + assert.equal(diff.new.length, 0)
  74 + assert.equal(diff.changed.length, 0)
  75 + assert.equal(diff.unchanged.length, 4)
  76 +
  77 + // 改一张卡 → changed;加一张卡 → new;删 FE-02 一行靠改 docs/08 → removed
  78 + const reqDir = join(root, 'docs', '01-需求清单', 'USR-UserInfo')
  79 + writeFileSync(join(reqDir, 'USR-UserInfo-Login.md'), '# 登录\n目标: 用户登录(改了规则)\n')
  80 + writeFileSync(join(reqDir, 'USR-UserInfo-PwdReset.md'), '# 改密\n目标: 重置密码\n')
  81 + writeFileSync(join(root, 'docs', '08-模块任务管理.md'),
  82 + '## 一、Plan\n## 三、前端\n- 功能:\n - [ ] FE-01 登录页\n\n## 四、其它\n')
  83 +
  84 + diff = diffLedger(loadLedger(root), computeUnits(root))
  85 + assert.deepEqual(diff.new.map((x) => x.id), ['USR-UserInfo-PwdReset'])
  86 + assert.deepEqual(diff.changed.map((x) => x.id), ['USR-UserInfo-Login'])
  87 + assert.deepEqual(diff.removed.map((x) => x.id), ['FE-02'])
  88 + } finally { rmSync(root, { recursive: true, force: true }) }
  89 +})
... ...
skills/plan/add-req/SKILL.md 0 → 100644
  1 +---
  2 +name: add-req
  3 +description: 增量需求入口——初始 Plan 完结后追加 / 修改需求时运行。基于 lib/req-ledger.mjs 内容哈希台账识别新增 / 变更的 REQ 卡片与 FE 行,只对增量做 A3-delta(写 V_n migration + 同步 docs/03,绝不改 V1)+ A5-delta(补 docs/05 端点 / docs/02 顺序 / docs/08 模块行),并作废变更单元已有的 req-done/milestone tag,使 coding.mjs Router 只重跑增量、不必整仓重跑。
  4 +user-invocable: true
  5 +allowed-tools: Read Write Edit Grep Glob AskUserQuestion Bash(node *) Bash(git *) Bash(ls *) Bash(mkdir *)
  6 +---
  7 +
  8 +**所有输出必须使用中文。**
  9 +
  10 +# add-req — 增量需求识别与生成
  11 +
  12 +用于**初始 Plan(A0~A5)已完结**之后,往项目里**追加新需求**或**修改已有需求**。不重跑整条 Plan,只处理增量;产出与 A1/A3/A5 完全一致,交由 `coding.mjs` Router 增量编码。
  13 +
  14 +> 用法约定:先**人工**在 `docs/01-需求清单/` 里把新 REQ 卡片写好(真实业务内容,同 A1 填法)或修改已有卡片,再运行 `/erp-workflow:add-req`。本 skill 负责「识别 + 下游生成 + 作废过期完成标记」,不替你写业务需求本身。
  15 +
  16 +`<root>` = 项目根(含 `docs/`、`config-vars.yaml`、`sql/`、`.git`)。`${CLAUDE_PLUGIN_ROOT}` = 插件根。
  17 +
  18 +## 前置:Plan 必须已完结
  19 +
  20 +用 `Read` 读 `docs/08-模块任务管理.md § 一`。若 § 一存在任一 `- [ ]` 未勾子项 → 初始 Plan 未完结,**停下**并提示:
  21 +
  22 +```
  23 +[add-req] ⛔ 初始 Plan(A0~A5)尚未完结,增量入口暂不可用。
  24 +请先运行 /erp-workflow:plan-start 完成初始规划,再用 /add-req 追加需求。
  25 +```
  26 +
  27 +## 步骤 0:台账基线(首次启用 / 老项目迁移)
  28 +
  29 +```
  30 +node ${CLAUDE_PLUGIN_ROOT}/lib/req-ledger.mjs scan <root>
  31 +```
  32 +
  33 +解析输出 JSON 的 `ledgerExists`:
  34 +
  35 +- `false`(项目还没有 `.req-ledger.json`)→ 这是首次启用增量台账。**先建基线**:
  36 + ```
  37 + node ${CLAUDE_PLUGIN_ROOT}/lib/req-ledger.mjs commit <root>
  38 + ```
  39 + 然后打印并**停下**:
  40 + ```
  41 + [add-req] 已为现有 <N> 个需求单元建立台账基线(.req-ledger.json)。
  42 + 首次基线无法区分存量与新增,故本次不做增量生成。
  43 + 请在 docs/01 追加 / 修改需求后,再次运行 /erp-workflow:add-req。
  44 + ```
  45 + (理由:首跑若把存量全当新增,会重复生成已有的 docs/03/05 工件并误重跑整仓。基线建立后,后续改动才能被准确 diff。)
  46 +- `true` → 进入步骤 1。
  47 +
  48 +## 步骤 1:检测增量
  49 +
  50 +```
  51 +node ${CLAUDE_PLUGIN_ROOT}/lib/req-ledger.mjs scan <root>
  52 +```
  53 +
  54 +解析 `new[]` / `changed[]` / `removed[]`(每项 `{id, kind}`,kind = `req` 后端卡片 / `fe` 前端功能行)。
  55 +
  56 +- `new` 与 `changed` 均为空 → 打印 `[add-req] 无新增 / 变更需求,无需处理。` **停下**(不提交台账)。
  57 +- `removed` 非空 → **仅提示、不自动删**:打印 `检测到台账登记但 docs 已移除的单元:<列出>。下线需求请人工同步 docs/02/03/05 并删除对应 tag,本 skill 不自动执行删除。` 然后继续处理 new/changed(removed 不写回台账,留待人工,下次仍会提示)。
  58 +
  59 +## 步骤 2:校验新增 / 变更 REQ 卡真实数据(仅 kind=req)
  60 +
  61 +对 `new` ∪ `changed` 里 `kind=req` 的每个 id(卡片路径 `docs/01-需求清单/<module>/<id>.md`):`Read` + `Grep` 校验**无 `{{` 残留、无 `【人工填写`**(同 A1 E.1)。命中缺口 → 打印卡片路径 + 缺口行,用 `AskUserQuestion` 引导用户补齐后重检,直到全部为真实数据。
  62 +(`kind=fe` 的增量行来自 docs/08 §三 推导,不在此校验。)
  63 +
  64 +## 步骤 3:A3-delta — schema 增量(仅当新增/变更 REQ 影响数据模型)
  65 +
  66 +对 `new`/`changed` 的 REQ 卡片,判断是否需要**新表**或**给已有表加列**(依据卡片业务 + `依赖表`):
  67 +
  68 +1. **写增量 migration(绝不改 V1 或任何已存在 V_n)**:
  69 + - `ls sql/migrations/V*.sql` 取当前最大版本号 `n`,新文件 `sql/migrations/V<n+1>__<snake_case_desc>.sql`(如 `V2__add_order_refund.sql`)。
  70 + - 新表 → `CREATE TABLE`;给已有表加列 → `ALTER TABLE ... ADD COLUMN`。**追加式**,套用与 A3/A4 相同的命名规范、匈牙利列前缀(`i/s/t/b/d`)、标准列约定(主表 15 列 / 从表 12 列 / 基础 11 列)与 DDL 默认值翻译规则(见 `CLAUDE.md` Schema 演化规约 + db-init 约定)。
  71 +2. **同步 docs/03**:参照 `${CLAUDE_PLUGIN_ROOT}/skills/plan/db-design-gen/templates/docs-03-table-template.md`,新表追加表小节 / 已有表小节增列,保持 docs/03 为 schema SSoT。
  72 +3. **回填卡片**:把该 REQ 卡片 `依赖表:` 的 `TBD` / 占位 `Edit` 成实际表名。
  73 +
  74 +> **不在此 apply 到数据库、不跑 validate-ddl**:新 schema 由 Coding 阶段冷起栈时 Flyway 自动 apply 全部 `V*.sql`;validate-ddl 是「docs/03 ↔ 单一 V 文件」整库 4 维比对,多 migration 场景不适用。schema 一致性由 Coding 阶段 testGate / Seed 冷起栈兜底。
  75 +
  76 +## 步骤 4:A5-delta — 下游文档增量(每个新增 REQ)
  77 +
  78 +对 `new` 的 `kind=req`:
  79 +
  80 +1. **docs/05 端点**:参照 `${CLAUDE_PLUGIN_ROOT}/skills/plan/downstream-gen/templates/docs-05-endpoint-template.md` 追加该 REQ 的接口小节(对齐 docs/06 实现策略的分页/鉴权/响应包络/错误码风格)。
  81 +2. **docs/02 顺序**:把该 REQ 按依赖拓扑插入 `docs/02-开发计划.md` 顺序清单,**同模块 REQ 保持连续**;环依赖按启发式破环并在 `note` 注明。
  82 +3. **回填卡片**:`依赖接口:` 的 `TBD` / 占位 `Edit` 成实际 endpoint。
  83 +4. **docs/08 §二**:
  84 + - 该 REQ 属**新模块** → 参照 `${CLAUDE_PLUGIN_ROOT}/skills/plan/downstream-gen/templates/docs-08-module-row-template.md` 追加模块 bullet,`里程碑:` 字段填 `—`。
  85 + - 属**已有模块** → 在该模块 bullet 下追加该 REQ 子项行。
  86 +
  87 +对 `changed` 的 `kind=req`:若接口/字段语义变化,相应 `Edit` docs/05 端点小节、按需调整 docs/02(一般顺序不变)。
  88 +
  89 +**前端增量**:若新增需求带来新前端功能,`Glob` `prototype/**/*.html` 确认有原型后,用 `AskUserQuestion` 与用户确认是否新增 FE 行;新增则在 `docs/08 §三` 「功能:」下追加 ` - [ ] FE-NN <功能名>`(NN 取现有最大值+1)。
  90 +
  91 +## 步骤 5:作废变更单元的完成标记(关键——否则 Router 会跳过)
  92 +
  93 +对每个 `changed` 单元(`new` 单元无 tag,跳过):
  94 +
  95 +- **kind=req**:
  96 + - `git -C <root> tag -l "req-done/<id>"` 存在 → `git -C <root> tag -d req-done/<id>`。
  97 + - 该 REQ 所属后端模块若已打里程碑(`git -C <root> tag -l "milestone/<module_id>"` 存在)→ `git -C <root> tag -d milestone/<module_id>`,并 `Edit` `docs/08 §二` 该模块 `里程碑:` 字段从 `milestone/<module_id>` 复位为 `—`。(否则 Router 判定模块 done 而整模块跳过,改动不会重跑。)
  98 +- **kind=fe**:
  99 + - `git -C <root> tag -l "req-done/<FE-NN>"` 存在 → 删。
  100 + - 前端阶段若已 `milestone/frontend-phase` → 删该 tag,并 `Edit` `docs/08 §三` `整体里程碑:` 复位 `—`。
  101 +
  102 +逐条记录已删的 tag,供步骤 6 横幅汇总。
  103 +
  104 +## 步骤 6:提交台账 + 完成横幅
  105 +
  106 +```
  107 +node ${CLAUDE_PLUGIN_ROOT}/lib/req-ledger.mjs commit <root>
  108 +```
  109 +
  110 +把当前哈希写回 `.req-ledger.json`(new/changed 单元自此成为新基线)。然后打印横幅并**停下**(不自动进编码,与「Plan 完不自动进 B」一致):
  111 +
  112 +```
  113 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  114 + [add-req] ✅ 增量需求已处理
  115 +
  116 + 新增 REQ:<列出 id 或 无>
  117 + 变更 REQ:<列出 id 或 无>
  118 + 新增 FE :<列出 FE-NN 或 无>
  119 + 新增 migration:<V_n 文件名 或 无>
  120 + 作废 tag:<列出已删的 req-done/* 与 milestone/* 或 无>
  121 +
  122 + 台账已更新(.req-ledger.json)。
  123 + 运行 /erp-workflow:coding-start 增量编码(Router 只跑缺 tag 的模块/功能)。
  124 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  125 +```
  126 +
  127 +## 参考
  128 +
  129 +- `${CLAUDE_PLUGIN_ROOT}/lib/req-ledger.mjs`(台账:scan / commit)
  130 +- `docs/01-需求清单/<module>/<req_id>.md`(REQ SSoT,人工先填)
  131 +- `docs/03-数据库设计文档.md` + `sql/migrations/V*.sql`(schema 演化,追加 V_n)
  132 +- `docs/05-API接口契约.md` / `docs/02-开发计划.md` / `docs/08-模块任务管理.md`(下游增量)
  133 +- `CLAUDE.md` Schema 演化规约(V1 永不改,增量走 V_n)
  134 +- 跨 skill 模板:`${CLAUDE_PLUGIN_ROOT}/skills/plan/db-design-gen/templates/docs-03-table-template.md`、`${CLAUDE_PLUGIN_ROOT}/skills/plan/downstream-gen/templates/{docs-05-endpoint-template.md,docs-08-module-row-template.md}`
... ...
skills/plan/project-init/templates/CLAUDE-template.md
... ... @@ -35,6 +35,7 @@
35 35 4. **已合并的 migration 永不修改**:如果发现错了,写一个补救 migration(如 `V7__fix_V5_index_name.sql`)修正,旧 `V_n.sql` 保持原样、永不回改
36 36 5. **临时调试 DDL**:临时在本地试字段/索引可手动 `mysql -e`,但不写 migration;下次 `setup-test-db.mjs` 会 drop+create 清掉
37 37 6. **A4 生成的 V1**:`V1__initial_schema.sql` 是 A 阶段由 `db-init` 从 `docs/03-数据库设计文档.md`(A3 正向设计的 schema SSoT)翻译生成的初始版本;后续 V2/V3/... 由 B 阶段每个 REQ 按需写入,**同时**反向同步更新 docs/03 对应表小节以保持 SSoT 一致
  38 +7. **增量需求新表/新列**:Plan 完结后用 `/erp-workflow:add-req` 追加需求时,其新表 / 新列同样落为新的 `V_n`(`ls V*.sql` 取 max+1)追加式 migration,并同步 docs/03——**永不改 V1**;schema 由下次 Coding 冷起栈时 Flyway 统一 apply
38 39  
39 40 ---
40 41  
... ...