Commit d875806a8cf3792a441616090c29c4e128d6d914

Authored by zichun
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)
.claude-plugin/plugin.json
1 1 {
2 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 4 "version": "0.2.0",
5 5 "skills": ["./skills"]
6 6 }
... ...
README.md
... ... @@ -2,12 +2,12 @@
2 2  
3 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 12 A0 project-init → A1 scope-lock(结构化 REQ 卡片 + secrets/commands 锁)
13 13
... ... @@ -33,12 +33,20 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。
33 33  
34 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 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 51 子代理无法弹窗 → 缺值即写阻塞点并 halt(终止态,非对话框);fail-fast 后等人工修复重跑 coding-start
44 52 ```
... ... @@ -73,10 +81,10 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。
73 81  
74 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 89 `docs/08 § 二` 每后端模块占一行 bullet,`§ 三` 是前端阶段整体段,完成信号统一由本地 `git tag -l 'milestone/<id>'` 判定。
82 90  
... ... @@ -121,7 +129,7 @@ erp-workflow-plugin/
121 129  
122 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 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 135 ### Plan 阶段 A skill(7 个 = A0~A6,均由 `plan-start` 按 docs/08 § 一 顺序派发)
... ... @@ -129,7 +137,7 @@ erp-workflow-plugin/
129 137 | # | Skill | 作用 | 流程中谁调用 |
130 138 |---|---|---|---|
131 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 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 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 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:&quot;…/workf
145 153
146 154 ▼ coding.mjs(各 stage 派 agent 子代理执行,无弹窗)
147 155 Router ── 解析 docs/08 § 二/§ 三 + git tag → 结构化模块清单(schema 校验)
148   - │ docs/08 字段与 git tag 不一致 → halt
  156 + │ docs/08 字段与 git tag 不一致 → halt;运行时断言 reqs/feItems 互斥
149 157
150 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 163 │ spec → plan → tdd → verify → reviewWithFixLoop(有界 5 轮:
154 164 │ review(code-reviewer) approve → 过;request-changes → fix → 重审;
155   - │ 第 5 轮仍未过 → throw HALT
  165 + │ 第 5 轮仍未过 → throw HALT,由 for-await 冒泡到主循环
156 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 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 197 ## Agent 清单(1 个)
171 198  
... ... @@ -216,6 +243,6 @@ coding-start(skill)校验 Plan 终结闸 → Workflow({scriptPath:&quot;…/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 248 最关键的 1 条:"**所有测试与验证派发到全新子会话执行,主会话只接收结构化结论**"——避免主会话被测试输出污染,并让测试结果作为独立证据存档。
... ...
agents/code-reviewer.md
1 1 ---
2 2 name: code-reviewer
3 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 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 26 ## Output contract (required)
11 27  
... ... @@ -13,9 +29,13 @@ Return a structured result matching the workflow&#39;s `REVIEW_SCHEMA`:
13 29  
14 30 - `verdict`: `approve` or `request-changes`
15 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 40 ## Decision discipline (avoid non-deterministic loops)
21 41  
... ...
lib/merge-gitignore.mjs
1 1 // lib/merge-gitignore.mjs
  2 +// 合并两份 .gitignore,对**规则行**逐行判重并集合并;注释行透传(相邻去重),空行丢弃(节由注释头承担)。
  3 +// 之所以不对注释去重:两段分组各自的同名注释头(如多次出现的 `# generated`)是分节标题,
  4 +// 全局去重会把第二段的标题吞掉,让 add 文件的规则被并入第一段的注释下、破坏分节语义。
2 5 export function mergeGitignore(baseText, addText) {
3   - const seen = new Set()
  6 + const seenRules = new Set()
4 7 const out = []
5 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 20 out.push(line)
11 21 }
12 22 for (const l of baseText.split('\n')) push(l)
13 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 25 return text
16 26 }
17 27  
... ...
lib/merge-gitignore.test.mjs
... ... @@ -12,3 +12,27 @@ test(&#39;union dedupes and preserves base order, appends new&#39;, () =&gt; {
12 12 test('blank lines and comments in add are ignored for dedupe but kept once', () => {
13 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 1 // lib/validate-ddl.mjs — docs/03 表格 ↔ DDL(V1.sql)一致性 5 维校验
2 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 7 // 用法(CLI):node lib/validate-ddl.mjs <docs03Path> <ddlPath>
5 8 // 退出码 0 = 一致;1 = 存在差异(diff 明细打印到 stderr);2 = 用法/路径错误。
... ... @@ -74,25 +77,66 @@ export function parseDocsTables(text) {
74 77 // type 为 PRIMARY(不分大小写)→ 记 'PRIMARY'(匹配 parseDDL 对主键的归一化);
75 78 // 否则记索引名 name(匹配 parseDDL 对命名索引存 name)。
76 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 82 if (!m) return
79 83 const name = m[1].trim()
80 84 const type = (m[2] || '').trim()
  85 + const colsRaw = (m[3] || '').trim()
81 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 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 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 138 if (!fromCols || !toTable || !toCols) return
95   - foreignKeys.add(`${fromCols}->${toTable}(${toCols})`)
  139 + foreignKeys.add(`${fromCols}->${toTable}(${toCols}):${onDelete}`)
96 140 }
97 141  
98 142 // ── 解析 CREATE TABLE DDL ────────────────────────────────────────
... ... @@ -101,8 +145,9 @@ export function parseDDL(text) {
101 145 const tables = new Map()
102 146 // 先剥离 SQL 注释,避免被注释掉的 CREATE TABLE 被当成真实表(幽灵表假阳性)。
103 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 151 let m
107 152 while ((m = createRe.exec(src)) !== null) {
108 153 const tableName = m[1]
... ... @@ -128,12 +173,14 @@ function parseTableBody(body) {
128 173  
129 174 // 外键约束(可带前缀 CONSTRAINT <name>)
130 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 178 if (fk) {
133 179 const fromCols = fk[1].replace(/`/g, '').replace(/\s+/g, '')
134 180 const refTable = fk[2]
135 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 184 } else {
138 185 foreignKeys.add(item)
139 186 }
... ... @@ -146,10 +193,24 @@ function parseTableBody(body) {
146 193 continue
147 194 }
148 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 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 215 // CONSTRAINT <name> 但非外键(如 UNIQUE/CHECK 约束)→ 当索引/约束记
155 216 if (/^CONSTRAINT\b/i.test(upper)) {
... ... @@ -161,10 +222,13 @@ function parseTableBody(body) {
161 222 if (/^CHECK\b/i.test(upper)) continue
162 223  
163 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 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 232 columns.set(name, type)
169 233 }
170 234 return { columns, indexes, foreignKeys }
... ... @@ -176,10 +240,13 @@ function extractType(rest) {
176 240 // 类型形如 varchar(100) / decimal(10,2) / int unsigned / bigint
177 241 const m = s.match(/^([A-Za-z]+(?:\s+(?:unsigned|signed|zerofill))*)\s*(\([^)]*\))?/i)
178 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 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 252 // ── 5 维 diff ────────────────────────────────────────────────────
... ... @@ -244,12 +311,54 @@ export function diffSchema(docsTables, ddlTables) {
244 311  
245 312 // ── 工具函数 ─────────────────────────────────────────────────────
246 313 // 剥离 SQL 注释:-- 行注释(到行尾)、# 行注释(到行尾)、/* */ 块注释。
247   -// 保守起见不解析字符串字面量内的注释符(DDL 极少在标识符/默认值里出现裸 -- 或 /*)。
  314 +// **字符串字面量感知**:单引号 / 双引号 / 反引号字面量内部的注释符按原文保留(DEFAULT 'a--b' /
  315 +// DEFAULT '#tag' 之类不会被错剥成"列丢失")。转义引号支持 SQL 标准的 '' 与反斜杠 \\'。
248 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 364 function stripTicks(s) {
... ... @@ -274,32 +383,65 @@ function isHeaderLabel(cell) {
274 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 401 // 提取从 openIdx(指向 '(')开始的平衡括号内部内容(不含最外层括号)。
  402 +// **字符串字面量感知**:DEFAULT ')' / DEFAULT '(a,b)' 等不会让 depth 提前减为 0 截断表体。
278 403 function extractBalancedParens(src, openIdx) {
279 404 if (src[openIdx] !== '(') return null
280 405 let depth = 0
281   - for (let i = openIdx; i < src.length; i++) {
  406 + let i = openIdx
  407 + while (i < src.length) {
282 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 415 depth--
286 416 if (depth === 0) return src.slice(openIdx + 1, i)
  417 + i++
  418 + continue
287 419 }
  420 + i++
288 421 }
289 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 427 function splitTopLevelCommas(body) {
294 428 const out = []
295 429 let depth = 0
296 430 let buf = ''
297   - for (let i = 0; i < body.length; i++) {
  431 + let i = 0
  432 + while (i < body.length) {
298 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 446 if (buf.trim()) out.push(buf)
305 447 return out
... ...
lib/validate-ddl.test.mjs
... ... @@ -86,7 +86,7 @@ const DDL_FULL = [
86 86 ' `sUserId` varchar(100) NOT NULL,',
87 87 ' PRIMARY KEY (`iId`),',
88 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 90 ') ENGINE=InnoDB;',
91 91 ].join('\n')
92 92  
... ... @@ -94,8 +94,10 @@ test(&#39;parseDocsTables: parses ### 索引 / ### 外键 bullets into sets (C2 regr
94 94 const t = parseDocsTables(DOCS_FULL).get('t_order')
95 95 assert.ok(t)
96 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 103 test('full chain: matching docs/03 (with indexes+FK) ↔ DDL yields no diff (C2 regression)', () => {
... ... @@ -115,7 +117,7 @@ test(&#39;full chain: a real FK present in docs but missing from DDL is caught&#39;, ()
115 117 ') ENGINE=InnoDB;',
116 118 ].join('\n')
117 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 121 assert.equal(d.hasDiff, true)
120 122 })
121 123  
... ... @@ -164,8 +166,8 @@ test(&#39;parseDDL: columns, types, indexes, foreign keys (backtick-quoted)&#39;, () =&gt;
164 166 assert.deepEqual([...t.columns.keys()], ['iIncrement', 'sId', 'sUserId'])
165 167 assert.equal(t.columns.get('sId'), 'varchar(100)')
166 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 171 assert.ok([...t.indexes].some(i => i.toUpperCase().includes('PRIMARY')))
170 172 // foreign key collected
171 173 assert.ok([...t.foreignKeys].some(fk => fk.includes('sUserId') && fk.includes('t_user')))
... ... @@ -209,10 +211,10 @@ test(&#39;diffSchema: extra column in DDL reported as columnMismatch&#39;, () =&gt; {
209 211 })
210 212  
211 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 215 const ddl = parseDDL('CREATE TABLE t ( c int );') // no indexes
214 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 220 test('diffSchema: foreign-key dimension diff reported', () => {
... ... @@ -228,3 +230,237 @@ test(&#39;diffSchema: hasDiff is false when everything matches, true otherwise&#39;, ()
228 230 const bad = diffSchema(parseDocsTables(DOCS), parseDDL('CREATE TABLE t_user ( iId bigint );'))
229 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 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 4 user-invocable: true
5 5 allowed-tools: Read Glob Workflow
6 6 ---
... ... @@ -84,22 +84,22 @@ allowed-tools: Read Glob Workflow
84 84  
85 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 90 Workflow({
91 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 104 进度概述:<步骤 3 概述,如「待跑 3 模块 + 前端阶段」>
105 105  
... ... @@ -107,8 +107,8 @@ Workflow({
107 107 • 当前已在本地默认分支(main / master)
108 108 • 工作树干净,Plan 产物(docs/* + skeleton + DDL)已 commit
109 109  
110   - Workflow 将按模块顺序全自动、静默推进;跑完所有模块或在某模块
111   - halt(测试闸持续 RED / review 5 轮未过 / 缺值阻塞等)时会通知你。
  110 + Workflow 将按模块顺序全自动、静默推进,跑完所有模块或在某模块
  111 + halt(测试闸持续 RED / review 5 轮未过 / 缺值阻塞等)时返回最终状态。
112 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 16 **清单颗粒度**:一行一个 REQ,同一模块的 REQ 必须**连续排列**。
17 17  
18 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 22 - **模块级**:若模块 DAG 存在环(module_A ↔ module_B),按启发式(字母序 / 被依赖次数多者先)破环排出 `module_topo_order`,并在**参与环的模块里第一个 REQ** 的 `note` 字段填入原因(如 "A↔B 互依赖:先做 A 的骨架")。
23 23 - **REQ 级(同模块内)**:若模块内 REQ 互依赖,同样破环,`note` 填原因。
24 24 - 非环 REQ `note` 留 `—`。
25   -6. 为 `req_order[]` 每项生成字段:
  25 +5. 为 `req_order[]` 每项生成字段:
26 26 - `index`:行号(从 1 开始)
27 27 - `req_id`:如 `REQ-SYS-001`
28 28 - `module_id`:该 REQ 所属模块,如 `module_sys`
29 29 - `rationale`(**选中理由**):依赖驱动的简短描述,如 `所属模块无依赖,基础模块` / `依赖 REQ-SYS-001 已在前` / `所属模块依赖 module_sys 已在前`
30 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 34 完成后,用 `Edit` 在 `docs/08-模块任务管理.md` 中勾选:
35 35 - ` - [ ] docs/02 开发计划已生成`
... ... @@ -120,21 +120,19 @@ allowed-tools: Read Write Edit Glob Grep Skill AskUserQuestion
120 120 4. 完成后,用 `Edit` 在 `docs/08-模块任务管理.md` 勾选 A5 父项:
121 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 2  
3 3 通用验收项(全项目适用):
4 4  
5   -- [ ] `scripts/test.sh` 本地全绿
  5 +- [ ] `node scripts/test.mjs` 本地全绿
6 6 - [ ] 所有 schema 改动都有对应 `sql/migrations/V_n__<desc>.sql`
7 7 - [ ] 所有新接口在 `docs/05` 中有契约定义
8 8 - [ ] 所有新功能代码注释含 REQ-XXX-NNN
... ...
skills/frontend-scope-lock/SKILL.md
... ... @@ -42,7 +42,11 @@ A6 是 **Plan 阶段最后一个前端守门 skill**,由 `plan-start` 在 A5
42 42 - **至少 1 个 `.html`** → 通过,记下文件清单,进入步骤 2。
43 43 - **0 个** → 这是 Plan 期,**可以问**。用 `AskUserQuestion` 告知用户「未在 prototype/ 找到任何 .html 原型,前端范围锁定依赖原型作为页面骨架权威」,给「我已补齐原型,请重新检查」和「本项目无前端,跳过 A6」两个选项。
44 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 51 ### 步骤 2:收集证据(只读,不问)
48 52  
... ...
skills/plan-start/SKILL.md
... ... @@ -21,17 +21,22 @@ docs/08 § 一 是**Plan 阶段进度追踪**(A0~A6 çš„ checkbox)。§ 二çš
21 21  
22 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 41 ## 第二步:分å‘通知 + 调用目标 skill
37 42  
... ... @@ -46,8 +51,8 @@ A 阶段所有 checkbox å‡ `[x]` æ—¶**ä¸ä»£è¡¨å¯ä»¥è¿› B 阶段**。Coding é˜
46 51 1. **REQ å¡ç‰‡çœŸå®žæ•°æ®**(æ¥è‡ª A1 scope-lock)
47 52 - `Glob` 找出全部 REQ å¡ç‰‡ï¼ˆå¦‚ `docs/01-需求清å•/**/*.md`)。
48 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 57 2. **secrets / 项目é…置全é”**(æ¥è‡ª A1 收集的 secret/account/package-name/namespace 清å•)
53 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 68 - 校验:(a) docs/05 æ¯ä¸ªç«¯ç‚¹éƒ½æœ‰è¯·æ±‚/å“应 schemaã€æ—  `ã€äººå·¥å¡«å†™`/`TBD`ï¼›(b) docs/02 æ¯ä¸ª REQ éƒ½åœ¨æž„å»ºé¡ºåº DAG 中ã€cycle-breaking é¡ºåºæœ‰ `note` 说明。缺任一å³ç¼ºå£ã€‚(A5 父项已勾本身å³è•´å« downstream-gen 评审闸已过——downstream-gen åœ¨ç”¨æˆ·æœªç¡®è®¤æ—¶ç¦æ­¢å‹¾ A5ï¼Œæ•…æ— éœ€ç‹¬ç«‹çš„ã€Œå·²è¯„å®¡ã€æ ‡è®°ã€‚)
64 69  
65 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 74 #### 第 2 步(A):全部通过 → 放行
70 75  
... ...
skills/project-init/templates/CLAUDE-template.md
... ... @@ -60,7 +60,7 @@ B 阶段整体是**一个静默 Workflow 脚本 `workflows/coding.mjs`**(由
60 60 每个后端模块在 docs/08 § 二 中长这样:
61 61  
62 62 ```markdown
63   -- module_0 系统管理
  63 +- module_sys 系统管理
64 64 - 依赖: —
65 65 - 路径: backend/module/sys/
66 66 - 里程碑: —
... ...
skills/project-init/templates/docs-08-initial-template.md
... ... @@ -20,6 +20,7 @@
20 20 - [ ] A2 骨架生成 — skeleton-gen
21 21 - [ ] 架构文档已生成(docs/04 § 一+、docs/06、docs/07、docs/09)
22 22 - [ ] 工具脚本已生成(scripts/*.mjs、.env.local)
  23 + - [ ] 样式 token 骨架已生成(src/styles/tokens.css)
23 24 - [ ] .gitignore 已配置
24 25  
25 26 - [ ] A3 DB 设计 + REQ 回填 — db-design-gen
... ... @@ -51,7 +52,7 @@
51 52 (A5 填入后,每行一个后端模块。每个模块的 `里程碑:` 字段在 `—` 和 `milestone/<id>` 之间变化,完成由本地 `git tag -l` 判定。`coding-start` 每次按 docs/02 REQ 序扫每模块的里程碑 tag 决定派发。后端模块全部打里程碑后自动进入 § 三 前端阶段。)
52 53  
53 54 <!-- 模块格式示例(由 A5 downstream-gen 追加;功能子项由 coding.mjs 的 review stage 在 approve 时勾选):
54   -- module_0 系统管理
  55 +- module_sys 系统管理
55 56 - 依赖: —
56 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 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 34 | `docs/07-环境配置.md` | `${CLAUDE_SKILL_DIR}/templates/docs-07-env-template.md` |
35 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 3 skeleton-gen 读取 docs/04 § 零 和 docs/01 index,按下述大纲生成项目专属内容。
4 4 布局/页面骨架以项目根的 prototype/ 静态 HTML mockup 为权威,本文件仅承载跨页面通用规则与 Design Tokens。
5 5 -->
... ...
skills/skeleton-gen/templates/env-local-template
... ... @@ -4,7 +4,7 @@
4 4 # 1. 值含 `$`、反引号、空格、`!` 等 shell 特殊字符时,必须用单引号包裹:
5 5 # DB_PASSWORD='p@ss$w0rd!'
6 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 8 # 若必须用远程测试库,把 host 列入下方 TEST_DB_ALLOWED_HOSTS。
9 9 # 3. DB_SCHEMA 建议命名含 test / _dev / _local / _ci,避免与生产库同名。
10 10  
... ... @@ -19,7 +19,7 @@ JWT_SECRET=【人工填写:JWT 签名密钥,256+ bit 随机串】
19 19 # 非本地服务器时填写;留空表示只允许 localhost / 127.0.0.1 / ::1。
20 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 23 # 仅用于你完全可控的测试库;生产/共享库/多人共享的 staging 库**千万别列**。
24 24 # (防护 2 还会检查 schema 名须含 test/_dev/_local/_ci,独立兜底。)
25 25 TEST_DB_ALLOWED_HOSTS=
... ...
workflows/coding.mjs
... ... @@ -2,15 +2,22 @@
2 2 //
3 3 // 整个 ERP Coding(B 阶段)= 一个静默、全自动的 Workflow 脚本。
4 4 //
5   -// 设计原则(见 docs/superpowers/specs/2026-05-26-workflow-migration-design.md):
  5 +// 设计原则(详见仓库根 README.md 「阶段 B」 节 与 「设计原则」 节):
6 6 // - 所有 stage 都是 agent() 子代理,物理上无法 AskUserQuestion → 编码期结构性静默。
7 7 // - 缺值不再问人:派生 stage 把具体阻塞点写进产物并 throw(fail-fast,合法 halt → 终止态,非对话框)。
8 8 // - 后端 / 前端功能循环由同一份 featureLoop(items, phase) 驱动;phase 切换 reviewer checklist、
9 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 17 // - 状态账本 = docs/08 §二/§三 + git tag;halt 后重跑 coding-start,router 从账本+tag 重算进度。
11 18 // - reviewer 统一为 agents/code-reviewer.md,review stage 用 agentType:'code-reviewer'。
12 19 //
13   -// 运行时约束:Workflow 运行时禁用非确定性内建(Date.now / Math.random 等)。本脚本不调用它们;
  20 +// 运行时约束:Workflow 运行时禁用非确定性内建(取当天日期 / 随机数的 API)。本脚本不调用它们;
14 21 // 凡需要"当天日期"的产物路径(<YYYY-MM-DD>-<id>.md),一律由子代理在其自身上下文中解析并落盘,
15 22 // 脚本只负责编排,不计算日期 / 随机数。
16 23  
... ... @@ -31,21 +38,128 @@ const ROUTER_SCHEMA = { type:&#39;object&#39;, additionalProperties:false,
31 38 reqs:{type:'array',items:{type:'string'}},
32 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 47 const REVIEW_SCHEMA = { type:'object', additionalProperties:false,
35 48 required:['verdict','round','issues'], properties:{
36 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 73 const GATE_SCHEMA = { type:'object', additionalProperties:false,
40 74 required:['status'], properties:{ status:{type:'string',enum:['green','red']},
41 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 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 160 // Stage prompt builders(纯字符串构造;只用 ROOT / id / phase / 入参,不触非确定性内建)
47 161 //
48   -// 每个 prompt 的共同契约(见 commonContract):
  162 +// 每个 prompt 的共同契约(见 featureStageContract):
49 163 // - 子代理是非交互的,物理上无法弹窗;缺任何值都不要"问人"——把具体阻塞点写进产物并失败。
50 164 // - phase=backend 与 phase=frontend 的差异(路径作用域 / id 形态 / 测试命令来源)逐条写明。
51 165 // - 所有输出文档用中文。
... ... @@ -53,8 +167,25 @@ const ROOT = args?.projectRoot || &#39;.&#39;
53 167  
54 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 189 const fe = isFrontend(phase)
59 190 return [
60 191 '## 硬约束(非交互子代理)',
... ... @@ -106,7 +237,7 @@ function deriveSpecPrompt(id, phase) {
106 237 return [
107 238 `# ${fe ? 'fe-feature-brainstorm' : 'feature-brainstorm'} — 派生规格 ${id}`,
108 239 '',
109   - commonContract(phase),
  240 + featureStageContract(phase),
110 241 '',
111 242 '## 目标',
112 243 `静默派生 \`${id}\` 的实现规格(无 Q&A)。需求歧义本应在 Plan 期的结构化 per-REQ 表单 / 前端 scope-lock 锁定;这里**只消费已锁定的事实**,不再澄清。`,
... ... @@ -127,31 +258,40 @@ function deriveSpecPrompt(id, phase) {
127 258 ].join('\n'),
128 259 '',
129 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 263 fe
132 264 ? '- 规格至少含:关联 REQ + 关联原型;组件树(按页面 / 区域分块,推导自 prototype DOM);页面状态机(loading / empty / error / 正常 / 表单提交中 至少 5 态);消费的后端端点(对齐 docs/05);业务规则前端复刻清单(逐条:规则 / 触发时机 / 报错文案 / 来源 REQ);Design Tokens 引用清单(`var(--color-*)`)。'
133 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 273 '## 自审(inline 修,无须等待)',
136 274 `- 占位符扫描:\`TBD\` / \`TODO\` / \`【人工填写:】\`${fe ? ' / `controller` / `service` / `SQL` / `migration`(前端 spec 不应出现后端字样)' : ''} → 命中即修;修不掉的缺值按硬约束失败。`,
137 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 281 ].join('\n')
143 282 }
144 283  
145 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 287 const fe = isFrontend(phase)
148 288 return [
149 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 295 fe
156 296 ? `- \`${ROOT}/docs/04-技术规范.md § 一 前端架构\`(路由 / 状态库 / 组件目录约定 / 测试栈);\`${ROOT}/docs/09-项目目录结构.md § 前端目录结构\`(落盘位置)。用 Grep 在 \`${ROOT}/frontend/\` 定位现有文件。`
157 297 : `- \`${ROOT}/docs/04-技术规范.md\` 与 \`${ROOT}/docs/09-项目目录结构.md\`(编码规范 + 目录规范)。用 Grep 在现有代码定位待修改文件。`,
... ... @@ -172,27 +312,36 @@ function planPrompt(id, phase, spec) {
172 312 '- 首次出现的类 / 方法 / 组件 / hook / API client 函数必须给出签名;跨 task 的签名 / 错误码 / props 类型必须一致。',
173 313 '',
174 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 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 328 ].filter(Boolean).join('\n')
181 329 }
182 330  
183 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 334 const fe = isFrontend(phase)
186 335 return [
187 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 342 `- 测试命令来源:\`${ROOT}/docs/04-技术规范.md § 零\`${fe
194 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 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 355 fe
207 356 ? '- **绝不**写非 `frontend/`(或 docs/09 前端根)路径的 `impl_file`;命中 `backend/` / `sql/` / `scripts/` → 硬停并打印 `不允许写非前端文件:<impl_file>`。'
208 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 368 ].filter(Boolean).join('\n')
215 369 }
216 370  
217 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 375 const fe = isFrontend(phase)
  376 + const suffix = round === 0 ? 'verify' : `verify-r${round}`
220 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 383 `把 \`${id}\` 的功能测试**派发到 Agent 子会话**执行,按结构化结果渲染证据。**主会话从不直接跑测试,也不自由编写证据。**`,
227   - impl ? `(上游 TDD 摘要:${impl})` : '',
  384 + `- 上游 spec:\`${specPath}\`(日期前缀来源);本次产物文件名前缀必须 = spec 文件名首段 \`YYYY-MM-DD\`。`,
  385 + implSummary ? `- 上游 TDD 摘要:${implSummary}` : '',
228 386 '',
229 387 '## 流程',
230 388 fe
231 389 ? [
232 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 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 393 ].join('\n')
236 394 : [
237 395 `- 测试目标:从 plan 或项目标准命令确定(Maven profile / pnpm script / pytest path / \`${ROOT}/docs/04-技术规范.md § 零\` 的后端命令)。`,
238 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 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 410 ].filter(Boolean).join('\n')
246 411 }
247 412  
248 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 417 const fe = isFrontend(phase)
251 418 return [
252 419 `# ${fe ? 'fe-feature-review' : 'feature-review'} — AI 自审 ${id}(第 ${round} 轮)`,
253 420 '',
254   - commonContract(phase),
  421 + featureStageContract(phase),
255 422 '',
256 423 '## 目标',
257 424 `对 \`${id}\` 本轮引入的代码 diff 做 AI 自审,给出 \`approve\` 或 \`request-changes\` 裁决。`,
258 425 '',
259 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 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 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 447 ].filter(Boolean).join('\n')
270 448 }
271 449  
272 450 // ---- stage 5b:按 review must-fix 修复并重新 commit(review 循环的 fix 步)----
  451 +// issues:结构化对象数组 {summary, locator, severity}(见 REVIEW_SCHEMA)。
273 452 function fixPrompt(id, phase, issues) {
274 453 const fe = isFrontend(phase)
275 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 457 return [
279 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 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 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 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 484 const fe = isFrontend(phase)
300 485 const id = module?.id ?? '<module>'
  486 + const phaseId = fe ? 'frontend-phase' : id
301 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 493 `打里程碑 tag 前的唯一硬测试门。**派发 Agent 子会话**跑测试,绿则通过,红则失败。**绝不**在主会话直接跑测试,红色时**绝不**跳过。`,
  494 + attempt > 1 ? `- 本次 = 第 ${attempt} 次(上一次 red,本轮用于辨识 flaky);证据**写到独立文件**不要覆盖前一次。` : '',
308 495 '',
309 496 '## 命令',
310 497 fe
... ... @@ -313,33 +500,374 @@ function gatePrompt(module, phase) {
313 500 '- 子会话只返回结构化 JSON:`{command, exit_code, passed, failed, stdout_excerpt}`(`stdout_excerpt` ≤ 30 行含 FAIL 摘要)。',
314 501 '',
315 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 506 '## 输出(必须符合下发的 GATE JSON schema)',
319 507 '- `status`: `green`(`exit_code = 0` 且 `failed = 0`)| `red`;`failures`: 失败用例摘要(green 时可省略 / 空数组)。',
320 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 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 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 871 ].join('\n')
344 872 }
345 873  
... ... @@ -347,16 +875,17 @@ function crossModulePrompt(module) {
347 875 function reportPrompt(module) {
348 876 const id = module?.id ?? '<module>'
349 877 const fe = id === 'frontend-phase'
  878 + const phaseId = fe ? 'frontend-phase' : id
350 879 return [
351 880 `# module-report — ${fe ? '前端阶段' : `模块 ${id}`} 12 节完成报告`,
352 881 '',
353   - commonContract(fe ? 'frontend' : 'backend'),
  882 + featureStageContract(fe ? 'frontend' : 'backend'),
354 883 '',
355 884 '## 目标',
356 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 891 fe
... ... @@ -365,14 +894,14 @@ function reportPrompt(module) {
365 894 `- § ② "FE 完成清单":扫 \`${ROOT}/docs/superpowers/{specs,plans,reviews}/<日期>-FE-*.md\`,按 FE-NN 顺序列出。`,
366 895 `- § ③ 文件变更:\`git -C ${ROOT} diff --stat <默认分支 main/master>...HEAD\`(三点 diff,区间 = 功能分支 \`frontend-phase\` 自默认分支分叉以来的全部改动)。`,
367 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 898 '- § ⑧ 偏离清单:额外审查"实际渲染 DOM 与各 FE 关联原型主结构的差异",逐 FE 列出。',
370 899 '- § ⑪ 下一模块预览:填"上线 / 部署后续步骤"。',
371 900 ].join('\n')
372 901 : [
373 902 `- § ③ 文件变更:\`git -C ${ROOT} diff --stat <默认分支 main/master>...HEAD\` / \`--name-status\` / \`git log <默认分支>..HEAD --oneline\`(区间 = 功能分支 \`module-${id}\` 自默认分支分叉以来的全部改动)。`,
374 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 905 `- § ⑥ Migration:\`git -C ${ROOT} diff --name-only --diff-filter=A -- 'sql/migrations/V*.sql'\` 列新增,每个读第一行作说明。`,
377 906 `- § ⑦ 跨模块改动:读 \`${ROOT}/docs/superpowers/module-reports/${id}-cross-module.md\`(如存在;其中不应再有 \`TBD(CC 补)\`,上一步 cross-module-log 已补齐)。`,
378 907 '- § ④ 读写的表:grep 定位涉 SQL 文件后按需读片段,**不全量读 docs/03**。',
... ... @@ -380,66 +909,132 @@ function reportPrompt(module) {
380 909 '',
381 910 '## 渲染 + 验证 + commit',
382 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 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 923 const id = module?.id ?? '<module>'
393 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 952 const id = module?.id ?? '<module>'
422 953 const fe = id === 'frontend-phase'
  954 + const phaseId = fe ? 'frontend-phase' : id
423 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 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 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 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 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 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 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 1160 if (g.status === 'red') throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${(g.failures||[]).join('; ')}`)
480 1161 return g
... ... @@ -482,34 +1163,80 @@ async function testGate(module, phase) {
482 1163  
483 1164 phase('Router')
484 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 1192 const todo = routed.modules.filter(m => !m.done)
486 1193 log(`coding: ${todo.length}/${routed.modules.length} modules to run`)
487 1194  
488 1195 const results = []
489   -for (const module of todo) {
  1196 +let haltedAtIdx = -1
  1197 +for (const [idx, module] of todo.entries()) {
490 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 1203 if (module.reqs.length) { // 后端段(frontend-phase 模块 reqs 为空 → 跳过)
  1204 + phase('Backend')
494 1205 await featureLoop(module.reqs, 'backend')
  1206 + phase('Gate')
495 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 1211 if (module.feItems.length) { // 前端段(仅末尾 frontend-phase 聚合模块)
  1212 + phase('Frontend')
499 1213 await featureLoop(module.feItems, 'frontend')
  1214 + phase('Gate')
500 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 1223 results.push({ module: module.id, status:'done' })
505 1224 } catch (e) {
506 1225 results.push({ module: module.id, status:'halted', reason: String(e.message||e) })
  1226 + haltedAtIdx = idx
507 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 }
... ...