Commit 32a0adb2507e5ec783d5699391cde213099c5b0b

Authored by zichun
1 parent 57025b00

fix: harden workflow gates and db safety

README.md
... ... @@ -31,7 +31,7 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。
31 31  
32 32 coding-start(瘦入口 skill)校验 Plan 终结闸 → 启动 Workflow
33 33  
34   - coding.mjs Router → 解析 docs/08 § 二/§ 三 + git tag,列出待跑模块
  34 + coding.mjs Router → 解析 docs/08 § 二/§ 三 + milestone/* / req-done/* git tag,列出待跑模块
35 35
36 36 ├─ B-后端(按模块循环,每模块一个里程碑 tag;功能链顺序 for-await,单工作树串行 commit)
37 37 │ runBranchSetup(module-<id>) ← JS 编排:detect default → wt clean → exists? →
... ... @@ -77,7 +77,7 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。
77 77 ```
78 78 Plan 全部完成后由你显式触发;详细职责见下方 Skill 清单。详细流程见上方阶段 B 流程图。
79 79  
80   -4. **中途恢复**:任何时候重跑 `/erp-workflow:coding-start`——`coding.mjs` 的 Router 根据 docs/08 § 二/§ 三 里程碑字段 + 本地 git tag 跳到当前该做的模块/阶段。
  80 +4. **中途恢复**:任何时候重跑 `/erp-workflow:coding-start`——`coding.mjs` 的 Router 根据 docs/08 § 二/§ 三 里程碑字段 + 本地 `milestone/*` / `req-done/*` tag 跳到当前该做的模块/阶段。
81 81  
82 82 ## 目录结构
83 83  
... ... @@ -117,7 +117,7 @@ erp-workflow-plugin/
117 117 | Skill | 作用 | 谁调用 |
118 118 |---|---|---|
119 119 | `plan-start` | **A 阶段入口 + Plan 终结硬闸**。读 docs/08 § 一 找第一个未勾 A 子项 → 派发对应 A skill(含 A6 → `frontend-scope-lock`);A 全部完成时校验 5 项前移闸门(REQ 真实数据、`.env.local` secrets 全锁 + `config-vars.yaml` 配置字段全锁、docs/04 § 零 命令齐、docs/05+02 已评审、A6 前端 scope 已锁),全过才提示运行 `/erp-workflow:coding-start`,否则指出缺口不放行 | **用户手动** `/erp-workflow:plan-start` |
120   -| `coding-start` | **B 阶段瘦入口**(`allowed-tools: Read Glob Workflow`)。校验 Plan 终结闸(docs/08 § 一 全勾、git 在默认分支、工作树干净)→ 读 docs/08 § 二/§ 三 + `git tag -l 'milestone/*'` 概述进度 → 调用 `Workflow({scriptPath:"${CLAUDE_PLUGIN_ROOT}/workflows/coding.mjs", args:{projectRoot}})` 启动整个编码阶段 → 告知"已在后台启动" | **用户手动** `/erp-workflow:coding-start` |
  120 +| `coding-start` | **B 阶段瘦入口**(`allowed-tools: Read Glob Workflow Bash(git ...) Bash(pwd)`)。校验 Plan 终结闸(docs/08 § 一 全勾、git 在默认分支、工作树干净)→ 读 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` |
121 121  
122 122 ### Plan 阶段 A skill(A0~A6,共 7 个)
123 123  
... ... @@ -157,7 +157,7 @@ erp-workflow-plugin/
157 157 | skeleton-gen | `docs-07-env-template.md` | docs/07 环境配置大纲(只记规则/约定,不记具体值;配置值指向 config-vars.yaml + .env.local) |
158 158 | skeleton-gen | `docs-09-structure-template.md` | docs/09 目录结构大纲 |
159 159 | skeleton-gen | `scripts-setup-test-db-template.mjs` | 跨平台 drop + create 空库脚本(安全 env 解析,无 shell-source);schema apply 交给 Flyway |
160   -| skeleton-gen | `scripts-test-template.mjs` | test.mjs 骨架(命令槽位 {{TEST_CMD}} / {{E2E_CMD}},`spawnSync(shell:true)` 跨平台执行) |
  160 +| skeleton-gen | `scripts-test-template.mjs` | test.mjs 骨架(命令槽位按后端/前端/build/lint/test/e2e 分开,`spawnSync(shell:true)` 跨平台执行) |
161 161 | skeleton-gen | `env-local-template` | 凭据模板(DB_* + JWT_SECRET);A2 据 config-vars.yaml `secrets_ref` 追加项目专属 secret 键 |
162 162 | skeleton-gen | `gitignore-append-template` | 插件推荐忽略项(`.env.local`、`.tmp/`、构建产物等) |
163 163 | skeleton-gen | `styles-tokens-template.css` | 前端 design tokens CSS 变量骨架 |
... ... @@ -173,9 +173,9 @@ erp-workflow-plugin/
173 173  
174 174 ## 前置依赖
175 175  
176   -- **Node.js ≥ 18**:`lib/*.mjs` 助手 + `workflows/coding.mjs` + 生成进目标项目的 `scripts/*.mjs` 均为 ESM;CC 运行时自带。A0 `project-init` 检测 git / mysql / node 在 PATH,缺失则按 OS 打印安装指引并 halt(不自动安装)
177   -- **MySQL 8.x** 实例已就绪(推荐本地 / `*.local` host;A4 `db-init` 的安全守护要求 host 在白名单且 schema 名含 `test`/`dev`/`local`,避免误删生产库)
178   -- **`mysql2`(目标项目侧)**:A4 `db-init` 经 `lib/apply-ddl.mjs` 用 mysql2 连接 + 安全 env 解析 apply V1;生成的 `scripts/setup-test-db.mjs` 在测试闸门前后 drop+create 空库
  176 +- **Node.js ≥ 18**:`lib/*.mjs` 助手 + 生成进目标项目的 `scripts/*.mjs` 为 Node ESM;`workflows/coding.mjs` 是 Claude Workflow 运行时脚本(由 `Workflow` 工具执行,不作为普通 `node` CLI 入口)。A0 `project-init` 检测 git / mysql / node 在 PATH,缺失则按 OS 打印安装指引并 halt(不自动安装)
  177 +- **MySQL 8.x** 实例已就绪(推荐本地 / `*.local` host;A4 `db-init` 的安全守护要求 host 在白名单且 schema 名含 `test` 或以 `_dev` / `_local` / `_ci` 结尾,避免误删生产库)
  178 +- **`mysql2`(目标项目侧)**:A4 `db-init` 经 `lib/apply-ddl.mjs` 用 mysql2 连接 + 安全 env 解析 apply V1;`apply-ddl.mjs` 与生成的 `scripts/setup-test-db.mjs` 都会校验 host 白名单 + schema 名测试库特征,生成的 `scripts/setup-test-db.mjs` 在测试闸门前后 drop+create 空库
179 179 - **Spring Boot + Flyway**(**必需**):pom.xml 声明 `flyway-core` + `flyway-mysql`;Spring 启动时自动 apply `sql/migrations/V*.sql`。本插件生成的 `setup-test-db.mjs` 只清库,schema 必须由 Flyway 应用
180 180 - **本地 git 仓库**(纯本地,无需远程):A0 `project-init` 执行 `git init`;B 阶段每模块由 `coding.mjs` 的 milestone stage 本地 `git merge --no-ff` 进默认分支并 `git tag -a milestone/<id>`,完成信号由 `git tag -l` 判定。**不依赖任何远程仓库 / push / GitLab**
181 181 - **本地可运行 `mvn test` / `pnpm test`**:测试命令由 A1 写入 docs/04 § 零,生成的 `scripts/test.mjs` 由 `skeleton-gen` 产出,`coding.mjs` 的 testGate stage 调用
... ...
lib/apply-ddl.mjs
... ... @@ -55,6 +55,11 @@ export function parseEnv(text) {
55 55 export async function applyDDL({ envPath, ddlPath }) {
56 56 const { readFileSync } = await import('node:fs')
57 57  
  58 + const env = parseEnv(readFileSync(envPath, 'utf8'))
  59 + const ddl = readFileSync(ddlPath, 'utf8')
  60 + const { host, port, user, password, database } = resolveDbConfig(env, envPath)
  61 + assertSafeDbTarget({ host, database, env, label: 'apply-ddl' })
  62 +
58 63 let mysql
59 64 try {
60 65 ;({ default: mysql } = await import('mysql2/promise'))
... ... @@ -62,10 +67,6 @@ export async function applyDDL({ envPath, ddlPath }) {
62 67 throw new MysqlUnavailableError()
63 68 }
64 69  
65   - const env = parseEnv(readFileSync(envPath, 'utf8'))
66   - const ddl = readFileSync(ddlPath, 'utf8')
67   - const { host, port, user, password, database } = resolveDbConfig(env, envPath)
68   -
69 70 const conn = await mysql.createConnection({
70 71 host,
71 72 port,
... ... @@ -100,9 +101,37 @@ export function resolveDbConfig(env, envPath = &#39;.env.local&#39;) {
100 101 if (!database) {
101 102 throw new Error(`apply-ddl: 缺数据库名 — 请在 ${envPath} 设置 DB_SCHEMA(或 DB_NAME / MYSQL_DATABASE)`)
102 103 }
  104 + if (!Number.isInteger(port) || port <= 0 || port > 65535) {
  105 + throw new Error(`apply-ddl: DB_PORT 非法 — ${envPath} 中端口必须是 1..65535 的整数`)
  106 + }
103 107 return { host, port, user, password, database }
104 108 }
105 109  
  110 +/**
  111 + * Fail closed for direct DDL application. setup-test-db.mjs has the same guard
  112 + * before DROP+CREATE; apply-ddl repeats it so direct CLI use cannot hit prod.
  113 + *
  114 + * @param {{host:string, database:string, env?:Record<string,string>, label?:string}} opts
  115 + * @returns {true}
  116 + */
  117 +export function assertSafeDbTarget({ host, database, env = {}, label = 'apply-ddl' }) {
  118 + const extraHosts = String(env.TEST_DB_ALLOWED_HOSTS || '')
  119 + .split(/[\s,]+/)
  120 + .filter(Boolean)
  121 + const allowedHosts = ['localhost', '127.0.0.1', '::1', ...extraHosts]
  122 + if (!allowedHosts.includes(host)) {
  123 + throw new Error(`${label}: 拒绝连接非白名单 host (${host});如确认是测试库,请在 .env.local 设置 TEST_DB_ALLOWED_HOSTS`)
  124 + }
  125 + if (!/^[A-Za-z0-9_]+$/.test(database)) {
  126 + throw new Error(`${label}: DB_SCHEMA 只能包含字母、数字、下划线,当前为 ${JSON.stringify(database)}`)
  127 + }
  128 + const looksLikeTest = /test/i.test(database) || /_dev$/i.test(database) || /_local$/i.test(database) || /_ci$/i.test(database)
  129 + if (!looksLikeTest) {
  130 + throw new Error(`${label}: schema '${database}' 不像测试/开发库(需含 test 或以 _dev/_local/_ci 结尾)`)
  131 + }
  132 + return true
  133 +}
  134 +
106 135 /** Distinct error type so the CLI can emit a friendly install hint. */
107 136 export class MysqlUnavailableError extends Error {
108 137 constructor() {
... ...
lib/apply-ddl.test.mjs
1 1 import { test } from 'node:test'
2 2 import assert from 'node:assert/strict'
3   -import { parseEnv, resolveDbConfig } from './apply-ddl.mjs'
  3 +import { assertSafeDbTarget, parseEnv, resolveDbConfig } from './apply-ddl.mjs'
4 4  
5 5 test('parseEnv ignores comments, trims, keeps special chars literally', () => {
6 6 const env = parseEnv('# c\nDB_PASS=p@ss$word!\nDB_NAME = erp \n')
... ... @@ -84,3 +84,35 @@ test(&#39;resolveDbConfig applies sane defaults for host/port/user/password&#39;, () =&gt;
84 84 assert.equal(c.user, 'root')
85 85 assert.equal(c.password, '')
86 86 })
  87 +
  88 +test('resolveDbConfig rejects invalid ports', () => {
  89 + assert.throws(() => resolveDbConfig({ DB_SCHEMA: 'erp_test', DB_PORT: 'abc' }), /DB_PORT/)
  90 + assert.throws(() => resolveDbConfig({ DB_SCHEMA: 'erp_test', DB_PORT: '70000' }), /DB_PORT/)
  91 +})
  92 +
  93 +test('assertSafeDbTarget allows local and explicitly allowlisted test targets', () => {
  94 + assert.equal(assertSafeDbTarget({ host: 'localhost', database: 'erp_test' }), true)
  95 + assert.equal(
  96 + assertSafeDbTarget({
  97 + host: 'mysql.dev.internal',
  98 + database: 'erp_dev',
  99 + env: { TEST_DB_ALLOWED_HOSTS: 'mysql.dev.internal' },
  100 + }),
  101 + true
  102 + )
  103 +})
  104 +
  105 +test('assertSafeDbTarget rejects prod-looking or injectable targets', () => {
  106 + assert.throws(
  107 + () => assertSafeDbTarget({ host: 'prod.db.internal', database: 'erp_test' }),
  108 + /非白名单 host/
  109 + )
  110 + assert.throws(
  111 + () => assertSafeDbTarget({ host: 'localhost', database: 'erp_prod' }),
  112 + /不像测试\/开发库/
  113 + )
  114 + assert.throws(
  115 + () => assertSafeDbTarget({ host: 'localhost', database: 'erp_test`; DROP DATABASE prod; --' }),
  116 + /只能包含/
  117 + )
  118 +})
... ...
skills/coding/coding-start/SKILL.md
... ... @@ -2,7 +2,7 @@
2 2 name: coding-start
3 3 description: B 阶段(Coding)瘦入口。校验 Plan 终结闸(docs/08 §一 A0~A6 全勾、已在本地默认分支、工作树干净)后,读取 docs/08 §二/§三 概述模块/前端进度,然后调用 workflows/coding.mjs Workflow 全自动、静默地跑完整个编码阶段(后端+前端功能循环、测试闸、里程碑 tag),跑完或 halt 时返回最终状态。本入口不写任何文件、不做编码决策。
4 4 user-invocable: true
5   -allowed-tools: Read Glob Workflow
  5 +allowed-tools: Read Glob Workflow Bash(pwd) Bash(git rev-parse *) Bash(git status *) Bash(git branch *) Bash(git tag *)
6 6 ---
7 7  
8 8 **所有输出必须使用中文。**
... ... @@ -44,11 +44,13 @@ allowed-tools: Read Glob Workflow
44 44 - 任一未勾 → 缺口:`Plan 未完成(<未勾项>)→ 先运行 /erp-workflow:plan-start`。
45 45  
46 46 2. **当前在本地默认分支(main / master)**
47   - - 本入口无 Bash,无法直接查 git。改为信任用户:在放行横幅中**显式要求**用户确认当前已在默认分支。若用户已说明不在默认分支,则拦截。
48   - - (权威的分支/tag 状态由 `coding.mjs` 的 milestone stage 在 merge 时校验。)
  47 + - 用 `Bash(git rev-parse --show-toplevel)` 确认当前目录在 git 仓库中,并记录 stdout 作为 `projectRoot`。
  48 + - 用 `Bash(git branch --show-current)` 读取当前分支。
  49 + - 用 `Bash(git rev-parse --verify refs/heads/main)` / `refs/heads/master` 依次检测本地默认分支,取第一个存在者;两者都不存在则缺口:`找不到本地默认分支 main/master`。
  50 + - 当前分支不等于默认分支 → 缺口:`当前分支 <branch> 不是默认分支 <default>`。
49 51  
50 52 3. **工作树干净(Plan 产物已 commit)**
51   - - 同样无法用 Bash 直接查。在放行横幅中**显式要求**用户确认工作树干净、Plan 产物已提交
  53 + - 用 `Bash(git status --porcelain)` 检查,stdout 非空即缺口:`工作树不干净(列出前 20 行 dirty 路径)`
52 54  
53 55 任一缺口 → 输出拦截横幅,逐条列出缺口与回填位置,**停下**,不启动 Workflow:
54 56  
... ... @@ -65,7 +67,7 @@ allowed-tools: Read Glob Workflow
65 67  
66 68 ### 步骤 3:概述进度(信息提要)
67 69  
68   -仅当步骤 2 的 § 一 校验通过后,`Read` `docs/08 § 二`(后端模块元数据 + `里程碑:` 字段)与 `§ 三`(前端阶段 `整体里程碑:` 字段),概述:
  70 +仅当步骤 2 的 § 一 校验通过后,`Read` `docs/08 § 二`(后端模块元数据 + `里程碑:` 字段)与 `§ 三`(前端阶段 `整体里程碑:` 字段),并用 `Bash(git tag -l "milestone/*")` 读取本地里程碑 tag,概述:
69 71  
70 72 - 后端:每个模块的 `里程碑:` 是否已是 `milestone/<module_id>`(已完成)还是 `—`(待跑)。
71 73 - 前端:`§ 三 整体里程碑:` 是否已是 `milestone/frontend-phase`(已完成)还是 `—`(待跑)。
... ... @@ -74,12 +76,12 @@ allowed-tools: Read Glob Workflow
74 76  
75 77 ### 步骤 4:启动 Coding Workflow
76 78  
77   -用 `Workflow` 工具调用编码编排脚本。`projectRoot` 必须绝对路径(coding.mjs 顶部对相对路径硬校验,传 `.` 会立即 halt)。
  79 +用 `Workflow` 工具调用编码编排脚本。`projectRoot` 必须使用步骤 2 里 `git rev-parse --show-toplevel` 得到的绝对路径(coding.mjs 顶部对相对路径硬校验,传 `.` 会立即 halt)。
78 80  
79 81 ```
80 82 Workflow({
81 83 scriptPath: "${CLAUDE_PLUGIN_ROOT}/workflows/coding.mjs",
82   - args: { projectRoot: "<当前项目根绝对路径>" }
  84 + args: { projectRoot: "<git rev-parse --show-toplevel 的 stdout>" }
83 85 })
84 86 ```
85 87  
... ... @@ -93,9 +95,9 @@ Workflow({
93 95  
94 96 进度概述:<步骤 3 概述,如「待跑 3 模块 + 前端阶段」>
95 97  
96   - ⚠️ 请确认(本入口无法程序化核对):
97   - • 当前已在本地默认分支(main / master)
98   - • 工作树干净,Plan 产物(docs/* + skeleton + DDL)已 commit
  98 + 已程序化校验:
  99 + ✓ 当前在本地默认分支(main / master)
  100 + ✓ 工作树干净,Plan 产物(docs/* + skeleton + DDL)已 commit
99 101  
100 102 Workflow 将按模块顺序全自动、静默推进,跑完所有模块或在某模块
101 103 halt(测试闸持续 RED / review 5 轮未过 / 缺值阻塞等)时返回最终状态。
... ...
skills/plan/db-init/SKILL.md
... ... @@ -2,7 +2,7 @@
2 2 name: db-init
3 3 description: A4 DB 初始化——LLM 解析 docs/03-数据库设计文档.md → 生成 sql/migrations/V1__initial_schema.sql(DDL only,Flyway 初始 migration)→ 用 lib/validate-ddl.mjs 全量校验 DDL ↔ docs/03 一致性 → 验证 MySQL 连接 → 调 scripts/setup-test-db.mjs 复用三层防护并 DROP+CREATE 空库 → 用 lib/apply-ddl.mjs apply V1。
4 4 user-invocable: false
5   -allowed-tools: Read Write Edit Glob Skill Bash(node *) Bash(mysql *)
  5 +allowed-tools: Read Write Edit Glob Skill Bash(node *)
6 6 ---
7 7  
8 8 **所有输出必须使用中文。**
... ... @@ -58,10 +58,34 @@ node &quot;${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs&quot; \
58 58  
59 59 用 `Glob` 确认 `.env.local` 存在(不存在 → 提示重跑 A2 `skeleton-gen` 并停下)。用 `Read` 逐行解析 `KEY=VALUE`(跳过空行 / `#` 注释,**不做 shell-source / 变量展开**),校验 `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_SCHEMA` 5 项均非空——任一缺失 → 打印缺失字段并停下。
60 60  
61   -用解析出的值跑连通性自检
  61 +用解析出的值跑连通性自检。必须用 Node `spawnSync('mysql', args, {shell:false})`,不要把密码拼进 shell 命令;空密码也要传 `--password=`,避免 `mysql -p` 进入交互式等待。
62 62  
63 63 ```bash
64   -mysql -h<DB_HOST> -P<DB_PORT> -u<DB_USER> -p<DB_PASSWORD> -e "SELECT 1;"
  64 +node -e '
  65 +const { spawnSync } = require("node:child_process");
  66 +const { readFileSync } = require("node:fs");
  67 +const env = {};
  68 +for (const raw of readFileSync(".env.local", "utf8").split(/\r?\n/)) {
  69 + const line = raw.trim();
  70 + if (!line || line.startsWith("#")) continue;
  71 + const eq = line.indexOf("=");
  72 + if (eq < 0) continue;
  73 + const key = line.slice(0, eq).trim();
  74 + let value = line.slice(eq + 1).trim();
  75 + if (value.length >= 2 && ((value[0] === "\"" && value.at(-1) === "\"") || (value[0] === "'"'"'" && value.at(-1) === "'"'"'"))) value = value.slice(1, -1);
  76 + env[key] = value;
  77 +}
  78 +const args = [
  79 + `--host=${env.DB_HOST}`,
  80 + `--port=${env.DB_PORT || "3306"}`,
  81 + `--user=${env.DB_USER}`,
  82 + `--password=${env.DB_PASSWORD || ""}`,
  83 + "-e",
  84 + "SELECT 1;"
  85 +];
  86 +const r = spawnSync("mysql", args, { stdio: "inherit" });
  87 +process.exit(r.status === null ? 1 : r.status);
  88 +'
65 89 ```
66 90  
67 91 成功 → 进入步骤 C;失败 → 打印具体错误(认证 / 主机不可达 / 端口拒接)并停下。
... ... @@ -78,7 +102,7 @@ node scripts/setup-test-db.mjs
78 102  
79 103 #### C.2 把 V1 灌入已清空的 schema
80 104  
81   -调 `${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs`:它用纯 JS 解析 `.env.local`(**不** shell-source,消除注入),再经 mysql2 把 DDL 灌入 schema。
  105 +调 `${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs`:它用纯 JS 解析 `.env.local`(**不** shell-source,消除注入),复用 host 白名单 + schema 名安全闸,再经 mysql2 把 DDL 灌入 schema。
82 106  
83 107 ```bash
84 108 node "${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs" .env.local sql/migrations/V1__initial_schema.sql
... ...
skills/plan/project-init/SKILL.md
... ... @@ -70,9 +70,11 @@ if (missing.length) {
70 70  
71 71 ### C. 初始化 Git(如尚未初始化)
72 72  
73   -用 `Glob` 检查 `.git/` 目录是否存在。
74   -- 不存在 → 用 `Bash` 执行 `git init`。
75   -- 已存在 → 跳过。
  73 +用 `Bash` 执行 `git rev-parse --is-inside-work-tree`。
  74 +- 退出码 `0` 且 stdout 为 `true` → 已在 git 仓库中,跳过。
  75 +- 非 `0` → 用 `Bash` 执行 `git init`。
  76 +
  77 +不要用 `.git/` 目录是否存在判断:git worktree / 子模块 / 某些托管环境会使用 `.git` 文件,目录探测会误判。
76 78  
77 79 完成后,用 `Edit` 在 `docs/08-模块任务管理.md` 中勾选(A0 子项 + A0 顶层):
78 80 - ` - [ ] Git 已初始化`
... ...
skills/plan/project-init/templates/docs-08-initial-template.md
... ... @@ -2,7 +2,7 @@
2 2  
3 3 > 全流程进度跟踪。CC 每完成一项产出就勾选一项。
4 4 > - **§ 一 Plan(A0~A6)**:`plan-start` 找第一个未勾 A 子项分发到对应 skill
5   -> - **§ 二 Coding(模块)**:分发以 `docs/02-开发计划.md § 二 开发顺序清单` 为准;`coding-start` 按 docs/02 顺序扫描,对每个 REQ 所属模块查询本 § 二的 `里程碑:` 字段 + 本地 `git tag -l 'milestone/<id>'`,找第一个未打里程碑模块分发。本 § 二 行序无语义,仅作模块元数据表
  5 +> - **§ 二 Coding(模块)**:分发以 `docs/02-开发计划.md § 二 开发顺序清单` 为准;`coding.mjs` Router 按 docs/02 顺序扫描,对每个 REQ 所属模块查询本 § 二的 `里程碑:` 字段 + 本地 `git tag -l 'milestone/<id>'` 判定模块完成,并用 `git tag -l 'req-done/<REQ-or-FE>'` 判定功能级跳过。本 § 二 行序无语义,仅作模块元数据表
6 6  
7 7 ## 一、Plan 阶段(一次性)
8 8  
... ... @@ -49,11 +49,11 @@
49 49  
50 50 ## 二、Coding 阶段(后端模块循环)
51 51  
52   -(A5 填入后,每行一个后端模块。每个模块的 `里程碑:` 字段在 `—` 和 `milestone/<id>` 之间变化,完成由本地 `git tag -l` 判定。`coding-start` 每次按 docs/02 REQ 序扫每模块的里程碑 tag 决定派发。后端模块全部打里程碑后自动进入 § 三 前端阶段。)
  52 +(A5 填入后,每行一个后端模块。每个模块的 `里程碑:` 字段在 `—` 和 `milestone/<id>` 之间变化,完成由本地 `git tag -l 'milestone/<id>'` 判定。功能行 checkbox 只作可视化,真正的功能级 resume 由 `req-done/<REQ>` tag 判定。后端模块全部打里程碑后自动进入 § 三 前端阶段。)
53 53  
54 54 ## 三、Coding 阶段(前端整体)
55 55  
56   -(FE 业务功能清单在 Plan 期 A6 `frontend-scope-lock` 由 prototype/ + docs/01 + docs/05 推导后写入下方"功能:"项;Coding 阶段 `coding.mjs` 的 Router 把全部未完成 FE 聚合为单一 `frontend-phase` 阶段,排在所有后端模块之后。整个前端阶段 1 个里程碑 tag,分支 `frontend-phase`。)
  56 +(FE 业务功能清单在 Plan 期 A6 `frontend-scope-lock` 由 prototype/ + docs/01 + docs/05 推导后写入下方"功能:"项;Coding 阶段 `coding.mjs` 的 Router 把缺少 `req-done/<FE-NN>` tag 的 FE 聚合为单一 `frontend-phase` 阶段,排在所有后端模块之后。整个前端阶段 1 个里程碑 tag,分支 `frontend-phase`。)
57 57  
58 58 - 整体里程碑: —
59 59 - 功能:
... ...
skills/plan/scope-lock/SKILL.md
... ... @@ -2,7 +2,7 @@
2 2 name: scope-lock
3 3 description: A1 计划范围锁定——引导用户填写项目概述 + 技术栈 + 需求索引,并按模块子目录生成 REQ 卡片骨架(CC 推断 req_id/title/goal/rules/constraints/acceptance;输入/输出 字段表为结构化 6 列表单由人工逐行填真实数据);末尾执行 A1 终结校验:每张 REQ 卡片字段含真实数据、配置字段名锁进 config-vars.yaml(非敏感填值 + secrets_ref 键名引用 .env.local)、build/lint/unit/e2e 命令锁进 docs/04 §零,缺则当场 AskUserQuestion 问清。
4 4 user-invocable: false
5   -allowed-tools: Read Write Edit Grep Glob Skill AskUserQuestion Bash(mkdir *) Bash(node *) Bash(rm *)
  5 +allowed-tools: Read Write Edit Grep Glob Skill AskUserQuestion Bash(mkdir *) Bash(node *)
6 6 ---
7 7  
8 8 **所有输出必须使用中文。**
... ... @@ -27,7 +27,7 @@ allowed-tools: Read Write Edit Grep Glob Skill AskUserQuestion Bash(mkdir *) Bas
27 27  
28 28 1. 用 `Grep` 校验 `docs/01-需求清单/index.md` 无 `【人工填写:` 残留;有则回步骤 C。
29 29 2. 用 `Read` 读 `index.md` 解析模块索引。
30   -3. **每模块/每 REQ 渲染**:`Write` vars.json → `mkdir -p` → `node render.mjs`。`<MOD>` / `<模块名>` / `<REQ-MOD-NNN>` 按 `index.md` 实际值替换。
  30 +3. **每模块/每 REQ 渲染**:`mkdir -p` → `Write` vars.json → `node render.mjs`。`<MOD>` / `<模块名>` / `<REQ-MOD-NNN>` 按 `index.md` 实际值替换。
31 31  
32 32 ```bash
33 33 # 模块头:先 Write docs/01-需求清单/<MOD>-<模块名>/_module.vars.json,内容形如
... ...
skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs
... ... @@ -46,6 +46,16 @@ const DB_USER = env.DB_USER ?? &#39;&#39;
46 46 const DB_PASSWORD = env.DB_PASSWORD ?? ''
47 47 const DB_SCHEMA = env.DB_SCHEMA ?? ''
48 48  
  49 +if (!/^\d+$/.test(DB_PORT) || Number(DB_PORT) <= 0 || Number(DB_PORT) > 65535) {
  50 + console.error(`[setup-test-db] DB_PORT 非法: ${DB_PORT}(必须是 1..65535 的整数)`)
  51 + process.exit(1)
  52 +}
  53 +
  54 +if (!/^[A-Za-z0-9_]+$/.test(DB_SCHEMA)) {
  55 + console.error(`[setup-test-db] DB_SCHEMA 只能包含字母、数字、下划线,当前为: ${DB_SCHEMA}`)
  56 + process.exit(1)
  57 +}
  58 +
49 59 // 防护 1:默认只允许本地 host(localhost / 127.0.0.1 / ::1)。
50 60 // 额外允许的远程 host 在 .env.local 的 TEST_DB_ALLOWED_HOSTS 中(空格或逗号分隔)。
51 61 const extraHosts = (env.TEST_DB_ALLOWED_HOSTS ?? '')
... ... @@ -85,10 +95,10 @@ const sql =
85 95 `CREATE DATABASE \`${DB_SCHEMA}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`
86 96  
87 97 const mysqlArgs = [
88   - `-h${DB_HOST}`,
89   - `-P${DB_PORT}`,
90   - `-u${DB_USER}`,
91   - `-p${DB_PASSWORD}`,
  98 + `--host=${DB_HOST}`,
  99 + `--port=${DB_PORT}`,
  100 + `--user=${DB_USER}`,
  101 + `--password=${DB_PASSWORD}`,
92 102 '-e',
93 103 sql,
94 104 ]
... ...
workflows/coding.mjs
... ... @@ -164,21 +164,23 @@ function routerPrompt(root) {
164 164 '',
165 165 '你是 Coding 阶段的路由子代理。**只读不写**(不改任何代码 / 文档),仅从状态账本重算"哪些模块还要跑",返回结构化结果。',
166 166 '',
167   - '## 读取来源(账本 = docs/08 + git tag,二者一致才算完成)',
  167 + '## 读取来源(账本 = docs/08 + git tag,里程碑和功能级完成都以 tag 为真值)',
168 168 '1. `docs/08-模块任务管理.md § 二`(后端模块元数据):逐个模块取 `id`(英文蛇形 module id)、本模块的 REQ 列表(按 `docs/02-开发计划.md § 二 开发顺序清单` 的顺序,A5 约束保证同模块 REQ 连续),以及该模块的 `里程碑:` 字段。',
169 169 '2. `docs/08-模块任务管理.md § 三`(前端阶段元数据):取 `整体里程碑:` 字段,以及 `功能:` 项下所有 `- [ ] FE-NN ...` / `- [x] FE-NN ...` 行(FE 清单)。前端 item 形如 `FE-NN`。',
170 170 '3. `git -C <root> tag -l "milestone/*"`:列出已打的里程碑 tag。',
  171 + '4. `git -C <root> tag -l "req-done/*"`:列出已通过 review 并落地的功能级完成 tag。`docs/08` checkbox 只作可视化,不作为跳过功能的真值。',
171 172 '',
172 173 '## 完成判定(每个模块独立)',
173 174 '- 后端模块 `done = true` 当且仅当:§二 该模块 `里程碑:` 字段 == `milestone/<module_id>` **且** `git tag -l "milestone/<module_id>"` 能查到该 tag。任一缺失 → `done = false`。',
174 175 '- 前端 item(FE-NN)归属一个"逻辑前端模块"。前端阶段整体 `done` 当且仅当 §三 `整体里程碑:` == `milestone/frontend-phase` 且 `git tag -l "milestone/frontend-phase"` 存在。',
  176 + '- 后端 REQ / 前端 FE 的功能级完成判定:仅当 `git tag -l "req-done/<id>"` 能查到该 tag 才视为已 approve。不要因为存在 review markdown 或 docs/08 checkbox 已勾就跳过;若 tag 缺失,必须把该 id 放回待跑列表。',
175 177 '',
176 178 '## 输出(必须符合下发的 JSON schema)',
177 179 '- `modules`: 数组。**先**按 `docs/02 § 二` 的模块顺序列出全部后端模块,**再在末尾追加唯一一个前端聚合模块**(仅当存在前端 FE 时)。每项:',
178 180 ' - `id`: 模块标识(后端为英文蛇形 module id;前端聚合模块固定用 `frontend-phase`)。',
179   - ' - `done`: 该模块是否已完成(按上面的判定)。',
180   - ' - `reqs`: **仅后端模块**填本模块**未完成**后端 REQ 的有序列表(已 `verdict=approve`,见 `docs/superpowers/reviews/*-<REQ>.md` 的 REQ 跳过);模块已 done → 空数组。**前端聚合模块 `reqs` 恒为空数组**。',
181   - ' - `feItems`: **仅前端聚合模块**填——把**全部模块**的**未完成**前端 FE-NN 汇总为一个有序列表(已 approve 的 FE 跳过)放进 `frontend-phase` 这一项。**后端模块 `feItems` 恒为空数组**(前端不分摊到后端模块)。',
  181 + ' - `done`: 该模块/前端阶段是否已完成(按上面的 milestone 判定)。',
  182 + ' - `reqs`: **仅后端模块**填本模块**缺少 `req-done/<REQ>` tag** 的后端 REQ 有序列表;模块已 done → 空数组。**前端聚合模块 `reqs` 恒为空数组**。',
  183 + ' - `feItems`: **仅前端聚合模块**填——把**全部模块**缺少 `req-done/<FE-NN>` tag 的前端 FE-NN 汇总为一个有序列表放进 `frontend-phase` 这一项。**后端模块 `feItems` 恒为空数组**(前端不分摊到后端模块)。',
182 184 '- 即:后端模块只承载 `reqs`、`feItems=[]`;末尾的 `frontend-phase` 模块只承载 `feItems`、`reqs=[]`。整个项目至多一个前端聚合模块,对应至多一个 `milestone/frontend-phase` tag。',
183 185 '- 不要返回任何额外字段(schema 为 `additionalProperties:false`)。',
184 186 '',
... ... @@ -467,7 +469,7 @@ function detectDefaultBranchPromptM() {
467 469 '# 检测本地默认分支',
468 470 microStepContract(),
469 471 '',
470   - `用 \`git -C ${ROOT} rev-parse --verify <name>- `用 \`git -C ${ROOT} rev-parse --verify 依次试 - `用 \`git -C ${ROOT} rev-parse --verify main- `用 \`git -C ${ROOT} rev-parse --verify / - `用 \`git -C ${ROOT} rev-parse --verify master- `用 \`git -C ${ROOT} rev-parse --verify ,取第一个 exit=0 的为默认分支。`,
  472 + `用 \`git -C ${ROOT} rev-parse --verify refs/heads/<name>+ `用 \`git -C ${ROOT} rev-parse --verify refs/heads/ 依次试 + `用 \`git -C ${ROOT} rev-parse --verify refs/heads/main+ `用 \`git -C ${ROOT} rev-parse --verify refs/heads/ / + `用 \`git -C ${ROOT} rev-parse --verify refs/heads/master+ `用 \`git -C ${ROOT} rev-parse --verify refs/heads/,取第一个 exit=0 的为默认分支。`,
471 473 '## 输出(DEFAULT_BRANCH_SCHEMA)',
472 474 '- 两者其一存在:`{ "branch": "main" }` 或 `{ "branch": "master" }`',
473 475 '- 都不存在:本步骤失败(返回 schema 失败即可,调用方会 halt)。',
... ... @@ -491,7 +493,7 @@ function checkBranchExistsPromptM(branch) {
491 493 `# 本地分支 \`${branch}\` 是否存在`,
492 494 microStepContract(),
493 495 '',
494   - `跑 \`git -C ${ROOT} rev-parse --verify ${branch}- `跑 \`git -C ${ROOT} rev-parse --verify (用 2>/dev/null 抑制 stderr)。`,
  496 + `跑 \`git -C ${ROOT} rev-parse --verify refs/heads/${branch}+ `跑 \`git -C ${ROOT} rev-parse --verify refs/heads/(用 2>/dev/null 抑制 stderr)。`,
495 497 '## 输出(EXISTS_SCHEMA)',
496 498 '- exit=0 → `{ "exists": true }`;非 0 → `{ "exists": false }`',
497 499 ].join('\n')
... ... @@ -634,8 +636,8 @@ function readDocs08CheckboxPromptM(fe, id) {
634 636 const section = fe ? '§ 三' : '§ 二'
635 637 const kind = fe ? '功能' : 'REQ'
636 638 const locator = fe
637   - ? `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 ${section}(前端阶段)下的 \`功能:\` 项,从中找以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(注意 id 后必须紧跟空格,避免误中前缀同名)。`
638   - : `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 ${section},找以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(id 后必须紧跟空格)。该行可能位于任一模块 bullet 下。`
  639 + ? `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 ${section}(前端阶段)下的 \`功能:\` 项,从中找**去掉行首空白后**以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(注意 id 后必须紧跟空格,避免误中前缀同名)。`
  640 + : `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 ${section},找**去掉行首空白后**以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(id 后必须紧跟空格)。该行可能位于任一模块 bullet 下。`
639 641 return [
640 642 `# 读 docs/08 ${section} ${kind} \`${id}\` 的勾选态(\`- [ ] ${id} ...\` / \`- [x] ${id} ...\`)`,
641 643 microStepContract(),
... ... @@ -644,18 +646,21 @@ function readDocs08CheckboxPromptM(fe, id) {
644 646 '## 输出(CHECKBOX_STATE_SCHEMA)',
645 647 `- 命中 \`- [x] ${id} ...\`:\`{ "found": true, "state": "checked", "lineNumber": <行号> }\``,
646 648 `- 命中 \`- [ ] ${id} ...\`:\`{ "found": true, "state": "unchecked", "lineNumber": <行号> }\``,
647   - '- 找不到:`{ "found": false }`',
  649 + '- 找不到:`{ "found": false, "state": "unchecked" }`(state 仍必填,避免 schema 失败掩盖真实缺口)。',
648 650 ].join('\n')
649 651 }
650 652  
651   -function writeDocs08CheckboxPromptM(fe, id, phase) {
  653 +function writeDocs08CheckboxPromptM(fe, id, phase, lineNumber) {
652 654 const scope = fe ? `§ 三 功能 ${id}` : `§ 二 REQ ${id}`
  655 + const lineGuard = (typeof lineNumber === 'number' && Number.isFinite(lineNumber))
  656 + ? `先 Read \`${ROOT}/docs/08-模块任务管理.md\` 第 ${lineNumber} 行(1-based),确认该行去掉行首空白后以 \`- [ ] ${id} \` 开头;不满足则返回 \`{success:false, error:"line-${lineNumber}-mismatch: actual=<actual>"}\`。然后只替换第 ${lineNumber} 行的第一个 \`[ ]\` 为 \`[x]\`,保留缩进与 id 之后的全部文本。`
  657 + : `定位 docs/08 ${scope} 中去掉行首空白后以 \`- [ ] ${id} \` 开头的唯一一行,只替换该行第一个 \`[ ]\` 为 \`[x]\`,保留缩进与 id 之后的全部文本。`
653 658 return [
654 659 `# 把 docs/08 ${scope} 的 \`[ ]\` 勾选为 \`[x]\` 并 commit`,
655 660 microStepContract(),
656 661 '',
657 662 `调用方已读到状态 = \`unchecked\`(你不必再读一遍)。`,
658   - `1. Edit \`${ROOT}/docs/08-模块任务管理.md\`:把以 \`- [ ] ${id} \` 开头的整行替换为对应的 \`- [x] ${id} ...\`(保留原行 id 之后的全部文本,仅 \`[ ]\` → \`[x]\`,精确字符串替换;只动一处)。`,
  663 + `1. ${lineGuard}`,
659 664 `2. 跑 \`git -C ${ROOT} add docs/08-模块任务管理.md\`。`,
660 665 `3. 跑 \`git -C ${ROOT} commit -m "chore(${phase}:${id}): mark ${id} approved in docs/08"\`。`,
661 666 '## 输出(ACTION_RESULT_SCHEMA)',
... ... @@ -1019,7 +1024,7 @@ async function reviewWithFixLoop(id, phase, verifyResult, specPath) {
1019 1024 throw new Error(`HALT docs08-checkbox-state-invalid ${phase}:${id}: cb.state = ${JSON.stringify(cb.state)}`)
1020 1025 }
1021 1026 if (cb.state === 'unchecked') {
1022   - const wr = await agent(writeDocs08CheckboxPromptM(fe, id, phase), {label:`cb:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA})
  1027 + const wr = await agent(writeDocs08CheckboxPromptM(fe, id, phase, cb.lineNumber), {label:`cb:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA})
1023 1028 if (!wr.success) throw new Error(`HALT docs08-checkbox-write ${phase}:${id}: ${wr.error || ''}`)
1024 1029 }
1025 1030 return { id, phase, approved:true, rounds:round }
... ... @@ -1122,8 +1127,8 @@ const pending = haltedAtIdx &gt;= 0
1122 1127 : []
1123 1128  
1124 1129 // Workflow 结果:跑完 / halt 的逐模块摘要 + halt 后未跑的 pending 模块列表。
1125   -// 注:顶层 `return` 在 CommonJS 中合法,但在 ESM 中非法。本脚本被 Workflow 运行时以 ESM 方式
1126   -// (dynamic import)加载时,运行时会把脚本体包进 async function 再执行,于是顶层 `return` 实际成为
1127   -// Workflow 的结果通道(与 `export const meta` 并存)。**不要**改成 `export default {...}` —— 那
1128   -// 会破坏返回值契约,Workflow 拿不到 results / pending。
  1130 +// 注:顶层 `return` 不是普通 Node ESM 语法;本文件由 Claude Workflow 运行时执行,
  1131 +// 运行时会把脚本体包进 async function,顶层 `return` 是 Workflow 的结果通道。
  1132 +// 不要把本文件作为 `node workflows/coding.mjs` 直接运行,也不要改成 `export default {...}`,
  1133 +// 否则 Workflow 拿不到 results / pending。
1129 1134 return { results, pending }
... ...