diff --git a/README.md b/README.md index 078862e..cdb1f36 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 -把"从零到 N 个模块上线 + 前端整体阶段"的整个流程固化成 **9 个 skill(Plan 阶段,交互式)+ 1 个 Workflow(`workflows/coding.mjs`,Coding 阶段,全自动静默)+ 1 个 reviewer agent + 4 个跨平台 Node 助手(`lib/*.mjs`)+ 24 份模板**,让 CC 在 schema 演化用 Flyway migration、需求可追溯、纯本地 git 的前提下推进编码。Plan 阶段把全部需求/配置/前端约定问询前移(4 个闸门);Coding 阶段整体是单个 Workflow 脚本,子代理无法弹窗 → 结构性静默,后端按模块循环依次打里程碑 tag,所有后端模块打里程碑后进入前端整体阶段(以项目根 `prototype/` 静态 HTML mockup 为页面权威)。 +把"从零到 N 个模块上线 + 前端整体阶段"的整个流程固化成 **9 个 skill(Plan 阶段,交互式)+ 1 个 Workflow(`workflows/coding.mjs`,Coding 阶段,全自动静默)+ 1 个 reviewer agent + 4 个跨平台 Node 助手(`lib/*.mjs`)+ 25 份模板**,让 CC 在 schema 演化用 Flyway migration、需求可追溯、纯本地 git 的前提下推进编码。Plan 阶段把全部需求/配置/前端约定问询前移(4 个闸门);Coding 阶段整体是单个 Workflow 脚本,子代理无法弹窗 → 结构性静默,后端按模块循环依次打里程碑 tag,所有后端模块打里程碑后进入前端整体阶段(以项目根 `prototype/` 静态 HTML mockup 为页面权威)。 ## 这个插件做什么 @@ -173,7 +173,7 @@ coding-start(skill)校验 Plan 终结闸 → Workflow({scriptPath:"…/workf |---|---|---| | `code-reviewer` | 统一 reviewer。`phase=backend` 跑通用代码审查维度;`phase=frontend` 附加前端 7 维 checklist(prototype 一致性 / design tokens / a11y / 响应式 / 业务校验前端复刻 / API 一致性 / 状态机覆盖,主观维度仅标记明显问题不触发 request-changes)。非交互,返回结构化 verdict,绝不弹窗 | `workflows/coding.mjs` 的 review stage:`agent(..., {agentType:'code-reviewer'})` | -## Templates 清单(24 份) +## Templates 清单(25 份) | 所属 Skill | 模板文件 | 用途 | |---|---|---| @@ -183,13 +183,14 @@ coding-start(skill)校验 Plan 终结闸 → Workflow({scriptPath:"…/workf | project-init | `docs-08-initial-template.md` | 工作流进度文件骨架(Plan A0~A6 checkbox) | | scope-lock | `req-card-template.md` | 单张 REQ-XXX-NNN 卡片骨架(结构化字段表:字段名/类型/必填/校验/业务规则/示例值,示例值须替换为真实约束) | | scope-lock | `_module-template.md` | 模块子目录的 `_module.md` 模块头(模块代码-名 / 简述 / 依赖模块 TBD / 涉及表 TBD) | +| scope-lock | `config-vars-template.yaml` | 仓库根 `config-vars.yaml` 骨架(跨栈中立):非敏感项目级配置(包名/端口/前端包名/初始账号)+ `secrets_ref` 键名引用 `.env.local`;A1 E.2 锁定 | | skeleton-gen | `docs-04-skeleton-template.md` | docs/04 § 一+ 编码规范大纲(HTML 注释引导 LLM) | | skeleton-gen | `docs-06-static-template.md` | docs/06 § 一~二 大纲(通用交互 + Design Tokens;布局以 prototype/ 为权威) | -| skeleton-gen | `docs-07-env-template.md` | docs/07 环境配置大纲 | +| skeleton-gen | `docs-07-env-template.md` | docs/07 环境配置大纲(只记规则/约定,不记具体值;配置值指向 config-vars.yaml + .env.local) | | skeleton-gen | `docs-09-structure-template.md` | docs/09 目录结构大纲 | | skeleton-gen | `scripts-setup-test-db-template.mjs` | 跨平台 drop + create 空库脚本(安全 env 解析,无 shell-source);schema apply 交给 Flyway | | skeleton-gen | `scripts-test-template.mjs` | test.mjs 骨架(命令槽位 {{TEST_CMD}} / {{E2E_CMD}},`spawnSync(shell:true)` 跨平台执行) | -| skeleton-gen | `env-local-template` | 凭据模板(DB_* + JWT_SECRET) | +| skeleton-gen | `env-local-template` | 凭据模板(DB_* + JWT_SECRET);A2 据 config-vars.yaml `secrets_ref` 追加项目专属 secret 键 | | skeleton-gen | `gitignore-append-template` | 插件推荐忽略项(`.env.local`、`.tmp/`、构建产物等) | | skeleton-gen | `styles-tokens-template.css` | 前端 design tokens CSS 变量骨架 | | db-design-gen | `docs-03-header-template.md` | docs/03 数据库设计头部 | diff --git a/lib/apply-ddl.mjs b/lib/apply-ddl.mjs index 089cec9..acbd06b 100644 --- a/lib/apply-ddl.mjs +++ b/lib/apply-ddl.mjs @@ -62,7 +62,11 @@ export function parseEnv(text) { * * Reads connection settings from the parsed env file. Recognised keys (with * common aliases) — DB_HOST/MYSQL_HOST, DB_PORT/MYSQL_PORT, DB_USER/MYSQL_USER, - * DB_PASS/DB_PASSWORD/MYSQL_PASSWORD, DB_NAME/MYSQL_DATABASE. + * DB_PASS/DB_PASSWORD/MYSQL_PASSWORD, DB_SCHEMA/DB_NAME/MYSQL_DATABASE. + * DB_SCHEMA is the plugin's canonical schema key (see .env.local template); the + * target database MUST resolve to a non-empty value or applyDDL fails closed — + * V1 DDL contains no `USE`/`CREATE DATABASE`, so a missing schema would only + * surface as an opaque `ER_NO_DB_ERROR` on the first CREATE TABLE. * * @param {{envPath: string, ddlPath: string}} opts * @returns {Promise} @@ -79,12 +83,7 @@ export async function applyDDL({ envPath, ddlPath }) { const env = parseEnv(readFileSync(envPath, 'utf8')) const ddl = readFileSync(ddlPath, 'utf8') - - const host = env.DB_HOST || env.MYSQL_HOST || '127.0.0.1' - const port = Number(env.DB_PORT || env.MYSQL_PORT || 3306) - const user = env.DB_USER || env.MYSQL_USER || 'root' - const password = env.DB_PASS || env.DB_PASSWORD || env.MYSQL_PASSWORD || '' - const database = env.DB_NAME || env.MYSQL_DATABASE || undefined + const { host, port, user, password, database } = resolveDbConfig(env, envPath) const conn = await mysql.createConnection({ host, @@ -101,6 +100,30 @@ export async function applyDDL({ envPath, ddlPath }) { } } +/** + * Resolve mysql2 connection settings from a parsed env object. Pure (no I/O), + * so it is unit-testable without mysql2 installed. + * + * fail-closed on missing schema: DB_SCHEMA is the plugin's canonical key; V1 DDL + * has no `USE`/`CREATE DATABASE`, so a missing database would otherwise surface + * only as an opaque ER_NO_DB_ERROR on the first CREATE TABLE. + * + * @param {Record} env + * @param {string} [envPath] only used to make the error message actionable + * @returns {{host:string, port:number, user:string, password:string, database:string}} + */ +export function resolveDbConfig(env, envPath = '.env.local') { + const host = env.DB_HOST || env.MYSQL_HOST || '127.0.0.1' + const port = Number(env.DB_PORT || env.MYSQL_PORT || 3306) + const user = env.DB_USER || env.MYSQL_USER || 'root' + const password = env.DB_PASS || env.DB_PASSWORD || env.MYSQL_PASSWORD || '' + const database = env.DB_SCHEMA || env.DB_NAME || env.MYSQL_DATABASE || undefined + if (!database) { + throw new Error(`apply-ddl: 缺数据库名 — 请在 ${envPath} 设置 DB_SCHEMA(或 DB_NAME / MYSQL_DATABASE)`) + } + return { host, port, user, password, database } +} + /** Distinct error type so the CLI can emit a friendly install hint. */ export class MysqlUnavailableError extends Error { constructor() { diff --git a/lib/apply-ddl.test.mjs b/lib/apply-ddl.test.mjs index 86480da..d2208a0 100644 --- a/lib/apply-ddl.test.mjs +++ b/lib/apply-ddl.test.mjs @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { parseEnv } from './apply-ddl.mjs' +import { parseEnv, resolveDbConfig } from './apply-ddl.mjs' test('parseEnv ignores comments, trims, keeps special chars literally', () => { const env = parseEnv('# c\nDB_PASS=p@ss$word!\nDB_NAME = erp \n') @@ -55,3 +55,32 @@ test('parseEnv on empty / non-string input returns empty object', () => { assert.deepEqual(parseEnv(undefined), {}) assert.deepEqual(parseEnv(null), {}) }) + +// ── resolveDbConfig(M1:DB_SCHEMA 是插件契约的 schema 键)───────── +test('resolveDbConfig maps DB_SCHEMA to database (plugin canonical key)', () => { + const c = resolveDbConfig({ DB_SCHEMA: 'erp_test', DB_USER: 'u', DB_PASS: 'p', DB_HOST: 'db.local', DB_PORT: '3307' }) + assert.equal(c.database, 'erp_test') + assert.equal(c.host, 'db.local') + assert.equal(c.port, 3307) + assert.equal(c.user, 'u') + assert.equal(c.password, 'p') +}) + +test('resolveDbConfig honors DB_NAME / MYSQL_DATABASE aliases', () => { + assert.equal(resolveDbConfig({ DB_NAME: 'a' }).database, 'a') + assert.equal(resolveDbConfig({ MYSQL_DATABASE: 'b' }).database, 'b') + // DB_SCHEMA wins over aliases + assert.equal(resolveDbConfig({ DB_SCHEMA: 's', DB_NAME: 'a' }).database, 's') +}) + +test('resolveDbConfig fails closed when no schema key is present (M1)', () => { + assert.throws(() => resolveDbConfig({ DB_USER: 'root' }, '.env.local'), /DB_SCHEMA/) +}) + +test('resolveDbConfig applies sane defaults for host/port/user/password', () => { + const c = resolveDbConfig({ DB_SCHEMA: 's' }) + assert.equal(c.host, '127.0.0.1') + assert.equal(c.port, 3306) + assert.equal(c.user, 'root') + assert.equal(c.password, '') +}) diff --git a/lib/render.mjs b/lib/render.mjs index e0bcd8a..976d9d2 100644 --- a/lib/render.mjs +++ b/lib/render.mjs @@ -8,7 +8,9 @@ export function render(template, vars) { const withoutComments = template.replace(//g, '') return withoutComments.replace(/\{\{(\w+)\}\}/g, (_, key) => { - if (!(key in vars)) throw new Error(`render: missing var "${key}"`) + // 用 Object.hasOwn 而非 `key in vars`:避免 {{constructor}} / {{toString}} 等 + // 沿原型链命中继承属性、静默渲染出垃圾(应按"缺变量"抛错)。 + if (!Object.hasOwn(vars, key)) throw new Error(`render: missing var "${key}"`) return String(vars[key]) // 字面插入,不二次解释 $ 或 {} }) } @@ -16,7 +18,7 @@ export function render(template, vars) { // 入口判定用 pathToFileURL 规范化 process.argv[1],使其与 import.meta.url 编码一致 // (路径含空格/非 ASCII/Windows 反斜杠时,字面 `file://${argv[1]}` 比较会失配)。 const { pathToFileURL } = await import('node:url') -if (import.meta.url === pathToFileURL(process.argv[1]).href) { +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const { readFileSync, writeFileSync } = await import('node:fs') const [tplPath, jsonPath, outPath] = process.argv.slice(2) const tpl = readFileSync(tplPath, 'utf8') diff --git a/lib/render.test.mjs b/lib/render.test.mjs index 9529d24..4623d8b 100644 --- a/lib/render.test.mjs +++ b/lib/render.test.mjs @@ -14,3 +14,10 @@ test('strips HTML comments used as template guides', () => { test('missing key throws (no silent blank)', () => { assert.throws(() => render('{{missing}}', {}), /missing/) }) +test('inherited prototype keys are treated as missing (not silently rendered)', () => { + // {{constructor}} / {{toString}} 不应沿原型链命中继承函数渲染出垃圾 + assert.throws(() => render('{{constructor}}', {}), /missing var "constructor"/) + assert.throws(() => render('{{toString}}', {}), /missing var "toString"/) + // 但 own 属性即便名为 constructor 也应正常渲染 + assert.equal(render('{{constructor}}', { constructor: 'X' }), 'X') +}) diff --git a/lib/validate-ddl.mjs b/lib/validate-ddl.mjs index a84ef9c..689f98c 100644 --- a/lib/validate-ddl.mjs +++ b/lib/validate-ddl.mjs @@ -17,7 +17,10 @@ // ── 解析 docs/03 markdown 表定义 ───────────────────────────────── // 约定:每张表一节,节标题形如 ## `表名` 或 ## `表名` — 业务含义 -// 节内的 markdown 表格首列是列名(可含反引号),次列是类型。 +// 节内分 ### 字段(markdown 表格,首列列名、次列类型)、### 索引、### 外键(项目符号列表)。 +// 索引/外键的 bullet 形态见 db-design-gen/templates/docs-03-table-template.md: +// ### 索引 → - `name` (type): cols +// ### 外键 → - `name`: from_col → to_table.to_col (on_delete) // 跳过表头行(列/字段/类型等标签)与分隔行(---)。 // 形如「## 一、全局约定」这类非反引号标题不视为表。 export function parseDocsTables(text) { @@ -25,13 +28,15 @@ export function parseDocsTables(text) { const lines = String(text).split('\n') // 反引号包裹的表名:## `name` 或 ## `name` — purpose const headerRe = /^##\s+`([^`]+)`/ - let current = null // { columns: Map } + let current = null // { columns, indexes, foreignKeys } + let mode = 'col' // 当前子区块:'col'(字段表格)/ 'idx'(索引)/ 'fk'(外键) for (const raw of lines) { const line = raw.replace(/\r$/, '') const h2 = line.match(headerRe) if (h2) { current = { columns: new Map(), indexes: new Set(), foreignKeys: new Set() } + mode = 'col' tables.set(h2[1].trim(), current) continue } @@ -41,7 +46,16 @@ export function parseDocsTables(text) { continue } if (!current) continue - // markdown 表格行:以 | 开头 + // ### 子区块切换(### 索引 / ### 外键 / 其它如 ### 字段、### 业务注记 → col) + const h3 = line.match(/^###\s+(.+)$/) + if (h3) { + const title = h3[1].trim() + mode = /索引|index/i.test(title) ? 'idx' : /外键|foreign/i.test(title) ? 'fk' : 'col' + continue + } + if (mode === 'idx') { parseIndexBullet(line, current.indexes); continue } + if (mode === 'fk') { parseForeignKeyBullet(line, current.foreignKeys); continue } + // mode === 'col':markdown 表格行(以 | 开头) if (!/^\s*\|/.test(line)) continue const cells = splitMarkdownRow(line) if (cells.length < 2) continue @@ -56,11 +70,37 @@ export function parseDocsTables(text) { return tables } +// 解析索引 bullet: - `name` (type): cols +// type 为 PRIMARY(不分大小写)→ 记 'PRIMARY'(匹配 parseDDL 对主键的归一化); +// 否则记索引名 name(匹配 parseDDL 对命名索引存 name)。 +function parseIndexBullet(line, indexes) { + const m = line.match(/^\s*-\s+`?([^`():]+)`?\s*(?:\(([^)]*)\))?\s*:?/) + if (!m) return + const name = m[1].trim() + const type = (m[2] || '').trim() + if (!name) return + if (/^primary$/i.test(type) || /^primary$/i.test(name)) indexes.add('PRIMARY') + else indexes.add(name) +} + +// 解析外键 bullet: - `name`: from_col → to_table.to_col (on_delete) +// 归一化为 parseDDL 同形的 `${fromCol}->${toTable}(${toCol})`(注意 docs 用 unicode → / DDL 用 ->)。 +function parseForeignKeyBullet(line, foreignKeys) { + const m = line.match(/^\s*-\s+`?[^`:]+`?\s*:\s*`?([A-Za-z0-9_,\s]+?)`?\s*(?:→|->|>)\s*`?([A-Za-z0-9_]+)`?\.`?([A-Za-z0-9_]+)`?/) + if (!m) return + const fromCols = m[1].replace(/`/g, '').replace(/\s+/g, '') + const toTable = m[2] + const toCols = m[3].replace(/`/g, '').replace(/\s+/g, '') + if (!fromCols || !toTable || !toCols) return + foreignKeys.add(`${fromCols}->${toTable}(${toCols})`) +} + // ── 解析 CREATE TABLE DDL ──────────────────────────────────────── // 提取每个 CREATE TABLE 的:列名→类型、索引名集合、外键描述集合。 export function parseDDL(text) { const tables = new Map() - const src = String(text) + // 先剥离 SQL 注释,避免被注释掉的 CREATE TABLE 被当成真实表(幽灵表假阳性)。 + const src = stripSqlComments(String(text)) // 抓取 CREATE TABLE ( ) ;name 可带反引号;body 到匹配的右括号 const createRe = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([A-Za-z0-9_]+)`?\s*\(/gi let m @@ -182,9 +222,10 @@ export function diffSchema(docsTables, ddlTables) { if (!d.columns.has(col)) diff.columnMismatches.push({ table: t, column: col, side: 'ddl' }) } - // 维度 4:索引 - const dIdx = d.indexes || new Set() - const sIdx = s.indexes || new Set() + // 维度 4:索引。PRIMARY 由列级主键约定治理(已在列维度校验),且 docs/03 常只在 ### 字段 + // 表内体现 PK、不在 ### 索引 重列 → 从两侧索引集剔除 PRIMARY,避免假阳性;命名二级索引仍比对。 + const dIdx = new Set([...(d.indexes || [])].filter(ix => ix !== 'PRIMARY')) + const sIdx = new Set([...(s.indexes || [])].filter(ix => ix !== 'PRIMARY')) for (const ix of dIdx) if (!sIdx.has(ix)) diff.indexMismatches.push({ table: t, index: ix, side: 'docs' }) for (const ix of sIdx) if (!dIdx.has(ix)) diff.indexMismatches.push({ table: t, index: ix, side: 'ddl' }) @@ -202,6 +243,15 @@ export function diffSchema(docsTables, ddlTables) { } // ── 工具函数 ───────────────────────────────────────────────────── +// 剥离 SQL 注释:-- 行注释(到行尾)、# 行注释(到行尾)、/* */ 块注释。 +// 保守起见不解析字符串字面量内的注释符(DDL 极少在标识符/默认值里出现裸 -- 或 /*)。 +function stripSqlComments(sql) { + return sql + .replace(/\/\*[\s\S]*?\*\//g, ' ') // 块注释 + .replace(/--.*$/gm, '') // -- 行注释 + .replace(/#.*$/gm, '') // # 行注释 +} + function stripTicks(s) { return String(s).replace(/`/g, '').trim() } diff --git a/lib/validate-ddl.test.mjs b/lib/validate-ddl.test.mjs index e8e34fd..49ad3e3 100644 --- a/lib/validate-ddl.test.mjs +++ b/lib/validate-ddl.test.mjs @@ -61,6 +61,75 @@ test('parseDocsTables: real docs/03 format — ## `t` — purpose + ### 字段 + assert.equal(order.columns.has('---'), false) }) +// 全链路:模板格式 docs/03(### 字段 + ### 索引 + ### 外键 bullet)→ parseDocsTables 必须 +// 把索引/外键解析进 Set(回归 C2:此前 parseDocsTables 从不写 indexes/foreignKeys)。 +const DOCS_FULL = [ + '## `t_order` — 订单主表', + '', + '### 字段', + '| 字段 | 类型 | Nullable | 默认 | 业务含义 |', + '|---|---|---|---|---|', + '| `iId` | bigint | 否 | 自增 | 主键 |', + '| `sUserId` | varchar(100) | 否 | — | 用户ID |', + '', + '### 索引', + '- `pk` (PRIMARY): iId', + '- `idx_user` (index): sUserId', + '', + '### 外键', + '- `fk_user`: sUserId → t_user.sId (CASCADE)', + '', +].join('\n') +const DDL_FULL = [ + 'CREATE TABLE `t_order` (', + ' `iId` bigint NOT NULL AUTO_INCREMENT,', + ' `sUserId` varchar(100) NOT NULL,', + ' PRIMARY KEY (`iId`),', + ' KEY `idx_user` (`sUserId`),', + ' CONSTRAINT `fk_user` FOREIGN KEY (`sUserId`) REFERENCES `t_user` (`sId`)', + ') ENGINE=InnoDB;', +].join('\n') + +test('parseDocsTables: parses ### 索引 / ### 外键 bullets into sets (C2 regression)', () => { + const t = parseDocsTables(DOCS_FULL).get('t_order') + assert.ok(t) + assert.ok(t.indexes.has('PRIMARY'), 'PRIMARY index normalized') + assert.ok(t.indexes.has('idx_user'), 'named index by name') + assert.ok(t.foreignKeys.has('sUserId->t_user(sId)'), 'FK normalized to parseDDL form') +}) + +test('full chain: matching docs/03 (with indexes+FK) ↔ DDL yields no diff (C2 regression)', () => { + const d = diffSchema(parseDocsTables(DOCS_FULL), parseDDL(DDL_FULL)) + assert.deepEqual(d.indexMismatches, [], 'index dimension clean') + assert.deepEqual(d.foreignKeyMismatches, [], 'FK dimension clean') + assert.equal(d.hasDiff, false, 'no spurious diff on a faithful schema') +}) + +test('full chain: a real FK present in docs but missing from DDL is caught', () => { + const ddlNoFk = [ + 'CREATE TABLE `t_order` (', + ' `iId` bigint NOT NULL AUTO_INCREMENT,', + ' `sUserId` varchar(100) NOT NULL,', + ' PRIMARY KEY (`iId`),', + ' KEY `idx_user` (`sUserId`)', + ') ENGINE=InnoDB;', + ].join('\n') + const d = diffSchema(parseDocsTables(DOCS_FULL), parseDDL(ddlNoFk)) + assert.ok(d.foreignKeyMismatches.some(m => m.side === 'docs' && m.foreignKey === 'sUserId->t_user(sId)')) + assert.equal(d.hasDiff, true) +}) + +test('parseDDL: CREATE TABLE inside a comment is NOT counted as a table (L4)', () => { + const ddl = [ + '-- CREATE TABLE ghost_line ( x int );', + '/* CREATE TABLE ghost_block ( y int ); */', + '# CREATE TABLE ghost_hash ( z int );', + 'CREATE TABLE real_one ( a int );', + ].join('\n') + const tables = parseDDL(ddl) + assert.deepEqual([...tables.keys()], ['real_one']) +}) + test('parseDocsTables: top-level ## headers like "## 一、全局约定" are NOT tables', () => { const docs = [ '## 一、全局约定(人工填)', diff --git a/skills/downstream-gen/templates/docs-02-template.md b/skills/downstream-gen/templates/docs-02-template.md index 9eed94e..68d29d5 100644 --- a/skills/downstream-gen/templates/docs-02-template.md +++ b/skills/downstream-gen/templates/docs-02-template.md @@ -10,7 +10,7 @@ ## 二、开发顺序清单(CC 分发权威) -> 本清单由 A5 `downstream-gen` 一次性生成。**每行是一个 REQ**,不是模块。CC 按表格行序从上到下扫描,对每个 REQ 所属模块查 `docs/08 § 二` 的 `里程碑:` 字段 + 本地 `git tag -l 'milestone/'`:tag 存在则跳过,否则(`—` / tag 不存在)选为当前模块;`module-start` 会把该模块的所有 REQ 一次做完。 +> 本清单由 A5 `downstream-gen` 一次性生成。**每行是一个 REQ**,不是模块。Coding 阶段(`coding.mjs` 的 Router)按表格行序确定模块顺序,对每个 REQ 所属模块查 `docs/08 § 二` 的 `里程碑:` 字段 + 本地 `git tag -l 'milestone/'`:tag 存在则跳过,否则(`—` / tag 不存在)选为待跑模块;顶层循环对每个待跑后端模块依次跑功能链(spec→plan→tdd→verify→review)+ 测试闸 + 里程碑,把该模块的所有 REQ 一次做完。 > > **约束**:同一模块的所有 REQ 必须**连续排列**。允许打破依赖拓扑(如环依赖、业务必须先做),但必须在「备注」列写明原因。 @@ -20,7 +20,7 @@ | {{index}} | **{{req_id}}** | {{module_id}} | {{rationale}} | {{note}} | {{/each}} -> **后端模块全部打里程碑后**:milestone-tag 自动回调 `coding-start` → coding-start 检测到 `backend_done=true && frontend_done=false` → 派发 `frontend-start`。`frontend-start` 步骤 1 自带 prototype/ 门禁(≥ 1 个 `*.html` mockup,缺失则 AskUserQuestion 提示用户补齐)。前端阶段以业务功能(不是 HTML 文件数)为粒度拆分 FE,每个 FE 跑一次 feature 循环(fe-feature-*),最后整个阶段打 1 个里程碑 tag(分支 `frontend-phase`,记录在 `docs/08 § 三 整体里程碑`)。 +> **后端模块全部打里程碑后**:`coding.mjs` 的 Router 把全部未完成前端 FE 聚合为**唯一一个** `frontend-phase` 阶段,排在所有后端模块之后由顶层循环跑(prototype/ 门禁已在 Plan 期 A6 `frontend-scope-lock` 前移完成)。前端阶段以业务功能(不是 HTML 文件数)为粒度拆分 FE(FE-NN,路径限 `frontend/`),每个 FE 跑一次功能链,整个阶段打 **1 个**里程碑 tag(分支 `frontend-phase`,`milestone/frontend-phase`,记录在 `docs/08 § 三 整体里程碑`)。 ## 三、关键说明 {{notes}} diff --git a/skills/downstream-gen/templates/docs-10-header-template.md b/skills/downstream-gen/templates/docs-10-header-template.md index 3a9c860..dc62c40 100644 --- a/skills/downstream-gen/templates/docs-10-header-template.md +++ b/skills/downstream-gen/templates/docs-10-header-template.md @@ -12,5 +12,5 @@ > 本文档仅维护项目级验收 SOP。粒度更细的验收信息分散在: > - **每 REQ 的业务验收点**:`docs/01-需求清单//.md § 验收` -> - **每模块的实测验收(数据 / UI / 自动化用例位置)**:由 B 阶段 `module-report` 在该模块完成时填入模块完成报告 -> - **REQ 开发进度(feature-review approve 状态)**:`docs/08 § 二` +> - **每模块的实测验收(数据 / UI / 自动化用例位置)**:由 B 阶段 coding.mjs 的 module-report stage 在该模块完成时填入模块完成报告 +> - **REQ 开发进度(review stage approve 状态)**:`docs/08 § 二` diff --git a/skills/frontend-scope-lock/SKILL.md b/skills/frontend-scope-lock/SKILL.md index 92682e6..b610215 100644 --- a/skills/frontend-scope-lock/SKILL.md +++ b/skills/frontend-scope-lock/SKILL.md @@ -19,6 +19,8 @@ A6 是 **Plan 阶段最后一个前端守门 skill**,由 `plan-start` 在 A5 ## 执行步骤 +> **关于 AskUserQuestion**:下文只描述「问什么、给哪些选项、各选项导向什么后续」。`header` / 各选项的 `description` / `multiSelect` 等具体参数由你按工具 schema 自行填全合法值——不要把下文的选项文字当成完整调用照抄。 + ### 步骤 0:打印当前位置流程图 向用户**直接输出**(模型自己打印,不调 bash / cat)当前位置: @@ -38,11 +40,9 @@ A6 是 **Plan 阶段最后一个前端守门 skill**,由 `plan-start` 在 A5 用 `Glob` 检查项目根 `prototype/**/*.html`: - **至少 1 个 `.html`** → 通过,记下文件清单,进入步骤 2。 -- **0 个** → 这是 Plan 期,**可以问**。用 `AskUserQuestion` 告知用户: - - **question**:`未在 prototype/ 找到任何 .html 原型。前端范围锁定依赖原型作为页面骨架权威。` - - 选项:「我已补齐原型,请重新检查」(→ 重新 `Glob`,仍为 0 则重复本问)/「本项目无前端,跳过 A6」。 +- **0 个** → 这是 Plan 期,**可以问**。用 `AskUserQuestion` 告知用户「未在 prototype/ 找到任何 .html 原型,前端范围锁定依赖原型作为页面骨架权威」,给「我已补齐原型,请重新检查」和「本项目无前端,跳过 A6」两个选项。 + - 选「已补齐」→ 重新 `Glob`:命中则进入步骤 2,仍为 0 则重复本问。 - 选「无前端」→ 在 docs/08 § 一 勾选 A6 父项并注明「无前端,A6 跳过」,打印步骤 6 的终止横幅(产出标注「跳过」),**停止**,不写 docs/06 / docs/04。 - - 选「已补齐」且 `Glob` 命中 → 进入步骤 2。 ### 步骤 2:收集证据(只读,不问) @@ -51,7 +51,7 @@ A6 是 **Plan 阶段最后一个前端守门 skill**,由 `plan-start` 在 A5 - **prototype/**:所有 `*.html`,作为页面布局 / 组件 / 交互的**实测权威**(DOM 结构、表单展现、列表范式、状态色实例)。 - **docs/01-需求清单/**:各 `_module.md` + `REQ-*.md`,提取 UI 描述、业务校验、acceptance 中与界面相关的部分。 - **docs/05-API接口契约.md**:端点列表,确认前端要消费的接口集合(影响页面状态机 / 加载态)。 -- **docs/06-UI交互规范.md**:已有的整体布局(§ 一)、标准页面类型(§ 二)、通用交互规则(§ 三)、Design Tokens(§ 四)、页面清单(§ 三 由 A5 填)。本 skill 在此基础上**收敛 / 补全**,不推翻已确认内容。 +- **docs/06-UI交互规范.md**:已有的通用交互规则(§ 一)、Design Tokens(§ 二)、页面清单(§ 三 由 A5 填)。布局以项目根 `prototype/` 为权威,docs/06 不设独立布局小节。本 skill 在此基础上**收敛 / 补全**,不推翻已确认内容。 - **docs/04-技术规范.md § 零**:技术栈表里的前端行(如 `前端 UI 组件` = Ant Design),作为组件库选型默认值来源。 把证据归纳为三组**草案**:(a) 项目级 UI 约定、(b) Design Tokens(全局调色板 + 组件级状态色)、(c) 组件库选型。草案优先复用 docs/06 / docs/04 既有值(已锁定的不重问),仅对 prototype 与现文档**不一致**或**缺失**的点进入步骤 3 确认。 @@ -70,13 +70,13 @@ A6 是 **Plan 阶段最后一个前端守门 skill**,由 `plan-start` 在 A5 用 `${CLAUDE_SKILL_DIR}/templates/fe-scope-template.md` 作为填充骨架,把步骤 3 确认后的真实值填入(剥掉模板内 HTML 注释),分别落盘: -- **docs/06-UI交互规范.md**(用 `Edit` 合并,不另起文件): - - 模板 § 一 → 收敛 / 补全 docs/06 § 一(整体布局)+ § 三(通用交互规则)的项目级约定。 - - 模板 § 二 → 写入 / 校正 docs/06 § 四(Design Tokens 全局调色板 + 组件级状态色 + Token 默认值),与 `src/styles/tokens.css` 命名规范(docs/04 § 2.5)一致。 +- **docs/06-UI交互规范.md**(用 `Edit` 合并,不另起文件;小节编号以 `skeleton-gen/templates/docs-06-static-template.md` 为权威:§ 一 通用交互规则 / § 二 Design Tokens / § 三 页面清单): + - 模板 § 一 → 收敛 / 补全 docs/06 § 一(通用交互规则)的项目级约定。 + - 模板 § 二 → 写入 / 校正 docs/06 § 二(Design Tokens 全局调色板 + 组件级状态色 + Token 默认值),与 `src/styles/tokens.css` 命名规范(docs/04 § 2.5)一致。 - 模板 § 五 → 追加到 docs/06 § 三(页面清单)之后,作为 **FE 级设计决策表**:FE 清单来自 docs/08 § 三(若 § 三 尚无 FE bullet,则在此按 prototype + docs/01 + docs/05 推导 FE 清单并**同时写入 docs/08 § 三**「功能:」项,行格式见 docs/08 模板)。一 FE 一行。 - **docs/04-技术规范.md § 二(前端编码规范)**(用 `Edit`): - - 把组件库选型 + 模板 § 四前端栈摘要写入 / 校正 § 2.3(组件 / 页面编写规范)与 § 2.5(样式与主题)的引用说明;色值约定指向 docs/06 § 四。 - - 不重复抄 docs/06 全文,只写「前端组件库 = X、tokens 锁定于 docs/06 § 四」这类引用,保持 SSoT。 + - 把组件库选型 + 模板 § 四前端栈摘要写入 / 校正 § 2.3(组件 / 页面编写规范)与 § 2.5(样式与主题)的引用说明;色值约定指向 docs/06 § 二。 + - 不重复抄 docs/06 全文,只写「前端组件库 = X、tokens 锁定于 docs/06 § 二」这类引用,保持 SSoT。 写入时遵循模板的字面安全约定:值含 `$` / `{` / `}` 等字符**原样写入**,不做二次解释。 @@ -101,8 +101,8 @@ A6 是 **Plan 阶段最后一个前端守门 skill**,由 `plan-start` 在 A5 [frontend-scope-lock] ✅ A6 前端范围锁定完成 产出: - ✓ docs/06 § 一/§ 三 项目级 UI 约定 - ✓ docs/06 § 四 Design Tokens(全局调色板 + 组件级状态色) + ✓ docs/06 § 一 项目级 UI 约定(通用交互规则) + ✓ docs/06 § 二 Design Tokens(全局调色板 + 组件级状态色) ✓ docs/06 § 三之后 各 FE-NN 设计决策表 ✓ docs/04 § 二 前端栈 + 组件库选型(引用 docs/06) @@ -119,7 +119,7 @@ A6 是 **Plan 阶段最后一个前端守门 skill**,由 `plan-start` 在 A5 - `prototype/**/*.html`(页面骨架实测权威,步骤 1 前置门由本 skill 自承) - `docs/01-需求清单/**/*.md`(UI 描述 / 业务校验来源) - `docs/05-API接口契约.md`(前端消费端点) -- `docs/06-UI交互规范.md`(写入目标:§ 一/§ 三 约定、§ 四 Tokens、§ 三之后 FE 决策表) +- `docs/06-UI交互规范.md`(写入目标:§ 一 通用交互约定、§ 二 Tokens、§ 三之后 FE 决策表) - `docs/04-技术规范.md § 二 / § 零`(前端栈 + 组件库选型写入目标) - `docs/08-模块任务管理.md § 一`(A6 进度勾选)/ `§ 三`(FE 清单) - 上游:`plan-start`(A5 完成后派发到此) diff --git a/skills/plan-start/SKILL.md b/skills/plan-start/SKILL.md index 1be47e7..814cf92 100644 --- a/skills/plan-start/SKILL.md +++ b/skills/plan-start/SKILL.md @@ -46,12 +46,13 @@ A 阶段所有 checkbox 均 `[x]` 时**不代表可以进 B 阶段**。Coding 1. **REQ 卡片真实数据**(来自 A1 scope-lock) - `Glob` 找出全部 REQ 卡片(如 `docs/01-需求清单/**/*.md`)。 - 对每张卡片 `Grep` 残留占位:命中任一即缺口 — - `【人工填写`、`TBD`、`待补`、`<示例`、`示例值`(结构化字段的 `示例值` 列若仍是模板默认占位)。 + `【人工填写`、`TBD`、`待补`、`<示例`(用有区分度的 `<示例` 而非裸 `示例值`,避免误命中卡片合法表头行 `| ... | 示例值 |`;与 scope-lock E.1 写法一致)。 - 缺口表述示例:`REQ-USER-001 仍含 TBD / 示例值未替换为真实约束`。 -2. **docs/07 secrets 全锁**(来自 A1 收集的 secret/account/package-name/namespace 清单) - - `Read` `docs/07-环境配置.md`。 - - 校验:scope-lock 写入的每个 secret/account/package-name/namespace 字段均有真实值,无 `【人工填写`/`TBD`/空值。任一未填即缺口。 +2. **secrets / 项目配置全锁**(来自 A1 收集的 secret/account/package-name/namespace 清单) + - `Read` `.env.local`(真实 secret 值所在;gitignored,docs/07 只记规则不记值):校验 `config-vars.yaml` 的 `secrets_ref` 列出的每个 secret 键(如 `DB_PASSWORD` / `JWT_SECRET`)均有真实值,无 `【人工填写`/`TBD`/空值。 + - `Read` `config-vars.yaml`(非敏感项目级配置):校验包名 / namespace / 端口 / 初始账号等字段均已填,无 `【人工填写`/`TBD`。 + - 任一未填即缺口。(docs/07-环境配置.md 仅承载规则/约定,不参与值校验。) 3. **docs/04 §零 命令齐**(来自 A1 收集的每栈构建/lint/单测/e2e 命令) - `Read` `docs/04-技术规范.md`,定位 `§ 零` 命令区。 @@ -59,7 +60,7 @@ A 阶段所有 checkbox 均 `[x]` 时**不代表可以进 B 阶段**。Coding 4. **docs/05 + docs/02 已评审**(来自 A5 downstream-gen 的评审闸) - `Read` `docs/05-API接口契约.md` 与 `docs/02-开发计划.md`。 - - 校验:(a) docs/05 每个端点都有请求/响应 schema、无 `【人工填写`/`TBD`;(b) docs/02 每个 REQ 都在构建顺序 DAG 中、cycle-breaking 顺序有 `note` 说明;(c) downstream-gen 记录的「已人工评审」标记存在。缺任一即缺口。 + - 校验:(a) docs/05 每个端点都有请求/响应 schema、无 `【人工填写`/`TBD`;(b) docs/02 每个 REQ 都在构建顺序 DAG 中、cycle-breaking 顺序有 `note` 说明。缺任一即缺口。(A5 父项已勾本身即蕴含 downstream-gen 评审闸已过——downstream-gen 在用户未确认时禁止勾 A5,故无需独立的「已评审」标记。) 5. **A6 前端 scope 已锁**(来自 A6 frontend-scope-lock) - `Read` `docs/06-UI交互规范.md`。 @@ -75,7 +76,7 @@ A 阶段所有 checkbox 均 `[x]` 时**不代表可以进 B 阶段**。Coding 已校验通过: ✓ REQ 卡片均为真实数据(无占位/示例残留) - ✓ docs/07 secrets/account/package/namespace 全锁 + ✓ .env.local secrets + config-vars.yaml(account/package/namespace)全锁 ✓ docs/04 §零 各栈 build/lint/unit/e2e 命令齐全 ✓ docs/05 API 契约 + docs/02 构建顺序已评审 ✓ A6 前端 scope(UI 约定 / tokens / 组件库)已锁 @@ -110,7 +111,7 @@ A 阶段所有 checkbox 均 `[x]` 时**不代表可以进 B 阶段**。Coding <逐条列出每个缺口,格式:[闸门] 缺口描述 → 回填位置> 例: [REQ 真实数据] REQ-USER-001 输入字段「示例值」列仍为模板占位 → docs/01-需求清单/... - [docs/07 secrets] DB_PASSWORD 未填 → docs/07-环境配置.md + [secrets] DB_PASSWORD 未填 → .env.local [docs/04 §零] node 栈缺 e2e 命令 → docs/04-技术规范.md §零 补齐后再次运行 /erp-workflow:plan-start 重新校验。 diff --git a/skills/project-init/templates/CLAUDE-template.md b/skills/project-init/templates/CLAUDE-template.md index 1ea345e..beb88a6 100644 --- a/skills/project-init/templates/CLAUDE-template.md +++ b/skills/project-init/templates/CLAUDE-template.md @@ -172,7 +172,7 @@ B 阶段整体是**一个静默 Workflow 脚本 `workflows/coding.mjs`**(由 | # | 中断 | 例子 | | - | --- | --- | | 1 | **测试反复失败** | 同一测试同一功能内连续 **10 次**修复失败 | -| 2 | **要改密钥 / 账密 / 包名** | `docs/07-环境配置.md` 里由人工标注必须填的字段 | +| 2 | **要改密钥 / 账密 / 包名** | 密钥 / 账密 在 `.env.local`、包名 / 命名空间 / 端口等在 `config-vars.yaml` 里由人工标注必须填的字段(规则见 `docs/07-环境配置.md`) | | 3 | **外部接口不可达** | 第三方 API 无法连接、证书失效等环境问题,并无法自行解决 | > 其余需要人类判断的场景一律走普通 `AskUserQuestion` Q&A,不中断、不写 Blocker 文件。 diff --git a/skills/project-init/templates/docs-08-initial-template.md b/skills/project-init/templates/docs-08-initial-template.md index 2b9349b..ee9ca3c 100644 --- a/skills/project-init/templates/docs-08-initial-template.md +++ b/skills/project-init/templates/docs-08-initial-template.md @@ -50,7 +50,7 @@ (A5 填入后,每行一个后端模块。每个模块的 `里程碑:` 字段在 `—` 和 `milestone/` 之间变化,完成由本地 `git tag -l` 判定。`coding-start` 每次按 docs/02 REQ 序扫每模块的里程碑 tag 决定派发。后端模块全部打里程碑后自动进入 § 三 前端阶段。) - @@ -14,12 +15,19 @@ skeleton-gen 基于 docs/04 § 零 技术栈表推导各节内容: ## 二、端口约定 - + -## 三、环境变量 +## 三、配置与凭据规则 -运行时凭据(数据库连接、JWT 密钥等)全部放在仓库根的 `.env.local`,不入 git。 -字段清单与占位符见该文件,真实值由开发者本地填写。 +项目配置分两处存放,**本文档只记规则、不记具体值**: + +- **非敏感、项目级配置**(根包名 / 命名空间、应用端口、前端包名、管理员初始账号等)→ 仓库根 `config-vars.yaml`,结构化 YAML,随项目提交。 +- **敏感凭据**(数据库密码、JWT / 签名密钥、Redis 密码、第三方 key/secret、管理员初始密码等)→ 仓库根 `.env.local`,入 `.gitignore`,**不提交**;`config-vars.yaml` 末尾 `secrets_ref` 只登记键名作引用。 + +规则: +- 根包名 / 命名空间一经在 `config-vars.yaml` 锁定,全项目复用,不得各模块各写。 +- 端口遵循 § 二 约定;调整时改 `config-vars.yaml`,本文档不写具体端口。 +- 任何敏感值不得出现在 `config-vars.yaml`、docs、源码或日志中——只允许出现在 `.env.local`。 ## 四、常用命令 diff --git a/workflows/coding.mjs b/workflows/coding.mjs index a2f2b75..ff1c4f2 100644 --- a/workflows/coding.mjs +++ b/workflows/coding.mjs @@ -87,11 +87,12 @@ function routerPrompt(root) { '- 前端 item(FE-NN)归属一个"逻辑前端模块"。前端阶段整体 `done` 当且仅当 §三 `整体里程碑:` == `milestone/frontend-phase` 且 `git tag -l "milestone/frontend-phase"` 存在。', '', '## 输出(必须符合下发的 JSON schema)', - '- `modules`: 数组,按 `docs/02 § 二` 的模块顺序排列。每项:', - ' - `id`: 模块标识(后端为英文蛇形 module id;前端聚合为单一逻辑模块时用 `frontend-phase`)。', + '- `modules`: 数组。**先**按 `docs/02 § 二` 的模块顺序列出全部后端模块,**再在末尾追加唯一一个前端聚合模块**(仅当存在前端 FE 时)。每项:', + ' - `id`: 模块标识(后端为英文蛇形 module id;前端聚合模块固定用 `frontend-phase`)。', ' - `done`: 该模块是否已完成(按上面的判定)。', - ' - `reqs`: 本模块**未完成**后端 REQ 的有序列表(已 `verdict=approve`(见 `docs/superpowers/reviews/*-.md`)的 REQ 跳过)。模块已 done → 空数组。', - ' - `feItems`: 本模块关联的**未完成**前端 FE-NN 列表(已 approve 的 FE 跳过);无前端 → 空数组。', + ' - `reqs`: **仅后端模块**填本模块**未完成**后端 REQ 的有序列表(已 `verdict=approve`,见 `docs/superpowers/reviews/*-.md` 的 REQ 跳过);模块已 done → 空数组。**前端聚合模块 `reqs` 恒为空数组**。', + ' - `feItems`: **仅前端聚合模块**填——把**全部模块**的**未完成**前端 FE-NN 汇总为一个有序列表(已 approve 的 FE 跳过)放进 `frontend-phase` 这一项。**后端模块 `feItems` 恒为空数组**(前端不分摊到后端模块)。', + '- 即:后端模块只承载 `reqs`、`feItems=[]`;末尾的 `frontend-phase` 模块只承载 `feItems`、`reqs=[]`。整个项目至多一个前端聚合模块,对应至多一个 `milestone/frontend-phase` tag。', '- 不要返回任何额外字段(schema 为 `additionalProperties:false`)。', '', '## 缺值处理', @@ -332,7 +333,7 @@ function crossModulePrompt(module) { `替代被删的 \`log-cross-module\` hook + \`cross-module-log\` skill:扫描本模块周期内对**非本模块**文件的改动,落跨模块日志(原因 + 影响评估),供 module-report § ⑦ 嵌入。`, '', '## 流程', - `- 用 \`git -C ${ROOT} diff --name-status\`(区间:模块分支起点 → HEAD)找出改动文件,判定哪些落在**其它模块**的目录下(按 docs/09 目录归属)。`, + `- 用 \`git -C ${ROOT} diff --name-status <默认分支 main/master>...HEAD\`(三点 diff,区间 = 本功能分支 \`module-${id}\` 自默认分支分叉以来的全部改动)找出改动文件,判定哪些落在**其它模块**的目录下(按 docs/09 目录归属)。`, `- 写 / 更新 \`${ROOT}/docs/superpowers/module-reports/${id}-cross-module.md\`,每行列:时间戳(你自身上下文解析当天,脚本不传)/ 目标模块 / 文件 / 改动摘要 / **原因**(本模块哪个 REQ 迫使改它)/ **影响评估**(目标模块哪些 API / 行为 / 调用方受影响、现有测试是否仍有效、是否需新测试,1-3 句)。`, '- 无跨模块改动 → 输出 `cross-module-log: 无跨模块改动,跳过`,不创建文件。', '- **不要留 `TBD(CC 补)`**:本步骤就是补齐的唯一时机;推不出原因/影响 → 按硬约束写阻塞点并失败。', @@ -362,14 +363,14 @@ function reportPrompt(module) { ? [ '- § ① `module_id = frontend-phase`,`module_name = 前端阶段(整体)`。', `- § ② "FE 完成清单":扫 \`${ROOT}/docs/superpowers/{specs,plans,reviews}/<日期>-FE-*.md\`,按 FE-NN 顺序列出。`, - `- § ③ 文件变更:\`git -C ${ROOT} diff --stat\`(区间 \`frontend-phase\` 分支起点 → HEAD)。`, + `- § ③ 文件变更:\`git -C ${ROOT} diff --stat <默认分支 main/master>...HEAD\`(三点 diff,区间 = 功能分支 \`frontend-phase\` 自默认分支分叉以来的全部改动)。`, '- § ④ 数据库使用表 / § ⑥ Migration / § ⑦ 跨模块:填 `N/A(前端阶段)`。', `- § ⑤:读 \`${ROOT}/docs/superpowers/module-reports/frontend-phase-test-gate.md\`。`, '- § ⑧ 偏离清单:额外审查"实际渲染 DOM 与各 FE 关联原型主结构的差异",逐 FE 列出。', '- § ⑪ 下一模块预览:填"上线 / 部署后续步骤"。', ].join('\n') : [ - `- § ③ 文件变更:\`git -C ${ROOT} diff --stat\` / \`--name-status\` / \`git log --oneline\`(区间:module 分支起点 → HEAD)。`, + `- § ③ 文件变更:\`git -C ${ROOT} diff --stat <默认分支 main/master>...HEAD\` / \`--name-status\` / \`git log <默认分支>..HEAD --oneline\`(区间 = 功能分支 \`module-${id}\` 自默认分支分叉以来的全部改动)。`, `- § ② / § ⑨:读 \`${ROOT}/docs/superpowers/{specs,plans,reviews}/<日期>-<本模块 REQ>.md\`。`, `- § ⑤:读 \`${ROOT}/docs/superpowers/module-reports/${id}-test-gate.md\`。`, `- § ⑥ Migration:\`git -C ${ROOT} diff --name-only --diff-filter=A -- 'sql/migrations/V*.sql'\` 列新增,每个读第一行作说明。`, @@ -414,6 +415,33 @@ function milestonePrompt(module) { ].join('\n') } +// ---- 功能分支生命周期:进入模块前建/切功能分支(milestone 的 merge 源)---- +// 幂等支持续跑:分支已存在则 checkout 续跑,否则从默认分支开新支。 +function branchSetupPrompt(module) { + const id = module?.id ?? '' + const fe = id === 'frontend-phase' + const branch = fe ? 'frontend-phase' : `module-${id}` + return [ + `# branch-setup — ${fe ? '前端阶段' : `模块 ${id}`} 功能分支准备(幂等)`, + '', + commonContract(fe ? 'frontend' : 'backend'), + '', + '## 目标', + `为本${fe ? '前端阶段' : '模块'}准备功能分支 \`${branch}\`,使后续 featureLoop / testGate / report 的 commit 都落在该分支上;milestone stage 再把它 \`merge --no-ff\` 回默认分支。**本 stage 内重入幂等**。`, + '', + '## 流程(顺序执行,任一硬错误 → 停下打印诊断,不自动 stash / 覆盖)', + `1. **探测默认分支**:用 \`git -C ${ROOT} rev-parse --verify\` 依次试本地 \`main\` / \`master\`,取第一个存在的为 \`default_branch\`;都不存在 → 失败。`, + `2. **校验工作树干净**:\`git -C ${ROOT} status --porcelain\` 非空 → 失败并打印 dirty 文件清单(进入模块前必须是干净状态)。`, + `3. **建 / 切功能分支**(幂等):`, + ` - 若 \`git -C ${ROOT} rev-parse --verify ${branch}\` 成功(分支已存在,续跑场景)→ \`git -C ${ROOT} checkout ${branch}\`。`, + ` - 否则 → \`git -C ${ROOT} checkout \` 后 \`git -C ${ROOT} checkout -b ${branch}\`(从含上一里程碑成果的默认分支开新支)。`, + `4. 确认当前已在 \`${branch}\`:\`git -C ${ROOT} rev-parse --abbrev-ref HEAD\` == \`${branch}\`,否则失败。`, + '', + '## 结束', + `- 输出一行 \`branch-setup: ${id} → ${branch}\`。`, + ].join('\n') +} + // ============================================================================ // 编排逻辑(结构按 plan 骨架;featureLoop / reviewWithFixLoop / testGate / 顶层循环) // ============================================================================ @@ -430,11 +458,15 @@ async function featureLoop(items, phase) { } // 有界 5 轮修复;超出 → throw(终止态,非对话框) +// fix 后重新跑 verify(功能复验,verify 内部失败即 throw → halt),再进入下一轮 review, +// 使 fixPrompt 对子代理"上层会重新跑 verify + review"的承诺成真。 async function reviewWithFixLoop(id, phase, verifyResult) { + const grp = phase === 'backend' ? 'Backend' : 'Frontend' for (let round = 1; round <= 5; round++) { - const r = await agent(reviewPrompt(id, phase, round), {label:`review:${phase}:${id}:r${round}`, phase: phase==='backend'?'Backend':'Frontend', schema: REVIEW_SCHEMA, agentType:'code-reviewer'}) + const r = await agent(reviewPrompt(id, phase, round), {label:`review:${phase}:${id}:r${round}`, phase: grp, schema: REVIEW_SCHEMA, agentType:'code-reviewer'}) if (r.verdict === 'approve') return { id, phase, approved:true, rounds:round } - await agent(fixPrompt(id, phase, r.issues), {label:`fix:${phase}:${id}:r${round}`, phase: phase==='backend'?'Backend':'Frontend'}) + await agent(fixPrompt(id, phase, r.issues), {label:`fix:${phase}:${id}:r${round}`, phase: grp}) + await agent(verifyPrompt(id, phase, `(第 ${round} 轮 fix 后复验)`), {label:`reverify:${phase}:${id}:r${round}`, phase: grp}) } throw new Error(`HALT review-unresolved ${phase}:${id} after 5 rounds`) } @@ -456,10 +488,17 @@ log(`coding: ${todo.length}/${routed.modules.length} modules to run`) const results = [] for (const module of todo) { try { - await featureLoop(module.reqs, 'backend') - await testGate(module, 'backend') - if (module.feItems.length) { await featureLoop(module.feItems, 'frontend'); await testGate(module, 'frontend') } - await agent(crossModulePrompt(module), {label:`xmod:${module.id}`, phase:'Milestone'}) // 替代被删 hook + // C1:进入模块前建/切功能分支(milestone 的 merge 源)。 + await agent(branchSetupPrompt(module), {label:`branch:${module.id}`, phase:'Milestone'}) + if (module.reqs.length) { // 后端段(frontend-phase 模块 reqs 为空 → 跳过) + await featureLoop(module.reqs, 'backend') + await testGate(module, 'backend') + await agent(crossModulePrompt(module), {label:`xmod:${module.id}`, phase:'Milestone'}) // 替代被删 hook + } + if (module.feItems.length) { // 前端段(仅末尾 frontend-phase 聚合模块) + await featureLoop(module.feItems, 'frontend') + await testGate(module, 'frontend') + } await agent(reportPrompt(module), {label:`report:${module.id}`, phase:'Milestone'}) await agent(milestonePrompt(module), {label:`milestone:${module.id}`, phase:'Milestone'}) // git merge --no-ff + tag + 更新 docs/08(单 stage 内幂等) results.push({ module: module.id, status:'done' })