Commit 32a0adb2507e5ec783d5699391cde213099c5b0b
1 parent
57025b00
fix: harden workflow gates and db safety
Showing
10 changed files
with
158 additions
and
54 deletions
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 = '.env.local') { | @@ -100,9 +101,37 @@ export function resolveDbConfig(env, envPath = '.env.local') { | ||
| 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('resolveDbConfig applies sane defaults for host/port/user/password', () => | @@ -84,3 +84,35 @@ test('resolveDbConfig applies sane defaults for host/port/user/password', () => | ||
| 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 "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ | @@ -58,10 +58,34 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ | ||
| 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 ?? '' | @@ -46,6 +46,16 @@ const DB_USER = env.DB_USER ?? '' | ||
| 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 >= 0 | @@ -1122,8 +1127,8 @@ const pending = haltedAtIdx >= 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 } |