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,7 +31,7 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。
31 31
32 coding-start(瘦入口 skill)校验 Plan 终结闸 → 启动 Workflow 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 ├─ B-后端(按模块循环,每模块一个里程碑 tag;功能链顺序 for-await,单工作树串行 commit) 36 ├─ B-后端(按模块循环,每模块一个里程碑 tag;功能链顺序 for-await,单工作树串行 commit)
37 │ runBranchSetup(module-<id>) ← JS 编排:detect default → wt clean → exists? → 37 │ runBranchSetup(module-<id>) ← JS 编排:detect default → wt clean → exists? →
@@ -77,7 +77,7 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 @@ -77,7 +77,7 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。
77 ``` 77 ```
78 Plan 全部完成后由你显式触发;详细职责见下方 Skill 清单。详细流程见上方阶段 B 流程图。 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,7 +117,7 @@ erp-workflow-plugin/
117 | Skill | 作用 | 谁调用 | 117 | Skill | 作用 | 谁调用 |
118 |---|---|---| 118 |---|---|---|
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` | 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 ### Plan 阶段 A skill(A0~A6,共 7 个) 122 ### Plan 阶段 A skill(A0~A6,共 7 个)
123 123
@@ -157,7 +157,7 @@ erp-workflow-plugin/ @@ -157,7 +157,7 @@ erp-workflow-plugin/
157 | skeleton-gen | `docs-07-env-template.md` | docs/07 环境配置大纲(只记规则/约定,不记具体值;配置值指向 config-vars.yaml + .env.local) | 157 | skeleton-gen | `docs-07-env-template.md` | docs/07 环境配置大纲(只记规则/约定,不记具体值;配置值指向 config-vars.yaml + .env.local) |
158 | skeleton-gen | `docs-09-structure-template.md` | docs/09 目录结构大纲 | 158 | skeleton-gen | `docs-09-structure-template.md` | docs/09 目录结构大纲 |
159 | skeleton-gen | `scripts-setup-test-db-template.mjs` | 跨平台 drop + create 空库脚本(安全 env 解析,无 shell-source);schema apply 交给 Flyway | 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 | skeleton-gen | `env-local-template` | 凭据模板(DB_* + JWT_SECRET);A2 据 config-vars.yaml `secrets_ref` 追加项目专属 secret 键 | 161 | skeleton-gen | `env-local-template` | 凭据模板(DB_* + JWT_SECRET);A2 据 config-vars.yaml `secrets_ref` 追加项目专属 secret 键 |
162 | skeleton-gen | `gitignore-append-template` | 插件推荐忽略项(`.env.local`、`.tmp/`、构建产物等) | 162 | skeleton-gen | `gitignore-append-template` | 插件推荐忽略项(`.env.local`、`.tmp/`、构建产物等) |
163 | skeleton-gen | `styles-tokens-template.css` | 前端 design tokens CSS 变量骨架 | 163 | skeleton-gen | `styles-tokens-template.css` | 前端 design tokens CSS 变量骨架 |
@@ -173,9 +173,9 @@ erp-workflow-plugin/ @@ -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 - **Spring Boot + Flyway**(**必需**):pom.xml 声明 `flyway-core` + `flyway-mysql`;Spring 启动时自动 apply `sql/migrations/V*.sql`。本插件生成的 `setup-test-db.mjs` 只清库,schema 必须由 Flyway 应用 179 - **Spring Boot + Flyway**(**必需**):pom.xml 声明 `flyway-core` + `flyway-mysql`;Spring 启动时自动 apply `sql/migrations/V*.sql`。本插件生成的 `setup-test-db.mjs` 只清库,schema 必须由 Flyway 应用
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** 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 - **本地可运行 `mvn test` / `pnpm test`**:测试命令由 A1 写入 docs/04 § 零,生成的 `scripts/test.mjs` 由 `skeleton-gen` 产出,`coding.mjs` 的 testGate stage 调用 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,6 +55,11 @@ export function parseEnv(text) {
55 export async function applyDDL({ envPath, ddlPath }) { 55 export async function applyDDL({ envPath, ddlPath }) {
56 const { readFileSync } = await import('node:fs') 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 let mysql 63 let mysql
59 try { 64 try {
60 ;({ default: mysql } = await import('mysql2/promise')) 65 ;({ default: mysql } = await import('mysql2/promise'))
@@ -62,10 +67,6 @@ export async function applyDDL({ envPath, ddlPath }) { @@ -62,10 +67,6 @@ export async function applyDDL({ envPath, ddlPath }) {
62 throw new MysqlUnavailableError() 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 const conn = await mysql.createConnection({ 70 const conn = await mysql.createConnection({
70 host, 71 host,
71 port, 72 port,
@@ -100,9 +101,37 @@ export function resolveDbConfig(env, envPath = &#39;.env.local&#39;) { @@ -100,9 +101,37 @@ export function resolveDbConfig(env, envPath = &#39;.env.local&#39;) {
100 if (!database) { 101 if (!database) {
101 throw new Error(`apply-ddl: 缺数据库名 — 请在 ${envPath} 设置 DB_SCHEMA(或 DB_NAME / MYSQL_DATABASE)`) 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 return { host, port, user, password, database } 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 /** Distinct error type so the CLI can emit a friendly install hint. */ 135 /** Distinct error type so the CLI can emit a friendly install hint. */
107 export class MysqlUnavailableError extends Error { 136 export class MysqlUnavailableError extends Error {
108 constructor() { 137 constructor() {
lib/apply-ddl.test.mjs
1 import { test } from 'node:test' 1 import { test } from 'node:test'
2 import assert from 'node:assert/strict' 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 test('parseEnv ignores comments, trims, keeps special chars literally', () => { 5 test('parseEnv ignores comments, trims, keeps special chars literally', () => {
6 const env = parseEnv('# c\nDB_PASS=p@ss$word!\nDB_NAME = erp \n') 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,3 +84,35 @@ test(&#39;resolveDbConfig applies sane defaults for host/port/user/password&#39;, () =&gt;
84 assert.equal(c.user, 'root') 84 assert.equal(c.user, 'root')
85 assert.equal(c.password, '') 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,7 +2,7 @@
2 name: coding-start 2 name: coding-start
3 description: B 阶段(Coding)瘦入口。校验 Plan 终结闸(docs/08 §一 A0~A6 全勾、已在本地默认分支、工作树干净)后,读取 docs/08 §二/§三 概述模块/前端进度,然后调用 workflows/coding.mjs Workflow 全自动、静默地跑完整个编码阶段(后端+前端功能循环、测试闸、里程碑 tag),跑完或 halt 时返回最终状态。本入口不写任何文件、不做编码决策。 3 description: B 阶段(Coding)瘦入口。校验 Plan 终结闸(docs/08 §一 A0~A6 全勾、已在本地默认分支、工作树干净)后,读取 docs/08 §二/§三 概述模块/前端进度,然后调用 workflows/coding.mjs Workflow 全自动、静默地跑完整个编码阶段(后端+前端功能循环、测试闸、里程碑 tag),跑完或 halt 时返回最终状态。本入口不写任何文件、不做编码决策。
4 user-invocable: true 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,11 +44,13 @@ allowed-tools: Read Glob Workflow
44 - 任一未勾 → 缺口:`Plan 未完成(<未勾项>)→ 先运行 /erp-workflow:plan-start`。 44 - 任一未勾 → 缺口:`Plan 未完成(<未勾项>)→ 先运行 /erp-workflow:plan-start`。
45 45
46 2. **当前在本地默认分支(main / master)** 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 3. **工作树干净(Plan 产物已 commit)** 52 3. **工作树干净(Plan 产物已 commit)**
51 - - 同样无法用 Bash 直接查。在放行横幅中**显式要求**用户确认工作树干净、Plan 产物已提交 53 + - 用 `Bash(git status --porcelain)` 检查,stdout 非空即缺口:`工作树不干净(列出前 20 行 dirty 路径)`
52 54
53 任一缺口 → 输出拦截横幅,逐条列出缺口与回填位置,**停下**,不启动 Workflow: 55 任一缺口 → 输出拦截横幅,逐条列出缺口与回填位置,**停下**,不启动 Workflow:
54 56
@@ -65,7 +67,7 @@ allowed-tools: Read Glob Workflow @@ -65,7 +67,7 @@ allowed-tools: Read Glob Workflow
65 67
66 ### 步骤 3:概述进度(信息提要) 68 ### 步骤 3:概述进度(信息提要)
67 69
68 -仅当步骤 2 的 § 一 校验通过后,`Read` `docs/08 § 二`(后端模块元数据 + `里程碑:` 字段)与 `§ 三`(前端阶段 `整体里程碑:` 字段),概述: 70 +仅当步骤 2 的 § 一 校验通过后,`Read` `docs/08 § 二`(后端模块元数据 + `里程碑:` 字段)与 `§ 三`(前端阶段 `整体里程碑:` 字段),并用 `Bash(git tag -l "milestone/*")` 读取本地里程碑 tag,概述:
69 71
70 - 后端:每个模块的 `里程碑:` 是否已是 `milestone/<module_id>`(已完成)还是 `—`(待跑)。 72 - 后端:每个模块的 `里程碑:` 是否已是 `milestone/<module_id>`(已完成)还是 `—`(待跑)。
71 - 前端:`§ 三 整体里程碑:` 是否已是 `milestone/frontend-phase`(已完成)还是 `—`(待跑)。 73 - 前端:`§ 三 整体里程碑:` 是否已是 `milestone/frontend-phase`(已完成)还是 `—`(待跑)。
@@ -74,12 +76,12 @@ allowed-tools: Read Glob Workflow @@ -74,12 +76,12 @@ allowed-tools: Read Glob Workflow
74 76
75 ### 步骤 4:启动 Coding Workflow 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 Workflow({ 82 Workflow({
81 scriptPath: "${CLAUDE_PLUGIN_ROOT}/workflows/coding.mjs", 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,9 +95,9 @@ Workflow({
93 95
94 进度概述:<步骤 3 概述,如「待跑 3 模块 + 前端阶段」> 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 Workflow 将按模块顺序全自动、静默推进,跑完所有模块或在某模块 102 Workflow 将按模块顺序全自动、静默推进,跑完所有模块或在某模块
101 halt(测试闸持续 RED / review 5 轮未过 / 缺值阻塞等)时返回最终状态。 103 halt(测试闸持续 RED / review 5 轮未过 / 缺值阻塞等)时返回最终状态。
skills/plan/db-init/SKILL.md
@@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
2 name: db-init 2 name: db-init
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。 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 user-invocable: false 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,10 +58,34 @@ node &quot;${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs&quot; \
58 58
59 用 `Glob` 确认 `.env.local` 存在(不存在 → 提示重跑 A2 `skeleton-gen` 并停下)。用 `Read` 逐行解析 `KEY=VALUE`(跳过空行 / `#` 注释,**不做 shell-source / 变量展开**),校验 `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_SCHEMA` 5 项均非空——任一缺失 → 打印缺失字段并停下。 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 ```bash 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 成功 → 进入步骤 C;失败 → 打印具体错误(认证 / 主机不可达 / 端口拒接)并停下。 91 成功 → 进入步骤 C;失败 → 打印具体错误(认证 / 主机不可达 / 端口拒接)并停下。
@@ -78,7 +102,7 @@ node scripts/setup-test-db.mjs @@ -78,7 +102,7 @@ node scripts/setup-test-db.mjs
78 102
79 #### C.2 把 V1 灌入已清空的 schema 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 ```bash 107 ```bash
84 node "${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs" .env.local sql/migrations/V1__initial_schema.sql 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,9 +70,11 @@ if (missing.length) {
70 70
71 ### C. 初始化 Git(如尚未初始化) 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 完成后,用 `Edit` 在 `docs/08-模块任务管理.md` 中勾选(A0 子项 + A0 顶层): 79 完成后,用 `Edit` 在 `docs/08-模块任务管理.md` 中勾选(A0 子项 + A0 顶层):
78 - ` - [ ] Git 已初始化` 80 - ` - [ ] Git 已初始化`
skills/plan/project-init/templates/docs-08-initial-template.md
@@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
2 2
3 > 全流程进度跟踪。CC 每完成一项产出就勾选一项。 3 > 全流程进度跟踪。CC 每完成一项产出就勾选一项。
4 > - **§ 一 Plan(A0~A6)**:`plan-start` 找第一个未勾 A 子项分发到对应 skill 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 ## 一、Plan 阶段(一次性) 7 ## 一、Plan 阶段(一次性)
8 8
@@ -49,11 +49,11 @@ @@ -49,11 +49,11 @@
49 49
50 ## 二、Coding 阶段(后端模块循环) 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 ## 三、Coding 阶段(前端整体) 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,7 +2,7 @@
2 name: scope-lock 2 name: scope-lock
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 问清。 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 user-invocable: false 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,7 +27,7 @@ allowed-tools: Read Write Edit Grep Glob Skill AskUserQuestion Bash(mkdir *) Bas
27 27
28 1. 用 `Grep` 校验 `docs/01-需求清单/index.md` 无 `【人工填写:` 残留;有则回步骤 C。 28 1. 用 `Grep` 校验 `docs/01-需求清单/index.md` 无 `【人工填写:` 残留;有则回步骤 C。
29 2. 用 `Read` 读 `index.md` 解析模块索引。 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 ```bash 32 ```bash
33 # 模块头:先 Write docs/01-需求清单/<MOD>-<模块名>/_module.vars.json,内容形如 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,6 +46,16 @@ const DB_USER = env.DB_USER ?? &#39;&#39;
46 const DB_PASSWORD = env.DB_PASSWORD ?? '' 46 const DB_PASSWORD = env.DB_PASSWORD ?? ''
47 const DB_SCHEMA = env.DB_SCHEMA ?? '' 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 // 防护 1:默认只允许本地 host(localhost / 127.0.0.1 / ::1)。 59 // 防护 1:默认只允许本地 host(localhost / 127.0.0.1 / ::1)。
50 // 额外允许的远程 host 在 .env.local 的 TEST_DB_ALLOWED_HOSTS 中(空格或逗号分隔)。 60 // 额外允许的远程 host 在 .env.local 的 TEST_DB_ALLOWED_HOSTS 中(空格或逗号分隔)。
51 const extraHosts = (env.TEST_DB_ALLOWED_HOSTS ?? '') 61 const extraHosts = (env.TEST_DB_ALLOWED_HOSTS ?? '')
@@ -85,10 +95,10 @@ const sql = @@ -85,10 +95,10 @@ const sql =
85 `CREATE DATABASE \`${DB_SCHEMA}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;` 95 `CREATE DATABASE \`${DB_SCHEMA}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`
86 96
87 const mysqlArgs = [ 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 '-e', 102 '-e',
93 sql, 103 sql,
94 ] 104 ]
workflows/coding.mjs
@@ -164,21 +164,23 @@ function routerPrompt(root) { @@ -164,21 +164,23 @@ function routerPrompt(root) {
164 '', 164 '',
165 '你是 Coding 阶段的路由子代理。**只读不写**(不改任何代码 / 文档),仅从状态账本重算"哪些模块还要跑",返回结构化结果。', 165 '你是 Coding 阶段的路由子代理。**只读不写**(不改任何代码 / 文档),仅从状态账本重算"哪些模块还要跑",返回结构化结果。',
166 '', 166 '',
167 - '## 读取来源(账本 = docs/08 + git tag,二者一致才算完成)', 167 + '## 读取来源(账本 = docs/08 + git tag,里程碑和功能级完成都以 tag 为真值)',
168 '1. `docs/08-模块任务管理.md § 二`(后端模块元数据):逐个模块取 `id`(英文蛇形 module id)、本模块的 REQ 列表(按 `docs/02-开发计划.md § 二 开发顺序清单` 的顺序,A5 约束保证同模块 REQ 连续),以及该模块的 `里程碑:` 字段。', 168 '1. `docs/08-模块任务管理.md § 二`(后端模块元数据):逐个模块取 `id`(英文蛇形 module id)、本模块的 REQ 列表(按 `docs/02-开发计划.md § 二 开发顺序清单` 的顺序,A5 约束保证同模块 REQ 连续),以及该模块的 `里程碑:` 字段。',
169 '2. `docs/08-模块任务管理.md § 三`(前端阶段元数据):取 `整体里程碑:` 字段,以及 `功能:` 项下所有 `- [ ] FE-NN ...` / `- [x] FE-NN ...` 行(FE 清单)。前端 item 形如 `FE-NN`。', 169 '2. `docs/08-模块任务管理.md § 三`(前端阶段元数据):取 `整体里程碑:` 字段,以及 `功能:` 项下所有 `- [ ] FE-NN ...` / `- [x] FE-NN ...` 行(FE 清单)。前端 item 形如 `FE-NN`。',
170 '3. `git -C <root> tag -l "milestone/*"`:列出已打的里程碑 tag。', 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 '- 后端模块 `done = true` 当且仅当:§二 该模块 `里程碑:` 字段 == `milestone/<module_id>` **且** `git tag -l "milestone/<module_id>"` 能查到该 tag。任一缺失 → `done = false`。', 174 '- 后端模块 `done = true` 当且仅当:§二 该模块 `里程碑:` 字段 == `milestone/<module_id>` **且** `git tag -l "milestone/<module_id>"` 能查到该 tag。任一缺失 → `done = false`。',
174 '- 前端 item(FE-NN)归属一个"逻辑前端模块"。前端阶段整体 `done` 当且仅当 §三 `整体里程碑:` == `milestone/frontend-phase` 且 `git tag -l "milestone/frontend-phase"` 存在。', 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 '## 输出(必须符合下发的 JSON schema)', 178 '## 输出(必须符合下发的 JSON schema)',
177 '- `modules`: 数组。**先**按 `docs/02 § 二` 的模块顺序列出全部后端模块,**再在末尾追加唯一一个前端聚合模块**(仅当存在前端 FE 时)。每项:', 179 '- `modules`: 数组。**先**按 `docs/02 § 二` 的模块顺序列出全部后端模块,**再在末尾追加唯一一个前端聚合模块**(仅当存在前端 FE 时)。每项:',
178 ' - `id`: 模块标识(后端为英文蛇形 module id;前端聚合模块固定用 `frontend-phase`)。', 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 '- 即:后端模块只承载 `reqs`、`feItems=[]`;末尾的 `frontend-phase` 模块只承载 `feItems`、`reqs=[]`。整个项目至多一个前端聚合模块,对应至多一个 `milestone/frontend-phase` tag。', 184 '- 即:后端模块只承载 `reqs`、`feItems=[]`;末尾的 `frontend-phase` 模块只承载 `feItems`、`reqs=[]`。整个项目至多一个前端聚合模块,对应至多一个 `milestone/frontend-phase` tag。',
183 '- 不要返回任何额外字段(schema 为 `additionalProperties:false`)。', 185 '- 不要返回任何额外字段(schema 为 `additionalProperties:false`)。',
184 '', 186 '',
@@ -467,7 +469,7 @@ function detectDefaultBranchPromptM() { @@ -467,7 +469,7 @@ function detectDefaultBranchPromptM() {
467 '# 检测本地默认分支', 469 '# 检测本地默认分支',
468 microStepContract(), 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 '## 输出(DEFAULT_BRANCH_SCHEMA)', 473 '## 输出(DEFAULT_BRANCH_SCHEMA)',
472 '- 两者其一存在:`{ "branch": "main" }` 或 `{ "branch": "master" }`', 474 '- 两者其一存在:`{ "branch": "main" }` 或 `{ "branch": "master" }`',
473 '- 都不存在:本步骤失败(返回 schema 失败即可,调用方会 halt)。', 475 '- 都不存在:本步骤失败(返回 schema 失败即可,调用方会 halt)。',
@@ -491,7 +493,7 @@ function checkBranchExistsPromptM(branch) { @@ -491,7 +493,7 @@ function checkBranchExistsPromptM(branch) {
491 `# 本地分支 \`${branch}\` 是否存在`, 493 `# 本地分支 \`${branch}\` 是否存在`,
492 microStepContract(), 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 '## 输出(EXISTS_SCHEMA)', 497 '## 输出(EXISTS_SCHEMA)',
496 '- exit=0 → `{ "exists": true }`;非 0 → `{ "exists": false }`', 498 '- exit=0 → `{ "exists": true }`;非 0 → `{ "exists": false }`',
497 ].join('\n') 499 ].join('\n')
@@ -634,8 +636,8 @@ function readDocs08CheckboxPromptM(fe, id) { @@ -634,8 +636,8 @@ function readDocs08CheckboxPromptM(fe, id) {
634 const section = fe ? '§ 三' : '§ 二' 636 const section = fe ? '§ 三' : '§ 二'
635 const kind = fe ? '功能' : 'REQ' 637 const kind = fe ? '功能' : 'REQ'
636 const locator = fe 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 return [ 641 return [
640 `# 读 docs/08 ${section} ${kind} \`${id}\` 的勾选态(\`- [ ] ${id} ...\` / \`- [x] ${id} ...\`)`, 642 `# 读 docs/08 ${section} ${kind} \`${id}\` 的勾选态(\`- [ ] ${id} ...\` / \`- [x] ${id} ...\`)`,
641 microStepContract(), 643 microStepContract(),
@@ -644,18 +646,21 @@ function readDocs08CheckboxPromptM(fe, id) { @@ -644,18 +646,21 @@ function readDocs08CheckboxPromptM(fe, id) {
644 '## 输出(CHECKBOX_STATE_SCHEMA)', 646 '## 输出(CHECKBOX_STATE_SCHEMA)',
645 `- 命中 \`- [x] ${id} ...\`:\`{ "found": true, "state": "checked", "lineNumber": <行号> }\``, 647 `- 命中 \`- [x] ${id} ...\`:\`{ "found": true, "state": "checked", "lineNumber": <行号> }\``,
646 `- 命中 \`- [ ] ${id} ...\`:\`{ "found": true, "state": "unchecked", "lineNumber": <行号> }\``, 648 `- 命中 \`- [ ] ${id} ...\`:\`{ "found": true, "state": "unchecked", "lineNumber": <行号> }\``,
647 - '- 找不到:`{ "found": false }`', 649 + '- 找不到:`{ "found": false, "state": "unchecked" }`(state 仍必填,避免 schema 失败掩盖真实缺口)。',
648 ].join('\n') 650 ].join('\n')
649 } 651 }
650 652
651 -function writeDocs08CheckboxPromptM(fe, id, phase) { 653 +function writeDocs08CheckboxPromptM(fe, id, phase, lineNumber) {
652 const scope = fe ? `§ 三 功能 ${id}` : `§ 二 REQ ${id}` 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 return [ 658 return [
654 `# 把 docs/08 ${scope} 的 \`[ ]\` 勾选为 \`[x]\` 并 commit`, 659 `# 把 docs/08 ${scope} 的 \`[ ]\` 勾选为 \`[x]\` 并 commit`,
655 microStepContract(), 660 microStepContract(),
656 '', 661 '',
657 `调用方已读到状态 = \`unchecked\`(你不必再读一遍)。`, 662 `调用方已读到状态 = \`unchecked\`(你不必再读一遍)。`,
658 - `1. Edit \`${ROOT}/docs/08-模块任务管理.md\`:把以 \`- [ ] ${id} \` 开头的整行替换为对应的 \`- [x] ${id} ...\`(保留原行 id 之后的全部文本,仅 \`[ ]\` → \`[x]\`,精确字符串替换;只动一处)。`, 663 + `1. ${lineGuard}`,
659 `2. 跑 \`git -C ${ROOT} add docs/08-模块任务管理.md\`。`, 664 `2. 跑 \`git -C ${ROOT} add docs/08-模块任务管理.md\`。`,
660 `3. 跑 \`git -C ${ROOT} commit -m "chore(${phase}:${id}): mark ${id} approved in docs/08"\`。`, 665 `3. 跑 \`git -C ${ROOT} commit -m "chore(${phase}:${id}): mark ${id} approved in docs/08"\`。`,
661 '## 输出(ACTION_RESULT_SCHEMA)', 666 '## 输出(ACTION_RESULT_SCHEMA)',
@@ -1019,7 +1024,7 @@ async function reviewWithFixLoop(id, phase, verifyResult, specPath) { @@ -1019,7 +1024,7 @@ async function reviewWithFixLoop(id, phase, verifyResult, specPath) {
1019 throw new Error(`HALT docs08-checkbox-state-invalid ${phase}:${id}: cb.state = ${JSON.stringify(cb.state)}`) 1024 throw new Error(`HALT docs08-checkbox-state-invalid ${phase}:${id}: cb.state = ${JSON.stringify(cb.state)}`)
1020 } 1025 }
1021 if (cb.state === 'unchecked') { 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 if (!wr.success) throw new Error(`HALT docs08-checkbox-write ${phase}:${id}: ${wr.error || ''}`) 1028 if (!wr.success) throw new Error(`HALT docs08-checkbox-write ${phase}:${id}: ${wr.error || ''}`)
1024 } 1029 }
1025 return { id, phase, approved:true, rounds:round } 1030 return { id, phase, approved:true, rounds:round }
@@ -1122,8 +1127,8 @@ const pending = haltedAtIdx &gt;= 0 @@ -1122,8 +1127,8 @@ const pending = haltedAtIdx &gt;= 0
1122 : [] 1127 : []
1123 1128
1124 // Workflow 结果:跑完 / halt 的逐模块摘要 + halt 后未跑的 pending 模块列表。 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 return { results, pending } 1134 return { results, pending }