Commit f659a376564dca2acdfae479a9999d74e41f7e57
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>
Showing
6 changed files
with
416 additions
and
7 deletions
.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 | ... | ... |