Commit d875806a8cf3792a441616090c29c4e128d6d914
1 parent
2463c1e5
fix: dual-reviewer audit (Claude+Codex) — workflows/lib/skills fixes
Independent Claude + Codex review of the working-tree diff surfaced 23 issues; all applied in-place across three domains. workflows/coding.mjs (9): - spec/plan/verify/review prompts now git-commit their artifacts (closes milestone-dirty-worktree halt risk) - CHECKBOX_STATE_SCHEMA requires `state` + defensive guard in reviewWithFixLoop - writeDocs08FieldPromptM scopes edits to module via line anchor - assertSafeId guards Router output (module/REQ/FE ids) - featureLoop captures reviewWithFixLoop's return value - dateFromArtifactPath rejects impossible dates (9999-99-99 etc.) - terminal HALT review-unresolved message disambiguated - updated comment on top-level `return` (ESM vs node --check) lib/validate-ddl.mjs + tests (10): - extractType preserves unsigned/signed modifiers - KEY/INDEX fallthrough no longer creates spurious 'KEY' columns - FOREIGN KEY REFERENCES accepts schema-qualified `db.table` - index keys normalized to name:kind:cols (UNIQUE-aware) - FK keys include ON DELETE action (defaults to RESTRICT) - CREATE TEMPORARY TABLE parsed - parseForeignKeyBullet/parseIndexBullet reject prose bullets - +10 regression tests (62 total, all pass) - merge-gitignore.mjs docstring synced to implementation skills/ (4): - docs-06 template header: § 一~二 → § 一~三 - downstream-gen §A steps renumbered 1-7 (was 1,3,4,5,6,7,8) - docs-08 initial template adds tokens.css checkbox - coding-start drops "已在后台启动" wording (Workflow is synchronous)
Showing
18 changed files
with
1471 additions
and
277 deletions
.claude-plugin/plugin.json
| 1 | { | 1 | { |
| 2 | "name": "erp-workflow", | 2 | "name": "erp-workflow", |
| 3 | - "description": "ERP 项目全流程框架:阶段 A 计划(交互式 skill 链,9 个 skill + 4 个前移闸门) + 阶段 B 编码(单个静默 Workflow 脚本 coding.mjs,子代理自动跑后端+前端功能循环、测试闸门、本地里程碑 tag)。", | 3 | + "description": "ERP 项目全流程框架:阶段 A 计划(plan-start 入口 + A0~A6 共 7 个 skill + B 阶段瘦入口 coding-start = 9 个 skill;plan-start 终结闸 5 项前移硬校验) + 阶段 B 编码(单个静默 Workflow 脚本 coding.mjs,子代理自动跑后端+前端功能循环、测试闸门、本地里程碑 tag)。", |
| 4 | "version": "0.2.0", | 4 | "version": "0.2.0", |
| 5 | "skills": ["./skills"] | 5 | "skills": ["./skills"] |
| 6 | } | 6 | } |
README.md
| @@ -2,12 +2,12 @@ | @@ -2,12 +2,12 @@ | ||
| 2 | 2 | ||
| 3 | Claude Code 插件:ERP / 后端管理系统全流程开发框架。 | 3 | Claude Code 插件:ERP / 后端管理系统全流程开发框架。 |
| 4 | 4 | ||
| 5 | -把"从零到 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 为页面权威)。 | 5 | +把"从零到 N 个模块上线 + 前端整体阶段"的整个流程固化成 **9 个 skill**(Plan 阶段 8 个交互 skill = `plan-start` 分发器 + A0~A6 七个;外加 B 阶段瘦入口 `coding-start`)**+ 1 个 Workflow(`workflows/coding.mjs`,Coding 阶段,全自动静默)+ 1 个 reviewer agent + 4 个跨平台 Node 助手(`lib/*.mjs`)+ 25 份模板**,让 CC 在 schema 演化用 Flyway migration、需求可追溯、纯本地 git 的前提下推进编码。Plan 阶段把全部需求/配置/前端约定问询前移:A 阶段过程中各 skill 自承的小闸门 + `plan-start` 终结时统一跑的 5 项前移硬闸(REQ 真实数据 / secrets+config-vars / docs/04 § 零 命令 / docs/05+02 评审 / A6 前端 scope);Coding 阶段整体是单个 Workflow 脚本,子代理无法弹窗 → 结构性静默,后端按模块循环依次打里程碑 tag,所有后端模块打里程碑后进入前端整体阶段(以项目根 `prototype/` 静态 HTML mockup 为页面权威)。 |
| 6 | 6 | ||
| 7 | ## 这个插件做什么 | 7 | ## 这个插件做什么 |
| 8 | 8 | ||
| 9 | ``` | 9 | ``` |
| 10 | -📋 阶段 A:规划(一次性,交互式 9 skill,入口 /erp-workflow:plan-start) | 10 | +📋 阶段 A:规划(一次性,交互式;入口 /erp-workflow:plan-start 派发 A0~A6 共 7 个 skill) |
| 11 | 11 | ||
| 12 | A0 project-init → A1 scope-lock(结构化 REQ 卡片 + secrets/commands 锁) | 12 | A0 project-init → A1 scope-lock(结构化 REQ 卡片 + secrets/commands 锁) |
| 13 | ↓ | 13 | ↓ |
| @@ -33,12 +33,20 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 | @@ -33,12 +33,20 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 | ||
| 33 | 33 | ||
| 34 | coding.mjs Router → 解析 docs/08 § 二/§ 三 + git tag,列出待跑模块 | 34 | coding.mjs Router → 解析 docs/08 § 二/§ 三 + git tag,列出待跑模块 |
| 35 | │ | 35 | │ |
| 36 | - ├─ B-后端(按模块循环,每模块一个里程碑 tag) | ||
| 37 | - │ featureLoop(后端):spec → plan → tdd → verify → review(有界 5 轮修复) | ||
| 38 | - │ testGate(backend) → 跨模块记录 → 模块报告 → milestone(本地 merge --no-ff + tag) | 36 | + ├─ B-后端(按模块循环,每模块一个里程碑 tag;功能链顺序 for-await,单工作树串行 commit) |
| 37 | + │ runBranchSetup(module-<id>) ← JS 编排:detect default → wt clean → exists? → | ||
| 38 | + │ checkout/create → confirm HEAD(5 微 agent) | ||
| 39 | + │ → featureLoop(后端):spec → plan → tdd → verify → review(有界 5 轮修复, | ||
| 40 | + │ throw 自然冒泡到模块主循环 try → fail-fast) | ||
| 41 | + │ → testGate(backend) → runCrossModule(JS 编排:diff → 分类 → 写日志) | ||
| 42 | + │ → reportPrompt(LLM 12 节叙述) | ||
| 43 | + │ → runMilestone(JS 编排:wt → default → 已合入? → merge → 字段当前值? | ||
| 44 | + │ → 写字段 → tag 已存在? → 打 tag → 报告 § ⑫ 当前值? → 替换占位; | ||
| 45 | + │ 10+ 微 agent,全部跳过/分支条件由 JS 判定,幂等) | ||
| 39 | │ | 46 | │ |
| 40 | └─ B-前端(后端全部打里程碑后,整体 1 个里程碑 tag) | 47 | └─ B-前端(后端全部打里程碑后,整体 1 个里程碑 tag) |
| 41 | - featureLoop(前端,FE-NN,路径限 frontend/) → testGate(frontend) → milestone | 48 | + runBranchSetup(frontend-phase) → featureLoop(前端,FE-NN,路径限 frontend/) |
| 49 | + → testGate(frontend) → runMilestone(milestone/frontend-phase) | ||
| 42 | 50 | ||
| 43 | 子代理无法弹窗 → 缺值即写阻塞点并 halt(终止态,非对话框);fail-fast 后等人工修复重跑 coding-start | 51 | 子代理无法弹窗 → 缺值即写阻塞点并 halt(终止态,非对话框);fail-fast 后等人工修复重跑 coding-start |
| 44 | ``` | 52 | ``` |
| @@ -73,10 +81,10 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 | @@ -73,10 +81,10 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 | ||
| 73 | 81 | ||
| 74 | **`coding.mjs` 的阶段(子代理执行,无弹窗)**: | 82 | **`coding.mjs` 的阶段(子代理执行,无弹窗)**: |
| 75 | 83 | ||
| 76 | - - **Router**:扫描 docs/08 § 二/§ 三 里程碑字段 + 本地 `git tag -l`,产出结构化模块清单(`{id, done, reqs[], feItems[]}`),过滤出待跑模块。docs/08 字段与 git tag 不一致 → halt 报错(绝不静默假设完成状态)。 | ||
| 77 | - - **后端模块循环**(顶层 `for module`,fail-fast):每模块依次 `featureLoop(reqs, 'backend')` →(spec → plan → tdd → verify → review,有界 5 轮修复,第 5 轮仍 request-changes 即 halt)→ `testGate(backend)`(红色自动重试 1 次防 flaky,仍红则 halt)→ 跨模块改动记录 → 模块报告 → milestone(本地 `git merge --no-ff` 进默认分支 + 打 `milestone/<id>` tag + 回写 docs/08 § 二)。 | ||
| 78 | - - **前端阶段**(后端全部打里程碑后):`featureLoop(feItems, 'frontend')`(FE-NN,路径限 `frontend/`,review 调统一 `code-reviewer` agent 附加前端 7 维 checklist)→ `testGate(frontend)` → milestone(docs/08 § 三 整体里程碑)。 | ||
| 79 | - - **halt 终止态**:子代理缺值不弹窗 → 写阻塞点并抛错;整阶段 fail-fast,halt 后停下等人工修复,修好重跑 `/erp-workflow:coding-start` 从断点续跑。 | 84 | + - **Router**:扫描 docs/08 § 二/§ 三 里程碑字段 + 本地 `git tag -l`,产出结构化模块清单(`{id, done, reqs[], feItems[]}`),过滤出待跑模块。docs/08 字段与 git tag 不一致 → halt 报错(绝不静默假设完成状态)。Router 后做运行时互斥断言(后端模块 `feItems` 必空、`frontend-phase` 聚合模块 `reqs` 必空),契约违例直接 halt。 |
| 85 | + - **后端模块循环**(顶层 `for module`,fail-fast):每模块依次 `runBranchSetup(module-<id>)`(**JS 编排**幂等切/建功能分支,见下方"JS 编排 vs LLM prompt")→ `featureLoop(reqs, 'backend')` 顺序 for-await 跑(spec → plan → tdd → verify → review)。三类有界重试:tdd 同一测试连续修复超过 **10 次** → halt;review **5 轮**仍未 approve → halt;testGate 红色**自动重试 1 次**防 flaky、仍红 → halt。任一 halt 由顺序 for-await 冒泡到模块主循环 try → 捕获 → 整阶段 break。绿则继续 `runCrossModule`(**JS 编排** diff/分类/写日志)→ `reportPrompt`(LLM 12 节叙述报告)→ `runMilestone`(**JS 编排**本地 `git merge --no-ff` 进默认分支 + 打 `milestone/<id>` tag + 回写 docs/08 § 二 + 替换报告 § ⑫ 占位;跨重入幂等)。 | ||
| 86 | + - **前端阶段**(后端全部打里程碑后):`runBranchSetup(frontend-phase)` → `featureLoop(feItems, 'frontend')`(FE-NN,路径限 `frontend/`,review 调统一 `code-reviewer` agent 附加前端 7 维 checklist)→ `testGate(frontend)` → `runMilestone`(docs/08 § 三 整体里程碑 `milestone/frontend-phase`)。 | ||
| 87 | + - **halt 终止态**:子代理缺值不弹窗 → 写阻塞点并抛错;整阶段 fail-fast,halt 后停下等人工修复,修好重跑 `/erp-workflow:coding-start` 从断点续跑(`runBranchSetup` / `runMilestone` 内的 read-then-decide 幂等支持续跑)。 | ||
| 80 | 88 | ||
| 81 | `docs/08 § 二` 每后端模块占一行 bullet,`§ 三` 是前端阶段整体段,完成信号统一由本地 `git tag -l 'milestone/<id>'` 判定。 | 89 | `docs/08 § 二` 每后端模块占一行 bullet,`§ 三` 是前端阶段整体段,完成信号统一由本地 `git tag -l 'milestone/<id>'` 判定。 |
| 82 | 90 | ||
| @@ -121,7 +129,7 @@ erp-workflow-plugin/ | @@ -121,7 +129,7 @@ erp-workflow-plugin/ | ||
| 121 | 129 | ||
| 122 | | Skill | 作用 | 谁调用 | | 130 | | Skill | 作用 | 谁调用 | |
| 123 | |---|---|---| | 131 | |---|---|---| |
| 124 | -| `plan-start` | **A 阶段入口 + Plan 终结硬闸**。读 docs/08 § 一 找第一个未勾 A 子项 → 派发对应 A skill(含 A6 → `frontend-scope-lock`);A 全部完成时校验全部前移闸门(REQ 真实数据、docs/07 secrets 全锁、docs/04 § 零 命令齐、docs/05+02 已评审、A6 前端 scope 已锁),全过才提示运行 `/erp-workflow:coding-start`,否则指出缺口不放行 | **用户手动** `/erp-workflow:plan-start` | | 132 | +| `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` | |
| 125 | | `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` | | 133 | | `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` | |
| 126 | 134 | ||
| 127 | ### Plan 阶段 A skill(7 个 = A0~A6,均由 `plan-start` 按 docs/08 § 一 顺序派发) | 135 | ### Plan 阶段 A skill(7 个 = A0~A6,均由 `plan-start` 按 docs/08 § 一 顺序派发) |
| @@ -129,7 +137,7 @@ erp-workflow-plugin/ | @@ -129,7 +137,7 @@ erp-workflow-plugin/ | ||
| 129 | | # | Skill | 作用 | 流程中谁调用 | | 137 | | # | Skill | 作用 | 流程中谁调用 | |
| 130 | |---|---|---|---| | 138 | |---|---|---|---| |
| 131 | | A0 | `project-init` | • **依赖检查**:检测 git / mysql / node 是否在 PATH,缺失则按 OS(darwin/win32/linux)打印安装指引并 halt(**不自动 brew/apt 安装**)<br>• 空目录初始化:用 Read/Write/Glob 工具拷模板创建 CLAUDE.md / docs/01/index.md / docs/08<br>• `git init` | `plan-start` | | 139 | | A0 | `project-init` | • **依赖检查**:检测 git / mysql / node 是否在 PATH,缺失则按 OS(darwin/win32/linux)打印安装指引并 halt(**不自动 brew/apt 安装**)<br>• 空目录初始化:用 Read/Write/Glob 工具拷模板创建 CLAUDE.md / docs/01/index.md / docs/08<br>• `git init` | `plan-start` | |
| 132 | -| A1 | `scope-lock` | • 引导填项目概述 / 技术栈 / 需求索引<br>• 按 `docs/01-需求清单/<module>/{_module.md, REQ-*.md}` 子目录结构生成**结构化** REQ 卡片(每字段一行:字段名/类型/必填/校验/业务规则/示例值,示例值必须替换为真实约束)<br>• **A1 终结校验**:REQ 字段非空且非占位、docs/07 secret/account/包名/namespace 字段清单已锁、各 stack 的 build/lint/unit/e2e 命令写入 docs/04 § 零;缺失则在此(Plan 期)用 `AskUserQuestion` 问清<br>• 用 `node ${CLAUDE_PLUGIN_ROOT}/lib/render.mjs` 渲染模板<br>• **停下**等人工审阅,审阅完毕用 `/plan-start` 续进 A2 | A0 | | 140 | +| A1 | `scope-lock` | • 引导填项目概述 / 技术栈 / 需求索引<br>• 按 `docs/01-需求清单/<module>/{_module.md, REQ-*.md}` 子目录结构生成**结构化** REQ 卡片(每字段一行:字段名/类型/必填/校验/业务规则/示例值,示例值必须替换为真实约束)<br>• **A1 终结校验**:REQ 字段非空且非占位、`config-vars.yaml` 配置字段(包名 / 端口 / 初始账号等)+ `secrets_ref` 键名(引用 `.env.local`)已锁、各 stack 的 build/lint/unit/e2e 命令写入 docs/04 § 零;缺失则在此(Plan 期)用 `AskUserQuestion` 问清<br>• 用 `node ${CLAUDE_PLUGIN_ROOT}/lib/render.mjs` 渲染模板<br>• **停下**等人工审阅,审阅完毕用 `/plan-start` 续进 A2 | A0 | |
| 133 | | A2 | `skeleton-gen` | • 生成架构文档:docs/04 § 一+ / docs/06 / docs/07 / docs/09<br>• 生成跨平台工具脚本:`scripts/*.mjs`、.env.local(**无 chmod**)<br>• 创建 `sql/migrations/` 空目录(Flyway 准备)<br>• 用 `node ${CLAUDE_PLUGIN_ROOT}/lib/merge-gitignore.mjs` 合并 .gitignore(逐行判重) | `plan-start` | | 141 | | A2 | `skeleton-gen` | • 生成架构文档:docs/04 § 一+ / docs/06 / docs/07 / docs/09<br>• 生成跨平台工具脚本:`scripts/*.mjs`、.env.local(**无 chmod**)<br>• 创建 `sql/migrations/` 空目录(Flyway 准备)<br>• 用 `node ${CLAUDE_PLUGIN_ROOT}/lib/merge-gitignore.mjs` 合并 .gitignore(逐行判重) | `plan-start` | |
| 134 | | A3 | `db-design-gen` | • A3 起始用 `AskUserQuestion` 确认 ERP 约定(主键策略 / 租户列 / 列前缀规则,默认值可覆盖),结果写 docs/04 + CLAUDE.md<br>• 从 docs/01 REQ 卡片正向设计 `docs/03-数据库设计文档.md`(schema SSoT)<br>• 回填 REQ 卡片依赖表(`TBD(A3 自动补)` → 实际表名)<br>• **停下**等人工审阅 docs/03,审阅完毕用 `/plan-start` 续进 A4 | A2 | | 142 | | A3 | `db-design-gen` | • A3 起始用 `AskUserQuestion` 确认 ERP 约定(主键策略 / 租户列 / 列前缀规则,默认值可覆盖),结果写 docs/04 + CLAUDE.md<br>• 从 docs/01 REQ 卡片正向设计 `docs/03-数据库设计文档.md`(schema SSoT)<br>• 回填 REQ 卡片依赖表(`TBD(A3 自动补)` → 实际表名)<br>• **停下**等人工审阅 docs/03,审阅完毕用 `/plan-start` 续进 A4 | A2 | |
| 135 | | A4 | `db-init` | • LLM 解析 docs/03 → `sql/migrations/V1__initial_schema.sql`(DDL only)<br>• `node ${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs` 校验 DDL ↔ docs/03(5 维:表/列名/列类型/索引/FK),fail-closed<br>• `node ${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs .env.local V1.sql`(安全 env 解析 + mysql2 apply,**无 shell-source**) | A3 | | 143 | | A4 | `db-init` | • LLM 解析 docs/03 → `sql/migrations/V1__initial_schema.sql`(DDL only)<br>• `node ${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs` 校验 DDL ↔ docs/03(5 维:表/列名/列类型/索引/FK),fail-closed<br>• `node ${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs .env.local V1.sql`(安全 env 解析 + mysql2 apply,**无 shell-source**) | A3 | |
| @@ -145,27 +153,46 @@ coding-start(skill)校验 Plan 终结闸 → Workflow({scriptPath:"…/workf | @@ -145,27 +153,46 @@ coding-start(skill)校验 Plan 终结闸 → Workflow({scriptPath:"…/workf | ||
| 145 | │ | 153 | │ |
| 146 | ▼ coding.mjs(各 stage 派 agent 子代理执行,无弹窗) | 154 | ▼ coding.mjs(各 stage 派 agent 子代理执行,无弹窗) |
| 147 | Router ── 解析 docs/08 § 二/§ 三 + git tag → 结构化模块清单(schema 校验) | 155 | Router ── 解析 docs/08 § 二/§ 三 + git tag → 结构化模块清单(schema 校验) |
| 148 | - │ docs/08 字段与 git tag 不一致 → halt | 156 | + │ docs/08 字段与 git tag 不一致 → halt;运行时断言 reqs/feItems 互斥 |
| 149 | │ | 157 | │ |
| 150 | 顶层 for module(fail-fast,halt 后 break): | 158 | 顶层 for module(fail-fast,halt 后 break): |
| 151 | │ | 159 | │ |
| 152 | - ├─ 后端:featureLoop(module.reqs, 'backend') | 160 | + ├─ 后端: |
| 161 | + │ runBranchSetup(module-<id>) ← JS 编排(5 微 agent) | ||
| 162 | + │ → featureLoop(module.reqs, 'backend') ← 顺序 for-await | ||
| 153 | │ spec → plan → tdd → verify → reviewWithFixLoop(有界 5 轮: | 163 | │ spec → plan → tdd → verify → reviewWithFixLoop(有界 5 轮: |
| 154 | │ review(code-reviewer) approve → 过;request-changes → fix → 重审; | 164 | │ review(code-reviewer) approve → 过;request-changes → fix → 重审; |
| 155 | - │ 第 5 轮仍未过 → throw HALT) | 165 | + │ 第 5 轮仍未过 → throw HALT,由 for-await 冒泡到主循环) |
| 156 | │ → testGate(backend)(红色自动重试 1 次防 flaky,仍红 → HALT) | 166 | │ → testGate(backend)(红色自动重试 1 次防 flaky,仍红 → HALT) |
| 157 | - │ → 跨模块改动记录 → 模块报告 | ||
| 158 | - │ → milestone(git merge --no-ff 进默认分支 + tag milestone/<id> + 回写 docs/08 § 二) | 167 | + │ → runCrossModule(module) ← JS 编排(4 微 agent:默认分支 → diff → 分类 → 写日志) |
| 168 | + │ → reportPrompt(LLM 12 节叙述报告) | ||
| 169 | + │ → runMilestone(module) ← JS 编排(10+ 微 agent,跨重入幂等) | ||
| 170 | + │ wt → default → 已合入? → merge → 字段当前值? → 写字段 + commit | ||
| 171 | + │ → tag 存在? → 打 tag → 报告 § ⑫ 当前值? → 替换占位 + commit | ||
| 159 | │ | 172 | │ |
| 160 | └─ 前端(module.feItems 非空时,后端全部打里程碑后): | 173 | └─ 前端(module.feItems 非空时,后端全部打里程碑后): |
| 161 | - featureLoop(feItems, 'frontend')(FE-NN,路径限 frontend/, | ||
| 162 | - review 调 code-reviewer + 前端 7 维 checklist) | ||
| 163 | - → testGate(frontend) → milestone(docs/08 § 三 整体里程碑) | 174 | + runBranchSetup(frontend-phase) |
| 175 | + → featureLoop(feItems, 'frontend')(FE-NN,路径限 frontend/, | ||
| 176 | + review 调 code-reviewer + 前端 7 维 checklist) | ||
| 177 | + → testGate(frontend) → runMilestone(docs/08 § 三 整体里程碑) | ||
| 164 | ``` | 178 | ``` |
| 165 | 179 | ||
| 166 | -`coding.mjs` 内部用 `pipeline` 把后端/前端功能链同构为一个 `featureLoop(items, phase)`(替代旧 10 个克隆 skill);`reviewWithFixLoop` 有界 5 轮;`testGate` 失败自动重试 1 次。完成信号统一由本地 `git tag -l 'milestone/<id>'` 判定,**不依赖任何远程仓库 / push**。 | 180 | +#### featureLoop 顺序 for-await(非 pipeline) |
| 167 | 181 | ||
| 168 | -> 旧 B 阶段的 15 个 skill(`module-start` / `feature-*` / `frontend-start` / `fe-feature-*` / `test-gate` / `module-report` / `milestone-tag`)与 2 个横切 skill(`interrupt-check` / `cross-module-log`)已删除,其意图融入 `coding.mjs` 的各 stage prompt 与 `lib/` 助手;中断/跨模块留痕由 workflow 的 halt 终止态 + `crossModulePrompt` stage 承担(替代被删 hook)。 | 182 | +`coding.mjs` 把后端/前端功能链同构为一个 `featureLoop(items, phase)`(替代旧 10 个克隆 skill),实现上采用**顺序 for-await**(**非 pipeline**):tdd/fix stage 共享单工作树 + 同一功能分支做 git commit,并发会争 `.git/index.lock` 且撞 migration `V<n>` 版本号;同时 pipeline 的"stage throw → item null"语义会吞掉 `reviewWithFixLoop`/`verify`/`tdd` 的 HALT throw 而让残缺模块照打 tag。顺序 for-await 让 throw 自然冒泡到主循环 try → fail-fast。`reviewWithFixLoop` 有界 5 轮且要求 reviewer 返回 `request-changes` 时 `issues` 非空(否则 halt 暴露 reviewer 契约违例);`testGate` 失败自动重试 1 次。 |
| 183 | + | ||
| 184 | +#### JS 编排 vs LLM prompt:哪些是 `run*` 哪些是 `*Prompt` | ||
| 185 | + | ||
| 186 | +`coding.mjs` 里两种 stage 实现形态,按"步骤是否需要 LLM 判断"分: | ||
| 187 | + | ||
| 188 | +| 形态 | 适用 | 示例 stage | 工作方式 | | ||
| 189 | +|---|---|---|---| | ||
| 190 | +| **`run*`(JS 编排)** | 纯机械的 git/文件操作 + 条件跳过;步骤本身有结构化 in/out | `runBranchSetup` / `runMilestone` / `runCrossModule` | JS 把流程切成多个单职责微 `agent()`,每个微 agent 带强 schema 返回(`WT_SCHEMA` / `DEFAULT_BRANCH_SCHEMA` / `FIELD_VALUE_SCHEMA` / `EXISTS_SCHEMA` / …)。所有"已是目标态则跳过"的分支由 **JS 在结构化返回上判定**,不再依赖子代理读"1. 2. 3. 若 X 则跳过"散文。action 步统一返回 `ACTION_RESULT_SCHEMA = {success, error?, detail?}`,JS 在 `success=false` 时显式抛 `HALT …` 让主循环 try catch break。idempotency 因此结构性可靠——续跑时同一微 agent 再读一次状态、JS 再判一次、得到同一决策。 | | ||
| 191 | +| **`*Prompt`(LLM 叙述)** | 真正需要 LLM 判断 / 上下文综合 / 文本生成的 stage | `routerPrompt` / `deriveSpecPrompt` / `planPrompt` / `tddPrompt` / `verifyPrompt` / `gatePrompt` / `reviewPrompt` / `fixPrompt` / `reportPrompt` | 一段较长的中文 prompt 文本,子代理读完后自由发挥实现意图(写 spec / 写 plan / 跑 TDD / 出 12 节报告 等)。结构化的部分仍走 schema(router / review / gate 都用 schema 约束最终结论)。 | | ||
| 192 | + | ||
| 193 | +> 完成信号统一由本地 `git tag -l 'milestone/<id>'` 判定,**不依赖任何远程仓库 / push**。 | ||
| 194 | + | ||
| 195 | +> 旧 B 阶段的 15 个 skill(`module-start` / `feature-*` / `frontend-start` / `fe-feature-*` / `test-gate` / `module-report` / `milestone-tag`)与 2 个横切 skill(`interrupt-check` / `cross-module-log`)已删除,其意图融入 `coding.mjs` 的各 `*Prompt` 与 `run*` 编排函数;中断/跨模块留痕由 workflow 的 halt 终止态 + `runCrossModule` 承担(替代被删 hook)。 | ||
| 169 | 196 | ||
| 170 | ## Agent 清单(1 个) | 197 | ## Agent 清单(1 个) |
| 171 | 198 | ||
| @@ -216,6 +243,6 @@ coding-start(skill)校验 Plan 终结闸 → Workflow({scriptPath:"…/workf | @@ -216,6 +243,6 @@ coding-start(skill)校验 Plan 终结闸 → Workflow({scriptPath:"…/workf | ||
| 216 | 243 | ||
| 217 | ## 设计原则 | 244 | ## 设计原则 |
| 218 | 245 | ||
| 219 | -参见 `project-init/templates/CLAUDE-template.md` 末尾的「🧭 通用工作准则」4 条:① Think Before Coding ② Simplicity First ③ Surgical Changes ④ Goal-Driven Execution。 | 246 | +参见 `skills/project-init/templates/CLAUDE-template.md` 末尾的「🧭 通用工作准则」4 条:① Think Before Coding ② Simplicity First ③ Surgical Changes ④ Goal-Driven Execution。 |
| 220 | 247 | ||
| 221 | 最关键的 1 条:"**所有测试与验证派发到全新子会话执行,主会话只接收结构化结论**"——避免主会话被测试输出污染,并让测试结果作为独立证据存档。 | 248 | 最关键的 1 条:"**所有测试与验证派发到全新子会话执行,主会话只接收结构化结论**"——避免主会话被测试输出污染,并让测试结果作为独立证据存档。 |
agents/code-reviewer.md
| 1 | --- | 1 | --- |
| 2 | name: code-reviewer | 2 | name: code-reviewer |
| 3 | description: | | 3 | description: | |
| 4 | - Unified code reviewer for the ERP coding Workflow. Invoked by `workflows/coding.mjs` at the review stage via `agentType:'code-reviewer'` (see `reviewWithFixLoop`). Reviews a single completed feature against its plan, spec, and the project's coding standards. The `phase` parameter selects the dimension set: `phase=backend` runs the generic review dimensions; `phase=frontend` additionally runs the frontend 7-dimension checklist. Runs as a non-interactive subagent inside a bounded review/fix loop (max 5 rounds) — it MUST return a structured verdict and never block on a prompt. | 4 | + Unified code reviewer for the ERP coding Workflow. Invoked by `workflows/coding.mjs` at the review stage via `agentType:'code-reviewer'` (see `reviewWithFixLoop`). Reviews a single completed feature against its plan, spec, and the project's coding standards. The domain phase (`backend` / `frontend`) is read from the prompt body — NOT from any `phase` option on the `agent()` call (that option is a harness-level UI group, e.g. `'Backend'` / `'Frontend'` / `'Milestone'`; same name, different concept). The prompt body contains an explicit `**phase = backend ...**` or `**phase = frontend ...**` line you must parse. Runs as a non-interactive subagent inside a bounded review/fix loop (max 5 rounds) — MUST return a structured verdict and never block on a prompt. |
| 5 | model: inherit | 5 | model: inherit |
| 6 | --- | 6 | --- |
| 7 | 7 | ||
| 8 | -You are a Senior Code Reviewer reviewing a single completed feature for the ERP coding Workflow. You are invoked non-interactively by `workflows/coding.mjs` (`agentType:'code-reviewer'`) inside a **bounded review/fix loop (max 5 rounds)**. You MUST return a structured verdict — never ask the user a question, never block on input. The `phase` parameter you receive (`backend` or `frontend`) and the round number determine your scope. | 8 | +You are a Senior Code Reviewer reviewing a single completed feature for the ERP coding Workflow. You are invoked non-interactively by `workflows/coding.mjs` (`agentType:'code-reviewer'`) inside a **bounded review/fix loop (max 5 rounds)**. You MUST return a structured verdict — never ask the user a question, never block on input. |
| 9 | + | ||
| 10 | +## Domain phase resolution (important: naming collision with harness) | ||
| 11 | + | ||
| 12 | +The `agent()` call passes a harness option named `phase` (e.g. `'Backend'`, `'Frontend'`, `'Milestone'`) — that controls the **UI progress group** in `/workflows`, NOT your review scope. **Do not** read the harness `phase` to decide review dimensions. | ||
| 13 | + | ||
| 14 | +Your **domain phase** (`backend` vs `frontend`) is encoded inside the prompt body as a bolded line: | ||
| 15 | + | ||
| 16 | +``` | ||
| 17 | +**phase = backend → 通用代码审查维度(正确性 / 边界 / 错误处理 / 一致性)。** | ||
| 18 | +``` | ||
| 19 | +or | ||
| 20 | +``` | ||
| 21 | +**phase = frontend → 附加前端 7 维 checklist。...** | ||
| 22 | +``` | ||
| 23 | + | ||
| 24 | +Parse this line first. The round number comes from the prompt header `第 N 轮`. | ||
| 9 | 25 | ||
| 10 | ## Output contract (required) | 26 | ## Output contract (required) |
| 11 | 27 | ||
| @@ -13,9 +29,13 @@ Return a structured result matching the workflow's `REVIEW_SCHEMA`: | @@ -13,9 +29,13 @@ Return a structured result matching the workflow's `REVIEW_SCHEMA`: | ||
| 13 | 29 | ||
| 14 | - `verdict`: `approve` or `request-changes` | 30 | - `verdict`: `approve` or `request-changes` |
| 15 | - `round`: the integer round number you were given | 31 | - `round`: the integer round number you were given |
| 16 | -- `issues`: array of strings — each a concrete, actionable must-fix (empty when `verdict` is `approve`) | ||
| 17 | - | ||
| 18 | -Each `issues[]` entry should be self-contained: `file:line — what is wrong — how to fix`. Optionally acknowledge what was done well in prose before the structured result, but the structured result is what the workflow consumes. | 32 | +- `issues`: array of **structured objects** (not strings) — each must-fix is `{ summary, locator, severity }`: |
| 33 | + - `summary`: one Chinese sentence describing what is wrong | ||
| 34 | + - `locator`: project-root-relative path (e.g. `backend/src/main/java/.../FooController.java`) or `path:line` — MUST identify a real file, because the downstream `fix` stage runs `git cat-file -e HEAD:<file>` to validate before editing. A finding without a concrete file locator is a Suggestion, not a must-fix — do NOT put it in `issues`. | ||
| 35 | + - `severity`: `blocker` | `high` | `medium` | `low` | ||
| 36 | +- Empty array `[]` when `verdict` is `approve`; non-empty when `request-changes`. | ||
| 37 | +- An audit report is written by the workflow's review prompt instructions (path `docs/superpowers/reviews/<YYYY-MM-DD>-<id>.md`); use that report for rich prose / suggestions / praise. The `issues` array is reserved for hard must-fixes only. | ||
| 38 | +- **Do NOT** edit `docs/08-模块任务管理.md` checkboxes in this step. The workflow handles that via a separate micro-step in the approve branch (`writeDocs08CheckboxPromptM`); editing here would shadow that observable side-effect and hide write failures. | ||
| 19 | 39 | ||
| 20 | ## Decision discipline (avoid non-deterministic loops) | 40 | ## Decision discipline (avoid non-deterministic loops) |
| 21 | 41 |
lib/merge-gitignore.mjs
| 1 | // lib/merge-gitignore.mjs | 1 | // lib/merge-gitignore.mjs |
| 2 | +// 合并两份 .gitignore,对**规则行**逐行判重并集合并;注释行透传(相邻去重),空行丢弃(节由注释头承担)。 | ||
| 3 | +// 之所以不对注释去重:两段分组各自的同名注释头(如多次出现的 `# generated`)是分节标题, | ||
| 4 | +// 全局去重会把第二段的标题吞掉,让 add 文件的规则被并入第一段的注释下、破坏分节语义。 | ||
| 2 | export function mergeGitignore(baseText, addText) { | 5 | export function mergeGitignore(baseText, addText) { |
| 3 | - const seen = new Set() | 6 | + const seenRules = new Set() |
| 4 | const out = [] | 7 | const out = [] |
| 5 | const push = (line) => { | 8 | const push = (line) => { |
| 6 | - const key = line.trim() | ||
| 7 | - if (!key) return // drop blank lines | ||
| 8 | - if (seen.has(key)) return // dedupe by trimmed content | ||
| 9 | - seen.add(key) | 9 | + const trimmed = line.trim() |
| 10 | + if (!trimmed) return // drop blank lines (输出靠 join('\n')+尾换行;分节由注释行承担) | ||
| 11 | + if (trimmed.startsWith('#')) { | ||
| 12 | + // 注释:仅折叠**相邻**完全相同的注释,避免分节标题被吞 | ||
| 13 | + if (out.length && out[out.length - 1].trim() === trimmed) return | ||
| 14 | + out.push(line) | ||
| 15 | + return | ||
| 16 | + } | ||
| 17 | + // 规则行:全局去重(含 negation `!pattern`,按原文比对,顺序保留首次出现位置) | ||
| 18 | + if (seenRules.has(trimmed)) return | ||
| 19 | + seenRules.add(trimmed) | ||
| 10 | out.push(line) | 20 | out.push(line) |
| 11 | } | 21 | } |
| 12 | for (const l of baseText.split('\n')) push(l) | 22 | for (const l of baseText.split('\n')) push(l) |
| 13 | for (const l of addText.split('\n')) push(l) | 23 | for (const l of addText.split('\n')) push(l) |
| 14 | - let text = out.join('\n').replace(/\n+$/,'') + '\n' | 24 | + let text = out.join('\n').replace(/\n+$/, '') + '\n' |
| 15 | return text | 25 | return text |
| 16 | } | 26 | } |
| 17 | 27 |
lib/merge-gitignore.test.mjs
| @@ -12,3 +12,27 @@ test('union dedupes and preserves base order, appends new', () => { | @@ -12,3 +12,27 @@ test('union dedupes and preserves base order, appends new', () => { | ||
| 12 | test('blank lines and comments in add are ignored for dedupe but kept once', () => { | 12 | test('blank lines and comments in add are ignored for dedupe but kept once', () => { |
| 13 | assert.equal(mergeGitignore('a\n', '\n# c\nb\n'), 'a\n# c\nb\n') | 13 | assert.equal(mergeGitignore('a\n', '\n# c\nb\n'), 'a\n# c\nb\n') |
| 14 | }) | 14 | }) |
| 15 | + | ||
| 16 | +// 回归:两段不同区块共用同一注释标题(如 `# generated`)时,第二段的标题不应被去重吞掉。 | ||
| 17 | +test('cross-section duplicate comment headers are preserved (no global dedupe on comments)', () => { | ||
| 18 | + const base = '# generated\na\n' | ||
| 19 | + const add = '# generated\nb\n' | ||
| 20 | + assert.equal(mergeGitignore(base, add), '# generated\na\n# generated\nb\n') | ||
| 21 | +}) | ||
| 22 | + | ||
| 23 | +// 相邻完全重复的注释会折叠成一行(避免无意义连续重复)。 | ||
| 24 | +test('adjacent duplicate comments are folded', () => { | ||
| 25 | + assert.equal(mergeGitignore('# x\n# x\n', 'a\n'), '# x\na\n') | ||
| 26 | +}) | ||
| 27 | + | ||
| 28 | +// negation 规则 (!pattern) 按原文比对、顺序保留。 | ||
| 29 | +test('negation patterns are deduped by literal and order is preserved', () => { | ||
| 30 | + const merged = mergeGitignore('node_modules\n!node_modules/keep\n', '!node_modules/keep\ndist\n') | ||
| 31 | + assert.equal(merged, 'node_modules\n!node_modules/keep\ndist\n') | ||
| 32 | +}) | ||
| 33 | + | ||
| 34 | +// 输出始终以单个 \n 结尾,即便 base 末尾无换行。 | ||
| 35 | +test('output always ends with exactly one newline', () => { | ||
| 36 | + assert.equal(mergeGitignore('a', 'b'), 'a\nb\n') | ||
| 37 | + assert.equal(mergeGitignore('a\n\n\n', 'b\n\n'), 'a\nb\n') | ||
| 38 | +}) |
lib/validate-ddl.mjs
| 1 | // lib/validate-ddl.mjs — docs/03 表格 ↔ DDL(V1.sql)一致性 5 维校验 | 1 | // lib/validate-ddl.mjs — docs/03 表格 ↔ DDL(V1.sql)一致性 5 维校验 |
| 2 | // 替换 db-init/scripts/validate.sh(跨平台、纯 Node、零外部依赖)。 | 2 | // 替换 db-init/scripts/validate.sh(跨平台、纯 Node、零外部依赖)。 |
| 3 | +// 语法基线偏向 MySQL 8(int/varchar/json 等 ANSI + MySQL 类型;KEY/UNIQUE KEY 索引语法)。 | ||
| 4 | +// 厂商扩展(Postgres `bytea`、Oracle `nvarchar2` 等)未列入 SQL_TYPE_RE,下游解析可能退化为 | ||
| 5 | +// 跳过整项(fix #2 起 KEY/INDEX 项遇未知类型保留字会跳过而非误判为列)。 | ||
| 3 | // | 6 | // |
| 4 | // 用法(CLI):node lib/validate-ddl.mjs <docs03Path> <ddlPath> | 7 | // 用法(CLI):node lib/validate-ddl.mjs <docs03Path> <ddlPath> |
| 5 | // 退出码 0 = 一致;1 = 存在差异(diff 明细打印到 stderr);2 = 用法/路径错误。 | 8 | // 退出码 0 = 一致;1 = 存在差异(diff 明细打印到 stderr);2 = 用法/路径错误。 |
| @@ -74,25 +77,66 @@ export function parseDocsTables(text) { | @@ -74,25 +77,66 @@ export function parseDocsTables(text) { | ||
| 74 | // type 为 PRIMARY(不分大小写)→ 记 'PRIMARY'(匹配 parseDDL 对主键的归一化); | 77 | // type 为 PRIMARY(不分大小写)→ 记 'PRIMARY'(匹配 parseDDL 对主键的归一化); |
| 75 | // 否则记索引名 name(匹配 parseDDL 对命名索引存 name)。 | 78 | // 否则记索引名 name(匹配 parseDDL 对命名索引存 name)。 |
| 76 | function parseIndexBullet(line, indexes) { | 79 | function parseIndexBullet(line, indexes) { |
| 77 | - const m = line.match(/^\s*-\s+`?([^`():]+)`?\s*(?:\(([^)]*)\))?\s*:?/) | 80 | + // 真正的索引 bullet 必须有 `(type)` 或 `: cols`(或两者皆有);纯散文 bullet 拒绝匹配。 |
| 81 | + const m = line.match(/^\s*-\s+`?([^`():]+)`?\s*(?:\(([^)]*)\))?\s*(?::\s*(.+))?$/) | ||
| 78 | if (!m) return | 82 | if (!m) return |
| 79 | const name = m[1].trim() | 83 | const name = m[1].trim() |
| 80 | const type = (m[2] || '').trim() | 84 | const type = (m[2] || '').trim() |
| 85 | + const colsRaw = (m[3] || '').trim() | ||
| 81 | if (!name) return | 86 | if (!name) return |
| 82 | - if (/^primary$/i.test(type) || /^primary$/i.test(name)) indexes.add('PRIMARY') | ||
| 83 | - else indexes.add(name) | 87 | + // 散文 bullet 守门:没有括号也没有冒号列段 → 不是索引项 |
| 88 | + if (!type && !colsRaw) return | ||
| 89 | + if (/^primary$/i.test(type) || /^primary$/i.test(name)) { | ||
| 90 | + indexes.add('PRIMARY') | ||
| 91 | + return | ||
| 92 | + } | ||
| 93 | + // 列与 UNIQUE/INDEX 类别一并参与等价比较(fix #10) | ||
| 94 | + const cols = colsRaw | ||
| 95 | + .split(',') | ||
| 96 | + .map(c => c.replace(/`/g, '').trim()) | ||
| 97 | + .filter(c => /^[A-Za-z0-9_]+$/.test(c)) | ||
| 98 | + .join(',') | ||
| 99 | + const kind = /^unique$/i.test(type) ? 'UNIQUE' : 'INDEX' | ||
| 100 | + indexes.add(`${name}:${kind}:${cols}`) | ||
| 84 | } | 101 | } |
| 85 | 102 | ||
| 86 | // 解析外键 bullet: - `name`: from_col → to_table.to_col (on_delete) | 103 | // 解析外键 bullet: - `name`: from_col → to_table.to_col (on_delete) |
| 87 | -// 归一化为 parseDDL 同形的 `${fromCol}->${toTable}(${toCol})`(注意 docs 用 unicode → / DDL 用 ->)。 | 104 | +// 归一化为 parseDDL 同形的 `${fromCols}->${toTable}(${toCols})`(注意 docs 用 unicode → / DDL 用 ->)。 |
| 105 | +// 复合外键的两种合法 docs 写法都支持,避免与 DDL 侧的 `(idA, idB)` 形态不对称: | ||
| 106 | +// - `fk`: colA, colB → other.idA, idB ← 平铺,到列也是逗号分隔 | ||
| 107 | +// - `fk`: colA, colB → other.(idA, idB) ← 目标列括起 | ||
| 108 | +// - `fk`: colA, colB → other.`idA`,`idB` ← 各列各带反引号 | ||
| 88 | function parseForeignKeyBullet(line, foreignKeys) { | 109 | function parseForeignKeyBullet(line, foreignKeys) { |
| 89 | - const m = line.match(/^\s*-\s+`?[^`:]+`?\s*:\s*`?([A-Za-z0-9_,\s]+?)`?\s*(?:→|->|>)\s*`?([A-Za-z0-9_]+)`?\.`?([A-Za-z0-9_]+)`?/) | ||
| 90 | - if (!m) return | ||
| 91 | - const fromCols = m[1].replace(/`/g, '').replace(/\s+/g, '') | ||
| 92 | - const toTable = m[2] | ||
| 93 | - const toCols = m[3].replace(/`/g, '').replace(/\s+/g, '') | 110 | + // 1) 先把头部 `- `name`: ... → table` 抠出来,保留"目标表后剩余的尾段"用于解析目标列(可能是 |
| 111 | + // `.idA`、`.idA, idB`、`.(idA, idB)` 或 `.`idA`,`idB``)。 | ||
| 112 | + const head = line.match(/^\s*-\s+`?[^`:]+`?\s*:\s*([^→>\n]+?)\s*(?:→|->)\s*`?([A-Za-z0-9_]+)`?\s*\.\s*(.+)$/) | ||
| 113 | + if (!head) return | ||
| 114 | + const fromRaw = head[1] | ||
| 115 | + const toTable = head[2] | ||
| 116 | + let toRaw = head[3] | ||
| 117 | + if (!fromRaw || !toTable || !toRaw) return | ||
| 118 | + | ||
| 119 | + const fromCols = fromRaw.replace(/`/g, '').replace(/\s+/g, '') | ||
| 120 | + | ||
| 121 | + // 2) 目标列:剥掉一对外层圆括号(如果有),按逗号切分,去反引号 / 空白;遇到第一个非 | ||
| 122 | + // `[A-Za-z0-9_]` 列分隔符以外的字符(如 `(CASCADE)`、` on delete ...`)就停止收集。 | ||
| 123 | + toRaw = toRaw.trim() | ||
| 124 | + // 在分列前先尝试抓取尾部的 on-delete 标记:(CASCADE) / (RESTRICT) / (SET NULL) / (NO ACTION) / | ||
| 125 | + // (SET DEFAULT);docs 模板规约把 action 写在一对独立括号里,紧跟在目标列之后。 | ||
| 126 | + const onDeleteMatch = toRaw.match(/\((CASCADE|RESTRICT|SET\s+NULL|SET\s+DEFAULT|NO\s+ACTION)\)\s*$/i) | ||
| 127 | + const onDelete = onDeleteMatch ? onDeleteMatch[1].toUpperCase().replace(/\s+/g, ' ') : 'RESTRICT' | ||
| 128 | + // 剥外层括号:(idA, idB) → idA, idB | ||
| 129 | + const paren = toRaw.match(/^\(([^)]*)\)/) | ||
| 130 | + let toBody = paren ? paren[1] : toRaw | ||
| 131 | + // 截断到第一个 `(`(如 `(CASCADE)`)或行尾。 | ||
| 132 | + toBody = toBody.split('(')[0] | ||
| 133 | + const toCols = toBody | ||
| 134 | + .split(',') | ||
| 135 | + .map(s => s.replace(/`/g, '').trim()) | ||
| 136 | + .filter(s => /^[A-Za-z0-9_]+$/.test(s)) | ||
| 137 | + .join(',') | ||
| 94 | if (!fromCols || !toTable || !toCols) return | 138 | if (!fromCols || !toTable || !toCols) return |
| 95 | - foreignKeys.add(`${fromCols}->${toTable}(${toCols})`) | 139 | + foreignKeys.add(`${fromCols}->${toTable}(${toCols}):${onDelete}`) |
| 96 | } | 140 | } |
| 97 | 141 | ||
| 98 | // ── 解析 CREATE TABLE DDL ──────────────────────────────────────── | 142 | // ── 解析 CREATE TABLE DDL ──────────────────────────────────────── |
| @@ -101,8 +145,9 @@ export function parseDDL(text) { | @@ -101,8 +145,9 @@ export function parseDDL(text) { | ||
| 101 | const tables = new Map() | 145 | const tables = new Map() |
| 102 | // 先剥离 SQL 注释,避免被注释掉的 CREATE TABLE 被当成真实表(幽灵表假阳性)。 | 146 | // 先剥离 SQL 注释,避免被注释掉的 CREATE TABLE 被当成真实表(幽灵表假阳性)。 |
| 103 | const src = stripSqlComments(String(text)) | 147 | const src = stripSqlComments(String(text)) |
| 104 | - // 抓取 CREATE TABLE <name> ( <body> ) ;name 可带反引号;body 到匹配的右括号 | ||
| 105 | - const createRe = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([A-Za-z0-9_]+)`?\s*\(/gi | 148 | + // 抓取 CREATE TABLE <name> ( <body> ) ;name 可带反引号;body 到匹配的右括号。 |
| 149 | + // 支持可选 schema 限定名 `db`.`t` / db.t(取末段为表名,与 docs/03 一致)。 | ||
| 150 | + const createRe = /CREATE\s+(?:(?:GLOBAL|LOCAL)\s+)?(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:`?[A-Za-z0-9_]+`?\s*\.\s*)?`?([A-Za-z0-9_]+)`?\s*\(/gi | ||
| 106 | let m | 151 | let m |
| 107 | while ((m = createRe.exec(src)) !== null) { | 152 | while ((m = createRe.exec(src)) !== null) { |
| 108 | const tableName = m[1] | 153 | const tableName = m[1] |
| @@ -128,12 +173,14 @@ function parseTableBody(body) { | @@ -128,12 +173,14 @@ function parseTableBody(body) { | ||
| 128 | 173 | ||
| 129 | // 外键约束(可带前缀 CONSTRAINT <name>) | 174 | // 外键约束(可带前缀 CONSTRAINT <name>) |
| 130 | if (/\bFOREIGN\s+KEY\b/i.test(item)) { | 175 | if (/\bFOREIGN\s+KEY\b/i.test(item)) { |
| 131 | - const fk = item.match(/FOREIGN\s+KEY\s*\(([^)]*)\)\s*REFERENCES\s+`?([A-Za-z0-9_]+)`?\s*\(([^)]*)\)/i) | 176 | + // REFERENCES 支持 schema 限定 `db`.`t` / db.t(取末段为表名,与 CREATE TABLE 一致)。 |
| 177 | + const fk = item.match(/FOREIGN\s+KEY\s*\(([^)]*)\)\s*REFERENCES\s+(?:`?[A-Za-z0-9_]+`?\s*\.\s*)?`?([A-Za-z0-9_]+)`?\s*\(([^)]*)\)(?:\s+ON\s+DELETE\s+(CASCADE|RESTRICT|SET\s+NULL|SET\s+DEFAULT|NO\s+ACTION))?/i) | ||
| 132 | if (fk) { | 178 | if (fk) { |
| 133 | const fromCols = fk[1].replace(/`/g, '').replace(/\s+/g, '') | 179 | const fromCols = fk[1].replace(/`/g, '').replace(/\s+/g, '') |
| 134 | const refTable = fk[2] | 180 | const refTable = fk[2] |
| 135 | const toCols = fk[3].replace(/`/g, '').replace(/\s+/g, '') | 181 | const toCols = fk[3].replace(/`/g, '').replace(/\s+/g, '') |
| 136 | - foreignKeys.add(`${fromCols}->${refTable}(${toCols})`) | 182 | + const onDelete = (fk[4] || 'RESTRICT').toUpperCase().replace(/\s+/g, ' ') |
| 183 | + foreignKeys.add(`${fromCols}->${refTable}(${toCols}):${onDelete}`) | ||
| 137 | } else { | 184 | } else { |
| 138 | foreignKeys.add(item) | 185 | foreignKeys.add(item) |
| 139 | } | 186 | } |
| @@ -146,10 +193,24 @@ function parseTableBody(body) { | @@ -146,10 +193,24 @@ function parseTableBody(body) { | ||
| 146 | continue | 193 | continue |
| 147 | } | 194 | } |
| 148 | // UNIQUE [KEY|INDEX] <name> (...) / KEY <name> (...) / INDEX <name> (...) | 195 | // UNIQUE [KEY|INDEX] <name> (...) / KEY <name> (...) / INDEX <name> (...) |
| 196 | + // 启发式消歧:若 `<KEY|INDEX> <ident> (...)` 中 ident 是 SQL 标量类型关键字(如 | ||
| 197 | + // `key varchar(10)`),更可能是未加反引号的保留字列名 + 类型,回退到普通列解析避免漏列; | ||
| 198 | + // 但下游列正则会显式排斥以 KEY/INDEX/UNIQUE/FULLTEXT/SPATIAL 开头的整项,避免 fix #2 的幽灵列。 | ||
| 149 | if (/^(UNIQUE\s+(KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\b/i.test(item)) { | 199 | if (/^(UNIQUE\s+(KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\b/i.test(item)) { |
| 150 | - const nameMatch = item.match(/^(?:UNIQUE\s+(?:KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\s+`?([A-Za-z0-9_]+)`?/i) | ||
| 151 | - indexes.add(nameMatch ? nameMatch[1] : item) | ||
| 152 | - continue | 200 | + const nameMatch = item.match(/^(?:UNIQUE\s+(?:KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\s+`?([A-Za-z0-9_]+)`?\s*\(([^)]*)\)/i) |
| 201 | + const SQL_TYPE_RE = /^(?:int|integer|bigint|smallint|tinyint|mediumint|varchar|char|text|blob|date|datetime|timestamp|time|year|decimal|numeric|float|double|real|bit|enum|set|json|binary|varbinary|longtext|longblob|mediumtext|mediumblob|tinytext|tinyblob)$/i | ||
| 202 | + if (nameMatch && !SQL_TYPE_RE.test(nameMatch[1])) { | ||
| 203 | + const kind = /^UNIQUE/i.test(item) ? 'UNIQUE' : 'INDEX' | ||
| 204 | + const cols = nameMatch[2] | ||
| 205 | + .split(',') | ||
| 206 | + .map(c => c.replace(/`/g, '').trim()) | ||
| 207 | + .filter(Boolean) | ||
| 208 | + .join(',') | ||
| 209 | + indexes.add(`${nameMatch[1]}:${kind}:${cols}`) | ||
| 210 | + continue | ||
| 211 | + } | ||
| 212 | + // 命名是类型关键字 / 无法定位 → 回退到列定义解析; | ||
| 213 | + // 列正则下游会拒绝以保留字开头的列名(fix #2)。 | ||
| 153 | } | 214 | } |
| 154 | // CONSTRAINT <name> 但非外键(如 UNIQUE/CHECK 约束)→ 当索引/约束记 | 215 | // CONSTRAINT <name> 但非外键(如 UNIQUE/CHECK 约束)→ 当索引/约束记 |
| 155 | if (/^CONSTRAINT\b/i.test(upper)) { | 216 | if (/^CONSTRAINT\b/i.test(upper)) { |
| @@ -161,10 +222,13 @@ function parseTableBody(body) { | @@ -161,10 +222,13 @@ function parseTableBody(body) { | ||
| 161 | if (/^CHECK\b/i.test(upper)) continue | 222 | if (/^CHECK\b/i.test(upper)) continue |
| 162 | 223 | ||
| 163 | // 普通列:<name> <type> ... name 可带反引号;type 取到第一个属性关键字/逗号前 | 224 | // 普通列:<name> <type> ... name 可带反引号;type 取到第一个属性关键字/逗号前 |
| 164 | - const col = item.match(/^`?([A-Za-z0-9_]+)`?\s+(.+)$/s) | 225 | + const col = item.match(/^(`?)([A-Za-z0-9_]+)\1\s+(.+)$/s) |
| 165 | if (!col) continue | 226 | if (!col) continue |
| 166 | - const name = col[1] | ||
| 167 | - const type = extractType(col[2]) | 227 | + const quoted = col[1] === '`' |
| 228 | + const name = col[2] | ||
| 229 | + // 未加反引号时拒绝索引保留字开头的"列",避免把 `UNIQUE KEY foo (c)` 等误吃成列(fix #2)。 | ||
| 230 | + if (!quoted && /^(KEY|INDEX|UNIQUE|FULLTEXT|SPATIAL|PRIMARY|CONSTRAINT|CHECK|FOREIGN)$/i.test(name)) continue | ||
| 231 | + const type = extractType(col[3]) | ||
| 168 | columns.set(name, type) | 232 | columns.set(name, type) |
| 169 | } | 233 | } |
| 170 | return { columns, indexes, foreignKeys } | 234 | return { columns, indexes, foreignKeys } |
| @@ -176,10 +240,13 @@ function extractType(rest) { | @@ -176,10 +240,13 @@ function extractType(rest) { | ||
| 176 | // 类型形如 varchar(100) / decimal(10,2) / int unsigned / bigint | 240 | // 类型形如 varchar(100) / decimal(10,2) / int unsigned / bigint |
| 177 | const m = s.match(/^([A-Za-z]+(?:\s+(?:unsigned|signed|zerofill))*)\s*(\([^)]*\))?/i) | 241 | const m = s.match(/^([A-Za-z]+(?:\s+(?:unsigned|signed|zerofill))*)\s*(\([^)]*\))?/i) |
| 178 | if (!m) return s.split(/\s+/)[0] | 242 | if (!m) return s.split(/\s+/)[0] |
| 179 | - let type = m[1].trim() | ||
| 180 | - // 仅保留基础类型词 + 括号;丢弃 unsigned/signed 这类修饰以贴近 docs/03 写法(docs 一般只写基础类型) | 243 | + const type = m[1].trim() |
| 181 | const base = type.split(/\s+/)[0] | 244 | const base = type.split(/\s+/)[0] |
| 182 | - return base + (m[2] ? m[2].replace(/\s+/g, '') : '') | 245 | + const paren = m[2] ? m[2].replace(/\s+/g, '') : '' |
| 246 | + // 保留 unsigned / signed 修饰,避免与 docs/03 写法(如 `int unsigned`)产生假阳性类型 mismatch。 | ||
| 247 | + // zerofill 较罕见且 docs 通常不写,仍丢弃。 | ||
| 248 | + const mod = /\bunsigned\b/i.test(type) ? ' unsigned' : /\bsigned\b/i.test(type) ? ' signed' : '' | ||
| 249 | + return base + paren + mod | ||
| 183 | } | 250 | } |
| 184 | 251 | ||
| 185 | // ── 5 维 diff ──────────────────────────────────────────────────── | 252 | // ── 5 维 diff ──────────────────────────────────────────────────── |
| @@ -244,12 +311,54 @@ export function diffSchema(docsTables, ddlTables) { | @@ -244,12 +311,54 @@ export function diffSchema(docsTables, ddlTables) { | ||
| 244 | 311 | ||
| 245 | // ── 工具函数 ───────────────────────────────────────────────────── | 312 | // ── 工具函数 ───────────────────────────────────────────────────── |
| 246 | // 剥离 SQL 注释:-- 行注释(到行尾)、# 行注释(到行尾)、/* */ 块注释。 | 313 | // 剥离 SQL 注释:-- 行注释(到行尾)、# 行注释(到行尾)、/* */ 块注释。 |
| 247 | -// 保守起见不解析字符串字面量内的注释符(DDL 极少在标识符/默认值里出现裸 -- 或 /*)。 | 314 | +// **字符串字面量感知**:单引号 / 双引号 / 反引号字面量内部的注释符按原文保留(DEFAULT 'a--b' / |
| 315 | +// DEFAULT '#tag' 之类不会被错剥成"列丢失")。转义引号支持 SQL 标准的 '' 与反斜杠 \\'。 | ||
| 248 | function stripSqlComments(sql) { | 316 | function stripSqlComments(sql) { |
| 249 | - return sql | ||
| 250 | - .replace(/\/\*[\s\S]*?\*\//g, ' ') // 块注释 | ||
| 251 | - .replace(/--.*$/gm, '') // -- 行注释 | ||
| 252 | - .replace(/#.*$/gm, '') // # 行注释 | 317 | + const s = String(sql) |
| 318 | + let out = '' | ||
| 319 | + let i = 0 | ||
| 320 | + while (i < s.length) { | ||
| 321 | + const ch = s[i] | ||
| 322 | + const next = s[i + 1] | ||
| 323 | + // 进入字符串 / 反引号:原样吐出整个字面量 | ||
| 324 | + if (ch === "'" || ch === '"' || ch === '`') { | ||
| 325 | + const q = ch | ||
| 326 | + out += ch | ||
| 327 | + i++ | ||
| 328 | + while (i < s.length) { | ||
| 329 | + const c = s[i] | ||
| 330 | + // SQL 标准的双引号转义:'' 或 "" | ||
| 331 | + if (c === q && s[i + 1] === q) { out += c + c; i += 2; continue } | ||
| 332 | + // 反斜杠转义:\' / \" / \\ 等(MySQL 默认开启 NO_BACKSLASH_ESCAPES 才禁,保守按开启处理) | ||
| 333 | + if (c === '\\' && i + 1 < s.length && q !== '`') { out += c + s[i + 1]; i += 2; continue } | ||
| 334 | + out += c | ||
| 335 | + i++ | ||
| 336 | + if (c === q) break | ||
| 337 | + } | ||
| 338 | + continue | ||
| 339 | + } | ||
| 340 | + // /* ... */ 块注释(吞到下一个 */) | ||
| 341 | + if (ch === '/' && next === '*') { | ||
| 342 | + i += 2 | ||
| 343 | + while (i < s.length && !(s[i] === '*' && s[i + 1] === '/')) i++ | ||
| 344 | + i += 2 | ||
| 345 | + out += ' ' | ||
| 346 | + continue | ||
| 347 | + } | ||
| 348 | + // -- 行注释(吞到行尾,不含换行) | ||
| 349 | + if (ch === '-' && next === '-') { | ||
| 350 | + while (i < s.length && s[i] !== '\n') i++ | ||
| 351 | + continue | ||
| 352 | + } | ||
| 353 | + // # 行注释(吞到行尾,不含换行) | ||
| 354 | + if (ch === '#') { | ||
| 355 | + while (i < s.length && s[i] !== '\n') i++ | ||
| 356 | + continue | ||
| 357 | + } | ||
| 358 | + out += ch | ||
| 359 | + i++ | ||
| 360 | + } | ||
| 361 | + return out | ||
| 253 | } | 362 | } |
| 254 | 363 | ||
| 255 | function stripTicks(s) { | 364 | function stripTicks(s) { |
| @@ -274,32 +383,65 @@ function isHeaderLabel(cell) { | @@ -274,32 +383,65 @@ function isHeaderLabel(cell) { | ||
| 274 | return ['列', '字段', '字段名', '列名', '类型', 'name', 'type', 'column'].includes(cell.trim()) | 383 | return ['列', '字段', '字段名', '列名', '类型', 'name', 'type', 'column'].includes(cell.trim()) |
| 275 | } | 384 | } |
| 276 | 385 | ||
| 386 | +// 推进字符串字面量游标:从指针指向开引号开始,返回字面量结束后(含闭引号)的下标。 | ||
| 387 | +// 支持 '' / "" 转义与反斜杠转义(反引号字面量不支持反斜杠转义)。 | ||
| 388 | +function advanceLiteral(src, i) { | ||
| 389 | + const q = src[i] | ||
| 390 | + i++ | ||
| 391 | + while (i < src.length) { | ||
| 392 | + const c = src[i] | ||
| 393 | + if (c === q && src[i + 1] === q) { i += 2; continue } | ||
| 394 | + if (c === '\\' && i + 1 < src.length && q !== '`') { i += 2; continue } | ||
| 395 | + i++ | ||
| 396 | + if (c === q) return i | ||
| 397 | + } | ||
| 398 | + return i | ||
| 399 | +} | ||
| 400 | + | ||
| 277 | // 提取从 openIdx(指向 '(')开始的平衡括号内部内容(不含最外层括号)。 | 401 | // 提取从 openIdx(指向 '(')开始的平衡括号内部内容(不含最外层括号)。 |
| 402 | +// **字符串字面量感知**:DEFAULT ')' / DEFAULT '(a,b)' 等不会让 depth 提前减为 0 截断表体。 | ||
| 278 | function extractBalancedParens(src, openIdx) { | 403 | function extractBalancedParens(src, openIdx) { |
| 279 | if (src[openIdx] !== '(') return null | 404 | if (src[openIdx] !== '(') return null |
| 280 | let depth = 0 | 405 | let depth = 0 |
| 281 | - for (let i = openIdx; i < src.length; i++) { | 406 | + let i = openIdx |
| 407 | + while (i < src.length) { | ||
| 282 | const ch = src[i] | 408 | const ch = src[i] |
| 283 | - if (ch === '(') depth++ | ||
| 284 | - else if (ch === ')') { | 409 | + if (ch === "'" || ch === '"' || ch === '`') { |
| 410 | + i = advanceLiteral(src, i) | ||
| 411 | + continue | ||
| 412 | + } | ||
| 413 | + if (ch === '(') { depth++; i++; continue } | ||
| 414 | + if (ch === ')') { | ||
| 285 | depth-- | 415 | depth-- |
| 286 | if (depth === 0) return src.slice(openIdx + 1, i) | 416 | if (depth === 0) return src.slice(openIdx + 1, i) |
| 417 | + i++ | ||
| 418 | + continue | ||
| 287 | } | 419 | } |
| 420 | + i++ | ||
| 288 | } | 421 | } |
| 289 | return null | 422 | return null |
| 290 | } | 423 | } |
| 291 | 424 | ||
| 292 | -// 在顶层(括号深度 0)按逗号切分 DDL body,保护 varchar(100) / decimal(10,2) 内的逗号。 | 425 | +// 在顶层(括号深度 0、字符串字面量外)按逗号切分 DDL body。 |
| 426 | +// 保护 varchar(100) / decimal(10,2) 内的逗号,也保护 DEFAULT 'a,b' / COMMENT '..., ...' 内的逗号。 | ||
| 293 | function splitTopLevelCommas(body) { | 427 | function splitTopLevelCommas(body) { |
| 294 | const out = [] | 428 | const out = [] |
| 295 | let depth = 0 | 429 | let depth = 0 |
| 296 | let buf = '' | 430 | let buf = '' |
| 297 | - for (let i = 0; i < body.length; i++) { | 431 | + let i = 0 |
| 432 | + while (i < body.length) { | ||
| 298 | const ch = body[i] | 433 | const ch = body[i] |
| 299 | - if (ch === '(') { depth++; buf += ch } | ||
| 300 | - else if (ch === ')') { depth--; buf += ch } | ||
| 301 | - else if (ch === ',' && depth === 0) { out.push(buf); buf = '' } | ||
| 302 | - else buf += ch | 434 | + if (ch === "'" || ch === '"' || ch === '`') { |
| 435 | + const end = advanceLiteral(body, i) | ||
| 436 | + buf += body.slice(i, end) | ||
| 437 | + i = end | ||
| 438 | + continue | ||
| 439 | + } | ||
| 440 | + if (ch === '(') { depth++; buf += ch; i++; continue } | ||
| 441 | + if (ch === ')') { depth--; buf += ch; i++; continue } | ||
| 442 | + if (ch === ',' && depth === 0) { out.push(buf); buf = ''; i++; continue } | ||
| 443 | + buf += ch | ||
| 444 | + i++ | ||
| 303 | } | 445 | } |
| 304 | if (buf.trim()) out.push(buf) | 446 | if (buf.trim()) out.push(buf) |
| 305 | return out | 447 | return out |
lib/validate-ddl.test.mjs
| @@ -86,7 +86,7 @@ const DDL_FULL = [ | @@ -86,7 +86,7 @@ const DDL_FULL = [ | ||
| 86 | ' `sUserId` varchar(100) NOT NULL,', | 86 | ' `sUserId` varchar(100) NOT NULL,', |
| 87 | ' PRIMARY KEY (`iId`),', | 87 | ' PRIMARY KEY (`iId`),', |
| 88 | ' KEY `idx_user` (`sUserId`),', | 88 | ' KEY `idx_user` (`sUserId`),', |
| 89 | - ' CONSTRAINT `fk_user` FOREIGN KEY (`sUserId`) REFERENCES `t_user` (`sId`)', | 89 | + ' CONSTRAINT `fk_user` FOREIGN KEY (`sUserId`) REFERENCES `t_user` (`sId`) ON DELETE CASCADE', |
| 90 | ') ENGINE=InnoDB;', | 90 | ') ENGINE=InnoDB;', |
| 91 | ].join('\n') | 91 | ].join('\n') |
| 92 | 92 | ||
| @@ -94,8 +94,10 @@ test('parseDocsTables: parses ### 索引 / ### 外键 bullets into sets (C2 regr | @@ -94,8 +94,10 @@ test('parseDocsTables: parses ### 索引 / ### 外键 bullets into sets (C2 regr | ||
| 94 | const t = parseDocsTables(DOCS_FULL).get('t_order') | 94 | const t = parseDocsTables(DOCS_FULL).get('t_order') |
| 95 | assert.ok(t) | 95 | assert.ok(t) |
| 96 | assert.ok(t.indexes.has('PRIMARY'), 'PRIMARY index normalized') | 96 | assert.ok(t.indexes.has('PRIMARY'), 'PRIMARY index normalized') |
| 97 | - assert.ok(t.indexes.has('idx_user'), 'named index by name') | ||
| 98 | - assert.ok(t.foreignKeys.has('sUserId->t_user(sId)'), 'FK normalized to parseDDL form') | 97 | + assert.ok(t.indexes.has('idx_user:INDEX:sUserId'), |
| 98 | + 'named index normalized to name:kind:cols — got: ' + [...t.indexes]) | ||
| 99 | + assert.ok(t.foreignKeys.has('sUserId->t_user(sId):CASCADE'), | ||
| 100 | + 'FK normalized to parseDDL form with on-delete — got: ' + [...t.foreignKeys]) | ||
| 99 | }) | 101 | }) |
| 100 | 102 | ||
| 101 | test('full chain: matching docs/03 (with indexes+FK) ↔ DDL yields no diff (C2 regression)', () => { | 103 | test('full chain: matching docs/03 (with indexes+FK) ↔ DDL yields no diff (C2 regression)', () => { |
| @@ -115,7 +117,7 @@ test('full chain: a real FK present in docs but missing from DDL is caught', () | @@ -115,7 +117,7 @@ test('full chain: a real FK present in docs but missing from DDL is caught', () | ||
| 115 | ') ENGINE=InnoDB;', | 117 | ') ENGINE=InnoDB;', |
| 116 | ].join('\n') | 118 | ].join('\n') |
| 117 | const d = diffSchema(parseDocsTables(DOCS_FULL), parseDDL(ddlNoFk)) | 119 | const d = diffSchema(parseDocsTables(DOCS_FULL), parseDDL(ddlNoFk)) |
| 118 | - assert.ok(d.foreignKeyMismatches.some(m => m.side === 'docs' && m.foreignKey === 'sUserId->t_user(sId)')) | 120 | + assert.ok(d.foreignKeyMismatches.some(m => m.side === 'docs' && m.foreignKey === 'sUserId->t_user(sId):CASCADE')) |
| 119 | assert.equal(d.hasDiff, true) | 121 | assert.equal(d.hasDiff, true) |
| 120 | }) | 122 | }) |
| 121 | 123 | ||
| @@ -164,8 +166,8 @@ test('parseDDL: columns, types, indexes, foreign keys (backtick-quoted)', () => | @@ -164,8 +166,8 @@ test('parseDDL: columns, types, indexes, foreign keys (backtick-quoted)', () => | ||
| 164 | assert.deepEqual([...t.columns.keys()], ['iIncrement', 'sId', 'sUserId']) | 166 | assert.deepEqual([...t.columns.keys()], ['iIncrement', 'sId', 'sUserId']) |
| 165 | assert.equal(t.columns.get('sId'), 'varchar(100)') | 167 | assert.equal(t.columns.get('sId'), 'varchar(100)') |
| 166 | // index keys (named) collected; PRIMARY collected too | 168 | // index keys (named) collected; PRIMARY collected too |
| 167 | - assert.ok(t.indexes.has('uk_sid')) | ||
| 168 | - assert.ok(t.indexes.has('idx_user')) | 169 | + assert.ok(t.indexes.has('uk_sid:UNIQUE:sId'), 'unique index normalized — got: ' + [...t.indexes]) |
| 170 | + assert.ok(t.indexes.has('idx_user:INDEX:sUserId'), 'named index normalized — got: ' + [...t.indexes]) | ||
| 169 | assert.ok([...t.indexes].some(i => i.toUpperCase().includes('PRIMARY'))) | 171 | assert.ok([...t.indexes].some(i => i.toUpperCase().includes('PRIMARY'))) |
| 170 | // foreign key collected | 172 | // foreign key collected |
| 171 | assert.ok([...t.foreignKeys].some(fk => fk.includes('sUserId') && fk.includes('t_user'))) | 173 | assert.ok([...t.foreignKeys].some(fk => fk.includes('sUserId') && fk.includes('t_user'))) |
| @@ -209,10 +211,10 @@ test('diffSchema: extra column in DDL reported as columnMismatch', () => { | @@ -209,10 +211,10 @@ test('diffSchema: extra column in DDL reported as columnMismatch', () => { | ||
| 209 | }) | 211 | }) |
| 210 | 212 | ||
| 211 | test('diffSchema: index dimension diff reported', () => { | 213 | test('diffSchema: index dimension diff reported', () => { |
| 212 | - const docs = new Map([['t', { columns: new Map([['c', 'int']]), indexes: new Set(['idx_c']), foreignKeys: new Set() }]]) | 214 | + const docs = new Map([['t', { columns: new Map([['c', 'int']]), indexes: new Set(['idx_c:INDEX:c']), foreignKeys: new Set() }]]) |
| 213 | const ddl = parseDDL('CREATE TABLE t ( c int );') // no indexes | 215 | const ddl = parseDDL('CREATE TABLE t ( c int );') // no indexes |
| 214 | const d = diffSchema(docs, ddl) | 216 | const d = diffSchema(docs, ddl) |
| 215 | - assert.ok(d.indexMismatches.some(m => m.table === 't' && m.index === 'idx_c')) | 217 | + assert.ok(d.indexMismatches.some(m => m.table === 't' && m.index === 'idx_c:INDEX:c')) |
| 216 | }) | 218 | }) |
| 217 | 219 | ||
| 218 | test('diffSchema: foreign-key dimension diff reported', () => { | 220 | test('diffSchema: foreign-key dimension diff reported', () => { |
| @@ -228,3 +230,237 @@ test('diffSchema: hasDiff is false when everything matches, true otherwise', () | @@ -228,3 +230,237 @@ test('diffSchema: hasDiff is false when everything matches, true otherwise', () | ||
| 228 | const bad = diffSchema(parseDocsTables(DOCS), parseDDL('CREATE TABLE t_user ( iId bigint );')) | 230 | const bad = diffSchema(parseDocsTables(DOCS), parseDDL('CREATE TABLE t_user ( iId bigint );')) |
| 229 | assert.equal(bad.hasDiff, true) | 231 | assert.equal(bad.hasDiff, true) |
| 230 | }) | 232 | }) |
| 233 | + | ||
| 234 | +// ── 字符串字面量感知(回归)────────────────────────────────────── | ||
| 235 | +test('parseDDL: DEFAULT \'a--b\' 字面量中的 -- 不应被当行注释剥离', () => { | ||
| 236 | + const ddl = "CREATE TABLE t ( s varchar(10) DEFAULT 'a--b', x int );" | ||
| 237 | + const t = parseDDL(ddl).get('t') | ||
| 238 | + assert.ok(t) | ||
| 239 | + assert.deepEqual([...t.columns.keys()], ['s', 'x'], '字面量内 -- 不应吞掉后续列 x') | ||
| 240 | +}) | ||
| 241 | + | ||
| 242 | +test('parseDDL: DEFAULT \'#tag\' 字面量中的 # 不应被当行注释剥离', () => { | ||
| 243 | + const ddl = "CREATE TABLE t ( s varchar(10) DEFAULT '#tag', x int );" | ||
| 244 | + const t = parseDDL(ddl).get('t') | ||
| 245 | + assert.ok(t) | ||
| 246 | + assert.deepEqual([...t.columns.keys()], ['s', 'x'], '字面量内 # 不应吞掉后续列 x') | ||
| 247 | +}) | ||
| 248 | + | ||
| 249 | +test('parseDDL: DEFAULT \')\' 字面量中的右括号不应提前截断表体', () => { | ||
| 250 | + const ddl = "CREATE TABLE t ( s varchar(10) DEFAULT ')', x int );" | ||
| 251 | + const t = parseDDL(ddl).get('t') | ||
| 252 | + assert.ok(t) | ||
| 253 | + assert.deepEqual([...t.columns.keys()], ['s', 'x'], '字面量内 ) 不应让 depth 提前归零截断表体') | ||
| 254 | +}) | ||
| 255 | + | ||
| 256 | +test('parseDDL: DEFAULT \'(a,b)\' 字面量中的逗号不应被当顶层分隔', () => { | ||
| 257 | + const ddl = "CREATE TABLE t ( s varchar(10) DEFAULT '(a,b)', x int );" | ||
| 258 | + const t = parseDDL(ddl).get('t') | ||
| 259 | + assert.ok(t) | ||
| 260 | + assert.deepEqual([...t.columns.keys()], ['s', 'x']) | ||
| 261 | +}) | ||
| 262 | + | ||
| 263 | +// ── schema 限定表名(回归)─────────────────────────────────────── | ||
| 264 | +test('parseDDL: CREATE TABLE db.t 与 `db`.`t` 都应解析(取末段为表名)', () => { | ||
| 265 | + const tables1 = parseDDL('CREATE TABLE mydb.t_user ( id int );') | ||
| 266 | + assert.deepEqual([...tables1.keys()], ['t_user']) | ||
| 267 | + const tables2 = parseDDL('CREATE TABLE `mydb`.`t_user` ( id int );') | ||
| 268 | + assert.deepEqual([...tables2.keys()], ['t_user']) | ||
| 269 | +}) | ||
| 270 | + | ||
| 271 | +// ── 复合外键 docs↔DDL 对称(回归)──────────────────────────────── | ||
| 272 | +test('parseDocsTables: 复合外键 - colA, colB → other.idA, idB 应平铺成 colA,colB->other(idA,idB)', () => { | ||
| 273 | + const docs = [ | ||
| 274 | + '## `t_link`', | ||
| 275 | + '### 字段', | ||
| 276 | + '| 列 | 类型 |', | ||
| 277 | + '|---|---|', | ||
| 278 | + '| `colA` | int |', | ||
| 279 | + '| `colB` | int |', | ||
| 280 | + '### 外键', | ||
| 281 | + '- `fk_x`: colA, colB → other.idA, idB (CASCADE)', | ||
| 282 | + ].join('\n') | ||
| 283 | + const t = parseDocsTables(docs).get('t_link') | ||
| 284 | + assert.ok(t) | ||
| 285 | + assert.ok(t.foreignKeys.has('colA,colB->other(idA,idB):CASCADE'), | ||
| 286 | + 'docs-side composite FK should normalize the same way as parseDDL — got: ' + [...t.foreignKeys]) | ||
| 287 | +}) | ||
| 288 | + | ||
| 289 | +test('full chain: 复合外键 docs ↔ DDL 一致时不应误报双向 mismatch', () => { | ||
| 290 | + const docs = [ | ||
| 291 | + '## `t_link`', | ||
| 292 | + '### 字段', | ||
| 293 | + '| 列 | 类型 |', | ||
| 294 | + '|---|---|', | ||
| 295 | + '| `colA` | int |', | ||
| 296 | + '| `colB` | int |', | ||
| 297 | + '### 外键', | ||
| 298 | + '- `fk_x`: colA, colB → other.(idA, idB)', | ||
| 299 | + ].join('\n') | ||
| 300 | + const ddl = [ | ||
| 301 | + 'CREATE TABLE `t_link` (', | ||
| 302 | + ' `colA` int NOT NULL,', | ||
| 303 | + ' `colB` int NOT NULL,', | ||
| 304 | + ' CONSTRAINT `fk_x` FOREIGN KEY (`colA`, `colB`) REFERENCES `other` (`idA`, `idB`)', | ||
| 305 | + ') ENGINE=InnoDB;', | ||
| 306 | + ].join('\n') | ||
| 307 | + const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) | ||
| 308 | + assert.deepEqual(d.foreignKeyMismatches, [], | ||
| 309 | + '复合 FK 一致时不应误报 — got: ' + JSON.stringify(d.foreignKeyMismatches)) | ||
| 310 | +}) | ||
| 311 | + | ||
| 312 | +// ── 未加引号的保留字列名(回归)───────────────────────────────── | ||
| 313 | +test('parseDDL: 未加引号的保留字列名 `key varchar(...)` 不应被误判为索引也不应制造幽灵列(fix #2)', () => { | ||
| 314 | + // 列名 key 未加反引号,且后面跟的是 `varchar(`(一个类型而非 `key <name> (`)。 | ||
| 315 | + // 新策略:未加反引号的保留字列名一律被跳过;用户需用反引号包裹保留字列名。 | ||
| 316 | + const ddl = 'CREATE TABLE t ( id int, key varchar(10) );' | ||
| 317 | + const t = parseDDL(ddl).get('t') | ||
| 318 | + assert.ok(t) | ||
| 319 | + assert.equal(t.columns.has('key'), false, '未加反引号的 key 应被跳过,不入 columns') | ||
| 320 | + assert.equal(t.indexes.size, 0, '也不应被当索引') | ||
| 321 | +}) | ||
| 322 | + | ||
| 323 | +test('parseDDL: 反引号包裹的保留字列名应正常解析(fix #2)', () => { | ||
| 324 | + const ddl = 'CREATE TABLE t ( id int, `key` varchar(10) );' | ||
| 325 | + const t = parseDDL(ddl).get('t') | ||
| 326 | + assert.ok(t) | ||
| 327 | + assert.ok(t.columns.has('key'), '加了反引号的 key 应被解析为普通列') | ||
| 328 | + assert.equal(t.columns.get('key'), 'varchar(10)') | ||
| 329 | +}) | ||
| 330 | + | ||
| 331 | +// ── #2 KEY/INDEX 启发式 fallthrough 不应制造幽灵列 ────────────── | ||
| 332 | +test('parseDDL: `KEY varchar (id)` 不应制造名为 `KEY` 的幽灵列(fix #2)', () => { | ||
| 333 | + const ddl = 'CREATE TABLE t ( id int, KEY varchar (id) );' | ||
| 334 | + const t = parseDDL(ddl).get('t') | ||
| 335 | + assert.ok(t) | ||
| 336 | + assert.deepEqual([...t.columns.keys()], ['id'], '不应出现 KEY 列') | ||
| 337 | + // varchar 是类型关键字,启发式跳过该项 → 既不入列也不入索引 | ||
| 338 | + assert.equal(t.indexes.size, 0, '保留字 + 类型名时该项应被跳过') | ||
| 339 | +}) | ||
| 340 | + | ||
| 341 | +test('parseDDL: `UNIQUE KEY double (c)` 不应被解析为列(fix #2/#20)', () => { | ||
| 342 | + const ddl = 'CREATE TABLE t ( c int, UNIQUE KEY double (c) );' | ||
| 343 | + const t = parseDDL(ddl).get('t') | ||
| 344 | + assert.ok(t) | ||
| 345 | + assert.deepEqual([...t.columns.keys()], ['c'], '不应出现 UNIQUE/KEY 列') | ||
| 346 | +}) | ||
| 347 | + | ||
| 348 | +test('parseDDL: `KEY decimal (c)` 不应被解析为列(fix #2/#20)', () => { | ||
| 349 | + const ddl = 'CREATE TABLE t ( c int, KEY decimal (c) );' | ||
| 350 | + const t = parseDDL(ddl).get('t') | ||
| 351 | + assert.ok(t) | ||
| 352 | + assert.deepEqual([...t.columns.keys()], ['c']) | ||
| 353 | +}) | ||
| 354 | + | ||
| 355 | +// ── #3 REFERENCES schema-qualified table ───────────────────────── | ||
| 356 | +test('parseDDL: FK REFERENCES mydb.users(id) 归一化为 uid->users(id)(fix #3)', () => { | ||
| 357 | + const ddl = [ | ||
| 358 | + 'CREATE TABLE t (', | ||
| 359 | + ' uid int NOT NULL,', | ||
| 360 | + ' FOREIGN KEY (uid) REFERENCES mydb.users(id)', | ||
| 361 | + ');', | ||
| 362 | + ].join('\n') | ||
| 363 | + const t = parseDDL(ddl).get('t') | ||
| 364 | + assert.ok(t) | ||
| 365 | + assert.ok(t.foreignKeys.has('uid->users(id):RESTRICT'), | ||
| 366 | + 'FK 表名应取末段 users 并附默认 on-delete — got: ' + [...t.foreignKeys]) | ||
| 367 | +}) | ||
| 368 | + | ||
| 369 | +// ── #4 extractType 保留 unsigned/signed 修饰 ───────────────────── | ||
| 370 | +test('extractType: `int unsigned` vs `int unsigned` 匹配,`int` vs `int unsigned` 报 mismatch(fix #4)', () => { | ||
| 371 | + const docsOk = parseDocsTables('## `t`\n| 列 | 类型 |\n|---|---|\n| id | int unsigned |\n') | ||
| 372 | + const ddlOk = parseDDL('CREATE TABLE t ( id int unsigned );') | ||
| 373 | + const ok = diffSchema(docsOk, ddlOk) | ||
| 374 | + assert.deepEqual(ok.typeMismatches, [], 'unsigned 两侧一致不应报错 — got: ' + JSON.stringify(ok.typeMismatches)) | ||
| 375 | + | ||
| 376 | + const docsMix = parseDocsTables('## `t`\n| 列 | 类型 |\n|---|---|\n| id | int unsigned |\n') | ||
| 377 | + const ddlMix = parseDDL('CREATE TABLE t ( id int );') | ||
| 378 | + const bad = diffSchema(docsMix, ddlMix) | ||
| 379 | + assert.ok(bad.typeMismatches.some(m => m.column === 'id' && m.docsType === 'int unsigned' && m.ddlType === 'int'), | ||
| 380 | + '一侧带 unsigned 一侧不带应报 mismatch — got: ' + JSON.stringify(bad.typeMismatches)) | ||
| 381 | +}) | ||
| 382 | + | ||
| 383 | +// ── #9 散文 bullet 不应被当 FK / 索引 ──────────────────────────── | ||
| 384 | +test('parseDocsTables: ### 外键 下的散文 bullet (含 `>`) 不应被当外键(fix #9)', () => { | ||
| 385 | + const docs = '## `t`\n### 外键\n- note: a > users.id\n' | ||
| 386 | + const t = parseDocsTables(docs).get('t') | ||
| 387 | + assert.ok(t) | ||
| 388 | + assert.equal(t.foreignKeys.size, 0, 'bare `>` 不再作为外键箭头 — got: ' + [...t.foreignKeys]) | ||
| 389 | +}) | ||
| 390 | + | ||
| 391 | +test('parseDocsTables: ### 索引 下纯散文 bullet 不应被当索引(fix #9)', () => { | ||
| 392 | + const docs = '## `t`\n### 索引\n- This bullet is not an index entry\n' | ||
| 393 | + const t = parseDocsTables(docs).get('t') | ||
| 394 | + assert.ok(t) | ||
| 395 | + assert.equal(t.indexes.size, 0, '散文 bullet 不再制造幽灵索引 — got: ' + [...t.indexes]) | ||
| 396 | +}) | ||
| 397 | + | ||
| 398 | +// ── #10 索引比较包含列与 UNIQUE-ness ──────────────────────────── | ||
| 399 | +test('diffSchema: 同名索引列不同应报 mismatch(fix #10)', () => { | ||
| 400 | + const docs = parseDocsTables([ | ||
| 401 | + '## `t`', | ||
| 402 | + '### 字段', | ||
| 403 | + '| 列 | 类型 |', | ||
| 404 | + '|---|---|', | ||
| 405 | + '| user_id | int |', | ||
| 406 | + '| wrong_col | int |', | ||
| 407 | + '### 索引', | ||
| 408 | + '- `idx_user` (index): user_id', | ||
| 409 | + ].join('\n')) | ||
| 410 | + const ddl = parseDDL([ | ||
| 411 | + 'CREATE TABLE `t` (', | ||
| 412 | + ' `user_id` int,', | ||
| 413 | + ' `wrong_col` int,', | ||
| 414 | + ' KEY `idx_user` (`wrong_col`)', | ||
| 415 | + ') ENGINE=InnoDB;', | ||
| 416 | + ].join('\n')) | ||
| 417 | + const d = diffSchema(docs, ddl) | ||
| 418 | + assert.ok(d.indexMismatches.length > 0, '同名但列不同应报 — got: ' + JSON.stringify(d.indexMismatches)) | ||
| 419 | +}) | ||
| 420 | + | ||
| 421 | +test('diffSchema: 同名索引 UNIQUE vs 非 UNIQUE 应报 mismatch(fix #10)', () => { | ||
| 422 | + const docs = parseDocsTables([ | ||
| 423 | + '## `t`', | ||
| 424 | + '### 字段', | ||
| 425 | + '| 列 | 类型 |', | ||
| 426 | + '|---|---|', | ||
| 427 | + '| c | int |', | ||
| 428 | + '### 索引', | ||
| 429 | + '- `uk_c` (unique): c', | ||
| 430 | + ].join('\n')) | ||
| 431 | + const ddl = parseDDL([ | ||
| 432 | + 'CREATE TABLE `t` (', | ||
| 433 | + ' `c` int,', | ||
| 434 | + ' KEY `uk_c` (`c`)', | ||
| 435 | + ') ENGINE=InnoDB;', | ||
| 436 | + ].join('\n')) | ||
| 437 | + const d = diffSchema(docs, ddl) | ||
| 438 | + assert.ok(d.indexMismatches.length > 0, 'UNIQUE vs INDEX 应报 — got: ' + JSON.stringify(d.indexMismatches)) | ||
| 439 | +}) | ||
| 440 | + | ||
| 441 | +// ── #11 ON DELETE actions differentiated ───────────────────────── | ||
| 442 | +test('diffSchema: FK ON DELETE CASCADE vs 缺省 RESTRICT 应报 mismatch(fix #11)', () => { | ||
| 443 | + const docs = parseDocsTables([ | ||
| 444 | + '## `t`', | ||
| 445 | + '### 字段', | ||
| 446 | + '| 列 | 类型 |', | ||
| 447 | + '|---|---|', | ||
| 448 | + '| `uid` | int |', | ||
| 449 | + '### 外键', | ||
| 450 | + '- `fk_uid`: uid → users.id (CASCADE)', | ||
| 451 | + ].join('\n')) | ||
| 452 | + const ddl = parseDDL([ | ||
| 453 | + 'CREATE TABLE `t` (', | ||
| 454 | + ' `uid` int,', | ||
| 455 | + ' FOREIGN KEY (`uid`) REFERENCES `users`(`id`)', | ||
| 456 | + ') ENGINE=InnoDB;', | ||
| 457 | + ].join('\n')) | ||
| 458 | + const d = diffSchema(docs, ddl) | ||
| 459 | + assert.ok(d.foreignKeyMismatches.length > 0, 'CASCADE vs RESTRICT 应报 — got: ' + JSON.stringify(d.foreignKeyMismatches)) | ||
| 460 | +}) | ||
| 461 | + | ||
| 462 | +// ── #16 CREATE TEMPORARY TABLE 也应被识别 ───────────────────────── | ||
| 463 | +test('parseDDL: CREATE TEMPORARY TABLE 也应被解析(fix #16)', () => { | ||
| 464 | + const tables = parseDDL('CREATE TEMPORARY TABLE t_tmp ( id int );') | ||
| 465 | + assert.deepEqual([...tables.keys()], ['t_tmp'], 'TEMPORARY 表应入 Map — got: ' + [...tables.keys()]) | ||
| 466 | +}) |
skills/coding-start/SKILL.md
| 1 | --- | 1 | --- |
| 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 |
| 6 | --- | 6 | --- |
| @@ -84,22 +84,22 @@ allowed-tools: Read Glob Workflow | @@ -84,22 +84,22 @@ allowed-tools: Read Glob Workflow | ||
| 84 | 84 | ||
| 85 | ### 步骤 4:启动 Coding Workflow | 85 | ### 步骤 4:启动 Coding Workflow |
| 86 | 86 | ||
| 87 | -用 `Workflow` 工具调用编码编排脚本(`<cwd>` 替换为当前项目根的绝对路径): | 87 | +用 `Workflow` 工具调用编码编排脚本。`projectRoot` **必须是绝对路径**(POSIX 形如 `/Users/.../my-erp`,Windows 形如 `C:\\Users\\...\\my-erp`),从你当前会话的工作目录读取——**绝不传相对路径如 `.`**。`coding.mjs` 顶部对相对路径做硬校验,传 `.` 会立即 halt(避免子代理在错误 cwd 上执行 `git -C .` 把 tag 打到错处)。 |
| 88 | 88 | ||
| 89 | ``` | 89 | ``` |
| 90 | Workflow({ | 90 | Workflow({ |
| 91 | scriptPath: "${CLAUDE_PLUGIN_ROOT}/workflows/coding.mjs", | 91 | scriptPath: "${CLAUDE_PLUGIN_ROOT}/workflows/coding.mjs", |
| 92 | - args: { projectRoot: "<cwd>" } | 92 | + args: { projectRoot: "<当前项目根绝对路径>" } |
| 93 | }) | 93 | }) |
| 94 | ``` | 94 | ``` |
| 95 | 95 | ||
| 96 | -### 步骤 5:告知用户已后台启动 | 96 | +### 步骤 5:告知用户 Workflow 已启动 |
| 97 | 97 | ||
| 98 | 启动后向用户输出: | 98 | 启动后向用户输出: |
| 99 | 99 | ||
| 100 | ``` | 100 | ``` |
| 101 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | 101 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ |
| 102 | - [coding-start] ✅ Coding Workflow 已在后台启动 | 102 | + [coding-start] ✅ Coding Workflow 已启动 |
| 103 | 103 | ||
| 104 | 进度概述:<步骤 3 概述,如「待跑 3 模块 + 前端阶段」> | 104 | 进度概述:<步骤 3 概述,如「待跑 3 模块 + 前端阶段」> |
| 105 | 105 | ||
| @@ -107,8 +107,8 @@ Workflow({ | @@ -107,8 +107,8 @@ Workflow({ | ||
| 107 | • 当前已在本地默认分支(main / master) | 107 | • 当前已在本地默认分支(main / master) |
| 108 | • 工作树干净,Plan 产物(docs/* + skeleton + DDL)已 commit | 108 | • 工作树干净,Plan 产物(docs/* + skeleton + DDL)已 commit |
| 109 | 109 | ||
| 110 | - Workflow 将按模块顺序全自动、静默推进;跑完所有模块或在某模块 | ||
| 111 | - halt(测试闸持续 RED / review 5 轮未过 / 缺值阻塞等)时会通知你。 | 110 | + Workflow 将按模块顺序全自动、静默推进,跑完所有模块或在某模块 |
| 111 | + halt(测试闸持续 RED / review 5 轮未过 / 缺值阻塞等)时返回最终状态。 | ||
| 112 | halt 后请按诊断修复,再重新运行 /erp-workflow:coding-start 续跑。 | 112 | halt 后请按诊断修复,再重新运行 /erp-workflow:coding-start 续跑。 |
| 113 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | 113 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ |
| 114 | ``` | 114 | ``` |
skills/downstream-gen/SKILL.md
| @@ -16,20 +16,20 @@ allowed-tools: Read Write Edit Glob Grep Skill AskUserQuestion | @@ -16,20 +16,20 @@ allowed-tools: Read Write Edit Glob Grep Skill AskUserQuestion | ||
| 16 | **清单颗粒度**:一行一个 REQ,同一模块的 REQ 必须**连续排列**。 | 16 | **清单颗粒度**:一行一个 REQ,同一模块的 REQ 必须**连续排列**。 |
| 17 | 17 | ||
| 18 | 1. 构建**模块依赖 DAG**。 | 18 | 1. 构建**模块依赖 DAG**。 |
| 19 | -3. 对**每个模块内部**构建 REQ 间依赖,得到模块内 REQ 顺序。 | ||
| 20 | -4. 合成 `req_order[]`:按 `module_topo_order[]` 依次铺开每个模块内的 REQ 序列(**同模块 REQ 连续**)。 | ||
| 21 | -5. **环依赖打破**: | 19 | +2. 对**每个模块内部**构建 REQ 间依赖,得到模块内 REQ 顺序。 |
| 20 | +3. 合成 `req_order[]`:按 `module_topo_order[]` 依次铺开每个模块内的 REQ 序列(**同模块 REQ 连续**)。 | ||
| 21 | +4. **环依赖打破**: | ||
| 22 | - **模块级**:若模块 DAG 存在环(module_A ↔ module_B),按启发式(字母序 / 被依赖次数多者先)破环排出 `module_topo_order`,并在**参与环的模块里第一个 REQ** 的 `note` 字段填入原因(如 "A↔B 互依赖:先做 A 的骨架")。 | 22 | - **模块级**:若模块 DAG 存在环(module_A ↔ module_B),按启发式(字母序 / 被依赖次数多者先)破环排出 `module_topo_order`,并在**参与环的模块里第一个 REQ** 的 `note` 字段填入原因(如 "A↔B 互依赖:先做 A 的骨架")。 |
| 23 | - **REQ 级(同模块内)**:若模块内 REQ 互依赖,同样破环,`note` 填原因。 | 23 | - **REQ 级(同模块内)**:若模块内 REQ 互依赖,同样破环,`note` 填原因。 |
| 24 | - 非环 REQ `note` 留 `—`。 | 24 | - 非环 REQ `note` 留 `—`。 |
| 25 | -6. 为 `req_order[]` 每项生成字段: | 25 | +5. 为 `req_order[]` 每项生成字段: |
| 26 | - `index`:行号(从 1 开始) | 26 | - `index`:行号(从 1 开始) |
| 27 | - `req_id`:如 `REQ-SYS-001` | 27 | - `req_id`:如 `REQ-SYS-001` |
| 28 | - `module_id`:该 REQ 所属模块,如 `module_sys` | 28 | - `module_id`:该 REQ 所属模块,如 `module_sys` |
| 29 | - `rationale`(**选中理由**):依赖驱动的简短描述,如 `所属模块无依赖,基础模块` / `依赖 REQ-SYS-001 已在前` / `所属模块依赖 module_sys 已在前` | 29 | - `rationale`(**选中理由**):依赖驱动的简短描述,如 `所属模块无依赖,基础模块` / `依赖 REQ-SYS-001 已在前` / `所属模块依赖 module_sys 已在前` |
| 30 | - `note`(**备注**):默认 `—`;仅环依赖打破场景填原因 | 30 | - `note`(**备注**):默认 `—`;仅环依赖打破场景填原因 |
| 31 | -7. 读取并填充 `${CLAUDE_SKILL_DIR}/templates/docs-02-template.md`。 | ||
| 32 | -8. 写入 `docs/02-开发计划.md`。 | 31 | +6. 读取并填充 `${CLAUDE_SKILL_DIR}/templates/docs-02-template.md`。 |
| 32 | +7. 写入 `docs/02-开发计划.md`。 | ||
| 33 | 33 | ||
| 34 | 完成后,用 `Edit` 在 `docs/08-模块任务管理.md` 中勾选: | 34 | 完成后,用 `Edit` 在 `docs/08-模块任务管理.md` 中勾选: |
| 35 | - ` - [ ] docs/02 开发计划已生成` | 35 | - ` - [ ] docs/02 开发计划已生成` |
| @@ -120,21 +120,19 @@ allowed-tools: Read Write Edit Glob Grep Skill AskUserQuestion | @@ -120,21 +120,19 @@ allowed-tools: Read Write Edit Glob Grep Skill AskUserQuestion | ||
| 120 | 4. 完成后,用 `Edit` 在 `docs/08-模块任务管理.md` 勾选 A5 父项: | 120 | 4. 完成后,用 `Edit` 在 `docs/08-模块任务管理.md` 勾选 A5 父项: |
| 121 | - `- [ ] A5 下游文档生成 — downstream-gen` | 121 | - `- [ ] A5 下游文档生成 — downstream-gen` |
| 122 | 122 | ||
| 123 | -5. 打印 Plan 阶段终止横幅并**停下**(不自动进入 B 阶段): | 123 | +5. 打印 A5 完成横幅并**停下**(A6 仍未跑——A5 之后由 plan-start 派发 A6 frontend-scope-lock,A6 完成后再由 plan-start 跑 5 项终结闸;只有终结闸全过才会提示运行 coding-start): |
| 124 | 124 | ||
| 125 | ``` | 125 | ``` |
| 126 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | 126 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ |
| 127 | - [downstream-gen] ✅ Plan 阶段(A0~A5)全部完成 | 127 | + [downstream-gen] ✅ A5 下游文档生成完成(A0~A5) |
| 128 | 128 | ||
| 129 | - 所有规划文档已就绪,docs/08 § 一 全部勾选。 | 129 | + docs/02 / docs/05 / docs/06 § 三 / docs/08 § 二 / docs/10 已就绪; |
| 130 | + docs/05 + docs/02 评审闸已通过;docs/08 § 一 A0~A5 已全勾。 | ||
| 130 | 131 | ||
| 131 | - ⚠️ 进入 B 阶段前必须完成: | ||
| 132 | - 1. 审核 docs/01~10 + CLAUDE.md + sql/migrations/V1 + 各 scripts/* | ||
| 133 | - | ||
| 134 | - 2. 把全部 Plan 产物 commit 到本地默认分支(main / master): | ||
| 135 | - git add -A && git commit -m "chore: plan phase done" | ||
| 136 | - | ||
| 137 | - 3. 运行 /erp-workflow:coding-start 进入 B 阶段 | 132 | + ⏭️ 下一步:运行 /erp-workflow:plan-start |
| 133 | + plan-start 会派发到 A6 frontend-scope-lock 锁定前端 scope, | ||
| 134 | + A6 完成后再由 plan-start 跑 5 项终结闸校验; | ||
| 135 | + 全过才会提示运行 /erp-workflow:coding-start 进入 B 阶段。 | ||
| 138 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | 136 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ |
| 139 | ``` | 137 | ``` |
| 140 | 138 |
skills/downstream-gen/templates/docs-10-header-template.md
| @@ -2,7 +2,7 @@ | @@ -2,7 +2,7 @@ | ||
| 2 | 2 | ||
| 3 | 通用验收项(全项目适用): | 3 | 通用验收项(全项目适用): |
| 4 | 4 | ||
| 5 | -- [ ] `scripts/test.sh` 本地全绿 | 5 | +- [ ] `node scripts/test.mjs` 本地全绿 |
| 6 | - [ ] 所有 schema 改动都有对应 `sql/migrations/V_n__<desc>.sql` | 6 | - [ ] 所有 schema 改动都有对应 `sql/migrations/V_n__<desc>.sql` |
| 7 | - [ ] 所有新接口在 `docs/05` 中有契约定义 | 7 | - [ ] 所有新接口在 `docs/05` 中有契约定义 |
| 8 | - [ ] 所有新功能代码注释含 REQ-XXX-NNN | 8 | - [ ] 所有新功能代码注释含 REQ-XXX-NNN |
skills/frontend-scope-lock/SKILL.md
| @@ -42,7 +42,11 @@ A6 是 **Plan 阶段最后一个前端守门 skill**,由 `plan-start` 在 A5 | @@ -42,7 +42,11 @@ A6 是 **Plan 阶段最后一个前端守门 skill**,由 `plan-start` 在 A5 | ||
| 42 | - **至少 1 个 `.html`** → 通过,记下文件清单,进入步骤 2。 | 42 | - **至少 1 个 `.html`** → 通过,记下文件清单,进入步骤 2。 |
| 43 | - **0 个** → 这是 Plan 期,**可以问**。用 `AskUserQuestion` 告知用户「未在 prototype/ 找到任何 .html 原型,前端范围锁定依赖原型作为页面骨架权威」,给「我已补齐原型,请重新检查」和「本项目无前端,跳过 A6」两个选项。 | 43 | - **0 个** → 这是 Plan 期,**可以问**。用 `AskUserQuestion` 告知用户「未在 prototype/ 找到任何 .html 原型,前端范围锁定依赖原型作为页面骨架权威」,给「我已补齐原型,请重新检查」和「本项目无前端,跳过 A6」两个选项。 |
| 44 | - 选「已补齐」→ 重新 `Glob`:命中则进入步骤 2,仍为 0 则重复本问。 | 44 | - 选「已补齐」→ 重新 `Glob`:命中则进入步骤 2,仍为 0 则重复本问。 |
| 45 | - - 选「无前端」→ 在 docs/08 § 一 勾选 A6 父项并注明「无前端,A6 跳过」,打印步骤 6 的终止横幅(产出标注「跳过」),**停止**,不写 docs/06 / docs/04。 | 45 | + - 选「无前端」→ 在 docs/08 § 一 把 A6 的**父项 + 全部 3 个子项**一并勾选并在父项行尾注明「(无前端,A6 跳过)」,打印步骤 6 的终止横幅(产出标注「跳过」),**停止**,不写 docs/06 / docs/04。 |
| 46 | + > 必须同时勾子项:`plan-start` 的分发依据是「§ 一 第一个未勾 [ ] 子项」,若只勾父项会让下一次 plan-start 重复派发回本 skill,无前端项目无法满足 Plan 终结闸。具体要勾的子项: | ||
| 47 | + > - ` - [x] docs/06 项目级 UI 约定 + Design Tokens + 组件库已锁定(无前端跳过)` | ||
| 48 | + > - ` - [x] docs/04 § 二 前端栈已锁定(引用 docs/06)(无前端跳过)` | ||
| 49 | + > - ` - [x] 各 FE-NN 设计决策表已生成(docs/06 § 三之后 / docs/08 § 三)(无前端跳过)` | ||
| 46 | 50 | ||
| 47 | ### 步骤 2:收集证据(只读,不问) | 51 | ### 步骤 2:收集证据(只读,不问) |
| 48 | 52 |
skills/plan-start/SKILL.md
| @@ -21,17 +21,22 @@ docs/08 § 一 是**Plan 阶段进度追踪**(A0~A6 çš„ checkbox)。§ äºŒçš | @@ -21,17 +21,22 @@ docs/08 § 一 是**Plan 阶段进度追踪**(A0~A6 çš„ checkbox)。§ äºŒçš | ||
| 21 | 21 | ||
| 22 | 2. **æ ¹æ® Â§ 一 找到当å‰è¿›åº¦** | 22 | 2. **æ ¹æ® Â§ 一 找到当å‰è¿›åº¦** |
| 23 | 23 | ||
| 24 | -| `进度` | `åŽç»` | `阶段` | | 24 | + **åˆ¤å®šç®—æ³•ï¼ˆåŠ¡å¿…æŒ‰æ¤æ‰§è¡Œï¼Œä¸è¦å‡"è¡¨é‡Œå« Axx"判æ–ï¼›docs/08 § 一 模æ¿å§‹ç»ˆåˆ—出 A0~A6 全部行,"å«"æ’为真)**: |
| 25 | + - 用 Read / Grep 把 `docs/08-模å—任务管ç†.md § 一` 全文读出,按文件顺åºè‡ªä¸Šè€Œä¸‹æ‰«æï¼Œæ‰¾åˆ°**第一个 `- [ ]` 未勾å项**(仅看 § 一,§ 二 / § 三 ä¸å‚与判定)。 | ||
| 26 | + - 该å项归属的父项 `Axx`(A0~A6)å³ä¸ºå½“å‰é˜¶æ®µï¼ŒæŒ‰ä¸‹è¡¨æ´¾å‘到对应 skill。 | ||
| 27 | + - è‹¥ § 一 所有 `[ ]` éƒ½å·²å˜æˆ `[x]`(å«çˆ¶é¡¹ä¸Žå…¨éƒ¨å项)→ 进入 §2.1 Plan 终结闸。 | ||
| 28 | + | ||
| 29 | +| `第一个未勾å项归属` | `åŽç»` | `阶段` | | ||
| 25 | |---|---|---| | 30 | |---|---|---| |
| 26 | -| æ— docs/08 | `project-init` | `A0` | | ||
| 27 | -| å« `A0` / `A0 å项` | `project-init` | `A0` | | ||
| 28 | -| å« `A1` / `A1 å项` | `scope-lock` | `A1` | | ||
| 29 | -| å« `A2` / `A2 å项` | `skeleton-gen` | `A2` | | ||
| 30 | -| å« `A3` / `A3 å项` | `db-design-gen` | `A3` | | ||
| 31 | -| å« `A4` / `A4 å项` | `db-init` | `A4` | | ||
| 32 | -| å« `A5` / `A5 å项` | `downstream-gen` | `A5` | | ||
| 33 | -| å« `A6` / `A6 å项` | `frontend-scope-lock` | `A6` | | ||
| 34 | -| `A` 全勾,Plan é˜¶æ®µç»“æŸ | **æ— åˆ†å‘** | - | | 31 | +| æ— docs/08(文件ä¸å˜åœ¨ï¼‰ | `project-init` | `A0` | |
| 32 | +| A0 父项或其任一å项 | `project-init` | `A0` | | ||
| 33 | +| A1 父项或其任一å项 | `scope-lock` | `A1` | | ||
| 34 | +| A2 父项或其任一å项 | `skeleton-gen` | `A2` | | ||
| 35 | +| A3 父项或其任一å项 | `db-design-gen` | `A3` | | ||
| 36 | +| A4 父项或其任一å项 | `db-init` | `A4` | | ||
| 37 | +| A5 父项或其任一å项 | `downstream-gen` | `A5` | | ||
| 38 | +| A6 父项或其任一å项 | `frontend-scope-lock` | `A6` | | ||
| 39 | +| § 一 全部 `[x]` | **æ— åˆ†å‘** → §2.1 Plan 终结闸 | - | | ||
| 35 | 40 | ||
| 36 | ## 第二æ¥ï¼šåˆ†å‘通知 + è°ƒç”¨ç›®æ ‡ skill | 41 | ## 第二æ¥ï¼šåˆ†å‘通知 + è°ƒç”¨ç›®æ ‡ skill |
| 37 | 42 | ||
| @@ -46,8 +51,8 @@ A 阶段所有 checkbox å‡ `[x]` æ—¶**ä¸ä»£è¡¨å¯ä»¥è¿› B 阶段**。Coding é˜ | @@ -46,8 +51,8 @@ A 阶段所有 checkbox å‡ `[x]` æ—¶**ä¸ä»£è¡¨å¯ä»¥è¿› B 阶段**。Coding é˜ | ||
| 46 | 1. **REQ å¡ç‰‡çœŸå®žæ•°æ®**(æ¥è‡ª A1 scope-lock) | 51 | 1. **REQ å¡ç‰‡çœŸå®žæ•°æ®**(æ¥è‡ª A1 scope-lock) |
| 47 | - `Glob` 找出全部 REQ å¡ç‰‡ï¼ˆå¦‚ `docs/01-需求清å•/**/*.md`)。 | 52 | - `Glob` 找出全部 REQ å¡ç‰‡ï¼ˆå¦‚ `docs/01-需求清å•/**/*.md`)。 |
| 48 | - 对æ¯å¼ å¡ç‰‡ `Grep` 残留å ä½ï¼šå‘½ä¸ä»»ä¸€å³ç¼ºå£ — | 53 | - 对æ¯å¼ å¡ç‰‡ `Grep` 残留å ä½ï¼šå‘½ä¸ä»»ä¸€å³ç¼ºå£ — |
| 49 | - `ã€äººå·¥å¡«å†™`ã€`TBD`ã€`å¾…è¡¥`ã€`<示例`(用有区分度的 `<示例` 而éžè£¸ `示例值`,é¿å…误命ä¸å¡ç‰‡åˆæ³•表头行 `| ... | 示例值 |`;与 scope-lock E.1 写法一致)。 | ||
| 50 | - - 缺å£è¡¨è¿°ç¤ºä¾‹ï¼š`REQ-USER-001 ä»å« TBD / 示例值未替æ¢ä¸ºçœŸå®žçº¦æŸ`。 | 54 | + `ã€äººå·¥å¡«å†™`ã€`TBD`ã€`å¾…è¡¥`ã€`<示例`ã€`ã€ç¤ºä¾‹è¡Œ`(与 scope-lock E.1 åŒå¼ºåº¦â€”—`<示例` 兜底 `<示例值>`,`ã€ç¤ºä¾‹è¡Œ` å…œåº•æœªåˆ çš„æ¨¡æ¿ç¤ºä¾‹è¡Œ `ã€ç¤ºä¾‹è¡Œï¼Œæ›¿æ¢ä¸ºçœŸå®žå—段】`ï¼›é¿å…åŠå¡«å¡ç‰‡ç»•过本闸)。 |
| 55 | + - 缺å£è¡¨è¿°ç¤ºä¾‹ï¼š`REQ-USER-001 ä»å« TBD / 示例值未替æ¢ä¸ºçœŸå®žçº¦æŸ / ç¤ºä¾‹è¡Œæœªåˆ é™¤`。 | ||
| 51 | 56 | ||
| 52 | 2. **secrets / 项目é…置全é”**(æ¥è‡ª A1 收集的 secret/account/package-name/namespace 清å•) | 57 | 2. **secrets / 项目é…置全é”**(æ¥è‡ª A1 收集的 secret/account/package-name/namespace 清å•) |
| 53 | - `Read` `.env.local`(真实 secret 值所在;gitignored,docs/07 åªè®°è§„则ä¸è®°å€¼ï¼‰ï¼šæ ¡éªŒ `config-vars.yaml` çš„ `secrets_ref` 列出的æ¯ä¸ª secret 键(如 `DB_PASSWORD` / `JWT_SECRET`ï¼‰å‡æœ‰çœŸå®žå€¼ï¼Œæ— `ã€äººå·¥å¡«å†™`/`TBD`/空值。 | 58 | - `Read` `.env.local`(真实 secret 值所在;gitignored,docs/07 åªè®°è§„则ä¸è®°å€¼ï¼‰ï¼šæ ¡éªŒ `config-vars.yaml` çš„ `secrets_ref` 列出的æ¯ä¸ª secret 键(如 `DB_PASSWORD` / `JWT_SECRET`ï¼‰å‡æœ‰çœŸå®žå€¼ï¼Œæ— `ã€äººå·¥å¡«å†™`/`TBD`/空值。 |
| @@ -63,8 +68,8 @@ A 阶段所有 checkbox å‡ `[x]` æ—¶**ä¸ä»£è¡¨å¯ä»¥è¿› B 阶段**。Coding é˜ | @@ -63,8 +68,8 @@ A 阶段所有 checkbox å‡ `[x]` æ—¶**ä¸ä»£è¡¨å¯ä»¥è¿› B 阶段**。Coding é˜ | ||
| 63 | - æ ¡éªŒï¼š(a) docs/05 æ¯ä¸ªç«¯ç‚¹éƒ½æœ‰è¯·æ±‚/å“应 schemaã€æ— `ã€äººå·¥å¡«å†™`/`TBD`ï¼›(b) docs/02 æ¯ä¸ª REQ éƒ½åœ¨æž„å»ºé¡ºåº DAG ä¸ã€cycle-breaking é¡ºåºæœ‰ `note` 说明。缺任一å³ç¼ºå£ã€‚(A5 父项已勾本身å³è•´å« downstream-gen 评审闸已过——downstream-gen åœ¨ç”¨æˆ·æœªç¡®è®¤æ—¶ç¦æ¢å‹¾ A5ï¼Œæ•…æ— éœ€ç‹¬ç«‹çš„ã€Œå·²è¯„å®¡ã€æ ‡è®°ã€‚) | 68 | - æ ¡éªŒï¼š(a) docs/05 æ¯ä¸ªç«¯ç‚¹éƒ½æœ‰è¯·æ±‚/å“应 schemaã€æ— `ã€äººå·¥å¡«å†™`/`TBD`ï¼›(b) docs/02 æ¯ä¸ª REQ éƒ½åœ¨æž„å»ºé¡ºåº DAG ä¸ã€cycle-breaking é¡ºåºæœ‰ `note` 说明。缺任一å³ç¼ºå£ã€‚(A5 父项已勾本身å³è•´å« downstream-gen 评审闸已过——downstream-gen åœ¨ç”¨æˆ·æœªç¡®è®¤æ—¶ç¦æ¢å‹¾ A5ï¼Œæ•…æ— éœ€ç‹¬ç«‹çš„ã€Œå·²è¯„å®¡ã€æ ‡è®°ã€‚) |
| 64 | 69 | ||
| 65 | 5. **A6 å‰ç«¯ scope å·²é”**(æ¥è‡ª A6 frontend-scope-lock) | 70 | 5. **A6 å‰ç«¯ scope å·²é”**(æ¥è‡ª A6 frontend-scope-lock) |
| 66 | - - `Read` `docs/06-UI交互规范.md`。 | ||
| 67 | - - æ ¡éªŒï¼šé¡¹ç›®çº§ UI 约定 / design tokens / 组件库选型已确认;æ¯ä¸ª FE-NN 的设计决ç–表éžå ä½ï¼›prototype 闸门已过(docs/08 § 一 A6 勾选å³ä»£è¡¨æ¤é¡¹å·²ç”± A6 skill é”å®šï¼Œä½†ä»æ ¸å¯¹ docs/06 æ— `ã€äººå·¥å¡«å†™`/`TBD` 残留)。缺任一å³ç¼ºå£ã€‚ | 71 | + - **æ— å‰ç«¯é¡¹ç›®åˆ†æ”¯**:先 `Read` `docs/08-模å—任务管ç†.md` § 一 A6 çˆ¶é¡¹è¡Œï¼Œè‹¥è¡Œå°¾å« `ï¼ˆæ— å‰ç«¯ï¼ŒA6 跳过)` æ ‡æ³¨ï¼ˆfrontend-scope-lock æ¥éª¤ 1 的跳过路径写入)→ 本项直接判 通过,**跳过下é¢çš„ docs/06 æ ¡éªŒ**ï¼ˆæ— å‰ç«¯é¡¹ç›®ä¸ä¼šæœ‰ FE 决ç–表 / prototype,强读 docs/06 会与跳过è¯ä¹‰å†²çªï¼‰ã€‚ |
| 72 | + - å¦åˆ™ï¼ˆæœ‰å‰ç«¯ï¼‰ï¼š`Read` `docs/06-UI交互规范.md`ï¼Œæ ¡éªŒé¡¹ç›®çº§ UI 约定 / Design Tokens / 组件库选型已确认;æ¯ä¸ª FE-NN 的设计决ç–表éžå ä½ï¼›prototype 闸门已过(docs/08 § 一 A6 勾选å³ä»£è¡¨æ¤é¡¹å·²ç”± A6 skill é”å®šï¼Œä½†ä»æ ¸å¯¹ docs/06 æ— `ã€äººå·¥å¡«å†™`/`TBD` 残留)。缺任一å³ç¼ºå£ã€‚ | ||
| 68 | 73 | ||
| 69 | #### 第 2 æ¥ï¼ˆA):全部通过 → 放行 | 74 | #### 第 2 æ¥ï¼ˆA):全部通过 → 放行 |
| 70 | 75 |
skills/project-init/templates/CLAUDE-template.md
| @@ -60,7 +60,7 @@ B 阶段整体是**一个静默 Workflow 脚本 `workflows/coding.mjs`**(由 | @@ -60,7 +60,7 @@ B 阶段整体是**一个静默 Workflow 脚本 `workflows/coding.mjs`**(由 | ||
| 60 | 每个后端模块在 docs/08 § 二 中长这样: | 60 | 每个后端模块在 docs/08 § 二 中长这样: |
| 61 | 61 | ||
| 62 | ```markdown | 62 | ```markdown |
| 63 | -- module_0 系统管理 | 63 | +- module_sys 系统管理 |
| 64 | - 依赖: — | 64 | - 依赖: — |
| 65 | - 路径: backend/module/sys/ | 65 | - 路径: backend/module/sys/ |
| 66 | - 里程碑: — | 66 | - 里程碑: — |
skills/project-init/templates/docs-08-initial-template.md
| @@ -20,6 +20,7 @@ | @@ -20,6 +20,7 @@ | ||
| 20 | - [ ] A2 骨架生成 — skeleton-gen | 20 | - [ ] A2 骨架生成 — skeleton-gen |
| 21 | - [ ] 架构文档已生成(docs/04 § 一+、docs/06、docs/07、docs/09) | 21 | - [ ] 架构文档已生成(docs/04 § 一+、docs/06、docs/07、docs/09) |
| 22 | - [ ] 工具脚本已生成(scripts/*.mjs、.env.local) | 22 | - [ ] 工具脚本已生成(scripts/*.mjs、.env.local) |
| 23 | + - [ ] 样式 token 骨架已生成(src/styles/tokens.css) | ||
| 23 | - [ ] .gitignore 已配置 | 24 | - [ ] .gitignore 已配置 |
| 24 | 25 | ||
| 25 | - [ ] A3 DB 设计 + REQ 回填 — db-design-gen | 26 | - [ ] A3 DB 设计 + REQ 回填 — db-design-gen |
| @@ -51,7 +52,7 @@ | @@ -51,7 +52,7 @@ | ||
| 51 | (A5 填入后,每行一个后端模块。每个模块的 `里程碑:` 字段在 `—` 和 `milestone/<id>` 之间变化,完成由本地 `git tag -l` 判定。`coding-start` 每次按 docs/02 REQ 序扫每模块的里程碑 tag 决定派发。后端模块全部打里程碑后自动进入 § 三 前端阶段。) | 52 | (A5 填入后,每行一个后端模块。每个模块的 `里程碑:` 字段在 `—` 和 `milestone/<id>` 之间变化,完成由本地 `git tag -l` 判定。`coding-start` 每次按 docs/02 REQ 序扫每模块的里程碑 tag 决定派发。后端模块全部打里程碑后自动进入 § 三 前端阶段。) |
| 52 | 53 | ||
| 53 | <!-- 模块格式示例(由 A5 downstream-gen 追加;功能子项由 coding.mjs 的 review stage 在 approve 时勾选): | 54 | <!-- 模块格式示例(由 A5 downstream-gen 追加;功能子项由 coding.mjs 的 review stage 在 approve 时勾选): |
| 54 | -- module_0 系统管理 | 55 | +- module_sys 系统管理 |
| 55 | - 依赖: — | 56 | - 依赖: — |
| 56 | - 路径: backend/module/sys/ | 57 | - 路径: backend/module/sys/ |
| 57 | - 里程碑: — | 58 | - 里程碑: — |
skills/skeleton-gen/SKILL.md
| @@ -30,7 +30,7 @@ allowed-tools: Read Write Edit Skill Grep Glob AskUserQuestion Bash(node *) | @@ -30,7 +30,7 @@ allowed-tools: Read Write Edit Skill Grep Glob AskUserQuestion Bash(node *) | ||
| 30 | 30 | ||
| 31 | | 目标文件 | 大纲模板 | | 31 | | 目标文件 | 大纲模板 | |
| 32 | |---|---| | 32 | |---|---| |
| 33 | -| `docs/06-UI交互规范.md`(§ 一 ~ 四,§ 五 占位) | `${CLAUDE_SKILL_DIR}/templates/docs-06-static-template.md` | | 33 | +| `docs/06-UI交互规范.md`(§ 一 ~ 三;FE 决策表由 A6 frontend-scope-lock 追加到 § 三之后) | `${CLAUDE_SKILL_DIR}/templates/docs-06-static-template.md` | |
| 34 | | `docs/07-环境配置.md` | `${CLAUDE_SKILL_DIR}/templates/docs-07-env-template.md` | | 34 | | `docs/07-环境配置.md` | `${CLAUDE_SKILL_DIR}/templates/docs-07-env-template.md` | |
| 35 | | `docs/09-项目目录结构.md` | `${CLAUDE_SKILL_DIR}/templates/docs-09-structure-template.md` | | 35 | | `docs/09-项目目录结构.md` | `${CLAUDE_SKILL_DIR}/templates/docs-09-structure-template.md` | |
| 36 | 36 |
skills/skeleton-gen/templates/docs-06-static-template.md
| 1 | <!-- | 1 | <!-- |
| 2 | -本文件是 docs/06-UI交互规范.md 的 § 一~二 大纲(§ 三由 downstream-gen 追加)。 | 2 | +本文件是 docs/06-UI交互规范.md 的 § 一 ~ 三 大纲(§ 三页面清单由 downstream-gen 按模块追加;FE 决策表由 A6 frontend-scope-lock 追加到 § 三之后)。 |
| 3 | skeleton-gen 读取 docs/04 § 零 和 docs/01 index,按下述大纲生成项目专属内容。 | 3 | skeleton-gen 读取 docs/04 § 零 和 docs/01 index,按下述大纲生成项目专属内容。 |
| 4 | 布局/页面骨架以项目根的 prototype/ 静态 HTML mockup 为权威,本文件仅承载跨页面通用规则与 Design Tokens。 | 4 | 布局/页面骨架以项目根的 prototype/ 静态 HTML mockup 为权威,本文件仅承载跨页面通用规则与 Design Tokens。 |
| 5 | --> | 5 | --> |
skills/skeleton-gen/templates/env-local-template
| @@ -4,7 +4,7 @@ | @@ -4,7 +4,7 @@ | ||
| 4 | # 1. 值含 `$`、反引号、空格、`!` 等 shell 特殊字符时,必须用单引号包裹: | 4 | # 1. 值含 `$`、反引号、空格、`!` 等 shell 特殊字符时,必须用单引号包裹: |
| 5 | # DB_PASSWORD='p@ss$w0rd!' | 5 | # DB_PASSWORD='p@ss$w0rd!' |
| 6 | # 否则 `set -a; . .env.local; set +a` 会做变量展开导致密码错乱。 | 6 | # 否则 `set -a; . .env.local; set +a` 会做变量展开导致密码错乱。 |
| 7 | -# 2. DB_HOST 建议保持 localhost / 127.0.0.1;非本地 host 默认会被 scripts/setup-test-db.sh 防护拒绝。 | 7 | +# 2. DB_HOST 建议保持 localhost / 127.0.0.1;非本地 host 默认会被 scripts/setup-test-db.mjs 防护拒绝。 |
| 8 | # 若必须用远程测试库,把 host 列入下方 TEST_DB_ALLOWED_HOSTS。 | 8 | # 若必须用远程测试库,把 host 列入下方 TEST_DB_ALLOWED_HOSTS。 |
| 9 | # 3. DB_SCHEMA 建议命名含 test / _dev / _local / _ci,避免与生产库同名。 | 9 | # 3. DB_SCHEMA 建议命名含 test / _dev / _local / _ci,避免与生产库同名。 |
| 10 | 10 | ||
| @@ -19,7 +19,7 @@ JWT_SECRET=【人工填写:JWT 签名密钥,256+ bit 随机串】 | @@ -19,7 +19,7 @@ JWT_SECRET=【人工填写:JWT 签名密钥,256+ bit 随机串】 | ||
| 19 | # 非本地服务器时填写;留空表示只允许 localhost / 127.0.0.1 / ::1。 | 19 | # 非本地服务器时填写;留空表示只允许 localhost / 127.0.0.1 / ::1。 |
| 20 | # 示例:TEST_DB_ALLOWED_HOSTS="118.178.19.35 test-mysql.internal" | 20 | # 示例:TEST_DB_ALLOWED_HOSTS="118.178.19.35 test-mysql.internal" |
| 21 | # | 21 | # |
| 22 | -# ⚠️ 列入后该 host 每次 test.sh 都会被 DROP CREATE(无二次确认)。 | 22 | +# ⚠️ 列入后该 host 每次 test.mjs 都会被 DROP CREATE(无二次确认)。 |
| 23 | # 仅用于你完全可控的测试库;生产/共享库/多人共享的 staging 库**千万别列**。 | 23 | # 仅用于你完全可控的测试库;生产/共享库/多人共享的 staging 库**千万别列**。 |
| 24 | # (防护 2 还会检查 schema 名须含 test/_dev/_local/_ci,独立兜底。) | 24 | # (防护 2 还会检查 schema 名须含 test/_dev/_local/_ci,独立兜底。) |
| 25 | TEST_DB_ALLOWED_HOSTS= | 25 | TEST_DB_ALLOWED_HOSTS= |
workflows/coding.mjs
| @@ -2,15 +2,22 @@ | @@ -2,15 +2,22 @@ | ||
| 2 | // | 2 | // |
| 3 | // 整个 ERP Coding(B 阶段)= 一个静默、全自动的 Workflow 脚本。 | 3 | // 整个 ERP Coding(B 阶段)= 一个静默、全自动的 Workflow 脚本。 |
| 4 | // | 4 | // |
| 5 | -// 设计原则(见 docs/superpowers/specs/2026-05-26-workflow-migration-design.md): | 5 | +// 设计原则(详见仓库根 README.md 「阶段 B」 节 与 「设计原则」 节): |
| 6 | // - 所有 stage 都是 agent() 子代理,物理上无法 AskUserQuestion → 编码期结构性静默。 | 6 | // - 所有 stage 都是 agent() 子代理,物理上无法 AskUserQuestion → 编码期结构性静默。 |
| 7 | // - 缺值不再问人:派生 stage 把具体阻塞点写进产物并 throw(fail-fast,合法 halt → 终止态,非对话框)。 | 7 | // - 缺值不再问人:派生 stage 把具体阻塞点写进产物并 throw(fail-fast,合法 halt → 终止态,非对话框)。 |
| 8 | // - 后端 / 前端功能循环由同一份 featureLoop(items, phase) 驱动;phase 切换 reviewer checklist、 | 8 | // - 后端 / 前端功能循环由同一份 featureLoop(items, phase) 驱动;phase 切换 reviewer checklist、 |
| 9 | // 测试命令、路径作用域(backend/ vs frontend/)、id 格式(REQ-XXX-NNN vs FE-NN)。 | 9 | // 测试命令、路径作用域(backend/ vs frontend/)、id 格式(REQ-XXX-NNN vs FE-NN)。 |
| 10 | +// - **featureLoop 采用顺序 for-await**(不是 pipeline)。两条理由: | ||
| 11 | +// (1) tdd/fix stage 会在共享工作树 + 同一功能分支上 git commit / 编辑源码;并发会争 .git/index.lock | ||
| 12 | +// 并撞 migration 版本号; | ||
| 13 | +// (2) pipeline 的语义是"stage 抛异常 → 该 item 掉 null、pipeline 永不 reject",会把 | ||
| 14 | +// reviewWithFixLoop / verify / tdd 的 HALT throw 静默吞掉,使 fail-fast 在功能链层级失效, | ||
| 15 | +// 残缺模块照样会被 testGate/report/milestone 推进。顺序 for-await 让 throw 自然冒泡到 | ||
| 16 | +// 模块主循环的 try/catch,被捕获后整阶段 fail-fast break。 | ||
| 10 | // - 状态账本 = docs/08 §二/§三 + git tag;halt 后重跑 coding-start,router 从账本+tag 重算进度。 | 17 | // - 状态账本 = docs/08 §二/§三 + git tag;halt 后重跑 coding-start,router 从账本+tag 重算进度。 |
| 11 | // - reviewer 统一为 agents/code-reviewer.md,review stage 用 agentType:'code-reviewer'。 | 18 | // - reviewer 统一为 agents/code-reviewer.md,review stage 用 agentType:'code-reviewer'。 |
| 12 | // | 19 | // |
| 13 | -// 运行时约束:Workflow 运行时禁用非确定性内建(Date.now / Math.random 等)。本脚本不调用它们; | 20 | +// 运行时约束:Workflow 运行时禁用非确定性内建(取当天日期 / 随机数的 API)。本脚本不调用它们; |
| 14 | // 凡需要"当天日期"的产物路径(<YYYY-MM-DD>-<id>.md),一律由子代理在其自身上下文中解析并落盘, | 21 | // 凡需要"当天日期"的产物路径(<YYYY-MM-DD>-<id>.md),一律由子代理在其自身上下文中解析并落盘, |
| 15 | // 脚本只负责编排,不计算日期 / 随机数。 | 22 | // 脚本只负责编排,不计算日期 / 随机数。 |
| 16 | 23 | ||
| @@ -31,21 +38,128 @@ const ROUTER_SCHEMA = { type:'object', additionalProperties:false, | @@ -31,21 +38,128 @@ const ROUTER_SCHEMA = { type:'object', additionalProperties:false, | ||
| 31 | reqs:{type:'array',items:{type:'string'}}, | 38 | reqs:{type:'array',items:{type:'string'}}, |
| 32 | feItems:{type:'array',items:{type:'string'}} } } } } } | 39 | feItems:{type:'array',items:{type:'string'}} } } } } } |
| 33 | 40 | ||
| 41 | +// REVIEW_SCHEMA:reviewer 返回的裁决。issues 改为结构化对象,避免"模糊一行 must-fix" | ||
| 42 | +// 让 fix stage 无从下手就空转 5 轮(见 reviewWithFixLoop 的 must-fix 闸门)。 | ||
| 43 | +// - summary:人类可读的问题摘要(一句)。 | ||
| 44 | +// - locator:必须能让 fix stage 定位到文件(含 `<repo-relative-path>` 或 `<path>:<line>`), | ||
| 45 | +// 否则在 reviewWithFixLoop 里直接判违约 HALT。 | ||
| 46 | +// - severity:blocker/high/medium/low,方便后续把 low/medium 降级为 suggestion 而不卡循环。 | ||
| 34 | const REVIEW_SCHEMA = { type:'object', additionalProperties:false, | 47 | const REVIEW_SCHEMA = { type:'object', additionalProperties:false, |
| 35 | required:['verdict','round','issues'], properties:{ | 48 | required:['verdict','round','issues'], properties:{ |
| 36 | verdict:{type:'string',enum:['approve','request-changes']}, | 49 | verdict:{type:'string',enum:['approve','request-changes']}, |
| 37 | - round:{type:'integer'}, issues:{type:'array',items:{type:'string'}} } } | 50 | + round:{type:'integer'}, |
| 51 | + issues:{ type:'array', items:{ | ||
| 52 | + type:'object', additionalProperties:false, | ||
| 53 | + required:['summary','locator','severity'], | ||
| 54 | + properties:{ | ||
| 55 | + summary:{type:'string'}, | ||
| 56 | + locator:{type:'string'}, | ||
| 57 | + severity:{type:'string', enum:['blocker','high','medium','low']} } } } } } | ||
| 58 | + | ||
| 59 | +// STAGE_RESULT_SCHEMA:派生 stage(spec/plan/tdd/verify/fix/report)的统一结构化返回。 | ||
| 60 | +// - status=ok:本步骤产出可用,artifactPath 必填(spec/plan/verify/report 的落盘文件), | ||
| 61 | +// summary 可放给下游用作 prompt 上下文。tdd/fix 没有单一 artifact,artifactPath 可省。 | ||
| 62 | +// - status=halt:sub-agent 已经决定无法继续(缺值 / 越界 / 重试到顶),把阻塞点写进 reason, | ||
| 63 | +// JS 端读到立即 throw `HALT …`,让 fail-fast 顺序 for-await 冒泡到模块主循环 try。 | ||
| 64 | +// - 无 schema 时,sub-agent 可以"写一段散文说我跑不下去了,但仍然算成功返回"——这是真正的 | ||
| 65 | +// fail-fast 漏洞;加 schema 后所有派生 stage 都有显式 halt 通道。 | ||
| 66 | +const STAGE_RESULT_SCHEMA = { type:'object', additionalProperties:false, | ||
| 67 | + required:['status'], properties:{ | ||
| 68 | + status:{type:'string', enum:['ok','halt']}, | ||
| 69 | + reason:{type:'string'}, | ||
| 70 | + artifactPath:{type:'string'}, | ||
| 71 | + summary:{type:'string'} } } | ||
| 38 | 72 | ||
| 39 | const GATE_SCHEMA = { type:'object', additionalProperties:false, | 73 | const GATE_SCHEMA = { type:'object', additionalProperties:false, |
| 40 | required:['status'], properties:{ status:{type:'string',enum:['green','red']}, | 74 | required:['status'], properties:{ status:{type:'string',enum:['green','red']}, |
| 41 | failures:{type:'array',items:{type:'string'}} } } | 75 | failures:{type:'array',items:{type:'string'}} } } |
| 42 | 76 | ||
| 77 | +// ── 微步骤 schemas(runBranchSetup / runMilestone / runCrossModule 用)───────── | ||
| 78 | +// 这三个阶段是纯机械的 git/文件操作 + 条件跳过;与其让子代理读"1. 2. 3. 若 X 则跳过"的散文 | ||
| 79 | +// 流程,不如把"observe → JS branch → execute"切成多个 agent 微步骤,每步带强 schema 返回。 | ||
| 80 | +// 这样:(a) 跳过/分支条件由 JS 判定(不再依赖 LLM 读散文条件),idempotency 一致; | ||
| 81 | +// (b) 每步语义单一、prompt 短,schema 校验阻断畸形返回; | ||
| 82 | +// (c) action 步统一返回 ACTION_RESULT_SCHEMA(success/error),失败可由 JS 抛错 halt。 | ||
| 83 | +const WT_SCHEMA = { type:'object', additionalProperties:false, | ||
| 84 | + required:['clean'], properties:{ | ||
| 85 | + clean:{type:'boolean'}, | ||
| 86 | + dirty:{type:'array', items:{type:'string'}} } } | ||
| 87 | + | ||
| 88 | +const DEFAULT_BRANCH_SCHEMA = { type:'object', additionalProperties:false, | ||
| 89 | + required:['branch'], properties:{ branch:{type:'string'} } } | ||
| 90 | + | ||
| 91 | +const EXISTS_SCHEMA = { type:'object', additionalProperties:false, | ||
| 92 | + required:['exists'], properties:{ exists:{type:'boolean'} } } | ||
| 93 | + | ||
| 94 | +const CURRENT_BRANCH_SCHEMA = { type:'object', additionalProperties:false, | ||
| 95 | + required:['branch'], properties:{ branch:{type:'string'} } } | ||
| 96 | + | ||
| 97 | +const FIELD_VALUE_SCHEMA = { type:'object', additionalProperties:false, | ||
| 98 | + required:['found','value'], properties:{ | ||
| 99 | + found:{type:'boolean'}, | ||
| 100 | + value:{type:'string'}, | ||
| 101 | + lineNumber:{type:'integer'} } } | ||
| 102 | + | ||
| 103 | +// CHECKBOX_STATE_SCHEMA:docs/08 中 `- [ ] REQ-XXX-NNN ...` / `- [x] FE-NN ...` 这类功能行 | ||
| 104 | +// 的勾选态。把"审阅 approve 后 flip checkbox"从 reviewPrompt 的隐式 side-effect 改为可观测 | ||
| 105 | +// 的 read-then-write micro step(参见 reviewWithFixLoop 的 approve 分支)。 | ||
| 106 | +// 注意:state 必填——schema 只 require found 时,sub-agent 返回 {found:true} 而漏掉 state 仍合法, | ||
| 107 | +// 上层 if (cb.state === 'unchecked') 会静默落入"已 checked"分支,docs/08 与 review 裁决悄悄背离。 | ||
| 108 | +const CHECKBOX_STATE_SCHEMA = { type:'object', additionalProperties:false, | ||
| 109 | + required:['found','state'], properties:{ | ||
| 110 | + found:{type:'boolean'}, | ||
| 111 | + state:{type:'string', enum:['checked','unchecked']}, | ||
| 112 | + lineNumber:{type:'integer'} } } | ||
| 113 | + | ||
| 114 | +// TAG_REPORT_FRESHNESS_SCHEMA:当 milestone tag 已存在(resume 或前一轮残留)时,校验 | ||
| 115 | +// tag 指向的 commit 是否包含已落地的 § ⑫ 值。旧版 bug:tag → § ⑫ commit 的错序会让 tag | ||
| 116 | +// 指向 \`{{milestone_tag}}\` 占位符 commit;新顺序(report → tag)下不会再产生,但保留 | ||
| 117 | +// freshness 自检以发现历史残留。 | ||
| 118 | +const TAG_REPORT_FRESHNESS_SCHEMA = { type:'object', additionalProperties:false, | ||
| 119 | + required:['fresh'], properties:{ | ||
| 120 | + fresh:{type:'boolean'}, | ||
| 121 | + tagReportValue:{type:'string'} } } | ||
| 122 | + | ||
| 123 | +const ALREADY_MERGED_SCHEMA = { type:'object', additionalProperties:false, | ||
| 124 | + required:['alreadyMerged'], properties:{ alreadyMerged:{type:'boolean'} } } | ||
| 125 | + | ||
| 126 | +const REPORT_PATH_SCHEMA = { type:'object', additionalProperties:false, | ||
| 127 | + required:['found'], properties:{ | ||
| 128 | + found:{type:'boolean'}, | ||
| 129 | + path:{type:'string'}, | ||
| 130 | + currentTagValue:{type:'string'} } } | ||
| 131 | + | ||
| 132 | +const CHANGED_FILES_SCHEMA = { type:'object', additionalProperties:false, | ||
| 133 | + required:['files'], properties:{ | ||
| 134 | + files:{type:'array', items:{type:'object', additionalProperties:false, | ||
| 135 | + required:['status','path'], | ||
| 136 | + properties:{ status:{type:'string'}, path:{type:'string'} } } } } } | ||
| 137 | + | ||
| 138 | +const CROSS_CLASSIFY_SCHEMA = { type:'object', additionalProperties:false, | ||
| 139 | + required:['crossModule'], properties:{ | ||
| 140 | + crossModule:{type:'array', items:{type:'object', additionalProperties:false, | ||
| 141 | + required:['file','targetModule','reason','impact'], | ||
| 142 | + properties:{ file:{type:'string'}, targetModule:{type:'string'}, | ||
| 143 | + reason:{type:'string'}, impact:{type:'string'} } } } } } | ||
| 144 | + | ||
| 145 | +// 所有 action 步骤(写文件 / git 改写仓库状态)统一返回 success/error;JS 据此抛错 halt。 | ||
| 146 | +const ACTION_RESULT_SCHEMA = { type:'object', additionalProperties:false, | ||
| 147 | + required:['success'], properties:{ | ||
| 148 | + success:{type:'boolean'}, | ||
| 149 | + error:{type:'string'}, | ||
| 150 | + detail:{type:'string'} } } | ||
| 151 | + | ||
| 43 | const ROOT = args?.projectRoot || '.' | 152 | const ROOT = args?.projectRoot || '.' |
| 153 | +// 子代理在 ${ROOT} 路径上跑 git -C / Read / Edit。相对路径 '.' 会绑定到子代理隐式 cwd,无保证。 | ||
| 154 | +// 必须由 coding-start 显式传绝对路径;否则 fail-fast 让人工修复入口而不是在错路径上静默打 tag。 | ||
| 155 | +if (ROOT === '.' || !(/^(?:\/|[A-Za-z]:[\\/])/.test(ROOT))) { | ||
| 156 | + throw new Error(`HALT invalid-projectRoot: must be absolute, got ${JSON.stringify(ROOT)}. coding-start 必须把绝对路径传入 args.projectRoot。`) | ||
| 157 | +} | ||
| 44 | 158 | ||
| 45 | // ============================================================================ | 159 | // ============================================================================ |
| 46 | // Stage prompt builders(纯字符串构造;只用 ROOT / id / phase / 入参,不触非确定性内建) | 160 | // Stage prompt builders(纯字符串构造;只用 ROOT / id / phase / 入参,不触非确定性内建) |
| 47 | // | 161 | // |
| 48 | -// 每个 prompt 的共同契约(见 commonContract): | 162 | +// 每个 prompt 的共同契约(见 featureStageContract): |
| 49 | // - 子代理是非交互的,物理上无法弹窗;缺任何值都不要"问人"——把具体阻塞点写进产物并失败。 | 163 | // - 子代理是非交互的,物理上无法弹窗;缺任何值都不要"问人"——把具体阻塞点写进产物并失败。 |
| 50 | // - phase=backend 与 phase=frontend 的差异(路径作用域 / id 形态 / 测试命令来源)逐条写明。 | 164 | // - phase=backend 与 phase=frontend 的差异(路径作用域 / id 形态 / 测试命令来源)逐条写明。 |
| 51 | // - 所有输出文档用中文。 | 165 | // - 所有输出文档用中文。 |
| @@ -53,8 +167,25 @@ const ROOT = args?.projectRoot || '.' | @@ -53,8 +167,25 @@ const ROOT = args?.projectRoot || '.' | ||
| 53 | 167 | ||
| 54 | function isFrontend(phase) { return phase === 'frontend' } | 168 | function isFrontend(phase) { return phase === 'frontend' } |
| 55 | 169 | ||
| 170 | +// 从 spec/plan 等 artifactPath 文件名提取 `YYYY-MM-DD` 前缀,下游所有日期相关产物(plan / verify / | ||
| 171 | +// review report)一律复用同一日期,避免长跑或次日 resume 时各 sub-agent 各自解析"今天"导致路径分叉。 | ||
| 172 | +// 纯字符串运算,不触发非确定性内建(Workflow runtime 仅禁用 time/random builtin)。 | ||
| 173 | +function dateFromArtifactPath(artifactPath) { | ||
| 174 | + const fname = (artifactPath || '').split('/').pop() || '' | ||
| 175 | + const m = fname.match(/^(\d{4}-\d{2}-\d{2})-/) | ||
| 176 | + if (!m) throw new Error(`HALT invalid-artifactPath: 文件名缺少 YYYY-MM-DD 前缀 (${JSON.stringify(artifactPath)})`) | ||
| 177 | + // 进一步排查 pattern 合法但语义无效的日期(如 9999-99-99-foo.md): | ||
| 178 | + // 正则只判位数;下面校验年/月/日落在真实日历范围内,防止下游 plan/verify 以无意义日期级联生成产物。 | ||
| 179 | + const [, yStr, moStr, dStr] = m[0].match(/^(\d{4})-(\d{2})-(\d{2})-/) || [] | ||
| 180 | + const y = Number(yStr), mo = Number(moStr), d = Number(dStr) | ||
| 181 | + if (!(y >= 2024 && y <= 2099) || !(mo >= 1 && mo <= 12) || !(d >= 1 && d <= 31)) { | ||
| 182 | + throw new Error(`HALT invalid-date-prefix: 文件名日期前缀语义无效 (${JSON.stringify(artifactPath)}),年须在 2024-2099、月 1-12、日 1-31`) | ||
| 183 | + } | ||
| 184 | + return m[1] | ||
| 185 | +} | ||
| 186 | + | ||
| 56 | // 所有子代理共享的"非交互静默"硬约束。 | 187 | // 所有子代理共享的"非交互静默"硬约束。 |
| 57 | -function commonContract(phase) { | 188 | +function featureStageContract(phase) { |
| 58 | const fe = isFrontend(phase) | 189 | const fe = isFrontend(phase) |
| 59 | return [ | 190 | return [ |
| 60 | '## 硬约束(非交互子代理)', | 191 | '## 硬约束(非交互子代理)', |
| @@ -106,7 +237,7 @@ function deriveSpecPrompt(id, phase) { | @@ -106,7 +237,7 @@ function deriveSpecPrompt(id, phase) { | ||
| 106 | return [ | 237 | return [ |
| 107 | `# ${fe ? 'fe-feature-brainstorm' : 'feature-brainstorm'} — 派生规格 ${id}`, | 238 | `# ${fe ? 'fe-feature-brainstorm' : 'feature-brainstorm'} — 派生规格 ${id}`, |
| 108 | '', | 239 | '', |
| 109 | - commonContract(phase), | 240 | + featureStageContract(phase), |
| 110 | '', | 241 | '', |
| 111 | '## 目标', | 242 | '## 目标', |
| 112 | `静默派生 \`${id}\` 的实现规格(无 Q&A)。需求歧义本应在 Plan 期的结构化 per-REQ 表单 / 前端 scope-lock 锁定;这里**只消费已锁定的事实**,不再澄清。`, | 243 | `静默派生 \`${id}\` 的实现规格(无 Q&A)。需求歧义本应在 Plan 期的结构化 per-REQ 表单 / 前端 scope-lock 锁定;这里**只消费已锁定的事实**,不再澄清。`, |
| @@ -127,31 +258,40 @@ function deriveSpecPrompt(id, phase) { | @@ -127,31 +258,40 @@ function deriveSpecPrompt(id, phase) { | ||
| 127 | ].join('\n'), | 258 | ].join('\n'), |
| 128 | '', | 259 | '', |
| 129 | '## 写 spec', | 260 | '## 写 spec', |
| 130 | - `- 落盘 \`${ROOT}/docs/superpowers/specs/<当天日期 YYYY-MM-DD>-${id}.md\`(当天日期由你在自身上下文解析,脚本不传日期)。`, | 261 | + `- 落盘路径:\`docs/superpowers/specs/<当天日期 YYYY-MM-DD>-${id}.md\`(项目根相对)。当天日期由你在自身上下文解析;**spec 是本功能链上唯一会解析"今天"的 stage**,下游 plan/verify/review 的产物日期一律复用本 spec 文件名前缀(脚本会从 artifactPath 读取)。`, |
| 262 | + `- 若已经存在 \`docs/superpowers/specs/*-${id}.md\`(resume 场景),**复用最新一份的日期前缀**,不要起新日期前缀的文件;按需 Edit 已存在的 spec 而不是另起新文件。`, | ||
| 131 | fe | 263 | fe |
| 132 | ? '- 规格至少含:关联 REQ + 关联原型;组件树(按页面 / 区域分块,推导自 prototype DOM);页面状态机(loading / empty / error / 正常 / 表单提交中 至少 5 态);消费的后端端点(对齐 docs/05);业务规则前端复刻清单(逐条:规则 / 触发时机 / 报错文案 / 来源 REQ);Design Tokens 引用清单(`var(--color-*)`)。' | 264 | ? '- 规格至少含:关联 REQ + 关联原型;组件树(按页面 / 区域分块,推导自 prototype DOM);页面状态机(loading / empty / error / 正常 / 表单提交中 至少 5 态);消费的后端端点(对齐 docs/05);业务规则前端复刻清单(逐条:规则 / 触发时机 / 报错文案 / 来源 REQ);Design Tokens 引用清单(`var(--color-*)`)。' |
| 133 | : '- 规格覆盖:goal / 输入输出 / 业务规则 / 约束 / schema / API 引用 / acceptance criteria。', | 265 | : '- 规格覆盖:goal / 输入输出 / 业务规则 / 约束 / schema / API 引用 / acceptance criteria。', |
| 134 | '', | 266 | '', |
| 267 | + '## commit', | ||
| 268 | + `- 写完 spec 后必须 commit(milestone 的 worktree-clean 前置依赖此 commit):`, | ||
| 269 | + ` 1. \`git -C ${ROOT} add <spec artifactPath>\``, | ||
| 270 | + ` 2. \`git -C ${ROOT} commit -m "docs(spec:${id}): 派生规格"\``, | ||
| 271 | + '- commit 失败 → halt,把 stderr 摘要写进 reason。', | ||
| 272 | + '', | ||
| 135 | '## 自审(inline 修,无须等待)', | 273 | '## 自审(inline 修,无须等待)', |
| 136 | `- 占位符扫描:\`TBD\` / \`TODO\` / \`【人工填写:】\`${fe ? ' / `controller` / `service` / `SQL` / `migration`(前端 spec 不应出现后端字样)' : ''} → 命中即修;修不掉的缺值按硬约束失败。`, | 274 | `- 占位符扫描:\`TBD\` / \`TODO\` / \`【人工填写:】\`${fe ? ' / `controller` / `service` / `SQL` / `migration`(前端 spec 不应出现后端字样)' : ''} → 命中即修;修不掉的缺值按硬约束失败。`, |
| 137 | '- 内部一致性 / 范围检查(单 plan 能消化吗)/ 歧义检查(任一 requirement 两种解读 → 挑一个写明)。', | 275 | '- 内部一致性 / 范围检查(单 plan 能消化吗)/ 歧义检查(任一 requirement 两种解读 → 挑一个写明)。', |
| 138 | '', | 276 | '', |
| 139 | - '## 结束', | ||
| 140 | - `- 成功:输出一行 \`${fe ? 'fe-' : ''}feature-brainstorm: ${id} → <spec path>\`,把该 spec 路径作为本步骤结果返回(供下游 plan stage 使用)。`, | ||
| 141 | - '- 不要输出"交给下一步 / 等待检查"之类的桥接叙述。', | 277 | + '## 输出(必须符合下发的 STAGE_RESULT JSON schema)', |
| 278 | + '- 成功:`{ "status": "ok", "artifactPath": "docs/superpowers/specs/YYYY-MM-DD-' + id + '.md", "summary": "<1-2 句中文摘要>" }`', | ||
| 279 | + '- 失败:`{ "status": "halt", "reason": "<缺值阻塞点:缺哪个值 / 应在哪个 Plan 闸门锁定 / 为何无法继续>" }`', | ||
| 280 | + '- `artifactPath` 必须为项目根相对路径(无前导斜杠),文件名首段必须是 `YYYY-MM-DD`;schema 是 `additionalProperties:false`,不要返回额外字段。', | ||
| 142 | ].join('\n') | 281 | ].join('\n') |
| 143 | } | 282 | } |
| 144 | 283 | ||
| 145 | // ---- stage 2:spec → 任务级 TDD 计划(原 feature-plan / fe-feature-plan)---- | 284 | // ---- stage 2:spec → 任务级 TDD 计划(原 feature-plan / fe-feature-plan)---- |
| 146 | -function planPrompt(id, phase, spec) { | 285 | +// specPath:调用方传入的 spec artifactPath(含 YYYY-MM-DD 前缀),plan 复用该日期。 |
| 286 | +function planPrompt(id, phase, specPath) { | ||
| 147 | const fe = isFrontend(phase) | 287 | const fe = isFrontend(phase) |
| 148 | return [ | 288 | return [ |
| 149 | `# ${fe ? 'fe-feature-plan' : 'feature-plan'} — 任务级计划 ${id}`, | 289 | `# ${fe ? 'fe-feature-plan' : 'feature-plan'} — 任务级计划 ${id}`, |
| 150 | '', | 290 | '', |
| 151 | - commonContract(phase), | 291 | + featureStageContract(phase), |
| 152 | '', | 292 | '', |
| 153 | '## 输入', | 293 | '## 输入', |
| 154 | - `- 上游 spec:${spec ? `\`${spec}\`` : `\`${ROOT}/docs/superpowers/specs/<当天日期>-${id}.md\``}(不存在则失败)。`, | 294 | + `- 上游 spec:\`${specPath}\`(已由 spec stage 落盘;不存在则 halt)。**plan 文件名日期前缀必须与 spec 一致**:取 spec 文件名首段 \`YYYY-MM-DD\`,写到 plan 路径,不要重新解析"今天"。`, |
| 155 | fe | 295 | fe |
| 156 | ? `- \`${ROOT}/docs/04-技术规范.md § 一 前端架构\`(路由 / 状态库 / 组件目录约定 / 测试栈);\`${ROOT}/docs/09-项目目录结构.md § 前端目录结构\`(落盘位置)。用 Grep 在 \`${ROOT}/frontend/\` 定位现有文件。` | 296 | ? `- \`${ROOT}/docs/04-技术规范.md § 一 前端架构\`(路由 / 状态库 / 组件目录约定 / 测试栈);\`${ROOT}/docs/09-项目目录结构.md § 前端目录结构\`(落盘位置)。用 Grep 在 \`${ROOT}/frontend/\` 定位现有文件。` |
| 157 | : `- \`${ROOT}/docs/04-技术规范.md\` 与 \`${ROOT}/docs/09-项目目录结构.md\`(编码规范 + 目录规范)。用 Grep 在现有代码定位待修改文件。`, | 297 | : `- \`${ROOT}/docs/04-技术规范.md\` 与 \`${ROOT}/docs/09-项目目录结构.md\`(编码规范 + 目录规范)。用 Grep 在现有代码定位待修改文件。`, |
| @@ -172,27 +312,36 @@ function planPrompt(id, phase, spec) { | @@ -172,27 +312,36 @@ function planPrompt(id, phase, spec) { | ||
| 172 | '- 首次出现的类 / 方法 / 组件 / hook / API client 函数必须给出签名;跨 task 的签名 / 错误码 / props 类型必须一致。', | 312 | '- 首次出现的类 / 方法 / 组件 / hook / API client 函数必须给出签名;跨 task 的签名 / 错误码 / props 类型必须一致。', |
| 173 | '', | 313 | '', |
| 174 | '## 写 plan + 自审', | 314 | '## 写 plan + 自审', |
| 175 | - `- 落盘 \`${ROOT}/docs/superpowers/plans/<当天日期 YYYY-MM-DD>-${id}.md- `- 落盘 \`${ROOT}/docs/superpowers/plans/<当天日期,文件头含 Goal / Architecture / Tech Stack + checkbox 任务。`, | 315 | + `- 落盘路径:\`docs/superpowers/plans/<同 spec 的 YYYY-MM-DD>-${id}.md+ `- 落盘路径:\`docs/superpowers/plans/<同 spec 的,文件头含 Goal / Architecture / Tech Stack + checkbox 任务。`, |
| 176 | '- 自审:占位符扫描(按硬约束清单);spec coverage(spec 每节至少指向一个 task,补 gap);类型一致性(签名 / 方法名 / 错误码 / props 一致)。', | 316 | '- 自审:占位符扫描(按硬约束清单);spec coverage(spec 每节至少指向一个 task,补 gap);类型一致性(签名 / 方法名 / 错误码 / props 一致)。', |
| 177 | '', | 317 | '', |
| 178 | - '## 结束', | ||
| 179 | - `- 成功:输出一行 \`${fe ? 'fe-' : ''}feature-plan: ${id} → <plan path>\`,把该 plan 路径作为结果返回。`, | 318 | + '## commit', |
| 319 | + `- 写完 plan 后必须 commit(milestone 的 worktree-clean 前置依赖此 commit):`, | ||
| 320 | + ` 1. \`git -C ${ROOT} add <plan artifactPath>\``, | ||
| 321 | + ` 2. \`git -C ${ROOT} commit -m "docs(plan:${id}): 任务级 TDD 计划"\``, | ||
| 322 | + '- commit 失败 → halt,把 stderr 摘要写进 reason。', | ||
| 323 | + '', | ||
| 324 | + '## 输出(必须符合下发的 STAGE_RESULT JSON schema)', | ||
| 325 | + '- 成功:`{ "status": "ok", "artifactPath": "docs/superpowers/plans/YYYY-MM-DD-' + id + '.md", "summary": "<1-2 句中文摘要:任务数 / 涉及文件作用域>" }`', | ||
| 326 | + '- 失败:`{ "status": "halt", "reason": "<阻塞点描述>" }`', | ||
| 327 | + '- 日期前缀必须与 spec 同;schema 是 `additionalProperties:false`。', | ||
| 180 | ].filter(Boolean).join('\n') | 328 | ].filter(Boolean).join('\n') |
| 181 | } | 329 | } |
| 182 | 330 | ||
| 183 | // ---- stage 3:按 plan 逐任务 TDD(原 feature-tdd / fe-feature-tdd)---- | 331 | // ---- stage 3:按 plan 逐任务 TDD(原 feature-tdd / fe-feature-tdd)---- |
| 184 | -function tddPrompt(id, phase, plan) { | 332 | +// planPath:上游 plan artifactPath;ledger 是 prompt 层的显式自约束(无 harness 强制)。 |
| 333 | +function tddPrompt(id, phase, planPath) { | ||
| 185 | const fe = isFrontend(phase) | 334 | const fe = isFrontend(phase) |
| 186 | return [ | 335 | return [ |
| 187 | `# ${fe ? 'fe-feature-tdd' : 'feature-tdd'} — 逐任务 TDD ${id}`, | 336 | `# ${fe ? 'fe-feature-tdd' : 'feature-tdd'} — 逐任务 TDD ${id}`, |
| 188 | '', | 337 | '', |
| 189 | - commonContract(phase), | 338 | + featureStageContract(phase), |
| 190 | '', | 339 | '', |
| 191 | '## 输入', | 340 | '## 输入', |
| 192 | - `- 计划文件:${plan ? `\`${plan}\`` : `\`${ROOT}/docs/superpowers/plans/<当天日期>-${id}.md\``}(不存在则失败)。`, | 341 | + `- 计划文件:\`${planPath}\`(不存在则 halt)。`, |
| 193 | `- 测试命令来源:\`${ROOT}/docs/04-技术规范.md § 零\`${fe | 342 | `- 测试命令来源:\`${ROOT}/docs/04-技术规范.md § 零\`${fe |
| 194 | ? ' 的 `frontend.unit_test_runner` / `frontend.e2e_runner` / `frontend.test_command` / `frontend.e2e_command`(缺失则默认 `pnpm test:ci` / `pnpm e2e:ci`)。' | 343 | ? ' 的 `frontend.unit_test_runner` / `frontend.e2e_runner` / `frontend.test_command` / `frontend.e2e_command`(缺失则默认 `pnpm test:ci` / `pnpm e2e:ci`)。' |
| 195 | - : ' 确认的后端测试命令(如 Maven profile / `./scripts/test.mjs`)。'}`, | 344 | + : ' 确认的后端测试命令(如 Maven profile / `./scripts/test.mjs`);缺失则默认 `node scripts/test.mjs`(与 test-gate 一致)。'}`, |
| 196 | '', | 345 | '', |
| 197 | '## 流程', | 346 | '## 流程', |
| 198 | fe ? '' : '- **Schema 改动前置**(仅当 plan 声明需要):第一个任务写 migration 文件 `V<n>__<snake_case>.sql`(`<n>` = 现有 `sql/migrations/V*.sql` 最大版本号 + 1,只含 DDL),**同步**把新 CREATE / ALTER 反向更新到 `docs/03-数据库设计文档.md` 对应表小节(docs/03 是 schema 的 SSoT),migration + docs/03 改动同一 commit。', | 347 | fe ? '' : '- **Schema 改动前置**(仅当 plan 声明需要):第一个任务写 migration 文件 `V<n>__<snake_case>.sql`(`<n>` = 现有 `sql/migrations/V*.sql` 最大版本号 + 1,只含 DDL),**同步**把新 CREATE / ALTER 反向更新到 `docs/03-数据库设计文档.md` 对应表小节(docs/03 是 schema 的 SSoT),migration + docs/03 改动同一 commit。', |
| @@ -206,105 +355,143 @@ function tddPrompt(id, phase, plan) { | @@ -206,105 +355,143 @@ function tddPrompt(id, phase, plan) { | ||
| 206 | fe | 355 | fe |
| 207 | ? '- **绝不**写非 `frontend/`(或 docs/09 前端根)路径的 `impl_file`;命中 `backend/` / `sql/` / `scripts/` → 硬停并打印 `不允许写非前端文件:<impl_file>`。' | 356 | ? '- **绝不**写非 `frontend/`(或 docs/09 前端根)路径的 `impl_file`;命中 `backend/` / `sql/` / `scripts/` → 硬停并打印 `不允许写非前端文件:<impl_file>`。' |
| 208 | : '- **后端阶段路径硬护栏**:任意 `impl_file` 以 `frontend/` 开头 → 硬停并打印 `后端阶段不允许写前端代码:<impl_file>`,不再继续 TDD。', | 357 | : '- **后端阶段路径硬护栏**:任意 `impl_file` 以 `frontend/` 开头 → 硬停并打印 `后端阶段不允许写前端代码:<impl_file>`,不再继续 TDD。', |
| 209 | - '- 每次 commit 含 REQ/FE 标签,不混合无关改动。', | ||
| 210 | - '- **同一测试修复超过 10 次仍失败 → 立即失败(halt)**,把"哪个测试、失败断言、已尝试的修复"写进诊断;**不要**问人、不要无限重试。', | 358 | + '- 每次 commit 含 REQ/FE 标签,不混合无关改同。', |
| 359 | + '', | ||
| 360 | + '## 同测试重试账本(硬上限 10 次 / 测试)', | ||
| 361 | + '- 你必须**显式**为每个出现过红色的测试维护一个内存账本 `attempts[<test_file>::<test_name>] = N`,每次该测试的"写失败实现 → 再跑"算 1 次。', | ||
| 362 | + '- 每次失败跑后,**在自身输出中显式打印一行** JSON:`{ "attempts": { "<test_file>::<test_name>": N } }`(便于 review/审计追溯)。', | ||
| 363 | + '- 任一测试的 `attempts >= 10` → **立刻 halt**:返回 `{status:"halt", reason:"tdd-test-stuck: <test_file>::<test_name> 已尝试 10 次"}`,把"该测试名 / 最近一次 failing_assertion / 已尝试的修复摘要"写进 reason,**不要**无限重试。', | ||
| 211 | '', | 364 | '', |
| 212 | - '## 结束', | ||
| 213 | - `- 全部任务通过:输出一行 \`${fe ? 'fe-' : ''}feature-tdd: ${id} 完成\`,把"已实现 + 已 commit"摘要作为结果返回(供 verify stage)。`, | 365 | + '## 输出(必须符合下发的 STAGE_RESULT JSON schema)', |
| 366 | + '- 全部任务通过:`{ "status": "ok", "summary": "<完成的任务数 / 引入的文件清单摘要>" }`(artifactPath 可省)。', | ||
| 367 | + '- 任意护栏 / 账本上限 / 缺值 → `{ "status": "halt", "reason": "<具体阻塞点>" }`。', | ||
| 214 | ].filter(Boolean).join('\n') | 368 | ].filter(Boolean).join('\n') |
| 215 | } | 369 | } |
| 216 | 370 | ||
| 217 | // ---- stage 4:把功能测试派子会话跑,渲染证据(原 feature-verify / fe-feature-verify)---- | 371 | // ---- stage 4:把功能测试派子会话跑,渲染证据(原 feature-verify / fe-feature-verify)---- |
| 218 | -function verifyPrompt(id, phase, impl) { | 372 | +// specPath:用于复用日期前缀;round:0 = TDD 后初次 verify,1..5 = fix 后 reverify(每轮独立证据文件, |
| 373 | +// 避免 reverify 覆盖前轮证据)。 | ||
| 374 | +function verifyPrompt(id, phase, implSummary, specPath, round = 0) { | ||
| 219 | const fe = isFrontend(phase) | 375 | const fe = isFrontend(phase) |
| 376 | + const suffix = round === 0 ? 'verify' : `verify-r${round}` | ||
| 220 | return [ | 377 | return [ |
| 221 | - `# ${fe ? 'fe-feature-verify' : 'feature-verify'} — 证据验证 ${id}`, | 378 | + `# ${fe ? 'fe-feature-verify' : 'feature-verify'} — 证据验证 ${id}${round > 0 ? `(第 ${round} 轮 fix 后复验)` : ''}`, |
| 222 | '', | 379 | '', |
| 223 | - commonContract(phase), | 380 | + featureStageContract(phase), |
| 224 | '', | 381 | '', |
| 225 | '## 目标', | 382 | '## 目标', |
| 226 | `把 \`${id}\` 的功能测试**派发到 Agent 子会话**执行,按结构化结果渲染证据。**主会话从不直接跑测试,也不自由编写证据。**`, | 383 | `把 \`${id}\` 的功能测试**派发到 Agent 子会话**执行,按结构化结果渲染证据。**主会话从不直接跑测试,也不自由编写证据。**`, |
| 227 | - impl ? `(上游 TDD 摘要:${impl})` : '', | 384 | + `- 上游 spec:\`${specPath}\`(日期前缀来源);本次产物文件名前缀必须 = spec 文件名首段 \`YYYY-MM-DD\`。`, |
| 385 | + implSummary ? `- 上游 TDD 摘要:${implSummary}` : '', | ||
| 228 | '', | 386 | '', |
| 229 | '## 流程', | 387 | '## 流程', |
| 230 | fe | 388 | fe |
| 231 | ? [ | 389 | ? [ |
| 232 | `- 测试目标:从 plan 取 \`测试先行类型 = jsdom\` 的 test_file → 拼 vitest/jest 过滤模式;\`= e2e\` 的 → 拼 Playwright spec 过滤模式。命令从 \`${ROOT}/docs/04-技术规范.md § 零 frontend.test_command\` / \`frontend.e2e_command\` 取(缺失默认 \`pnpm test:ci\` / \`pnpm e2e:ci\`)。`, | 390 | `- 测试目标:从 plan 取 \`测试先行类型 = jsdom\` 的 test_file → 拼 vitest/jest 过滤模式;\`= e2e\` 的 → 拼 Playwright spec 过滤模式。命令从 \`${ROOT}/docs/04-技术规范.md § 零 frontend.test_command\` / \`frontend.e2e_command\` 取(缺失默认 \`pnpm test:ci\` / \`pnpm e2e:ci\`)。`, |
| 233 | '- 派子会话依次跑 unit + e2e,子会话只返回结构化 JSON:`{ unit:{command,exit_code,passed,failed,failed_list,stdout_excerpt}, e2e:{...同结构} }`(`stdout_excerpt` ≤ 30 行)。', | 391 | '- 派子会话依次跑 unit + e2e,子会话只返回结构化 JSON:`{ unit:{command,exit_code,passed,failed,failed_list,stdout_excerpt}, e2e:{...同结构} }`(`stdout_excerpt` ≤ 30 行)。', |
| 234 | - '- **任一目标 `exit_code != 0` 或 `failed > 0`** → 渲染证据后失败,不进入 review。', | 392 | + '- **任一目标 `exit_code != 0` 或 `failed > 0`** → 渲染证据后 halt,不进入 review。', |
| 235 | ].join('\n') | 393 | ].join('\n') |
| 236 | : [ | 394 | : [ |
| 237 | `- 测试目标:从 plan 或项目标准命令确定(Maven profile / pnpm script / pytest path / \`${ROOT}/docs/04-技术规范.md § 零\` 的后端命令)。`, | 395 | `- 测试目标:从 plan 或项目标准命令确定(Maven profile / pnpm script / pytest path / \`${ROOT}/docs/04-技术规范.md § 零\` 的后端命令)。`, |
| 238 | '- 派子会话执行,子会话只返回结构化 JSON:`{command, exit_code, passed, failed, failed_list, stdout_excerpt}`(`stdout_excerpt` ≤ 30 行,不塞全文 stdout)。', | 396 | '- 派子会话执行,子会话只返回结构化 JSON:`{command, exit_code, passed, failed, failed_list, stdout_excerpt}`(`stdout_excerpt` ≤ 30 行,不塞全文 stdout)。', |
| 239 | - '- **`exit_code != 0` 或 `failed > 0`** → 渲染证据后失败,不进入 review。', | 397 | + '- **`exit_code != 0` 或 `failed > 0`** → 渲染证据后 halt,不进入 review。', |
| 240 | ].join('\n'), | 398 | ].join('\n'), |
| 241 | - `- 证据渲染并打印到会话;如需落盘,写 \`${ROOT}/docs/superpowers/reviews/\` 旁的证据位(沿用项目既有约定)。`, | 399 | + `- 证据落盘路径固定为 \`docs/superpowers/reviews/<同 spec 的 YYYY-MM-DD>-${id}-${suffix}.md\`(与 review 报告同目录;round=0 → \`-verify.md\`;round>=1 → \`-verify-r<N>.md\`,**每轮独立文件不覆盖前轮**)。同时把核心结构化结果摘要打印到会话便于上层 review stage 引用,**不要**自行另起目录或自由命名文件。`, |
| 400 | + '', | ||
| 401 | + '## commit', | ||
| 402 | + `- 写完证据后必须 commit(milestone 的 worktree-clean 前置依赖此 commit):`, | ||
| 403 | + ` 1. \`git -C ${ROOT} add <证据 artifactPath>\``, | ||
| 404 | + ` 2. \`git -C ${ROOT} commit -m "docs(verify:${id}${round > 0 ? `:r${round}` : ''}): 证据验证"\``, | ||
| 405 | + '- commit 失败 → halt,把 stderr 摘要写进 reason(仍要返回已写入的证据路径)。', | ||
| 242 | '', | 406 | '', |
| 243 | - '## 结束', | ||
| 244 | - `- 全部通过:输出一行 \`${fe ? 'fe-' : ''}feature-verify: ${id} 通过\`,把验证摘要作为结果返回(供 review stage)。`, | 407 | + '## 输出(必须符合下发的 STAGE_RESULT JSON schema)', |
| 408 | + `- 全部通过:\`{ "status": "ok", "artifactPath": "docs/superpowers/reviews/YYYY-MM-DD-${id}-${suffix}.md", "summary": "<exit_code / passed / failed / failed_list 摘要 ≤ 200 字>" }\`。`, | ||
| 409 | + '- 任一红色 / 越界 / 缺值 → `{ "status": "halt", "reason": "<具体阻塞点>", "artifactPath": "<已写入的证据路径(如有)>" }`。', | ||
| 245 | ].filter(Boolean).join('\n') | 410 | ].filter(Boolean).join('\n') |
| 246 | } | 411 | } |
| 247 | 412 | ||
| 248 | // ---- stage 5a:AI 自审 diff(原 feature-review / fe-feature-review)——委托统一 reviewer agent ---- | 413 | // ---- stage 5a:AI 自审 diff(原 feature-review / fe-feature-review)——委托统一 reviewer agent ---- |
| 249 | -function reviewPrompt(id, phase, round) { | 414 | +// lastVerifySummary:round>1 时传入上轮 fix 后复验摘要,让 reviewer 看到"上轮 must-fix 真的修了什么"。 |
| 415 | +// specPath:spec artifactPath(日期前缀来源 + reviewer 上下文输入)。 | ||
| 416 | +function reviewPrompt(id, phase, round, lastVerifySummary, specPath) { | ||
| 250 | const fe = isFrontend(phase) | 417 | const fe = isFrontend(phase) |
| 251 | return [ | 418 | return [ |
| 252 | `# ${fe ? 'fe-feature-review' : 'feature-review'} — AI 自审 ${id}(第 ${round} 轮)`, | 419 | `# ${fe ? 'fe-feature-review' : 'feature-review'} — AI 自审 ${id}(第 ${round} 轮)`, |
| 253 | '', | 420 | '', |
| 254 | - commonContract(phase), | 421 | + featureStageContract(phase), |
| 255 | '', | 422 | '', |
| 256 | '## 目标', | 423 | '## 目标', |
| 257 | `对 \`${id}\` 本轮引入的代码 diff 做 AI 自审,给出 \`approve\` 或 \`request-changes\` 裁决。`, | 424 | `对 \`${id}\` 本轮引入的代码 diff 做 AI 自审,给出 \`approve\` 或 \`request-changes\` 裁决。`, |
| 258 | '', | 425 | '', |
| 259 | '## 输入给 reviewer', | 426 | '## 输入给 reviewer', |
| 260 | - `- 本 ${fe ? 'FE' : 'REQ'} 引入的代码 diff + 规格 \`${ROOT}/docs/superpowers/specs/<当天日期>-${id}.md- `- 本 ${fe ? 'FE' : 'REQ'} 引入的代码 diff + 规格 \`${ROOT}/docs/superpowers/specs/<当天日期>-${id}.md。`, | 427 | + `- 本 ${fe ? 'FE' : 'REQ'} 引入的代码 diff + 规格 \`${specPath}+ `- 本 ${fe ? 'FE' : 'REQ'} 引入的代码 diff + 规格 \`${specPath}。`, |
| 261 | fe ? `- 本 FE 关联的所有 prototype 文件(spec 顶部"关联原型"列表),供对照渲染结构。` : '', | 428 | fe ? `- 本 FE 关联的所有 prototype 文件(spec 顶部"关联原型"列表),供对照渲染结构。` : '', |
| 262 | - `- **phase = ${fe ? 'frontend → 附加前端 7 维 checklist(a11y / 对比度 / 响应式 等);主观维度仅标记明显问题,不因主观判断触发 request-changes(避免非确定性循环耗尽 5 轮)。' : 'backend → 通用代码审查维度(正确性 / 边界 / 错误处理 / 一致性)。'}**`, | 429 | + `- **phase = ${fe ? 'frontend → 附加前端 7 维 checklist。其中仅"颜色对比度"(§3 子项)与"响应式"(§4)为主观/best-effort,绝不单独触发 request-changes;a11y 的 label/键盘可达/危险操作确认等客观项仍可作 must-fix(与 agents/code-reviewer.md §3-4 对齐,避免非确定性循环耗尽 5 轮)。' : 'backend → 通用代码审查维度(正确性 / 边界 / 错误处理 / 一致性)。'}**`, |
| 430 | + round > 1 && lastVerifySummary | ||
| 431 | + ? `\n## 上轮 fix 后复验摘要(round ${round - 1})\n${lastVerifySummary}\n\n你必须把"上轮 must-fix 在本轮 diff 中是否真的被修"作为本轮裁决的核心维度。已修的不要再次纳入 must-fix;未修 / 修得不对,单点列入 issues。` | ||
| 432 | + : '', | ||
| 263 | '', | 433 | '', |
| 264 | '## 输出(必须符合下发的 REVIEW JSON schema)', | 434 | '## 输出(必须符合下发的 REVIEW JSON schema)', |
| 265 | - `- \`verdict\`: \`approve\` | \`request-changes\`;\`round\`: 整数(本轮 = ${round});\`issues\`: must-fix 问题清单(approve 时可空数组)。`, | ||
| 266 | - `- 渲染审阅报告写入 \`${ROOT}/docs/superpowers/reviews/<当天日期 YYYY-MM-DD>-${id}.md\`(\`verdict\` 字段与返回值一致——router / 进度判定靠它)。`, | ||
| 267 | - `- approve 时,把 \`${ROOT}/docs/08-模块任务管理.md\` ${fe ? '§ 三' : '§ 二'} 中本 ${fe ? 'FE' : 'REQ'} 的 \`- [ ] ${id} ...\` 改为 \`- [x] ${id} ...\`(功能级可视化;模块完成仍以里程碑 tag 为准)。`, | ||
| 268 | - '- 不要返回额外字段(schema 为 `additionalProperties:false`)。', | 435 | + `- \`verdict\`: \`approve\` | \`request-changes\`;\`round\`: 整数(本轮 = ${round})。`, |
| 436 | + `- \`issues\`: 结构化 must-fix 数组。\`approve\` 时必须为空数组 \`[]\`;\`request-changes\` 时**必须非空**,每项形如 \`{ "summary": "<一句问题>", "locator": "<文件路径或 file:line>", "severity": "blocker|high|medium|low" }\`。`, | ||
| 437 | + `- \`locator\` **必须含可定位文件路径**(项目根相对,例如 \`backend/src/main/java/.../FooController.java\` 或 \`frontend/src/views/Bar.vue:42\`);没有具体文件无法定位 → 该项不是 must-fix(降级为口头建议,不要塞进 issues)。`, | ||
| 438 | + `- 渲染审阅报告写入 \`docs/superpowers/reviews/<同 spec 的 YYYY-MM-DD>-${id}.md\`(\`verdict\` 字段与返回值一致)。报告内可写更丰富的建议 / 风险 / 亮点;issues 数组只放硬性 must-fix。`, | ||
| 439 | + `- **不要**在本步骤里编辑 docs/08 的 \`- [ ]\` checkbox——该 side effect 由上层 Workflow 的 micro step 在 approve 后另行落盘(你只负责裁决)。`, | ||
| 440 | + '- 不要返回额外字段(schema 是 `additionalProperties:false`)。', | ||
| 441 | + '', | ||
| 442 | + '## commit', | ||
| 443 | + `- 写完审阅报告后必须 commit(milestone 的 worktree-clean 前置依赖此 commit;该 commit 与 verdict 无关,approve 或 request-changes 都要 commit 报告本身):`, | ||
| 444 | + ` 1. \`git -C ${ROOT} add docs/superpowers/reviews/<同 spec 的 YYYY-MM-DD>-${id}.md\``, | ||
| 445 | + ` 2. \`git -C ${ROOT} commit -m "docs(review:${id}:r${round}): <verdict>"\``, | ||
| 446 | + '- commit 失败时仍按 schema 返回 verdict / issues;commit 错误信息打印到日志即可(不要在 schema 中夹带额外字段)。', | ||
| 269 | ].filter(Boolean).join('\n') | 447 | ].filter(Boolean).join('\n') |
| 270 | } | 448 | } |
| 271 | 449 | ||
| 272 | // ---- stage 5b:按 review must-fix 修复并重新 commit(review 循环的 fix 步)---- | 450 | // ---- stage 5b:按 review must-fix 修复并重新 commit(review 循环的 fix 步)---- |
| 451 | +// issues:结构化对象数组 {summary, locator, severity}(见 REVIEW_SCHEMA)。 | ||
| 273 | function fixPrompt(id, phase, issues) { | 452 | function fixPrompt(id, phase, issues) { |
| 274 | const fe = isFrontend(phase) | 453 | const fe = isFrontend(phase) |
| 275 | const list = Array.isArray(issues) && issues.length | 454 | const list = Array.isArray(issues) && issues.length |
| 276 | - ? issues.map((x, i) => ` ${i + 1}. ${x}`).join('\n') | ||
| 277 | - : ' (上一轮 review 的 must-fix 清单——见 ' + (fe ? '§三' : '§二') + ' 对应 review 报告)' | 455 | + ? issues.map((x, i) => ` ${i + 1}. [${x.severity}] ${x.summary} — locator: \`${x.locator}\``).join('\n') |
| 456 | + : ' (上一轮 review 未提供 must-fix 清单——不应出现,调用方会先 halt)' | ||
| 278 | return [ | 457 | return [ |
| 279 | `# ${fe ? 'fe-feature' : 'feature'} fix — 修复 review must-fix ${id}`, | 458 | `# ${fe ? 'fe-feature' : 'feature'} fix — 修复 review must-fix ${id}`, |
| 280 | '', | 459 | '', |
| 281 | - commonContract(phase), | 460 | + featureStageContract(phase), |
| 282 | '', | 461 | '', |
| 283 | - '## 待修复 must-fix', | 462 | + '## 待修复 must-fix(已结构化)', |
| 284 | list, | 463 | list, |
| 285 | '', | 464 | '', |
| 286 | '## 流程', | 465 | '## 流程', |
| 287 | - '- 逐项编辑 must-fix 指向的代码文件(遵守阶段路径作用域护栏)。', | ||
| 288 | - `- 修复后 commit:\`fix(<scope>): 修复 review must-fix ${fe ? `REQ_ID: ${id}` : id}\`(不混合无关改动)。`, | 466 | + '- 逐项编辑 locator 指向的代码文件(遵守阶段路径作用域护栏)。', |
| 467 | + `- 编辑前必须先校验 locator 文件存在:跑 \`git -C ${ROOT} cat-file -e HEAD:<locator 的文件部分>\`(locator 形如 \`path:line\` 时取 \`path\`)。文件不存在 → halt,把 locator 写进 reason,不要"修一个不存在的文件"。`, | ||
| 468 | + fe | ||
| 469 | + ? '- **硬护栏(与 tdd 同款)**:命中 `backend/` / `sql/` / `scripts/` → halt 并把 file 路径写进 reason。' | ||
| 470 | + : '- **硬护栏(与 tdd 同款)**:任一被编辑文件以 `frontend/` 开头 → halt 并把 file 路径写进 reason。', | ||
| 471 | + `- 修复后 commit:\`fix(<scope>): 修复 review must-fix ${fe ? `FE: ${id}` : `REQ: ${id}`}\`(不混合无关改动)。`, | ||
| 289 | '- 修复完成后本步骤即结束;上层 Workflow 会重新跑 verify + review(下一轮)。', | 472 | '- 修复完成后本步骤即结束;上层 Workflow 会重新跑 verify + review(下一轮)。', |
| 290 | - '- **缺值仍不要问人**:按硬约束把阻塞点写进诊断并失败。', | ||
| 291 | '', | 473 | '', |
| 292 | - '## 结束', | ||
| 293 | - `- 输出一行 \`${fe ? 'fe-' : ''}feature-fix: ${id} 已修复 ${Array.isArray(issues) ? issues.length : ''} 项\`。`, | ||
| 294 | - ].join('\n') | 474 | + '## 输出(必须符合下发的 STAGE_RESULT JSON schema)', |
| 475 | + `- 全部修完:\`{ "status": "ok", "summary": "<已修复 ${Array.isArray(issues) ? issues.length : 0} 项的 1-2 句摘要>" }\`。`, | ||
| 476 | + '- 任意阻塞(locator 文件不存在 / 越界 / 缺值)→ `{ "status": "halt", "reason": "<具体阻塞点 + locator>" }`。', | ||
| 477 | + ].filter(Boolean).join('\n') | ||
| 295 | } | 478 | } |
| 296 | 479 | ||
| 297 | // ---- 测试闸(原 test-gate)---- | 480 | // ---- 测试闸(原 test-gate)---- |
| 298 | -function gatePrompt(module, phase) { | 481 | +// attempt:1 = 首次跑;2 = 上轮 red 后的 flake 重试。每次 attempt 写到独立证据文件,避免 retry |
| 482 | +// 把首次 red 证据覆盖掉(report § ⑤ 失去 flake 信号)。 | ||
| 483 | +function gatePrompt(module, phase, attempt = 1) { | ||
| 299 | const fe = isFrontend(phase) | 484 | const fe = isFrontend(phase) |
| 300 | const id = module?.id ?? '<module>' | 485 | const id = module?.id ?? '<module>' |
| 486 | + const phaseId = fe ? 'frontend-phase' : id | ||
| 301 | return [ | 487 | return [ |
| 302 | - `# test-gate — ${fe ? '前端阶段' : `模块 ${id}`} 硬测试闸(phase=${phase})`, | 488 | + `# test-gate — ${fe ? '前端阶段' : `模块 ${id}`} 硬测试闸(phase=${phase}, attempt=${attempt})`, |
| 303 | '', | 489 | '', |
| 304 | - commonContract(phase), | 490 | + featureStageContract(phase), |
| 305 | '', | 491 | '', |
| 306 | '## 目标', | 492 | '## 目标', |
| 307 | `打里程碑 tag 前的唯一硬测试门。**派发 Agent 子会话**跑测试,绿则通过,红则失败。**绝不**在主会话直接跑测试,红色时**绝不**跳过。`, | 493 | `打里程碑 tag 前的唯一硬测试门。**派发 Agent 子会话**跑测试,绿则通过,红则失败。**绝不**在主会话直接跑测试,红色时**绝不**跳过。`, |
| 494 | + attempt > 1 ? `- 本次 = 第 ${attempt} 次(上一次 red,本轮用于辨识 flaky);证据**写到独立文件**不要覆盖前一次。` : '', | ||
| 308 | '', | 495 | '', |
| 309 | '## 命令', | 496 | '## 命令', |
| 310 | fe | 497 | fe |
| @@ -313,33 +500,374 @@ function gatePrompt(module, phase) { | @@ -313,33 +500,374 @@ function gatePrompt(module, phase) { | ||
| 313 | '- 子会话只返回结构化 JSON:`{command, exit_code, passed, failed, stdout_excerpt}`(`stdout_excerpt` ≤ 30 行含 FAIL 摘要)。', | 500 | '- 子会话只返回结构化 JSON:`{command, exit_code, passed, failed, stdout_excerpt}`(`stdout_excerpt` ≤ 30 行含 FAIL 摘要)。', |
| 314 | '', | 501 | '', |
| 315 | '## 证据 + commit', | 502 | '## 证据 + commit', |
| 316 | - `- 渲染证据写入 \`${ROOT}/docs/superpowers/module-reports/${fe ? 'frontend-phase' : `${id}`}-test-gate.md\` 并 commit 到当前分支(保证证据随里程碑可审计)。`, | 503 | + `- 渲染证据写入 \`${ROOT}/docs/superpowers/module-reports/${phaseId}-test-gate-r${attempt}.md\` 并 commit 到当前分支(每个 attempt 独立文件,retry 不覆盖前一次 red 证据)。`, |
| 504 | + `- 文件头注明 \`attempt: ${attempt}\` + 命令 + 时间窗口(如可从子会话拿到),便于 report § ⑤ 识别 flake。`, | ||
| 317 | '', | 505 | '', |
| 318 | '## 输出(必须符合下发的 GATE JSON schema)', | 506 | '## 输出(必须符合下发的 GATE JSON schema)', |
| 319 | '- `status`: `green`(`exit_code = 0` 且 `failed = 0`)| `red`;`failures`: 失败用例摘要(green 时可省略 / 空数组)。', | 507 | '- `status`: `green`(`exit_code = 0` 且 `failed = 0`)| `red`;`failures`: 失败用例摘要(green 时可省略 / 空数组)。', |
| 320 | '- 不要返回额外字段。**不要在本步骤内自动重试**——重试由上层 Workflow 控制。', | 508 | '- 不要返回额外字段。**不要在本步骤内自动重试**——重试由上层 Workflow 控制。', |
| 509 | + ].filter(Boolean).join('\n') | ||
| 510 | +} | ||
| 511 | + | ||
| 512 | +// ---- 微步骤 prompt builders(runBranchSetup / runMilestone / runCrossModule 用)---- | ||
| 513 | +// 每个 prompt 单职责、短文本;返回严格 schema;执行(action)步统一返回 ACTION_RESULT_SCHEMA。 | ||
| 514 | +function microStepContract() { | ||
| 515 | + return [ | ||
| 516 | + '## 硬约束(非交互子代理)', | ||
| 517 | + '- 你是 Workflow 派生的**非交互子代理**,绝不弹问。', | ||
| 518 | + '- 全部输出**使用中文**。', | ||
| 519 | + `- 项目根 = \`${ROOT}\`。所有 git 命令必须用 \`git -C ${ROOT} ...\`;Read/Edit/Write 的路径都以 \`${ROOT}\` 为根。`, | ||
| 520 | + '- 严格按下方"输出"段返回 schema 字段;**不要**在 schema 外追加自由叙述。', | ||
| 321 | ].join('\n') | 521 | ].join('\n') |
| 322 | } | 522 | } |
| 323 | 523 | ||
| 324 | -// ---- 跨模块改动记录(替代被删的 cross-module hook + cross-module-log skill)---- | ||
| 325 | -function crossModulePrompt(module) { | ||
| 326 | - const id = module?.id ?? '<module>' | 524 | +// ── 微步骤:可重用 read(多个 orchestrator 共用)── |
| 525 | +function detectDefaultBranchPromptM() { | ||
| 327 | return [ | 526 | return [ |
| 328 | - `# cross-module-log — 记录模块 ${id} 的跨模块改动`, | 527 | + '# 检测本地默认分支', |
| 528 | + microStepContract(), | ||
| 329 | '', | 529 | '', |
| 330 | - commonContract('backend'), | 530 | + `用 \`git -C ${ROOT} rev-parse --verify <name>\` 依次试 \`main\` / \`master\`,取第一个 exit=0 的为默认分支。`, |
| 531 | + '## 输出(DEFAULT_BRANCH_SCHEMA)', | ||
| 532 | + '- 两者其一存在:`{ "branch": "main" }` 或 `{ "branch": "master" }`', | ||
| 533 | + '- 都不存在:本步骤失败(返回 schema 失败即可,调用方会 halt)。', | ||
| 534 | + ].join('\n') | ||
| 535 | +} | ||
| 536 | + | ||
| 537 | +function worktreeCleanPromptM() { | ||
| 538 | + return [ | ||
| 539 | + '# 检查工作树是否干净', | ||
| 540 | + microStepContract(), | ||
| 331 | '', | 541 | '', |
| 332 | - '## 目标', | ||
| 333 | - `替代被删的 \`log-cross-module\` hook + \`cross-module-log\` skill:扫描本模块周期内对**非本模块**文件的改动,落跨模块日志(原因 + 影响评估),供 module-report § ⑦ 嵌入。`, | 542 | + `跑 \`git -C ${ROOT} status --porcelain\`,按行解析 dirty 文件路径(第 4 字符起)。`, |
| 543 | + '## 输出(WT_SCHEMA)', | ||
| 544 | + '- 干净:`{ "clean": true }`', | ||
| 545 | + '- 不干净:`{ "clean": false, "dirty": ["<path>", ...] }`', | ||
| 546 | + ].join('\n') | ||
| 547 | +} | ||
| 548 | + | ||
| 549 | +function checkBranchExistsPromptM(branch) { | ||
| 550 | + return [ | ||
| 551 | + `# 本地分支 \`${branch}\` 是否存在`, | ||
| 552 | + microStepContract(), | ||
| 553 | + '', | ||
| 554 | + `跑 \`git -C ${ROOT} rev-parse --verify ${branch}\`(用 2>/dev/null 抑制 stderr)。`, | ||
| 555 | + '## 输出(EXISTS_SCHEMA)', | ||
| 556 | + '- exit=0 → `{ "exists": true }`;非 0 → `{ "exists": false }`', | ||
| 557 | + ].join('\n') | ||
| 558 | +} | ||
| 559 | + | ||
| 560 | +function currentBranchPromptM() { | ||
| 561 | + return [ | ||
| 562 | + '# 当前所在分支', | ||
| 563 | + microStepContract(), | ||
| 564 | + '', | ||
| 565 | + `跑 \`git -C ${ROOT} rev-parse --abbrev-ref HEAD\`。`, | ||
| 566 | + '## 输出(CURRENT_BRANCH_SCHEMA)', | ||
| 567 | + '- `{ "branch": "<stdout 第一行去空白>" }`', | ||
| 568 | + ].join('\n') | ||
| 569 | +} | ||
| 570 | + | ||
| 571 | +// ── 微步骤:分支生命周期 action ── | ||
| 572 | +function checkoutExistingBranchPromptM(branch) { | ||
| 573 | + return [ | ||
| 574 | + `# 切到已存在的本地分支 \`${branch}\``, | ||
| 575 | + microStepContract(), | ||
| 576 | + '', | ||
| 577 | + `跑 \`git -C ${ROOT} checkout ${branch}\`。`, | ||
| 578 | + '## 输出(ACTION_RESULT_SCHEMA)', | ||
| 579 | + '- 成功:`{ "success": true }`', | ||
| 580 | + '- 失败:`{ "success": false, "error": "<stderr 摘要>" }`', | ||
| 581 | + ].join('\n') | ||
| 582 | +} | ||
| 583 | + | ||
| 584 | +function createBranchFromPromptM(fromBranch, newBranch) { | ||
| 585 | + return [ | ||
| 586 | + `# 从 \`${fromBranch}\` 新建并切到 \`${newBranch}\``, | ||
| 587 | + microStepContract(), | ||
| 588 | + '', | ||
| 589 | + `按序跑:\`git -C ${ROOT} checkout ${fromBranch}\`,然后 \`git -C ${ROOT} checkout -b ${newBranch}\`。`, | ||
| 590 | + '## 输出(ACTION_RESULT_SCHEMA)', | ||
| 591 | + '- 全成功:`{ "success": true }`;任一失败:`{ "success": false, "error": "<which step + stderr>" }`', | ||
| 592 | + ].join('\n') | ||
| 593 | +} | ||
| 594 | + | ||
| 595 | +// ── 微步骤:REQ/FE 完成态 git tag(featureLoop dedup 的唯一 ground truth)── | ||
| 596 | +// req-done/<id> 是功能级 git tag,approve 时打一次;featureLoop 入口先 check,存在就 skip, | ||
| 597 | +// 避免 Router LLM 自审失误导致已 approve 的 REQ 被重新 spec→plan→tdd(撞 V<n>、污染源码)。 | ||
| 598 | +function checkReqDoneTagPromptM(id) { | ||
| 599 | + return [ | ||
| 600 | + `# tag \`req-done/${id}\` 是否存在(功能级 dedup 真值)`, | ||
| 601 | + microStepContract(), | ||
| 602 | + '', | ||
| 603 | + `跑 \`git -C ${ROOT} tag -l req-done/${id}\`。`, | ||
| 604 | + '## 输出(EXISTS_SCHEMA)', | ||
| 605 | + '- stdout 含完整匹配 → `{ "exists": true }`;为空 → `{ "exists": false }`', | ||
| 606 | + ].join('\n') | ||
| 607 | +} | ||
| 608 | + | ||
| 609 | +function createReqDoneTagPromptM(id, phase) { | ||
| 610 | + return [ | ||
| 611 | + `# 打 annotated tag \`req-done/${id}\`(${phase==='frontend'?'前端 FE':'后端 REQ'} approve 后落地)`, | ||
| 612 | + microStepContract(), | ||
| 613 | + '', | ||
| 614 | + `跑 \`git -C ${ROOT} tag -a req-done/${id} -m "feature(${id}): approved by code-reviewer (phase=${phase})"\`。`, | ||
| 615 | + `先用 \`git -C ${ROOT} tag -l req-done/${id}\` 检查;已存在则视为成功(幂等)直接返回 success。`, | ||
| 616 | + '## 输出(ACTION_RESULT_SCHEMA)', | ||
| 617 | + '- 成功 / 已存在:`{ "success": true }`;其它失败:`{ "success": false, "error": "<stderr>" }`', | ||
| 618 | + ].join('\n') | ||
| 619 | +} | ||
| 620 | + | ||
| 621 | +// ── 微步骤:milestone 专用 ── | ||
| 622 | +function checkAlreadyMergedPromptM(branch, defaultBranch) { | ||
| 623 | + return [ | ||
| 624 | + `# \`${branch}\` 是否已合入 \`${defaultBranch}\``, | ||
| 625 | + microStepContract(), | ||
| 626 | + '', | ||
| 627 | + `先跑 \`git -C ${ROOT} checkout ${defaultBranch}\` 确保 HEAD 在 ${defaultBranch};然后跑 \`git -C ${ROOT} merge-base --is-ancestor ${branch} HEAD\`。`, | ||
| 628 | + '## 输出(ALREADY_MERGED_SCHEMA)', | ||
| 629 | + '- 第二条 exit=0 → `{ "alreadyMerged": true }`(功能分支已是 HEAD 祖先,无需再 merge)', | ||
| 630 | + '- 非 0 → `{ "alreadyMerged": false }`', | ||
| 631 | + '- checkout 自身失败 → 整步失败(schema 失败即可)。', | ||
| 632 | + ].join('\n') | ||
| 633 | +} | ||
| 634 | + | ||
| 635 | +function executeMergePromptM(defaultBranch, branch, phaseId) { | ||
| 636 | + return [ | ||
| 637 | + `# 把 \`${branch}\` 合并进 \`${defaultBranch}\`(已确认尚未合入,已在默认分支)`, | ||
| 638 | + microStepContract(), | ||
| 639 | + '', | ||
| 640 | + `跑 \`git -C ${ROOT} merge --no-ff ${branch} -m "merge(${phaseId}): integrate ${branch}"\`。`, | ||
| 641 | + '- 成功 → `{ "success": true }`', | ||
| 642 | + '- 合并冲突 / 其它失败 → `{ "success": false, "error": "<simplified message>", "detail": "<conflict files newline-separated, 或 stderr 前 30 行>" }`', | ||
| 643 | + '- **不要**自动 \`git merge --abort\` / 自动 stash / 自动改文件——把树留给人工处理。', | ||
| 644 | + '## 输出(ACTION_RESULT_SCHEMA)', | ||
| 645 | + ].join('\n') | ||
| 646 | +} | ||
| 647 | + | ||
| 648 | +function readDocs08FieldPromptM(fe, id) { | ||
| 649 | + if (fe) { | ||
| 650 | + return [ | ||
| 651 | + '# 读 docs/08 § 三 `整体里程碑:` 字段当前值', | ||
| 652 | + microStepContract(), | ||
| 653 | + '', | ||
| 654 | + `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 § 三(前端阶段)下的 \`- 整体里程碑: <value>\` 行。`, | ||
| 655 | + '## 输出(FIELD_VALUE_SCHEMA)', | ||
| 656 | + '- 命中:`{ "found": true, "value": "<冒号后去空白的当前值>", "lineNumber": <该行 1-based 行号> }`', | ||
| 657 | + '- § 三 或该行不存在:`{ "found": false, "value": "" }`', | ||
| 658 | + ].join('\n') | ||
| 659 | + } | ||
| 660 | + return [ | ||
| 661 | + `# 读 docs/08 § 二 模块 \`${id}\` 的 \`里程碑:\` 字段当前值`, | ||
| 662 | + microStepContract(), | ||
| 663 | + '', | ||
| 664 | + `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 § 二 中 module id == \`${id}\` 的 bullet 段,取其 \` - 里程碑: <value>\` 子项。`, | ||
| 665 | + '## 输出(FIELD_VALUE_SCHEMA)', | ||
| 666 | + '- 命中:`{ "found": true, "value": "<冒号后去空白的当前值>", "lineNumber": <行号> }`', | ||
| 667 | + `- 模块 \`${id}\` 或该字段不存在:\`{ "found": false, "value": "" }\``, | ||
| 668 | + ].join('\n') | ||
| 669 | +} | ||
| 670 | + | ||
| 671 | +function writeDocs08FieldPromptM(fe, id, targetValue, phaseId, lineNumber) { | ||
| 672 | + const scope = fe ? `§ 三 整体里程碑` : `§ 二 模块 ${id} 里程碑` | ||
| 673 | + const oldStr = fe ? '- 整体里程碑: —' : ' - 里程碑: —' | ||
| 674 | + const newStr = fe ? `- 整体里程碑: ${targetValue}` : ` - 里程碑: ${targetValue}` | ||
| 675 | + // 后端模块多个 bullet 同时含 ` - 里程碑: —`:必须按调用方传入的精确行号定位,否则在多模块 docs/08 | ||
| 676 | + // 里 Edit 会替换到第一处(通常不是本模块),把别的模块误标 milestone-complete。 | ||
| 677 | + const lineGuard = (typeof lineNumber === 'number' && Number.isFinite(lineNumber)) | ||
| 678 | + ? `先 Read \`${ROOT}/docs/08-模块任务管理.md\` 第 ${lineNumber} 行(1-based),确认该行字面量等于 \`${oldStr}\`;不等则 halt(返回 \`{success:false, error:"line-${lineNumber}-mismatch: actual=<actual>"}\`)。然后仅替换第 ${lineNumber} 行;其余位置同名行**严禁**改动。` | ||
| 679 | + : `严禁全局替换:通过定位上下文(${fe ? '§ 三' : `§ 二 中 module_id == \`${id}\` 的 bullet 段`})找到该 bullet 的 \`里程碑\` 子项行,仅替换这一行。` | ||
| 680 | + return [ | ||
| 681 | + `# 把 docs/08 ${scope} 从 \`—\` 改为 \`${targetValue}\` 并 commit`, | ||
| 682 | + microStepContract(), | ||
| 683 | + '', | ||
| 684 | + `调用方已确认字段当前值 = \`—\`(你不必再读一遍)。`, | ||
| 685 | + `1. ${lineGuard} Edit \`${ROOT}/docs/08-模块任务管理.md\`:把整行 \`${oldStr}\` 替换为 \`${newStr}\`(精确字符串替换;**只动一处**)。`, | ||
| 686 | + `2. 跑 \`git -C ${ROOT} add docs/08-模块任务管理.md\`。`, | ||
| 687 | + `3. 跑 \`git -C ${ROOT} commit -m "chore(${phaseId}): record ${targetValue} in docs/08"\`。`, | ||
| 688 | + '## 输出(ACTION_RESULT_SCHEMA)', | ||
| 689 | + '- 三步全 OK:`{ "success": true }`;任一失败:`{ "success": false, "error": "<step + reason>" }`', | ||
| 690 | + ].join('\n') | ||
| 691 | +} | ||
| 692 | + | ||
| 693 | +// ── 微步骤:docs/08 功能行 checkbox 勾选态(reviewer approve 后的可观测 side effect)── | ||
| 694 | +// 原 reviewPrompt 让 reviewer 顺手 flip checkbox:reviewer 失败时 router/进度判定看不到,且 | ||
| 695 | +// reviewer agent 多了一项 file edit 副作用。拆为 read-then-write 两个 micro step: | ||
| 696 | +// reviewWithFixLoop approve → read → 若 unchecked → write → assert success; | ||
| 697 | +// 已 checked → 静默跳过(resume 幂等)。 | ||
| 698 | +function readDocs08CheckboxPromptM(fe, id) { | ||
| 699 | + if (fe) { | ||
| 700 | + return [ | ||
| 701 | + `# 读 docs/08 § 三 功能 \`${id}\` 的勾选态(\`- [ ] ${id} ...\` / \`- [x] ${id} ...\`)`, | ||
| 702 | + microStepContract(), | ||
| 703 | + '', | ||
| 704 | + `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 § 三(前端阶段)下的 \`功能:\` 项,从中找以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(注意 id 后必须紧跟空格,避免误中前缀同名)。`, | ||
| 705 | + '## 输出(CHECKBOX_STATE_SCHEMA)', | ||
| 706 | + `- 命中 \`- [x] ${id} ...\`:\`{ "found": true, "state": "checked", "lineNumber": <行号> }\``, | ||
| 707 | + `- 命中 \`- [ ] ${id} ...\`:\`{ "found": true, "state": "unchecked", "lineNumber": <行号> }\``, | ||
| 708 | + `- 找不到:\`{ "found": false }\``, | ||
| 709 | + ].join('\n') | ||
| 710 | + } | ||
| 711 | + return [ | ||
| 712 | + `# 读 docs/08 § 二 REQ \`${id}\` 的勾选态(\`- [ ] ${id} ...\` / \`- [x] ${id} ...\`)`, | ||
| 713 | + microStepContract(), | ||
| 714 | + '', | ||
| 715 | + `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 § 二,找以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(id 后必须紧跟空格)。该行可能位于任一模块 bullet 下。`, | ||
| 716 | + '## 输出(CHECKBOX_STATE_SCHEMA)', | ||
| 717 | + `- \`- [x] ${id} ...\` → \`{ "found": true, "state": "checked", "lineNumber": <行号> }\``, | ||
| 718 | + `- \`- [ ] ${id} ...\` → \`{ "found": true, "state": "unchecked", "lineNumber": <行号> }\``, | ||
| 719 | + `- 找不到:\`{ "found": false }\``, | ||
| 720 | + ].join('\n') | ||
| 721 | +} | ||
| 722 | + | ||
| 723 | +function writeDocs08CheckboxPromptM(fe, id, phase) { | ||
| 724 | + const scope = fe ? `§ 三 功能 ${id}` : `§ 二 REQ ${id}` | ||
| 725 | + return [ | ||
| 726 | + `# 把 docs/08 ${scope} 的 \`[ ]\` 勾选为 \`[x]\` 并 commit`, | ||
| 727 | + microStepContract(), | ||
| 728 | + '', | ||
| 729 | + `调用方已读到状态 = \`unchecked\`(你不必再读一遍)。`, | ||
| 730 | + `1. Edit \`${ROOT}/docs/08-模块任务管理.md\`:把以 \`- [ ] ${id} \` 开头的整行替换为对应的 \`- [x] ${id} ...\`(保留原行 id 之后的全部文本,仅 \`[ ]\` → \`[x]\`,精确字符串替换;只动一处)。`, | ||
| 731 | + `2. 跑 \`git -C ${ROOT} add docs/08-模块任务管理.md\`。`, | ||
| 732 | + `3. 跑 \`git -C ${ROOT} commit -m "chore(${phase}:${id}): mark ${id} approved in docs/08"\`。`, | ||
| 733 | + '## 输出(ACTION_RESULT_SCHEMA)', | ||
| 734 | + '- 三步全 OK:`{ "success": true }`;任一失败:`{ "success": false, "error": "<step + reason>" }`', | ||
| 735 | + ].join('\n') | ||
| 736 | +} | ||
| 737 | + | ||
| 738 | +function checkTagExistsPromptM(tagName) { | ||
| 739 | + return [ | ||
| 740 | + `# tag \`${tagName}\` 是否存在`, | ||
| 741 | + microStepContract(), | ||
| 742 | + '', | ||
| 743 | + `跑 \`git -C ${ROOT} tag -l ${tagName}\`。`, | ||
| 744 | + '## 输出(EXISTS_SCHEMA)', | ||
| 745 | + '- stdout 含完整匹配 → `{ "exists": true }`;为空 → `{ "exists": false }`', | ||
| 746 | + ].join('\n') | ||
| 747 | +} | ||
| 748 | + | ||
| 749 | +function createTagPromptM(phaseId, fe) { | ||
| 750 | + return [ | ||
| 751 | + `# 打 annotated tag \`milestone/${phaseId}\``, | ||
| 752 | + microStepContract(), | ||
| 753 | + '', | ||
| 754 | + `跑 \`git -C ${ROOT} tag -a milestone/${phaseId} -m "milestone(${phaseId}): ${fe ? '前端' : '后端'}阶段完成"\`。`, | ||
| 755 | + '## 输出(ACTION_RESULT_SCHEMA)', | ||
| 756 | + '- 成功:`{ "success": true }`;失败:`{ "success": false, "error": "<stderr>" }`', | ||
| 757 | + ].join('\n') | ||
| 758 | +} | ||
| 759 | + | ||
| 760 | +// 校验 milestone tag 指向的 commit 中报告 § ⑫ 是否已是 targetTag(而非 placeholder)。 | ||
| 761 | +// 用于识别旧 bug 残留:报告 § ⑫ commit 顺序在 tag 之后时,tag 指向占位符版本。 | ||
| 762 | +function checkTagReportFreshPromptM(targetTag, reportPath) { | ||
| 763 | + return [ | ||
| 764 | + `# 校验 tag \`${targetTag}\` 指向的 commit 中 \`${reportPath}\` § ⑫ 是否新鲜`, | ||
| 765 | + microStepContract(), | ||
| 766 | + '', | ||
| 767 | + `跑 \`git -C ${ROOT} show ${targetTag}:${reportPath}\`。在输出中定位 § ⑫("里程碑"小节)的 tag 字段值。`, | ||
| 768 | + '## 输出(TAG_REPORT_FRESHNESS_SCHEMA)', | ||
| 769 | + `- § ⑫ 字段值 == \`${targetTag}\`:\`{ "fresh": true, "tagReportValue": "${targetTag}" }\``, | ||
| 770 | + `- § ⑫ 字段值 == \`{{milestone_tag}}\` 或其它陈旧值:\`{ "fresh": false, "tagReportValue": "<实际值>" }\``, | ||
| 771 | + '- `git show` 失败(tag 不存在 / 报告路径不在 tag commit 中)→ 本步骤失败(schema 失败即可)。', | ||
| 772 | + ].join('\n') | ||
| 773 | +} | ||
| 774 | + | ||
| 775 | +function findReportPromptM(phaseId) { | ||
| 776 | + return [ | ||
| 777 | + `# 找最新的 \`${phaseId}\` 完成报告并读取 § ⑫ 的 milestone tag 字段当前值`, | ||
| 778 | + microStepContract(), | ||
| 779 | + '', | ||
| 780 | + `用 Glob 在 \`${ROOT}/docs/superpowers/module-reports/\` 查找 \`*-${phaseId}.md\`(按文件名 YYYY-MM-DD 日期前缀降序取最新一份)。`, | ||
| 781 | + 'Read 该文件,定位 § ⑫("里程碑"小节)。', | ||
| 782 | + '## 输出(REPORT_PATH_SCHEMA)', | ||
| 783 | + `- 找到:\`{ "found": true, "path": "docs/superpowers/module-reports/<file>", "currentTagValue": "<§ ⑫ 当前的字面值(应为 \\\`{{milestone_tag}}\\\` 或 \\\`milestone/${phaseId}\\\` 之一)>" }\``, | ||
| 784 | + '- 完全没有匹配文件:`{ "found": false }`', | ||
| 785 | + ].join('\n') | ||
| 786 | +} | ||
| 787 | + | ||
| 788 | +function updateReportPromptM(reportPath, targetTag, phaseId) { | ||
| 789 | + return [ | ||
| 790 | + `# 把 \`${reportPath}\` § ⑫ 的 \`{{milestone_tag}}\` 替换为 \`${targetTag}\` 并 commit`, | ||
| 791 | + microStepContract(), | ||
| 792 | + '', | ||
| 793 | + `1. Edit \`${ROOT}/${reportPath}\`:把字面量 \`{{milestone_tag}}\` 替换为 \`${targetTag}\`(精确替换;如多处出现就全部替换)。`, | ||
| 794 | + `2. \`git -C ${ROOT} add ${reportPath}\`;3. \`git -C ${ROOT} commit -m "docs(${phaseId}): record ${targetTag} in completion report"\`。`, | ||
| 795 | + '## 输出(ACTION_RESULT_SCHEMA)', | ||
| 796 | + '- 全 OK:`{ "success": true }`;失败:`{ "success": false, "error": "<which step + reason>" }`', | ||
| 797 | + ].join('\n') | ||
| 798 | +} | ||
| 799 | + | ||
| 800 | +// ── 微步骤:cross-module 专用 ── | ||
| 801 | +function collectCrossModuleChangedPromptM(defaultBranch) { | ||
| 802 | + return [ | ||
| 803 | + `# 收集功能分支自 \`${defaultBranch}\` 分叉以来的全部改动文件`, | ||
| 804 | + microStepContract(), | ||
| 805 | + '', | ||
| 806 | + `跑 \`git -C ${ROOT} diff --name-status ${defaultBranch}...HEAD\`(三点 diff)。按行解析每行 \`<status>\\t<path>\`(status 通常为 M/A/D/R/C 等)。`, | ||
| 807 | + '## 输出(CHANGED_FILES_SCHEMA)', | ||
| 808 | + '- `{ "files": [ { "status": "M", "path": "backend/.../X.java" }, ... ] }`', | ||
| 809 | + '- diff 为空 → `{ "files": [] }`', | ||
| 810 | + ].join('\n') | ||
| 811 | +} | ||
| 812 | + | ||
| 813 | +function classifyCrossModulePromptM(moduleId, files) { | ||
| 814 | + const filesText = files.map(f => `- ${f.status} ${f.path}`).join('\n') | ||
| 815 | + return [ | ||
| 816 | + `# 把改动文件分类:哪些落在**非本模块 \`${moduleId}\`** 的目录下`, | ||
| 817 | + microStepContract(), | ||
| 818 | + '', | ||
| 819 | + `本模块目录归属请以 \`${ROOT}/docs/09-项目目录结构.md\` 与 \`${ROOT}/docs/08-模块任务管理.md § 二\` 中本模块 bullet 的 \`路径:\` 字段为准。Read 这两份文档以建立"路径 → 模块"映射。`, | ||
| 820 | + '', | ||
| 821 | + '## 改动文件清单', | ||
| 822 | + filesText, | ||
| 823 | + '', | ||
| 824 | + '## 判定规则', | ||
| 825 | + `- 落在本模块路径(\`${moduleId}\`)下 → **不算**跨模块。`, | ||
| 826 | + '- 落在其它模块路径下 → 算跨模块,给出该文件归属的目标模块 id。', | ||
| 827 | + '- 落在共享根(如 `docs/`、`scripts/`、`sql/migrations/`、`README.md` 等)→ **不算**跨模块。', | ||
| 828 | + '', | ||
| 829 | + '## 输出(CROSS_CLASSIFY_SCHEMA)', | ||
| 830 | + '- `{ "crossModule": [ { "file": "...", "targetModule": "module_x", "reason": "<本模块哪个 REQ-XXX-NNN 迫使改它,1 句>", "impact": "<目标模块哪些 API/行为/调用方/测试受影响,1-3 句>" }, ... ] }`', | ||
| 831 | + '- 无跨模块改动:`{ "crossModule": [] }`', | ||
| 832 | + '- **不要留 `TBD(CC 补)`**:本步骤就是补齐的唯一时机;推不出原因 / 影响 → 整步失败(schema 失败即可,调用方会 halt)。', | ||
| 833 | + ].join('\n') | ||
| 834 | +} | ||
| 835 | + | ||
| 836 | +// dedup-and-rewrite 不再 append:resume / 多次跑同一模块时,append 会产生重复行污染 § ⑦。 | ||
| 837 | +// 改为整体重写:读现有行 → 与本次 items 合并 → 按 (file, targetModule) dedup(本次 items 覆盖旧值) | ||
| 838 | +// → 按 (targetModule, file) 排序 → 整表重写。commit 前用 `git diff --quiet` 判定,无变更则跳过 commit。 | ||
| 839 | +function writeCrossModuleLogPromptM(moduleId, items) { | ||
| 840 | + const newRowsJson = JSON.stringify(items, null, 2) | ||
| 841 | + return [ | ||
| 842 | + `# 把跨模块改动以 dedup-and-rewrite 方式写入 \`docs/superpowers/module-reports/${moduleId}-cross-module.md\``, | ||
| 843 | + microStepContract(), | ||
| 844 | + '', | ||
| 845 | + `目标文件(项目根相对):\`docs/superpowers/module-reports/${moduleId}-cross-module.md\`。`, | ||
| 334 | '', | 846 | '', |
| 335 | '## 流程', | 847 | '## 流程', |
| 336 | - `- 用 \`git -C ${ROOT} diff --name-status <默认分支 main/master>...HEAD\`(三点 diff,区间 = 本功能分支 \`module-${id}\` 自默认分支分叉以来的全部改动)找出改动文件,判定哪些落在**其它模块**的目录下(按 docs/09 目录归属)。`, | ||
| 337 | - `- 写 / 更新 \`${ROOT}/docs/superpowers/module-reports/${id}-cross-module.md\`,每行列:时间戳(你自身上下文解析当天,脚本不传)/ 目标模块 / 文件 / 改动摘要 / **原因**(本模块哪个 REQ 迫使改它)/ **影响评估**(目标模块哪些 API / 行为 / 调用方受影响、现有测试是否仍有效、是否需新测试,1-3 句)。`, | ||
| 338 | - '- 无跨模块改动 → 输出 `cross-module-log: 无跨模块改动,跳过`,不创建文件。', | ||
| 339 | - '- **不要留 `TBD(CC 补)`**:本步骤就是补齐的唯一时机;推不出原因/影响 → 按硬约束写阻塞点并失败。', | 848 | + `1. **读现有行**:如果文件存在,用 Read 取出表格内已有的数据行(跳过表头与分隔行)。把每行解析为 \`{ file, targetModule, reason, impact }\`,得到 \`existingRows\`。文件不存在 → \`existingRows = []\`。`, |
| 849 | + '2. **合并 + dedup**:把"本次新增行 JSON"中的项加入 `existingRows`,按 `(file + "\\u0001" + targetModule)` 作为 dedup key——**本次新增项覆盖旧项**(同一 file × targetModule 的最新原因 / 影响为准)。', | ||
| 850 | + '3. **排序**:按 `(targetModule, file)` 字典序升序。', | ||
| 851 | + '4. **整体重写**:用 Write 把整个文件重写为:', | ||
| 852 | + ' ```', | ||
| 853 | + ' # 跨模块改动日志', | ||
| 854 | + ' ', | ||
| 855 | + ' | 文件 | 目标模块 | 原因 | 影响 |', | ||
| 856 | + ' |---|---|---|---|', | ||
| 857 | + ' <已排序的全部行>', | ||
| 858 | + ' ```', | ||
| 859 | + `5. **空变更跳过 commit**:跑 \`git -C ${ROOT} diff --quiet -- docs/superpowers/module-reports/${moduleId}-cross-module.md\`。`, | ||
| 860 | + ' - exit_code = 0(无变更)→ 不要 commit,直接返回 `{ "success": true, "detail": "no-diff-skip-commit" }`。', | ||
| 861 | + ` - exit_code != 0(有变更)→ \`git add\` + \`git commit -m "chore(${moduleId}): record cross-module log"\`。`, | ||
| 862 | + '', | ||
| 863 | + '## 本次新增行(JSON,作为合并输入)', | ||
| 864 | + '```json', | ||
| 865 | + newRowsJson, | ||
| 866 | + '```', | ||
| 340 | '', | 867 | '', |
| 341 | - '## 结束', | ||
| 342 | - `- 输出一行 \`cross-module-log: 模块 ${id} 更新 N 行 / 跳过\`。`, | 868 | + '## 输出(ACTION_RESULT_SCHEMA)', |
| 869 | + '- 写成功且有/无 commit:`{ "success": true, "detail": "<written|no-diff-skip-commit>" }`', | ||
| 870 | + '- 任一步失败:`{ "success": false, "error": "<step + reason>" }`', | ||
| 343 | ].join('\n') | 871 | ].join('\n') |
| 344 | } | 872 | } |
| 345 | 873 | ||
| @@ -347,16 +875,17 @@ function crossModulePrompt(module) { | @@ -347,16 +875,17 @@ function crossModulePrompt(module) { | ||
| 347 | function reportPrompt(module) { | 875 | function reportPrompt(module) { |
| 348 | const id = module?.id ?? '<module>' | 876 | const id = module?.id ?? '<module>' |
| 349 | const fe = id === 'frontend-phase' | 877 | const fe = id === 'frontend-phase' |
| 878 | + const phaseId = fe ? 'frontend-phase' : id | ||
| 350 | return [ | 879 | return [ |
| 351 | `# module-report — ${fe ? '前端阶段' : `模块 ${id}`} 12 节完成报告`, | 880 | `# module-report — ${fe ? '前端阶段' : `模块 ${id}`} 12 节完成报告`, |
| 352 | '', | 881 | '', |
| 353 | - commonContract(fe ? 'frontend' : 'backend'), | 882 | + featureStageContract(fe ? 'frontend' : 'backend'), |
| 354 | '', | 883 | '', |
| 355 | '## 目标', | 884 | '## 目标', |
| 356 | `test-gate 绿后渲染标准化 **12 节**完成报告,commit 到当前分支(供 milestone 标记)。**只读 git 摘要,不读 diff 正文进上下文。**`, | 885 | `test-gate 绿后渲染标准化 **12 节**完成报告,commit 到当前分支(供 milestone 标记)。**只读 git 摘要,不读 diff 正文进上下文。**`, |
| 357 | '', | 886 | '', |
| 358 | '## 前置', | 887 | '## 前置', |
| 359 | - `- 验证上游 test-gate 已绿:读 \`${ROOT}/docs/superpowers/module-reports/${fe ? 'frontend-phase' : `${id}`}-test-gate.md\`;红则停。`, | 888 | + `- 验证上游 test-gate 绿:Glob \`${ROOT}/docs/superpowers/module-reports/${phaseId}-test-gate-r*.md\`,**按 attempt 数字升序**读取每一份。**最后一份必须 green**;只要最后一份 red 立即 halt。中间存在 red→green 切换 = flake,需在 § ⑤ 标注。`, |
| 360 | '', | 889 | '', |
| 361 | '## 收集输入(取摘要而非正文)', | 890 | '## 收集输入(取摘要而非正文)', |
| 362 | fe | 891 | fe |
| @@ -365,14 +894,14 @@ function reportPrompt(module) { | @@ -365,14 +894,14 @@ function reportPrompt(module) { | ||
| 365 | `- § ② "FE 完成清单":扫 \`${ROOT}/docs/superpowers/{specs,plans,reviews}/<日期>-FE-*.md\`,按 FE-NN 顺序列出。`, | 894 | `- § ② "FE 完成清单":扫 \`${ROOT}/docs/superpowers/{specs,plans,reviews}/<日期>-FE-*.md\`,按 FE-NN 顺序列出。`, |
| 366 | `- § ③ 文件变更:\`git -C ${ROOT} diff --stat <默认分支 main/master>...HEAD\`(三点 diff,区间 = 功能分支 \`frontend-phase\` 自默认分支分叉以来的全部改动)。`, | 895 | `- § ③ 文件变更:\`git -C ${ROOT} diff --stat <默认分支 main/master>...HEAD\`(三点 diff,区间 = 功能分支 \`frontend-phase\` 自默认分支分叉以来的全部改动)。`, |
| 367 | '- § ④ 数据库使用表 / § ⑥ Migration / § ⑦ 跨模块:填 `N/A(前端阶段)`。', | 896 | '- § ④ 数据库使用表 / § ⑥ Migration / § ⑦ 跨模块:填 `N/A(前端阶段)`。', |
| 368 | - `- § ⑤:读 \`${ROOT}/docs/superpowers/module-reports/frontend-phase-test-gate.md\`。`, | 897 | + `- § ⑤:把 \`${ROOT}/docs/superpowers/module-reports/frontend-phase-test-gate-r*.md\` 全部(按 attempt 排序)摘要汇总。若 attempt 数 > 1 且首次 red 末次 green → 在 § ⑤ 顶部明确标注 \`flake-detected: r1 red, r${'<最后一次>'} green\`,并附首次失败用例与最终绿色记录链接。`, |
| 369 | '- § ⑧ 偏离清单:额外审查"实际渲染 DOM 与各 FE 关联原型主结构的差异",逐 FE 列出。', | 898 | '- § ⑧ 偏离清单:额外审查"实际渲染 DOM 与各 FE 关联原型主结构的差异",逐 FE 列出。', |
| 370 | '- § ⑪ 下一模块预览:填"上线 / 部署后续步骤"。', | 899 | '- § ⑪ 下一模块预览:填"上线 / 部署后续步骤"。', |
| 371 | ].join('\n') | 900 | ].join('\n') |
| 372 | : [ | 901 | : [ |
| 373 | `- § ③ 文件变更:\`git -C ${ROOT} diff --stat <默认分支 main/master>...HEAD\` / \`--name-status\` / \`git log <默认分支>..HEAD --oneline\`(区间 = 功能分支 \`module-${id}\` 自默认分支分叉以来的全部改动)。`, | 902 | `- § ③ 文件变更:\`git -C ${ROOT} diff --stat <默认分支 main/master>...HEAD\` / \`--name-status\` / \`git log <默认分支>..HEAD --oneline\`(区间 = 功能分支 \`module-${id}\` 自默认分支分叉以来的全部改动)。`, |
| 374 | `- § ② / § ⑨:读 \`${ROOT}/docs/superpowers/{specs,plans,reviews}/<日期>-<本模块 REQ>.md\`。`, | 903 | `- § ② / § ⑨:读 \`${ROOT}/docs/superpowers/{specs,plans,reviews}/<日期>-<本模块 REQ>.md\`。`, |
| 375 | - `- § ⑤:读 \`${ROOT}/docs/superpowers/module-reports/${id}-test-gate.md\`。`, | 904 | + `- § ⑤:把 \`${ROOT}/docs/superpowers/module-reports/${id}-test-gate-r*.md\` 全部(按 attempt 排序)摘要汇总。若 attempt 数 > 1 且首次 red 末次 green → 在 § ⑤ 顶部明确标注 \`flake-detected: r1 red, r${'<最后一次>'} green\`,并附首次失败用例与最终绿色记录链接。`, |
| 376 | `- § ⑥ Migration:\`git -C ${ROOT} diff --name-only --diff-filter=A -- 'sql/migrations/V*.sql'\` 列新增,每个读第一行作说明。`, | 905 | `- § ⑥ Migration:\`git -C ${ROOT} diff --name-only --diff-filter=A -- 'sql/migrations/V*.sql'\` 列新增,每个读第一行作说明。`, |
| 377 | `- § ⑦ 跨模块改动:读 \`${ROOT}/docs/superpowers/module-reports/${id}-cross-module.md\`(如存在;其中不应再有 \`TBD(CC 补)\`,上一步 cross-module-log 已补齐)。`, | 906 | `- § ⑦ 跨模块改动:读 \`${ROOT}/docs/superpowers/module-reports/${id}-cross-module.md\`(如存在;其中不应再有 \`TBD(CC 补)\`,上一步 cross-module-log 已补齐)。`, |
| 378 | '- § ④ 读写的表:grep 定位涉 SQL 文件后按需读片段,**不全量读 docs/03**。', | 907 | '- § ④ 读写的表:grep 定位涉 SQL 文件后按需读片段,**不全量读 docs/03**。', |
| @@ -380,66 +909,132 @@ function reportPrompt(module) { | @@ -380,66 +909,132 @@ function reportPrompt(module) { | ||
| 380 | '', | 909 | '', |
| 381 | '## 渲染 + 验证 + commit', | 910 | '## 渲染 + 验证 + commit', |
| 382 | '- 渲染 12 节。硬验证:§ ⑧ 必须列举所有偏离(无则写"无偏离")。', | 911 | '- 渲染 12 节。硬验证:§ ⑧ 必须列举所有偏离(无则写"无偏离")。', |
| 383 | - `- 写入 \`${ROOT}/docs/superpowers/module-reports/<当天日期 YYYY-MM-DD>-${fe ? 'frontend-phase' : `${id}`}.md\`,连同跨模块日志(如存在)一起 commit 到当前分支(milestone 的 worktree-clean 前置依赖此 commit)。`, | 912 | + `- 写入 \`docs/superpowers/module-reports/<当天日期 YYYY-MM-DD>-${phaseId}.md\`(项目根相对;resume 时复用已存在的 \`*-${phaseId}.md\` 最新日期前缀,不要起新文件),连同跨模块日志(如存在)一起 commit 到当前分支(milestone 的 worktree-clean 前置依赖此 commit)。`, |
| 384 | '', | 913 | '', |
| 385 | - '## 结束', | ||
| 386 | - `- 输出一行 \`module-report: ${fe ? 'frontend-phase' : id} → <report path>\`。`, | 914 | + '## 输出(必须符合下发的 STAGE_RESULT JSON schema)', |
| 915 | + `- 成功:\`{ "status": "ok", "artifactPath": "docs/superpowers/module-reports/YYYY-MM-DD-${phaseId}.md", "summary": "<1-2 句中文摘要:测试是否 flake / 主要变更 / 是否有偏离>" }\`。`, | ||
| 916 | + '- 失败:`{ "status": "halt", "reason": "<阻塞点(如最后一次 test-gate red / 跨模块日志缺失)>" }`。', | ||
| 387 | ].join('\n') | 917 | ].join('\n') |
| 388 | } | 918 | } |
| 389 | 919 | ||
| 390 | -// ---- 里程碑:本地 merge --no-ff + tag + 回写 docs/08(原 milestone-tag,单 stage 内幂等)---- | ||
| 391 | -function milestonePrompt(module) { | 920 | +// ---- runBranchSetup:原 branchSetupPrompt 的散文流程 → JS 编排 + 微步骤 agent ---- |
| 921 | +// 幂等:分支已存在则 checkout,否则从默认分支新建。条件分支由 JS 判定,子代理只负责执行单一动作。 | ||
| 922 | +async function runBranchSetup(module) { | ||
| 392 | const id = module?.id ?? '<module>' | 923 | const id = module?.id ?? '<module>' |
| 393 | const fe = id === 'frontend-phase' | 924 | const fe = id === 'frontend-phase' |
| 394 | - const phaseId = fe ? 'frontend-phase' : id | ||
| 395 | - return [ | ||
| 396 | - `# milestone-tag — ${fe ? '前端阶段' : `模块 ${id}`} 本地集成 + 打里程碑(幂等)`, | ||
| 397 | - '', | ||
| 398 | - commonContract(fe ? 'frontend' : 'backend'), | ||
| 399 | - '', | ||
| 400 | - '## 目标', | ||
| 401 | - `把当前分支(${fe ? '`frontend-phase`' : `\`module-${id}\``})本地合并进默认分支并打 \`milestone/${phaseId}\` tag,把 tag 名回写 docs/08 + 报告 § ⑫。**全程无人工介入**;本 stage 内**重入幂等**(先写 docs/08,再打 tag,已存在则跳过)。`, | ||
| 402 | - '', | ||
| 403 | - '## 流程(顺序执行,任一硬错误 → 停下打印诊断,不自动 stash / 覆盖 / --abort)', | ||
| 404 | - '1. **验证 worktree 干净**:`git -C ' + ROOT + ' status --porcelain` 非空 → 失败并打印 dirty 文件清单(检查 test-gate / module-report 是否都已 commit)。', | ||
| 405 | - `2. **探测默认分支**:用 \`git -C ${ROOT} rev-parse --verify\` 依次试本地 \`main\` / \`master\`,取第一个存在的为 \`default_branch\`;都不存在 → 失败。`, | ||
| 406 | - `3. **本地集成**:\`git -C ${ROOT} checkout <default_branch>\` 后 \`git -C ${ROOT} merge --no-ff ${fe ? 'frontend-phase' : `module-${id}`} -m "merge(${phaseId}): integrate ${fe ? 'frontend-phase' : `module-${id}`}"\`。合并冲突 → 失败并打印冲突文件清单(引导人工解决后重跑 coding-start)。`, | ||
| 407 | - `4. **回写 docs/08 + commit**:在 default_branch 上 Edit \`${ROOT}/docs/08-模块任务管理.md\`:${fe | ||
| 408 | - ? '§ 三 `- 整体里程碑: —` 改为 `- 整体里程碑: milestone/frontend-phase`' | ||
| 409 | - : `§ 二 该模块 \` - 里程碑: —\` 改为 \` - 里程碑: milestone/${id}\``};commit \`chore(${phaseId}): record milestone/${phaseId} in docs/08\`。`, | ||
| 410 | - `5. **打 annotated tag**(幂等):\`git -C ${ROOT} tag -a milestone/${phaseId} -m "milestone(${phaseId}): ${fe ? '前端' : '后端'}阶段完成"\`;tag 已存在则跳过。`, | ||
| 411 | - `6. **追加 tag 到报告 § ⑫**:Edit 当天报告 \`${ROOT}/docs/superpowers/module-reports/<日期>-${phaseId}.md\` 的 § ⑫,把 \`{{milestone_tag}}\` 替换为 \`milestone/${phaseId}\`(已替换则跳过);commit \`docs(${phaseId}): record milestone/${phaseId} in completion report\`。`, | ||
| 412 | - '', | ||
| 413 | - '## 结束', | ||
| 414 | - `- 输出一行 \`milestone-tag: ${phaseId} → milestone/${phaseId}\`。不要在本 stage 内回调 coding-start——推进下一模块由上层 Workflow 的循环负责。`, | ||
| 415 | - ].join('\n') | 925 | + const branch = fe ? 'frontend-phase' : `module-${id}` |
| 926 | + const lbl = (k) => `branch:${k}:${id}` | ||
| 927 | + | ||
| 928 | + const def = await agent(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA}) | ||
| 929 | + | ||
| 930 | + const wt = await agent(worktreeCleanPromptM(), {label: lbl('wt'), phase: 'Milestone', schema: WT_SCHEMA}) | ||
| 931 | + if (!wt.clean) throw new Error(`HALT branchSetup-dirty-worktree ${branch}: ${(wt.dirty || []).join(', ')}`) | ||
| 932 | + | ||
| 933 | + const exists = await agent(checkBranchExistsPromptM(branch), {label: lbl('exists?'), phase: 'Milestone', schema: EXISTS_SCHEMA}) | ||
| 934 | + | ||
| 935 | + if (exists.exists) { | ||
| 936 | + const r = await agent(checkoutExistingBranchPromptM(branch), {label: lbl('checkout'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA}) | ||
| 937 | + if (!r.success) throw new Error(`HALT branchSetup-checkout ${branch}: ${r.error || ''}`) | ||
| 938 | + } else { | ||
| 939 | + const r = await agent(createBranchFromPromptM(def.branch, branch), {label: lbl('create'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA}) | ||
| 940 | + if (!r.success) throw new Error(`HALT branchSetup-create ${branch}: ${r.error || ''}`) | ||
| 941 | + } | ||
| 942 | + | ||
| 943 | + const head = await agent(currentBranchPromptM(), {label: lbl('head'), phase: 'Milestone', schema: CURRENT_BRANCH_SCHEMA}) | ||
| 944 | + if (head.branch !== branch) throw new Error(`HALT branchSetup-branch-mismatch ${branch}: HEAD on ${head.branch}`) | ||
| 945 | + | ||
| 946 | + log(`branch-setup: ${id} → ${branch}`) | ||
| 416 | } | 947 | } |
| 417 | 948 | ||
| 418 | -// ---- 功能分支生命周期:进入模块前建/切功能分支(milestone 的 merge 源)---- | ||
| 419 | -// 幂等支持续跑:分支已存在则 checkout 续跑,否则从默认分支开新支。 | ||
| 420 | -function branchSetupPrompt(module) { | 949 | +// ---- runMilestone:原 milestonePrompt 的 6 步散文流程 → JS 编排 ---- |
| 950 | +// 所有"已是目标态则跳过"的条件由 JS 在 read 结果上判定,子代理只执行确定性动作。 | ||
| 951 | +async function runMilestone(module) { | ||
| 421 | const id = module?.id ?? '<module>' | 952 | const id = module?.id ?? '<module>' |
| 422 | const fe = id === 'frontend-phase' | 953 | const fe = id === 'frontend-phase' |
| 954 | + const phaseId = fe ? 'frontend-phase' : id | ||
| 423 | const branch = fe ? 'frontend-phase' : `module-${id}` | 955 | const branch = fe ? 'frontend-phase' : `module-${id}` |
| 424 | - return [ | ||
| 425 | - `# branch-setup — ${fe ? '前端阶段' : `模块 ${id}`} 功能分支准备(幂等)`, | ||
| 426 | - '', | ||
| 427 | - commonContract(fe ? 'frontend' : 'backend'), | ||
| 428 | - '', | ||
| 429 | - '## 目标', | ||
| 430 | - `为本${fe ? '前端阶段' : '模块'}准备功能分支 \`${branch}\`,使后续 featureLoop / testGate / report 的 commit 都落在该分支上;milestone stage 再把它 \`merge --no-ff\` 回默认分支。**本 stage 内重入幂等**。`, | ||
| 431 | - '', | ||
| 432 | - '## 流程(顺序执行,任一硬错误 → 停下打印诊断,不自动 stash / 覆盖)', | ||
| 433 | - `1. **探测默认分支**:用 \`git -C ${ROOT} rev-parse --verify\` 依次试本地 \`main\` / \`master\`,取第一个存在的为 \`default_branch\`;都不存在 → 失败。`, | ||
| 434 | - `2. **校验工作树干净**:\`git -C ${ROOT} status --porcelain\` 非空 → 失败并打印 dirty 文件清单(进入模块前必须是干净状态)。`, | ||
| 435 | - `3. **建 / 切功能分支**(幂等):`, | ||
| 436 | - ` - 若 \`git -C ${ROOT} rev-parse --verify ${branch}\` 成功(分支已存在,续跑场景)→ \`git -C ${ROOT} checkout ${branch}\`。`, | ||
| 437 | - ` - 否则 → \`git -C ${ROOT} checkout <default_branch>\` 后 \`git -C ${ROOT} checkout -b ${branch}\`(从含上一里程碑成果的默认分支开新支)。`, | ||
| 438 | - `4. 确认当前已在 \`${branch}\`:\`git -C ${ROOT} rev-parse --abbrev-ref HEAD\` == \`${branch}\`,否则失败。`, | ||
| 439 | - '', | ||
| 440 | - '## 结束', | ||
| 441 | - `- 输出一行 \`branch-setup: ${id} → ${branch}\`。`, | ||
| 442 | - ].join('\n') | 956 | + const targetTag = `milestone/${phaseId}` |
| 957 | + const lbl = (k) => `milestone:${k}:${phaseId}` | ||
| 958 | + | ||
| 959 | + // step 1: worktree clean precondition | ||
| 960 | + const wt = await agent(worktreeCleanPromptM(), {label: lbl('wt'), phase: 'Milestone', schema: WT_SCHEMA}) | ||
| 961 | + if (!wt.clean) throw new Error(`HALT milestone-dirty-worktree ${phaseId}: ${(wt.dirty || []).join(', ')}`) | ||
| 962 | + | ||
| 963 | + // step 2: detect default branch | ||
| 964 | + const def = await agent(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA}) | ||
| 965 | + | ||
| 966 | + // step 3: merge (idempotent — skip if already an ancestor) | ||
| 967 | + const merged = await agent(checkAlreadyMergedPromptM(branch, def.branch), {label: lbl('merged?'), phase: 'Milestone', schema: ALREADY_MERGED_SCHEMA}) | ||
| 968 | + if (!merged.alreadyMerged) { | ||
| 969 | + const r = await agent(executeMergePromptM(def.branch, branch, phaseId), {label: lbl('merge'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA}) | ||
| 970 | + if (!r.success) throw new Error(`HALT milestone-merge ${phaseId}: ${r.error || ''}${r.detail ? '\n' + r.detail : ''}`) | ||
| 971 | + } | ||
| 972 | + | ||
| 973 | + // step 4: docs/08 field (idempotent — read first, only write if at initial '—') | ||
| 974 | + const field = await agent(readDocs08FieldPromptM(fe, id), {label: lbl('field?'), phase: 'Milestone', schema: FIELD_VALUE_SCHEMA}) | ||
| 975 | + if (!field.found) throw new Error(`HALT milestone-docs08-missing ${phaseId}: 字段不存在(docs/08 ${fe ? '§ 三' : `§ 二 模块 ${id}`})`) | ||
| 976 | + if (field.value === '—') { | ||
| 977 | + const r = await agent(writeDocs08FieldPromptM(fe, id, targetTag, phaseId, field.lineNumber), {label: lbl('field-write'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA}) | ||
| 978 | + if (!r.success) throw new Error(`HALT milestone-docs08-write ${phaseId}: ${r.error || ''}`) | ||
| 979 | + } else if (field.value !== targetTag) { | ||
| 980 | + throw new Error(`HALT milestone-docs08-unexpected ${phaseId}: 字段当前 = ${JSON.stringify(field.value)}(行 ${field.lineNumber || '?'}),期望 '—' 或 '${targetTag}'`) | ||
| 981 | + } | ||
| 982 | + // else: 已是 targetTag → 静默跳过(续跑场景) | ||
| 983 | + | ||
| 984 | + // step 5: report § ⑫ FIRST(关键顺序:tag 必须指向"§ ⑫ 已落地"的 commit,否则 | ||
| 985 | + // `git checkout milestone/<id>` 看到的报告 § ⑫ 仍是 placeholder。原版顺序 tag → § ⑫ 是已知 bug, | ||
| 986 | + // 此处显式倒过来;下面 step 6 的 tag 才会指向新鲜 commit。) | ||
| 987 | + const rpt = await agent(findReportPromptM(phaseId), {label: lbl('report?'), phase: 'Milestone', schema: REPORT_PATH_SCHEMA}) | ||
| 988 | + if (!rpt.found) throw new Error(`HALT milestone-report-missing ${phaseId}: 没有找到匹配 docs/superpowers/module-reports/*-${phaseId}.md 的报告文件`) | ||
| 989 | + if (rpt.currentTagValue === '{{milestone_tag}}') { | ||
| 990 | + const r = await agent(updateReportPromptM(rpt.path, targetTag, phaseId), {label: lbl('report'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA}) | ||
| 991 | + if (!r.success) throw new Error(`HALT milestone-report-update ${phaseId}: ${r.error || ''}`) | ||
| 992 | + } else if (rpt.currentTagValue !== targetTag) { | ||
| 993 | + throw new Error(`HALT milestone-report-unexpected ${phaseId}: ${rpt.path} § ⑫ 当前 = ${JSON.stringify(rpt.currentTagValue)}`) | ||
| 994 | + } | ||
| 995 | + // else: 已是 targetTag → 静默跳过(resume 幂等) | ||
| 996 | + | ||
| 997 | + // step 6: annotated tag (idempotent + stale-tag 自检) | ||
| 998 | + // 注:HEAD 此刻已包含 § ⑫ 更新 commit(或本来就在 targetTag 上),tag 指向新鲜 commit。 | ||
| 999 | + const tag = await agent(checkTagExistsPromptM(targetTag), {label: lbl('tag?'), phase: 'Milestone', schema: EXISTS_SCHEMA}) | ||
| 1000 | + if (tag.exists) { | ||
| 1001 | + // 旧版 bug 残留:tag 可能指向 § ⑫ 仍为占位符的 commit。检查并要求人工 `git tag -f`。 | ||
| 1002 | + const fresh = await agent(checkTagReportFreshPromptM(targetTag, rpt.path), {label: lbl('tag-fresh?'), phase: 'Milestone', schema: TAG_REPORT_FRESHNESS_SCHEMA}) | ||
| 1003 | + if (!fresh.fresh) { | ||
| 1004 | + throw new Error(`HALT milestone-stale-tag ${phaseId}: tag \`${targetTag}\` 指向的 commit 中 ${rpt.path} § ⑫ = ${JSON.stringify(fresh.tagReportValue || '?')},不是 ${targetTag}(旧版顺序 bug 残留)。请人工跑 \`git -C ${ROOT} tag -f ${targetTag}\` 把 tag 重指到 HEAD 后再重跑 coding-start。`) | ||
| 1005 | + } | ||
| 1006 | + } else { | ||
| 1007 | + const r = await agent(createTagPromptM(phaseId, fe), {label: lbl('tag'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA}) | ||
| 1008 | + if (!r.success) throw new Error(`HALT milestone-tag ${phaseId}: ${r.error || ''}`) | ||
| 1009 | + } | ||
| 1010 | + | ||
| 1011 | + log(`milestone: ${phaseId} → ${targetTag}`) | ||
| 1012 | +} | ||
| 1013 | + | ||
| 1014 | +// ---- runCrossModule:原 crossModulePrompt 的"diff → 分类 → 写日志" → JS 编排 ---- | ||
| 1015 | +// diff 和写文件是机械动作;"按 docs/09 路径归属判定哪些是跨模块"需要 LLM 判断,独立成一步。 | ||
| 1016 | +async function runCrossModule(module) { | ||
| 1017 | + const id = module?.id ?? '<module>' | ||
| 1018 | + const lbl = (k) => `xmod:${k}:${id}` | ||
| 1019 | + | ||
| 1020 | + const def = await agent(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA}) | ||
| 1021 | + | ||
| 1022 | + const changed = await agent(collectCrossModuleChangedPromptM(def.branch), {label: lbl('diff'), phase: 'Milestone', schema: CHANGED_FILES_SCHEMA}) | ||
| 1023 | + if (!changed.files.length) { | ||
| 1024 | + log(`cross-module-log: 模块 ${id} 无文件改动,跳过`) | ||
| 1025 | + return | ||
| 1026 | + } | ||
| 1027 | + | ||
| 1028 | + const classified = await agent(classifyCrossModulePromptM(id, changed.files), {label: lbl('classify'), phase: 'Milestone', schema: CROSS_CLASSIFY_SCHEMA}) | ||
| 1029 | + if (!classified.crossModule.length) { | ||
| 1030 | + log(`cross-module-log: 模块 ${id} 无跨模块改动,跳过`) | ||
| 1031 | + return | ||
| 1032 | + } | ||
| 1033 | + | ||
| 1034 | + const r = await agent(writeCrossModuleLogPromptM(id, classified.crossModule), {label: lbl('write'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA}) | ||
| 1035 | + if (!r.success) throw new Error(`HALT crossModule-write ${id}: ${r.error || ''}`) | ||
| 1036 | + | ||
| 1037 | + log(`cross-module-log: 模块 ${id} 更新 ${classified.crossModule.length} 行`) | ||
| 443 | } | 1038 | } |
| 444 | 1039 | ||
| 445 | // ============================================================================ | 1040 | // ============================================================================ |
| @@ -447,34 +1042,120 @@ function branchSetupPrompt(module) { | @@ -447,34 +1042,120 @@ function branchSetupPrompt(module) { | ||
| 447 | // ============================================================================ | 1042 | // ============================================================================ |
| 448 | 1043 | ||
| 449 | // ---- 单功能链(后端 / 前端同构)---- | 1044 | // ---- 单功能链(后端 / 前端同构)---- |
| 1045 | +// **顺序 for-await**(不是 pipeline)。理由: | ||
| 1046 | +// - tdd / fix stage 会编辑共享工作树并 git commit;并发会争 .git/index.lock、撞 migration V<n>。 | ||
| 1047 | +// - pipeline 的"stage throw → item 掉 null、pipeline 永不 reject"语义会吞掉 reviewWithFixLoop / | ||
| 1048 | +// verify / tdd 的 HALT throw,让模块主循环 try/catch 捕获不到,残缺模块照样被推进到 milestone。 | ||
| 1049 | +// 顺序 for-await 让 throw 自然冒泡到主循环 try → catch → break,使 fail-fast 真正生效。 | ||
| 1050 | +// | ||
| 1051 | +// 派生 stage 全部 schema 化:spec/plan/tdd/verify/fix 共用 STAGE_RESULT_SCHEMA, | ||
| 1052 | +// sub-agent 写 `{status:'halt', reason}` 时 JS 立即抛 HALT,让"无法继续"不再混入"成功返回"。 | ||
| 1053 | +// 功能级 dedup 真值 = `req-done/<id>` git tag:featureLoop 入口先 check,存在则 skip(Router 文档/ | ||
| 1054 | +// LLM 自审失误不再导致已 approve 的 REQ 被重新 spec→plan→tdd 污染源码 / 撞 V<n>)。 | ||
| 1055 | +// | ||
| 1056 | +// 语义边界(重要):`req-done/<id>` 表示"该功能在写 tag 时已通过 reviewer approve",**不**表示 | ||
| 1057 | +// "实现自此再未变化"。若 testGate / 后续模块工作中人工或子代理改动了已 approve 功能的代码,重跑 | ||
| 1058 | +// coding-start 时此 dedup 会跳过 spec/plan/tdd/verify/review,**不会**再次审阅这些后期改动。 | ||
| 1059 | +// 这是有意的设计:避免在共享工作树里因为别的模块的 cross-cut 改动反复重跑前面所有 REQ。 | ||
| 1060 | +// 若需要"approve 后改动必须再次走 review"的语义,请在改动前手动删除对应 `req-done/<id>` tag。 | ||
| 450 | async function featureLoop(items, phase) { | 1061 | async function featureLoop(items, phase) { |
| 451 | - return pipeline(items, | ||
| 452 | - (id) => agent(deriveSpecPrompt(id, phase), {label:`spec:${phase}:${id}`, phase: phase==='backend'?'Backend':'Frontend'}), | ||
| 453 | - (spec, id) => agent(planPrompt(id, phase, spec), {label:`plan:${phase}:${id}`, phase: phase==='backend'?'Backend':'Frontend'}), | ||
| 454 | - (plan, id) => agent(tddPrompt(id, phase, plan), {label:`tdd:${phase}:${id}`, phase: phase==='backend'?'Backend':'Frontend'}), | ||
| 455 | - (impl, id) => agent(verifyPrompt(id, phase, impl), {label:`verify:${phase}:${id}`, phase: phase==='backend'?'Backend':'Frontend'}), | ||
| 456 | - (v, id) => reviewWithFixLoop(id, phase, v), | ||
| 457 | - ) | ||
| 458 | -} | ||
| 459 | - | ||
| 460 | -// 有界 5 轮修复;超出 → throw(终止态,非对话框) | ||
| 461 | -// fix 后重新跑 verify(功能复验,verify 内部失败即 throw → halt),再进入下一轮 review, | ||
| 462 | -// 使 fixPrompt 对子代理"上层会重新跑 verify + review"的承诺成真。 | ||
| 463 | -async function reviewWithFixLoop(id, phase, verifyResult) { | ||
| 464 | const grp = phase === 'backend' ? 'Backend' : 'Frontend' | 1062 | const grp = phase === 'backend' ? 'Backend' : 'Frontend' |
| 1063 | + for (const id of items) { | ||
| 1064 | + // 入口 dedup:req-done/<id> 已存在 → 已 approve,整段 skip。 | ||
| 1065 | + const done = await agent(checkReqDoneTagPromptM(id), {label:`donecheck:${phase}:${id}`, phase: grp, schema: EXISTS_SCHEMA}) | ||
| 1066 | + if (done.exists) { log(`featureLoop skip ${phase}:${id} — tag req-done/${id} 已存在`); continue } | ||
| 1067 | + | ||
| 1068 | + const spec = await agent(deriveSpecPrompt(id, phase), {label:`spec:${phase}:${id}`, phase: grp, schema: STAGE_RESULT_SCHEMA}) | ||
| 1069 | + if (spec.status === 'halt') throw new Error(`HALT spec ${phase}:${id}: ${spec.reason || ''}`) | ||
| 1070 | + if (!spec.artifactPath) throw new Error(`HALT spec-no-artifactPath ${phase}:${id}: spec returned ok but no artifactPath`) | ||
| 1071 | + // 日期一致性自校验:spec 文件名首段必须可被解析为 YYYY-MM-DD(dateFromArtifactPath 会抛)。 | ||
| 1072 | + dateFromArtifactPath(spec.artifactPath) | ||
| 1073 | + | ||
| 1074 | + const plan = await agent(planPrompt(id, phase, spec.artifactPath), {label:`plan:${phase}:${id}`, phase: grp, schema: STAGE_RESULT_SCHEMA}) | ||
| 1075 | + if (plan.status === 'halt') throw new Error(`HALT plan ${phase}:${id}: ${plan.reason || ''}`) | ||
| 1076 | + if (!plan.artifactPath) throw new Error(`HALT plan-no-artifactPath ${phase}:${id}`) | ||
| 1077 | + if (dateFromArtifactPath(plan.artifactPath) !== dateFromArtifactPath(spec.artifactPath)) { | ||
| 1078 | + throw new Error(`HALT plan-date-mismatch ${phase}:${id}: plan ${plan.artifactPath} 与 spec ${spec.artifactPath} 日期前缀不一致`) | ||
| 1079 | + } | ||
| 1080 | + | ||
| 1081 | + const impl = await agent(tddPrompt(id, phase, plan.artifactPath), {label:`tdd:${phase}:${id}`, phase: grp, schema: STAGE_RESULT_SCHEMA}) | ||
| 1082 | + if (impl.status === 'halt') throw new Error(`HALT tdd ${phase}:${id}: ${impl.reason || ''}`) | ||
| 1083 | + | ||
| 1084 | + const v0 = await agent(verifyPrompt(id, phase, impl.summary || '', spec.artifactPath, 0), {label:`verify:${phase}:${id}`, phase: grp, schema: STAGE_RESULT_SCHEMA}) | ||
| 1085 | + if (v0.status === 'halt') throw new Error(`HALT verify ${phase}:${id}: ${v0.reason || ''}`) | ||
| 1086 | + | ||
| 1087 | + const reviewResult = await reviewWithFixLoop(id, phase, v0, spec.artifactPath) | ||
| 1088 | + log(`review approved ${phase}:${id} after ${reviewResult.rounds} round(s)`) | ||
| 1089 | + | ||
| 1090 | + // approve 后落地 dedup 真值:req-done/<id> tag。 | ||
| 1091 | + const tagR = await agent(createReqDoneTagPromptM(id, phase), {label:`reqdone:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA}) | ||
| 1092 | + if (!tagR.success) throw new Error(`HALT req-done-tag ${phase}:${id}: ${tagR.error || ''}`) | ||
| 1093 | + } | ||
| 1094 | +} | ||
| 1095 | + | ||
| 1096 | +// 有界 5 轮修复;超出 → throw(终止态,非对话框)。 | ||
| 1097 | +// fix 后再跑 reverify 让 fix-stage 的 commit 有机会被新一轮 verify 看到; | ||
| 1098 | +// verify 内部失败 throw 在顺序 for-await 下会冒泡到模块主循环 try。 | ||
| 1099 | +// approve 后通过独立 micro step 把 docs/08 对应 checkbox flip 为 `[x]`(拆出 reviewer side-effect, | ||
| 1100 | +// 写失败可观测;幂等:已 checked 静默跳过)。 | ||
| 1101 | +async function reviewWithFixLoop(id, phase, verifyResult, specPath) { | ||
| 1102 | + const grp = phase === 'backend' ? 'Backend' : 'Frontend' | ||
| 1103 | + const fe = isFrontend(phase) | ||
| 1104 | + let lastVerify = verifyResult | ||
| 1105 | + let lastIssuesCount = 0 | ||
| 465 | for (let round = 1; round <= 5; round++) { | 1106 | for (let round = 1; round <= 5; round++) { |
| 466 | - const r = await agent(reviewPrompt(id, phase, round), {label:`review:${phase}:${id}:r${round}`, phase: grp, schema: REVIEW_SCHEMA, agentType:'code-reviewer'}) | ||
| 467 | - if (r.verdict === 'approve') return { id, phase, approved:true, rounds:round } | ||
| 468 | - await agent(fixPrompt(id, phase, r.issues), {label:`fix:${phase}:${id}:r${round}`, phase: grp}) | ||
| 469 | - await agent(verifyPrompt(id, phase, `(第 ${round} 轮 fix 后复验)`), {label:`reverify:${phase}:${id}:r${round}`, phase: grp}) | 1107 | + const lastVerifySummary = (lastVerify && (lastVerify.summary || lastVerify.reason)) || '' |
| 1108 | + // 命名碰撞警告:opts.phase = grp('Backend'/'Frontend'/'Milestone')是 harness 的 UI 分组, | ||
| 1109 | + // 与 code-reviewer.md 文档里的"domain phase = backend|frontend"是**同名不同物**。 | ||
| 1110 | + // reviewer agent 从 prompt 正文 `**phase = ...**` 那一行读 domain phase,**不要**读 opts.phase。 | ||
| 1111 | + // 见 agents/code-reviewer.md "Domain phase resolution" 段。 | ||
| 1112 | + const r = await agent( | ||
| 1113 | + reviewPrompt(id, phase, round, lastVerifySummary, specPath), | ||
| 1114 | + {label:`review:${phase}:${id}:r${round}`, phase: grp, schema: REVIEW_SCHEMA, agentType:'code-reviewer'} | ||
| 1115 | + ) | ||
| 1116 | + if (r.verdict === 'approve') { | ||
| 1117 | + // docs/08 checkbox flip(observable side effect,原 reviewer 隐式 Edit → micro step) | ||
| 1118 | + const cb = await agent(readDocs08CheckboxPromptM(fe, id), {label:`cb?:${phase}:${id}`, phase: grp, schema: CHECKBOX_STATE_SCHEMA}) | ||
| 1119 | + if (!cb.found) throw new Error(`HALT docs08-checkbox-missing ${phase}:${id}: docs/08 ${fe?'§ 三':'§ 二'} 中找不到 \`- [ ] ${id} ...\` / \`- [x] ${id} ...\` 行`) | ||
| 1120 | + // 防御:即使 schema 已 require state,再做一次 JS 校验,杜绝"found:true 但 state 缺失/枚举外"静默走 checked 分支。 | ||
| 1121 | + if (cb.state !== 'checked' && cb.state !== 'unchecked') { | ||
| 1122 | + throw new Error(`HALT docs08-checkbox-state-invalid ${phase}:${id}: cb.state = ${JSON.stringify(cb.state)}`) | ||
| 1123 | + } | ||
| 1124 | + if (cb.state === 'unchecked') { | ||
| 1125 | + const wr = await agent(writeDocs08CheckboxPromptM(fe, id, phase), {label:`cb:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA}) | ||
| 1126 | + if (!wr.success) throw new Error(`HALT docs08-checkbox-write ${phase}:${id}: ${wr.error || ''}`) | ||
| 1127 | + } | ||
| 1128 | + // cb.state === 'checked' → 静默跳过(resume 幂等) | ||
| 1129 | + return { id, phase, approved:true, rounds:round } | ||
| 1130 | + } | ||
| 1131 | + // request-changes 必须带 must-fix 清单(结构化对象数组);否则 fix 步无法定位 → 直接 halt 暴露 reviewer 契约违例。 | ||
| 1132 | + if (!Array.isArray(r.issues) || r.issues.length === 0) { | ||
| 1133 | + throw new Error(`HALT review-empty-issues ${phase}:${id} r${round}: reviewer 返回 request-changes 但 issues 为空,无法驱动 fix 步`) | ||
| 1134 | + } | ||
| 1135 | + lastIssuesCount = r.issues.length | ||
| 1136 | + // 每个 issue 必须含 locator(locator 校验由 fix sub-agent 在 git cat-file 阶段再做一次硬把关)。 | ||
| 1137 | + const missingLocator = r.issues.filter(x => !x || typeof x.locator !== 'string' || !x.locator.trim()) | ||
| 1138 | + if (missingLocator.length) { | ||
| 1139 | + throw new Error(`HALT review-issue-no-locator ${phase}:${id} r${round}: ${missingLocator.length} 个 issue 缺 locator,reviewer 契约违例(issue summaries: ${missingLocator.map(x=>x?.summary||'').join(' | ')})`) | ||
| 1140 | + } | ||
| 1141 | + | ||
| 1142 | + const fixR = await agent(fixPrompt(id, phase, r.issues), {label:`fix:${phase}:${id}:r${round}`, phase: grp, schema: STAGE_RESULT_SCHEMA}) | ||
| 1143 | + if (fixR.status === 'halt') throw new Error(`HALT fix ${phase}:${id} r${round}: ${fixR.reason || ''}`) | ||
| 1144 | + | ||
| 1145 | + lastVerify = await agent( | ||
| 1146 | + verifyPrompt(id, phase, `(第 ${round} 轮 fix 后复验,上轮 must-fix: ${r.issues.length} 项)`, specPath, round), | ||
| 1147 | + {label:`reverify:${phase}:${id}:r${round}`, phase: grp, schema: STAGE_RESULT_SCHEMA} | ||
| 1148 | + ) | ||
| 1149 | + if (lastVerify.status === 'halt') throw new Error(`HALT reverify ${phase}:${id} r${round}: ${lastVerify.reason || ''}`) | ||
| 470 | } | 1150 | } |
| 471 | - throw new Error(`HALT review-unresolved ${phase}:${id} after 5 rounds`) | 1151 | + throw new Error(`HALT review-unresolved ${phase}:${id}: 5 轮 review 仍未 approve(最后一次 reverify ${lastVerify?.status || '?'},最后一轮 must-fix ${lastIssuesCount} 项)`) |
| 472 | } | 1152 | } |
| 473 | 1153 | ||
| 1154 | +// flake 重试 1 次:attempt=2 写到独立证据文件 `<id>-test-gate-r2.md`,不覆盖 r1 的 red 证据(report § ⑤ 用得到)。 | ||
| 474 | async function testGate(module, phase) { | 1155 | async function testGate(module, phase) { |
| 475 | - let g = await agent(gatePrompt(module, phase), {label:`gate:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA}) | 1156 | + let g = await agent(gatePrompt(module, phase, 1), {label:`gate:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA}) |
| 476 | if (g.status === 'red') { // 自动重试 1 次(防 flaky) | 1157 | if (g.status === 'red') { // 自动重试 1 次(防 flaky) |
| 477 | - g = await agent(gatePrompt(module, phase) + '\n(retry once for flakiness)', {label:`gate-retry:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA}) | 1158 | + g = await agent(gatePrompt(module, phase, 2), {label:`gate-retry:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA}) |
| 478 | } | 1159 | } |
| 479 | if (g.status === 'red') throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${(g.failures||[]).join('; ')}`) | 1160 | if (g.status === 'red') throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${(g.failures||[]).join('; ')}`) |
| 480 | return g | 1161 | return g |
| @@ -482,34 +1163,80 @@ async function testGate(module, phase) { | @@ -482,34 +1163,80 @@ async function testGate(module, phase) { | ||
| 482 | 1163 | ||
| 483 | phase('Router') | 1164 | phase('Router') |
| 484 | const routed = await agent(routerPrompt(ROOT), {label:'router', phase:'Router', schema: ROUTER_SCHEMA}) | 1165 | const routed = await agent(routerPrompt(ROOT), {label:'router', phase:'Router', schema: ROUTER_SCHEMA}) |
| 1166 | + | ||
| 1167 | +// Router 语义运行时断言:后端模块 feItems 必空、frontend-phase 聚合模块 reqs 必空。 | ||
| 1168 | +// schema 用 additionalProperties:false 但不强制互斥;这里把契约违例在最早处暴露而不是让错配 phase 静默跑下去。 | ||
| 1169 | +// | ||
| 1170 | +// 同时硬约束所有 id 形状为 /^[A-Za-z0-9_-]+$/:下游 micro step prompt 大量把 id / branch / phaseId | ||
| 1171 | +// 模板进 `git ... ${id}` shell 命令字符串,未单引号包裹也未做字符校验。LLM 返回畸形 id(含 ;、`、 | ||
| 1172 | +// $()、空格等)会改变子代理执行的命令;这里在 Router 出口一次性把关,让 fail-fast 比 shell 注入早。 | ||
| 1173 | +const ID_PATTERN = /^[A-Za-z0-9_-]+$/ | ||
| 1174 | +function assertSafeId(kind, value) { | ||
| 1175 | + if (typeof value !== 'string' || !ID_PATTERN.test(value)) { | ||
| 1176 | + throw new Error(`HALT router-invalid-${kind}: ${JSON.stringify(value)}(必须匹配 /^[A-Za-z0-9_-]+$/,用于安全地拼入 git 命令)`) | ||
| 1177 | + } | ||
| 1178 | +} | ||
| 1179 | +for (const m of routed.modules) { | ||
| 1180 | + assertSafeId('module-id', m.id) | ||
| 1181 | + for (const r of m.reqs || []) assertSafeId('req-id', r) | ||
| 1182 | + for (const f of m.feItems || []) assertSafeId('fe-id', f) | ||
| 1183 | + const isFE = m.id === 'frontend-phase' | ||
| 1184 | + if (isFE && Array.isArray(m.reqs) && m.reqs.length) { | ||
| 1185 | + throw new Error(`HALT router-violation: frontend-phase 聚合模块的 reqs 必须为空,实测含 ${m.reqs.length} 项 (${m.reqs.join(',')})`) | ||
| 1186 | + } | ||
| 1187 | + if (!isFE && Array.isArray(m.feItems) && m.feItems.length) { | ||
| 1188 | + throw new Error(`HALT router-violation: 后端模块 ${m.id} 的 feItems 必须为空(前端只在 frontend-phase 聚合),实测含 ${m.feItems.length} 项 (${m.feItems.join(',')})`) | ||
| 1189 | + } | ||
| 1190 | +} | ||
| 1191 | + | ||
| 485 | const todo = routed.modules.filter(m => !m.done) | 1192 | const todo = routed.modules.filter(m => !m.done) |
| 486 | log(`coding: ${todo.length}/${routed.modules.length} modules to run`) | 1193 | log(`coding: ${todo.length}/${routed.modules.length} modules to run`) |
| 487 | 1194 | ||
| 488 | const results = [] | 1195 | const results = [] |
| 489 | -for (const module of todo) { | 1196 | +let haltedAtIdx = -1 |
| 1197 | +for (const [idx, module] of todo.entries()) { | ||
| 490 | try { | 1198 | try { |
| 491 | - // C1:进入模块前建/切功能分支(milestone 的 merge 源)。 | ||
| 492 | - await agent(branchSetupPrompt(module), {label:`branch:${module.id}`, phase:'Milestone'}) | 1199 | + // C1:进入模块前建/切功能分支(milestone 的 merge 源)。runBranchSetup 把"探测默认分支 / |
| 1200 | + // 校验工作树 / 切或新建分支 / 校验 HEAD"分解为 4-5 个微 agent,分支判定全在 JS 里。 | ||
| 1201 | + phase('Milestone') | ||
| 1202 | + await runBranchSetup(module) | ||
| 493 | if (module.reqs.length) { // 后端段(frontend-phase 模块 reqs 为空 → 跳过) | 1203 | if (module.reqs.length) { // 后端段(frontend-phase 模块 reqs 为空 → 跳过) |
| 1204 | + phase('Backend') | ||
| 494 | await featureLoop(module.reqs, 'backend') | 1205 | await featureLoop(module.reqs, 'backend') |
| 1206 | + phase('Gate') | ||
| 495 | await testGate(module, 'backend') | 1207 | await testGate(module, 'backend') |
| 496 | - await agent(crossModulePrompt(module), {label:`xmod:${module.id}`, phase:'Milestone'}) // 替代被删 hook | 1208 | + phase('Milestone') |
| 1209 | + await runCrossModule(module) // 替代被删 hook,JS 编排:diff → 分类 → 写日志 | ||
| 497 | } | 1210 | } |
| 498 | if (module.feItems.length) { // 前端段(仅末尾 frontend-phase 聚合模块) | 1211 | if (module.feItems.length) { // 前端段(仅末尾 frontend-phase 聚合模块) |
| 1212 | + phase('Frontend') | ||
| 499 | await featureLoop(module.feItems, 'frontend') | 1213 | await featureLoop(module.feItems, 'frontend') |
| 1214 | + phase('Gate') | ||
| 500 | await testGate(module, 'frontend') | 1215 | await testGate(module, 'frontend') |
| 501 | } | 1216 | } |
| 502 | - await agent(reportPrompt(module), {label:`report:${module.id}`, phase:'Milestone'}) | ||
| 503 | - await agent(milestonePrompt(module), {label:`milestone:${module.id}`, phase:'Milestone'}) // git merge --no-ff + tag + 更新 docs/08(单 stage 内幂等) | 1217 | + phase('Milestone') |
| 1218 | + const rep = await agent(reportPrompt(module), {label:`report:${module.id}`, phase:'Milestone', schema: STAGE_RESULT_SCHEMA}) | ||
| 1219 | + if (rep.status === 'halt') throw new Error(`HALT report ${module.id}: ${rep.reason || ''}`) | ||
| 1220 | + // runMilestone:原 6 步散文(worktree / 默认分支 / merge / docs/08 / tag / report)由 JS 编排, | ||
| 1221 | + // 每个"已是目标态则跳过"的条件由 JS 在 read 微 agent 的结构化返回上判定,跨重入幂等。 | ||
| 1222 | + await runMilestone(module) | ||
| 504 | results.push({ module: module.id, status:'done' }) | 1223 | results.push({ module: module.id, status:'done' }) |
| 505 | } catch (e) { | 1224 | } catch (e) { |
| 506 | results.push({ module: module.id, status:'halted', reason: String(e.message||e) }) | 1225 | results.push({ module: module.id, status:'halted', reason: String(e.message||e) }) |
| 1226 | + haltedAtIdx = idx | ||
| 507 | break // 整阶段 fail-fast:halt 后停,等人工修复后重跑 coding-start | 1227 | break // 整阶段 fail-fast:halt 后停,等人工修复后重跑 coding-start |
| 508 | } | 1228 | } |
| 509 | } | 1229 | } |
| 510 | 1230 | ||
| 511 | -// Workflow 结果:跑完 / halt 的逐模块摘要。 | ||
| 512 | -// 注:Workflow 运行时在异步包装上下文中执行脚本体,顶层 `return` 即为结果(与 `export const meta` | ||
| 513 | -// 并存)。这是 Workflow 脚本的契约,**不是**独立 ESM 模块——因此 `node --check` 会报 Illegal | ||
| 514 | -// return statement,但运行时正确(不要据 node --check 改成 export default,那会让结果丢失)。 | ||
| 515 | -return { results } | 1231 | +// pending:halt 后被跳过的剩余模块(M5)。caller / coding-start 可据此告知用户"修好后还有哪些待跑", |
| 1232 | +// 而不是仅看到一个 halted 模块就误以为只剩一个。 | ||
| 1233 | +const pending = haltedAtIdx >= 0 | ||
| 1234 | + ? todo.slice(haltedAtIdx + 1).map(m => ({ module: m.id, status: 'pending' })) | ||
| 1235 | + : [] | ||
| 1236 | + | ||
| 1237 | +// Workflow 结果:跑完 / halt 的逐模块摘要 + halt 后未跑的 pending 模块列表。 | ||
| 1238 | +// 注:顶层 `return` 在 CommonJS 中合法,但在 ESM 中非法。本脚本被 Workflow 运行时以 ESM 方式 | ||
| 1239 | +// (dynamic import)加载时,运行时会把脚本体包进 async function 再执行,于是顶层 `return` 实际成为 | ||
| 1240 | +// Workflow 的结果通道(与 `export const meta` 并存)。**不要**改成 `export default {...}` —— 那 | ||
| 1241 | +// 会破坏返回值契约,Workflow 拿不到 results / pending。 | ||
| 1242 | +return { results, pending } |